diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index c3e45cc9f..b7f125679 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -534,6 +534,11 @@ final class ConversationController { typing.isCounterpartTyping(in: conversationID) } + /// Returns the other members typing in the conversation, oldest to newest by when they started. + func typists(in conversationID: ConversationID) -> [UserID] { + typing.typists(in: conversationID) + } + func stop() { startTask?.cancel() startTask = nil diff --git a/Flipcash/Core/Controllers/ConversationTyping.swift b/Flipcash/Core/Controllers/ConversationTyping.swift index d30aa0bb5..b3ef1da29 100644 --- a/Flipcash/Core/Controllers/ConversationTyping.swift +++ b/Flipcash/Core/Controllers/ConversationTyping.swift @@ -10,7 +10,7 @@ import FlipcashCore nonisolated private let logger = Logger(label: "flipcash.conversation-controller") -/// Typing indicators for DM conversations: broadcasts the signed-in user's typing state +/// Typing indicators for conversations: broadcasts the signed-in user's typing state /// and tracks which counterparts are typing. The server relays typing best-effort with /// no timeout of its own, so stale typists are expired locally. @MainActor @@ -25,8 +25,8 @@ final class ConversationTyping { private let timeout: Duration private let incomingExpiry: Duration - /// OTHER members typing per conversation, each keyed to their staleness deadline. - private var typists: [ConversationID: [UserID: ContinuousClock.Instant]] = [:] + /// OTHER members typing per conversation, each with when they started and their staleness deadline. + private var typists: [ConversationID: [UserID: Typist]] = [:] @ObservationIgnored private var expiryTask: Task? @ObservationIgnored private var isSelfTyping = false @@ -59,12 +59,27 @@ final class ConversationTyping { !(typists[conversationID]?.isEmpty ?? true) } + /// Returns the other members typing in the conversation, oldest to newest by when they started. + func typists(in conversationID: ConversationID) -> [UserID] { + guard let typists = typists[conversationID] else { return [] } + // Ties on the start instant fall back to the id, so the order never flips between reads. + return typists + .sorted { ($0.value.startedAt, $0.key.uuidString) < ($1.value.startedAt, $1.key.uuidString) } + .map(\.key) + } + /// Applies typing notifications from the live stream, ignoring the user's own. func apply(_ notifications: [TypingNotification], in conversationID: ConversationID) { for notification in notifications where notification.userID != selfUserID { switch notification.isActive { case true: - typists[conversationID, default: [:]][notification.userID] = ContinuousClock.now + incomingExpiry + let now = ContinuousClock.now + // A STILL heartbeat extends the deadline but keeps the typist's place in line. + let startedAt = typists[conversationID]?[notification.userID]?.startedAt ?? now + typists[conversationID, default: [:]][notification.userID] = Typist( + startedAt: startedAt, + deadline: now + incomingExpiry + ) case false: removeTypist(notification.userID, in: conversationID) } @@ -83,7 +98,7 @@ final class ConversationTyping { private func scheduleExpirySweep() { expiryTask?.cancel() expiryTask = nil - guard let earliest = typists.values.flatMap(\.values).min() else { return } + guard let earliest = typists.values.flatMap(\.values).map(\.deadline).min() else { return } expiryTask = Task { [weak self] in try? await Task.sleep(until: earliest, clock: .continuous) guard let self, !Task.isCancelled else { return } @@ -93,8 +108,8 @@ final class ConversationTyping { private func sweepExpiredTypists() { let now = ContinuousClock.now - for (conversationID, deadlines) in typists { - for (userID, deadline) in deadlines where deadline <= now { + for (conversationID, members) in typists { + for (userID, typist) in members where typist.deadline <= now { logger.debug("Expiring stale typist", metadata: [ "conversationID": "\(conversationID)", "userID": "\(userID)", @@ -105,6 +120,11 @@ final class ConversationTyping { scheduleExpirySweep() } + private struct Typist { + var startedAt: ContinuousClock.Instant + var deadline: ContinuousClock.Instant + } + // MARK: - Outgoing /// STARTED on first input, STILL at least every `heartbeatInterval` while typing diff --git a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift index c3b261ba2..d60582868 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift @@ -26,13 +26,13 @@ final class ConversationLoadCoordinator { private(set) var headsHistory = false /// The people the landed transcript attributes its rows to, in the order the window first shows - /// them. The view fetches their avatars from this: the mapped rows carry only a BlurHash — + /// them, then the group's typists the rows leave out. The view fetches their avatars from this: the mapped rows carry only a BlurHash — /// thumbnail bytes must not ride in ``Inputs``, which is compared on every observation tick, nor /// into the app-group cache the rows are written to in the clear. private(set) var attributedMembers: [ConversationMember] = [] - /// The window's senders that neither the chat's roster nor the local cache could name, in the - /// order the window first shows them. The view resolves these over the network — the transcript + /// The window's senders and typists that neither the chat's roster nor the local cache could + /// name, in the order the window first shows them. The view resolves these over the network — the transcript /// itself never blocks on it, and a sender that lands is attributed by the next re-map. private(set) var unattributedSenders: [UserID] = [] @@ -242,7 +242,9 @@ final class ConversationLoadCoordinator { counterpartPointer: read?.pointer, counterpartReadDate: read?.date, suppressReceiptFor: controller.settlingSendID, - isTyping: controller.isCounterpartTyping(in: conversationID), + // Capped here rather than in `map`, so a typist the row will never draw is neither + // compared on every tick nor sent off to have their picture fetched. + typists: Array(controller.typists(in: conversationID).suffix(ChatItem.maxTypingAvatars)), profileCard: headsHistory ? profileCard() : nil, branding: branding, conversation: conversation, @@ -319,8 +321,13 @@ final class ConversationLoadCoordinator { linkCard: { links in classifier.firstCard(in: links) }, unreadBoundary: inputs.unreadBoundary ) - if inputs.isTyping { - items.append(.typingIndicator) + if !inputs.typists.isEmpty { + // Only a group draws faces ahead of the dots; a DM's bubble stays as it was. A typist no + // roster can name still gets a face, the placeholder one. + let typists = inputs.namesAuthors + ? inputs.typists.map { authors[$0] ?? ChatAuthor(id: $0, name: "") } + : [] + items.append(.typingIndicator(typists: typists)) } if let card = inputs.profileCard { items.insert(.profileCard(card), at: 0) @@ -335,6 +342,7 @@ final class ConversationLoadCoordinator { /// what the transcript is attributing. Everyone it leaves out — the subset a large group embeds /// leaves plenty — falls through to whatever the device knows about them from elsewhere. The /// senders neither source covers come back in `unnamed`, for the view to fetch. + /// The group's typists follow the window's senders, so their pictures are fetched too. /// Both are empty for every transcript that does not attribute its rows. nonisolated private static func attribution(in inputs: Inputs) -> (members: [ConversationMember], unnamed: [UserID]) { guard inputs.namesAuthors else { return ([], []) } @@ -348,8 +356,8 @@ final class ConversationLoadCoordinator { var seen: Set = [] var members: [ConversationMember] = [] var unnamed: [UserID] = [] - for message in inputs.messages { - guard let senderID = message.senderID, seen.insert(senderID).inserted else { continue } + for senderID in inputs.messages.compactMap(\.senderID) + inputs.typists { + guard seen.insert(senderID).inserted else { continue } // The viewer's own rows are never attributed, so their id is not worth a round trip. guard senderID != inputs.selfUserID else { continue } guard let member = roster[senderID] ?? inputs.knownAuthors.membersByUserID[senderID] else { @@ -389,7 +397,8 @@ final class ConversationLoadCoordinator { var counterpartPointer: MessageID? var counterpartReadDate: Date? var suppressReceiptFor: String? - var isTyping: Bool + /// The other members typing, oldest first, capped at ``ChatItem/maxTypingAvatars``. + var typists: [UserID] var profileCard: ChatProfileCard? var branding: [PublicKey: Branding] var conversation: Conversation? diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift index cc5fe6e34..8d961cf78 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift @@ -19,7 +19,9 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { /// opened the chat. At most one per transcript. case unreadDivider(count: Int) /// A leading-aligned typing bubble pinned at the transcript tail while the counterpart types. - case typingIndicator + /// In a group, `typists` holds the newest few people typing, oldest first, drawn as avatars + /// ahead of the bubble; it is empty in a DM, which draws the bubble alone. + case typingIndicator(typists: [ChatAuthor]) /// The counterpart's profile card at the head of a short transcript. case profileCard(ChatProfileCard) /// The group's own card at the head of a short group transcript — picture, title, and the @@ -31,12 +33,16 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { case .message(let message): message.id case .dateSeparator(let id, _): id case .unreadDivider: "unread-divider" + // Independent of `typists`, so a typist joining or leaving updates the row in place. case .typingIndicator: "typing-indicator" case .profileCard: "profile-card" case .groupCard: "group-card" } } + /// The most typists a group's typing indicator draws; the newest win. + public static let maxTypingAvatars = 3 + /// The id of the message this row draws some or all of, or nil for a row that is not a message. /// A message split around its link card spans several rows that all answer the same here. public var messageID: String? { diff --git a/FlipcashTests/Chat/ChatChangesetFlatteningTests.swift b/FlipcashTests/Chat/ChatChangesetFlatteningTests.swift index 6388e1aeb..a52f751a9 100644 --- a/FlipcashTests/Chat/ChatChangesetFlatteningTests.swift +++ b/FlipcashTests/Chat/ChatChangesetFlatteningTests.swift @@ -64,7 +64,7 @@ struct ChatChangesetFlatteningTests { func updateDeleteAndInsert_mergesIntoOneBatch() throws { // The typing indicator clears (delete), the reply lands (insert), and the previous row's // grouping flips (update) — three DifferenceKit stages in one push. - let before = [message("a", sender: .other), .typingIndicator] + let before = [message("a", sender: .other), .typingIndicator(typists: [])] let after = [ message("a", sender: .other, continuedByNext: true), message("b", sender: .other, continuationFromPrevious: true), @@ -79,13 +79,13 @@ struct ChatChangesetFlatteningTests { #expect(batch.changeset.elementInserted == [ElementPath(element: 1, section: 0)]) #expect(batch.changeset.elementMoved.isEmpty) #expect(batch.changeset.data == after) - #expect(batch.reconfigureData == [after[0], .typingIndicator]) + #expect(batch.reconfigureData == [after[0], .typingIndicator(typists: [])]) } @Test("A pure delete + insert pair merges with no reconfigure data") func deleteAndInsert_mergesWithoutReconfigureData() throws { // The pre-existing behavior (typing indicator swaps for a message with no other change). - let before = [message("a", sender: .other), .typingIndicator] + let before = [message("a", sender: .other), .typingIndicator(typists: [])] let after = [message("a", sender: .other), message("b", sender: .other)] let batch = try #require(StagedChangeset(source: before, target: after).singleBatch()) diff --git a/FlipcashTests/Chat/ChatTranscriptDiffFuzzTests.swift b/FlipcashTests/Chat/ChatTranscriptDiffFuzzTests.swift index 2c9b61a86..c553f2242 100644 --- a/FlipcashTests/Chat/ChatTranscriptDiffFuzzTests.swift +++ b/FlipcashTests/Chat/ChatTranscriptDiffFuzzTests.swift @@ -77,7 +77,7 @@ struct ChatTranscriptDiffFuzzTests { case .separator(let id): .dateSeparator(id: "sep\(id)", text: "Day \(id)") case .typing: - .typingIndicator + .typingIndicator(typists: []) } } } diff --git a/FlipcashTests/Chat/ChatViewControllerTests.swift b/FlipcashTests/Chat/ChatViewControllerTests.swift index bafe1e8c0..675eba8d6 100644 --- a/FlipcashTests/Chat/ChatViewControllerTests.swift +++ b/FlipcashTests/Chat/ChatViewControllerTests.swift @@ -104,7 +104,7 @@ struct ChatViewControllerTests { let before: [ChatItem] = (0..<8).map { item($0, $0.isMultiple(of: 2) ? .me : .other) } + [ .message(ChatMessage(id: "them-1", text: "typing next", sender: .other)), - .typingIndicator, + .typingIndicator(typists: []), ] controller.update(items: before) for _ in 0..<3 { @@ -290,7 +290,7 @@ struct ChatRowSpacingTests { // 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] + let items: [ChatItem] = [message("1", .other), .typingIndicator(typists: [])] #expect(gap(after: 0, in: items) == 10) } } diff --git a/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift b/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift new file mode 100644 index 000000000..0cb6e6f50 --- /dev/null +++ b/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift @@ -0,0 +1,144 @@ +// +// ConversationTypingIndicatorTests.swift +// FlipcashTests +// + +import Testing +import Foundation +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +/// The typing row the load coordinator appends: a group's carries the typists' avatars, a DM's +/// carries none. +@MainActor +@Suite("ConversationLoadCoordinator typing indicator") +struct ConversationTypingIndicatorTests { + + private func waitUntil(_ condition: () -> Bool, sourceLocation: SourceLocation = #_sourceLocation) async throws { + for _ in 0..<50 where !condition() { + try? await Task.sleep(for: .milliseconds(20)) + } + try #require(condition(), "Timed out waiting for condition after ~1s", sourceLocation: sourceLocation) + } + + private let me = UUID() + + private func conversation(_ type: ConversationType, members: [ConversationMember]) -> Conversation { + Conversation( + id: .test(1), + members: [ConversationMember(userID: me, displayName: "Me")] + members, + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 100), + type: type, + title: type == .group ? "Group" : nil + ) + } + + /// A started controller whose store holds `conversation`, and a coordinator over it. + private func make( + _ conversation: Conversation, + database: Database + ) async throws -> (MockConversations, ConversationController, ConversationLoadCoordinator) { + let mock = MockConversations() + switch conversation.type { + case .group: + mock.groupFeed = [conversation] + case .contactDm, .tipDm: + mock.feed = [conversation] + } + let controller = ConversationController( + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, + contactNaming: MockDMContactNaming(), + database: database, + owner: .generate()!, selfUserID: me + ) + controller.start() + try await waitUntil { mock.streamOpened && controller.conversation(withID: .test(1)) != nil } + + let coordinator = ConversationLoadCoordinator( + conversationID: .test(1), + controller: controller, + session: .mock, + knownAuthors: KnownAuthorDirectory(read: { [:] }, fetch: { _ in throw CancellationError() }, cache: { _, _ in }), + profileCard: { nil } + ) + return (mock, controller, coordinator) + } + + private func typing(_ userID: UserID, in mock: MockConversations) { + mock.emit(.typingChanged(conversationID: .test(1), notifications: [TypingNotification(userID: userID, isActive: true)])) + } + + /// The typists the coordinator's typing row carries, or nil when it has no typing row. + private func typingRow(_ coordinator: ConversationLoadCoordinator) -> [ChatAuthor]? { + for item in coordinator.items { + switch item { + case .typingIndicator(let typists): + return typists + case .message, .dateSeparator, .unreadDivider, .profileCard, .groupCard: + continue + } + } + return nil + } + + @Test("A group's typing row carries each typist, oldest first") + func typingRow_group_carriesTypistsOldestFirst() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let alice = UUID(), bob = UUID() + let group = conversation(.group, members: [ + ConversationMember(userID: alice, displayName: "Alice"), + ConversationMember(userID: bob, displayName: "Bob"), + ]) + let (mock, controller, coordinator) = try await make(group, database: database) + + typing(alice, in: mock) + try await waitUntil { controller.typists(in: .test(1)).count == 1 } + typing(bob, in: mock) + try await waitUntil { typingRow(coordinator)?.count == 2 } + + let typists = try #require(typingRow(coordinator)) + #expect(typists.map(\.id) == [alice, bob]) + #expect(typists.map(\.name) == ["Alice", "Bob"]) + // The typists' pictures are fetched alongside the senders'. + #expect(Set(coordinator.attributedMembers.compactMap(\.userID)) == [alice, bob]) + } + + @Test("A DM's typing row carries no avatars") + func typingRow_dm_carriesNoAvatars() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let them = UUID() + let dm = conversation(.contactDm, members: [ConversationMember(userID: them, displayName: "Them")]) + let (mock, _, coordinator) = try await make(dm, database: database) + + typing(them, in: mock) + try await waitUntil { typingRow(coordinator) != nil } + + #expect(typingRow(coordinator) == []) + #expect(coordinator.attributedMembers.isEmpty) + } + + @Test("A group with more typists than the cap keeps the newest three") + func typingRow_overCap_keepsNewestThree() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let typists = (0..<5).map { _ in UUID() } + let group = conversation(.group, members: typists.enumerated().map { + ConversationMember(userID: $0.element, displayName: "Typist \($0.offset)") + }) + let (mock, controller, coordinator) = try await make(group, database: database) + + // One at a time, so each starts at a distinct instant and the order is the emit order. + for (index, typist) in typists.enumerated() { + typing(typist, in: mock) + try await waitUntil { controller.typists(in: .test(1)).count == index + 1 } + } + try await waitUntil { typingRow(coordinator)?.first?.id == typists[2] } + + #expect(ChatItem.maxTypingAvatars == 3) + #expect(typingRow(coordinator)?.map(\.id) == Array(typists.suffix(3))) + } +} diff --git a/FlipcashTests/ConversationControllerTests.swift b/FlipcashTests/ConversationControllerTests.swift index c8741b137..110416e48 100644 --- a/FlipcashTests/ConversationControllerTests.swift +++ b/FlipcashTests/ConversationControllerTests.swift @@ -159,6 +159,48 @@ struct ConversationControllerTests { #expect(refreshedAt.duration(to: ContinuousClock.now) >= .milliseconds(400)) } + @Test("typists list oldest to newest, and a heartbeat keeps a typist's place") + func typists_heartbeatFromOldest_keepsStartOrder() async throws { + let a = UUID(), b = UUID(), c = UUID() + let mock = MockConversations() + let controller = makeController(mock) + controller.start() + try await waitUntil { mock.streamOpened } + + for (index, typist) in [a, b, c].enumerated() { + mock.emit(.typingChanged(conversationID: .test(1), notifications: [TypingNotification(userID: typist, isActive: true)])) + try await waitUntil { controller.typists(in: .test(1)).count == index + 1 } + } + #expect(controller.typists(in: .test(1)) == [a, b, c]) + + // A STILL from the oldest typist extends their deadline without moving them to the back. + mock.emit(.typingChanged(conversationID: .test(1), notifications: [TypingNotification(userID: a, isActive: true)])) + mock.emit(.typingChanged(conversationID: .test(1), notifications: [TypingNotification(userID: b, isActive: false)])) + try await waitUntil { controller.typists(in: .test(1)).count == 2 } + #expect(controller.typists(in: .test(1)) == [a, c]) + #expect(controller.typists(in: .test(2)).isEmpty) + } + + @Test("an expired typist drops out of the ordered list") + func typists_staleTypist_dropsOut() async throws { + let a = UUID(), b = UUID() + let mock = MockConversations() + let controller = makeController(mock, incomingTypingExpiry: .milliseconds(300)) + controller.start() + try await waitUntil { mock.streamOpened } + + mock.emit(.typingChanged(conversationID: .test(1), notifications: [TypingNotification(userID: a, isActive: true)])) + try await waitUntil { controller.typists(in: .test(1)) == [a] } + try await Task.sleep(for: .milliseconds(150)) + mock.emit(.typingChanged(conversationID: .test(1), notifications: [TypingNotification(userID: b, isActive: true)])) + try await waitUntil { controller.typists(in: .test(1)) == [a, b] } + + // `a` lapses first; `b` started later, so outlives it. + try await waitUntil { controller.typists(in: .test(1)) == [b] } + try await waitUntil { controller.typists(in: .test(1)).isEmpty } + #expect(!controller.isCounterpartTyping(in: .test(1))) + } + @Test("a STOPPED never overtakes an in-flight earlier send") func typingSendsStayOrdered() async throws { let mock = MockConversations() diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotionSandbox.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotionSandbox.swift index 48762eb00..0fc65c906 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotionSandbox.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotionSandbox.swift @@ -193,7 +193,7 @@ public final class ChatMotionSandboxViewController: UIViewController { private func items(appending tail: [ChatMessage], typing: Bool = false) -> [ChatItem] { var items = Self.grouped(base + tail).map { ChatItem.message($0) } if typing { - items.append(.typingIndicator) + items.append(.typingIndicator(typists: [])) } return items } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift index 2a67b3fc2..6e54579cc 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift @@ -7,10 +7,12 @@ #if canImport(UIKit) import UIKit +import FlipcashCore /// Three dots in a leading incoming bubble, shown while the counterpart is typing. The bubble reuses /// the shared `BubbleBackgroundView` chrome (incoming fill + hairline + 12pt radius), and the dots run /// a repeating iMessage-style opacity wave — each brightens to `peakOpacity` in turn and settles back. +/// In a group, the typists' avatars overlap in a row ahead of the bubble. public final class ChatTypingIndicatorCell: UICollectionViewCell { public static let reuseIdentifier = "ChatTypingIndicatorCell" @@ -33,10 +35,31 @@ public final class ChatTypingIndicatorCell: UICollectionViewCell { private static let dotSize: CGFloat = 7 + // Android's `TypingIndicator`: the transcript's 24pt sender avatar, overlapping by 8, with a 4pt + // gap to the bubble and a 2pt ring cut out of each avatar beneath another. + private static let avatarSize = ChatAuthorAvatarView.size + private static let avatarOverlap: CGFloat = 8 + private static let avatarGap: CGFloat = 4 + private static let avatarRing: CGFloat = 2 + private static let avatarStep = avatarSize - avatarOverlap + /// How small an avatar is as it starts to grow in, or finishes shrinking out. + private static let avatarEnterScale: CGFloat = 0.4 + private let bubble = BubbleBackgroundView() private let dotsRow = UIStackView() private var dots: [UIView] = [] + private let avatarRow = UIView() + private var avatarRowWidth: NSLayoutConstraint! + /// Every avatar drawn, including ones still animating out after their typist left. + private var entries: [AvatarEntry] = [] + /// The number of avatars the row's width is sized for, animated so the bubble slides. + private var count = Spring(0) + private var displayLink: CADisplayLink? + private var lastTick: CFTimeInterval? + /// False until the first configure after a dequeue, which draws its avatars in place. + private var hasConfigured = false + public override init(frame: CGRect) { super.init(frame: frame) @@ -64,12 +87,22 @@ public final class ChatTypingIndicatorCell: UICollectionViewCell { return dot } + // A shrinking avatar draws past the row's animated width. + avatarRow.clipsToBounds = false + avatarRow.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(avatarRow) + avatarRowWidth = avatarRow.widthAnchor.constraint(equalToConstant: 0) + let trailing = bubble.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -12) trailing.priority = .defaultHigh NSLayoutConstraint.activate([ + avatarRow.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), + avatarRow.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + avatarRow.heightAnchor.constraint(equalToConstant: Self.avatarSize), + avatarRowWidth, bubble.topAnchor.constraint(equalTo: contentView.topAnchor), bubble.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - bubble.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), + bubble.leadingAnchor.constraint(equalTo: avatarRow.trailingAnchor), trailing, // The bubble's own padding matches a text bubble's 12/9, plus the dot row's inner 6 vertical. dotsRow.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12), @@ -85,6 +118,225 @@ public final class ChatTypingIndicatorCell: UICollectionViewCell { @available(*, unavailable) public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + public override func prepareForReuse() { + super.prepareForReuse() + stopAvatarMotion() + entries.forEach { $0.view.removeFromSuperview() } + entries = [] + count = Spring(0) + hasConfigured = false + layoutAvatars() + } + + /// Draws the newest ``ChatItem/maxTypingAvatars`` of `typists` as an overlapping stack ahead of + /// the dots, oldest at the back on the left and newest on top at the right, reading pictures out + /// of `imageData`. An empty list draws the dots alone. Once the cell has been configured, a + /// typist joining grows in from its left edge, one leaving shrinks out beneath the others as they + /// slide over it, and the bubble slides with the stack's width. + public func configure(typists: [ChatAuthor], imageData: [UserID: Data]) { + let animated = hasConfigured && window != nil && !UIAccessibility.isReduceMotionEnabled + hasConfigured = true + let shown = Array(typists.suffix(ChatItem.maxTypingAvatars)) + let shownIDs = Set(shown.map(\.id)) + + for (index, author) in shown.enumerated() { + let slot = CGFloat(index) + let entry: AvatarEntry + if let existing = entries.first(where: { $0.id == author.id && !$0.isLeaving }) { + entry = existing + } else { + let view = ChatAuthorAvatarView(frame: .zero) + // Grows from and shrinks to its left edge, as Android's `TransformOrigin(0, 0.5)`. + view.layer.anchorPoint = CGPoint(x: 0, y: 0.5) + avatarRow.addSubview(view) + entry = AvatarEntry(id: author.id, view: view, slot: Spring(slot), presence: Spring(animated ? 0 : 1)) + entries.append(entry) + } + entry.view.configure(with: author, imageData: imageData[author.id]) + entry.slot.target = slot + entry.presence.target = 1 + } + for entry in entries where !entry.isLeaving && !shownIDs.contains(entry.id) { + entry.isLeaving = true + entry.presence.target = 0 + } + count.target = CGFloat(shown.count) + + if animated { + startAvatarMotion() + } else { + stopAvatarMotion() + entries.forEach { $0.settle() } + count.settle() + layoutAvatars() + } + } + + // MARK: - Avatar motion + + /// One avatar in the stack, kept after its typist leaves until it has animated out. + private final class AvatarEntry { + let id: UserID + let view: ChatAuthorAvatarView + let ring = CALayer() + /// The avatar's horizontal place in the stack, in steps from the left. + var slot: Spring + /// 0 while absent, 1 once fully in: drives the avatar's alpha, scale and the ring it cuts. + var presence: Spring + var isLeaving = false + + init(id: UserID, view: ChatAuthorAvatarView, slot: Spring, presence: Spring) { + self.id = id + self.view = view + self.slot = slot + self.presence = presence + view.layer.mask = ring + } + + /// Later slots draw on top, and a leaving avatar beneath everything, so the ones that close + /// the gap slide over it. + var z: CGFloat { isLeaving ? -1 : slot.value } + + var scale: CGFloat { + ChatTypingIndicatorCell.avatarEnterScale + (1 - ChatTypingIndicatorCell.avatarEnterScale) * presence.value + } + + var isSettled: Bool { slot.isSettled && presence.isSettled } + + func settle() { + slot.settle() + presence.settle() + } + } + + /// A critically damped spring at Compose's `StiffnessMediumLow`, the `AvatarMotion` Android uses. + private struct Spring { + private static let stiffness: CGFloat = 400 + + var value: CGFloat + var target: CGFloat + private var velocity: CGFloat = 0 + + init(_ value: CGFloat) { + self.value = value + self.target = value + } + + var isSettled: Bool { abs(value - target) < 0.001 && abs(velocity) < 0.01 } + + mutating func settle() { + value = target + velocity = 0 + } + + mutating func step(by elapsed: CFTimeInterval) { + // Small fixed substeps keep the integration stable through a dropped frame. + var remaining = CGFloat(min(elapsed, 1.0 / 15)) + while remaining > 0 { + let dt = min(remaining, 1.0 / 240) + let acceleration = -Self.stiffness * (value - target) - 2 * Self.stiffness.squareRoot() * velocity + velocity += acceleration * dt + value += velocity * dt + remaining -= dt + } + if isSettled { settle() } + } + } + + private func startAvatarMotion() { + guard displayLink == nil else { return } + lastTick = nil + let link = CADisplayLink(target: self, selector: #selector(stepAvatarMotion(_:))) + link.add(to: .main, forMode: .common) + displayLink = link + } + + private func stopAvatarMotion() { + displayLink?.invalidate() + displayLink = nil + lastTick = nil + } + + @objc private func stepAvatarMotion(_ link: CADisplayLink) { + let elapsed = lastTick.map { link.timestamp - $0 } ?? link.duration + lastTick = link.timestamp + for entry in entries { + entry.slot.step(by: elapsed) + entry.presence.step(by: elapsed) + } + count.step(by: elapsed) + + for entry in entries where entry.isLeaving && entry.presence.isSettled { + entry.view.removeFromSuperview() + } + entries.removeAll { $0.isLeaving && $0.presence.isSettled } + layoutAvatars() + + if count.isSettled, entries.allSatisfy(\.isSettled) { + stopAvatarMotion() + } + } + + /// Places every avatar and the row's width from the springs' current values, and recuts the + /// rings each avatar shows where the ones above it sit. + private func layoutAvatars() { + CATransaction.begin() + CATransaction.setDisableActions(true) + defer { CATransaction.commit() } + + for entry in entries { + entry.view.bounds = CGRect(x: 0, y: 0, width: Self.avatarSize, height: Self.avatarSize) + entry.view.center = CGPoint(x: entry.slot.value * Self.avatarStep, y: Self.avatarSize / 2) + entry.view.transform = CGAffineTransform(scaleX: entry.scale, y: entry.scale) + entry.view.alpha = entry.presence.value + entry.view.layer.zPosition = entry.z + cutRings(in: entry) + } + + // n avatars are n steps plus one overlap wide; the gap to the bubble grows in with the first + // avatar, so the dots never jump. + let count = max(count.value, 0) + avatarRowWidth.constant = count * Self.avatarStep + (Self.avatarOverlap + Self.avatarGap) * min(count, 1) + contentView.layoutIfNeeded() + } + + /// Masks `entry` with a transparent ring wherever an avatar above it sits, faded by that avatar's + /// presence, so overlapping faces stay apart over any background. Worked in the avatar's unscaled + /// space, which is scaled about its left edge. + private func cutRings(in entry: AvatarEntry) { + let above = entries.filter { $0 !== entry && $0.z > entry.z && $0.presence.value > 0 } + let size = CGSize(width: Self.avatarSize, height: Self.avatarSize) + entry.ring.frame = CGRect(origin: .zero, size: size) + guard !above.isEmpty else { + entry.ring.contents = nil + entry.ring.backgroundColor = UIColor.black.cgColor + return + } + let radius = Self.avatarSize / 2 + let format = UIGraphicsImageRendererFormat.preferred() + format.opaque = false + let image = UIGraphicsImageRenderer(size: size, format: format).image { context in + let cg = context.cgContext + cg.setFillColor(UIColor.black.cgColor) + cg.fill(CGRect(origin: .zero, size: size)) + cg.setBlendMode(.destinationOut) + for other in above { + let centerX = (other.slot.value - entry.slot.value) * Self.avatarStep + radius * other.scale + let cutRadius = (radius * other.scale + Self.avatarRing) / entry.scale + let center = CGPoint(x: centerX / entry.scale, y: radius) + cg.setFillColor(UIColor(white: 0, alpha: min(max(other.presence.value, 0), 1)).cgColor) + cg.fillEllipse(in: CGRect( + x: center.x - cutRadius, + y: center.y - cutRadius, + width: cutRadius * 2, + height: cutRadius * 2 + )) + } + } + entry.ring.backgroundColor = nil + entry.ring.contents = image.cgImage + } + @objc private func restartAnimationIfVisible() { if window != nil { startAnimating() } } @@ -119,6 +371,15 @@ public final class ChatTypingIndicatorCell: UICollectionViewCell { /// Stop the dot wave — the cell is leaving the screen. public func stopAnimating() { dots.forEach { $0.layer.removeAnimation(forKey: "typingWave") } + // Nothing is left to watch the avatars settle, so land them. + if displayLink != nil { + stopAvatarMotion() + entries.filter(\.isLeaving).forEach { $0.view.removeFromSuperview() } + entries.removeAll(where: \.isLeaving) + entries.forEach { $0.settle() } + count.settle() + layoutAvatars() + } } } #endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 52afe1aa9..848d7ca6d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -78,16 +78,23 @@ public final class ChatViewController: UICollectionViewController { /// The widest a bubble may grow, as a share of the collection view's width. private static let maxBubbleWidthFraction: CGFloat = 0.78 - /// Avatar bytes for the transcript's authors, keyed by user id. Empty in a DM, where no row is - /// attributed. The owner fills it as pictures download; rows already on screen pick the new - /// bytes up without a diff, since nothing about the message itself changed. + /// Avatar bytes for the transcript's authors and typists, keyed by user id. Empty in a DM, where + /// no row is attributed. The owner fills it as pictures download; rows already on screen pick the + /// new bytes up without a diff, since nothing about the row itself changed. public var authorAvatars: [UserID: Data] = [:] { didSet { guard authorAvatars != oldValue, isViewLoaded else { return } for cell in collectionView.visibleCells { guard let indexPath = collectionView.indexPath(for: cell), - let message = message(at: indexPath) else { continue } - configure(cell, with: message) + items.indices.contains(indexPath.item) else { continue } + switch items[indexPath.item] { + case .message(let message): + configure(cell, with: message) + case .typingIndicator(let typists): + (cell as? ChatTypingIndicatorCell)?.configure(typists: typists, imageData: authorAvatars) + case .dateSeparator, .unreadDivider, .profileCard, .groupCard: + continue + } } } } @@ -390,8 +397,8 @@ public final class ChatViewController: UICollectionViewController { // there, and a reconfigure can never land on a cell of a different class (UIKit forbids that). let cell = collectionView.dequeueReusableCell(withReuseIdentifier: item.cellReuseIdentifier, for: indexPath) switch item { - case .typingIndicator: - break + case .typingIndicator(let typists): + (cell as! ChatTypingIndicatorCell).configure(typists: typists, imageData: authorAvatars) case .profileCard(let card): let profileTap: (() -> Void)? = onProfileTap == nil ? nil : { [weak self] in self?.onProfileTap?() } (cell as! ChatProfileCardCell).configure(