Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions FlipcashTests/Chat/ChatViewControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import Testing
import UIKit
import ChatLayout
import FlipcashCore
@testable import FlipcashUI

Expand Down Expand Up @@ -160,3 +161,69 @@ struct ChatViewControllerTests {
#expect(plainCell is ChatMessageCell)
}
}

@MainActor
@Suite("Transcript row spacing")
struct ChatRowSpacingTests {

/// The gap the transcript leaves after row `index` of `items` — the delegate's answer, or the
/// base spacing where it declines to give one, which is what the layout itself would apply.
private func gap(after index: Int, in items: [ChatItem]) -> CGFloat {
let controller = ChatViewController()
controller.loadViewIfNeeded()
controller.update(items: items)
let layout = controller.collectionView.collectionViewLayout as! CollectionViewChatLayout
return controller.interItemSpacing(layout, after: IndexPath(item: index, section: 0))
?? layout.settings.interItemSpacing
}

private func message(
_ id: String,
_ sender: ChatMessage.Sender,
continuedByNext: Bool = false
) -> ChatItem {
.message(ChatMessage(
id: id,
content: .text("hi"),
sender: sender,
isContinuedByNext: continuedByNext
))
}

@Test("A run from one sender stays tight")
func groupedRun_isTight() {
let items = [message("1", .me, continuedByNext: true), message("2", .me)]
#expect(gap(after: 0, in: items) == 5)
}

@Test("Two messages from one sender outside the grouping window take the normal gap")
func ungroupedSameSender_isNormal() {
let items = [message("1", .me), message("2", .me)]
#expect(gap(after: 0, in: items) == 10)
}

@Test("A change of speaker takes the widest gap")
func senderFlip_isWide() {
let items = [message("1", .me, continuedByNext: true), message("2", .other)]
#expect(gap(after: 0, in: items) == 15)
}

@Test("A date separator takes the normal gap on both sides")
func dateSeparator_isNormalOnBothSides() {
let items = [
message("1", .me, continuedByNext: true),
.dateSeparator(id: "sep", text: "Today"),
message("2", .me, continuedByNext: true),
]
#expect(gap(after: 0, in: items) == 10)
#expect(gap(after: 1, in: items) == 10)
}

// The dots are appended after the grouping pass and never join a run, so the message above
// them is never marked as continued — which is what puts a new turn's gap under it.
@Test("The typing indicator reads as a new turn, not a continuation")
func typingIndicator_isNotAContinuation() {
let items: [ChatItem] = [message("1", .other), .typingIndicator]
#expect(gap(after: 0, in: items) == 10)
}
}
58 changes: 48 additions & 10 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,24 @@ public final class ChatViewController: UICollectionViewController {
/// Within this many points of the bottom counts as "at the bottom".
private static let bottomThreshold: CGFloat = 50

/// Extra spacing where the sender flips, on top of the base inter-item spacing, so a change of
/// speaker reads as a break in the column rather than another row in the same run.
private static let senderFlipExtraSpacing: CGFloat = 6
/// The gap the transcript leaves between two rows, in three tiers. The same three Android
/// picks from in `bottomSpacingFor`, at the same values, so a thread reads at one density on
/// both platforms.
///
/// ``normal`` is the layout's base spacing: any pairing `interItemSpacing(_:after:)` does not
/// answer for takes it.
private enum RowGap {
/// Two messages from one sender inside the grouping window. Their facing corners are
/// already flattened, so the run needs only enough air to keep the bubbles apart.
static let tight: CGFloat = 5
/// Two messages from one sender that the grouping window has broken apart, and either side
/// of a date separator. The corners are round again, and the gap says what they no longer
/// do — that these are separate moments.
static let normal: CGFloat = 10
/// A change of speaker, which reads as a break in the column rather than another row in
/// the same run.
static let wide: CGFloat = 15
}

private let chatLayout = CollectionViewChatLayout()
private var items: [ChatItem] = []
Expand Down Expand Up @@ -133,7 +148,7 @@ public final class ChatViewController: UICollectionViewController {
public init() {
super.init(collectionViewLayout: chatLayout)
chatLayout.delegate = self
chatLayout.settings.interItemSpacing = 8
chatLayout.settings.interItemSpacing = RowGap.normal
// ChatLayout owns the bottom anchoring: stay pinned to the newest message across batch
// updates (so an append at the bottom follows and a prepend preserves position). Content
// shorter than the viewport top-aligns — the profile card sits under the nav bar with
Expand Down Expand Up @@ -588,12 +603,35 @@ extension ChatViewController: ChatLayoutDelegate {
}

public func interItemSpacing(_ chatLayout: CollectionViewChatLayout, after indexPath: IndexPath) -> CGFloat? {
// Only a message→message pair with different senders widens. Any other pairing (into or out
// of a separator, the typing indicator, the profile card) takes the base spacing.
guard let current = sender(at: indexPath),
let next = sender(at: IndexPath(item: indexPath.item + 1, section: indexPath.section)),
current != next else { return nil }
return chatLayout.settings.interItemSpacing + Self.senderFlipExtraSpacing
let below = IndexPath(item: indexPath.item + 1, section: indexPath.section)

// Checked before the senders, as Android does: a separator is the heading for the run under
// it, so it takes the same air on both sides whatever it happens to separate.
guard !isDateSeparator(at: indexPath), !isDateSeparator(at: below) else { return nil }

// A pairing with no sender on one side is the profile card, which is not a bubble and keeps
// the base spacing.
guard let current = sender(at: indexPath), let next = sender(at: below) else { return nil }
guard current == next else { return RowGap.wide }

// Same sender: tight only while they are one run. The typing indicator never joins one, so
// the dots arriving after the counterpart's own message read as a new turn.
return message(at: indexPath)?.isContinuedByNext == true ? RowGap.tight : nil
}

/// The message at `indexPath`, or nil for a row that is not one. Bounds-checked for the same
/// reason ``sender(at:)`` is.
private func message(at indexPath: IndexPath) -> ChatMessage? {
guard items.indices.contains(indexPath.item),
case .message(let message) = items[indexPath.item] else { return nil }
return message
}

/// Whether the row at `indexPath` is a day header. Bounds-checked; out of range is not one.
private func isDateSeparator(at indexPath: IndexPath) -> Bool {
guard items.indices.contains(indexPath.item),
case .dateSeparator = items[indexPath.item] else { return false }
return true
}

/// Which side of the thread the row at `indexPath` belongs to, or nil for a row that belongs to
Expand Down
Loading