From cb5b7e08bda7e8191d94c22f6424e556cd53fd4d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 15:47:38 -0400 Subject: [PATCH 1/2] feat(chat): show typists' avatars ahead of the group typing indicator In a group chat, the typing bubble now has an overlapping row of the typists' avatars on its leading side, matching Android's TypingIndicator: 40pt circles overlapping by 20pt, a 10pt gap before the bubble, the newest 10 typists, and the earliest typist drawn on top. Avatars fade and scale in and out as people start and stop typing, and the row's width animates. ConversationTyping now records when each typist started, so a STILL heartbeat extends the deadline without moving them in line, and exposes typists(in:) oldest to newest. The coordinator carries them on .typingIndicator(typists:) only for group transcripts; DMs and tip chats keep an empty list and draw the bubble alone. The item's id ignores the typists, so a typist joining reconfigures the row instead of reinserting it. --- .../Controllers/ConversationController.swift | 5 + .../Core/Controllers/ConversationTyping.swift | 34 ++++- .../ConversationLoadCoordinator.swift | 27 ++-- .../FlipcashCore/Models/Chat/ChatItem.swift | 8 +- .../Chat/ChatChangesetFlatteningTests.swift | 6 +- .../Chat/ChatTranscriptDiffFuzzTests.swift | 2 +- .../Chat/ChatViewControllerTests.swift | 4 +- .../ConversationTypingIndicatorTests.swift | 144 ++++++++++++++++++ .../ConversationControllerTests.swift | 42 +++++ .../Chat/ChatAuthorAvatarView.swift | 56 +++++-- .../FlipcashUI/Chat/ChatMotionSandbox.swift | 2 +- .../Chat/ChatTypingIndicatorCell.swift | 138 ++++++++++++++++- .../FlipcashUI/Chat/ChatViewController.swift | 21 ++- 13 files changed, 442 insertions(+), 47 deletions(-) create mode 100644 FlipcashTests/Chat/ConversationTypingIndicatorTests.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 5f18980d7..72c3fc01c 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 a4a313712..44c9a066e 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] = [] @@ -210,7 +210,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, @@ -267,8 +269,13 @@ final class ConversationLoadCoordinator { // here touches the network, and an answer landing cannot re-diff this window. linkCard: { links in classifier.firstCard(in: links) } ) - 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) @@ -283,6 +290,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 ([], []) } @@ -296,8 +304,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 { @@ -337,7 +345,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 e60419ec3..e58fc8b2d 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift @@ -16,7 +16,9 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { /// A centered day + time header, e.g. "Today 12:13 PM". `text` is already formatted. case dateSeparator(id: String, text: String) /// 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 @@ -27,12 +29,16 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { switch self { case .message(let message): message.id case .dateSeparator(let id, _): id + // 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 = 10 + /// 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..2197bff6c --- /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, .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 ten") + func typingRow_overCap_keepsNewestTen() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let typists = (0..<12).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 == 10) + #expect(typingRow(coordinator)?.map(\.id) == Array(typists.suffix(10))) + } +} 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/ChatAuthorAvatarView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatAuthorAvatarView.swift index a636181c2..e18b962a8 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatAuthorAvatarView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatAuthorAvatarView.swift @@ -10,7 +10,8 @@ import UIKit import SwiftUI import FlipcashCore -/// The 24pt circle in the leading gutter of an attributed transcript row, drawn in UIKit. +/// The 24pt circle in the leading gutter of an attributed transcript row, drawn in UIKit. The +/// typing indicator draws larger ones with a person glyph as the fallback. /// /// Deliberately not a `UIHostingController` around ``ContactAvatarView``: the transcript recycles /// its cells, and hosting a SwiftUI view per recycled row costs a view-controller adoption on every @@ -22,14 +23,25 @@ final class ChatAuthorAvatarView: UIView { /// Figma sizes the gutter circle at 24pt (nodes 10125:19169-19184). static let size: CGFloat = 24 + /// What stands in for a picture the author does not have. + enum Fallback { + /// The author's initials, or the bare gradient when the name has none. + case monogram + /// A white person glyph, whatever the name. + case personGlyph + } + + private let fallback: Fallback private let imageView = UIImageView() private let monogram = UILabel() + private let personGlyph = UIImageView(image: UIImage(systemName: "person.fill")) private let gradient = CAGradientLayer() - override init(frame: CGRect) { - super.init(frame: frame) + init(size: CGFloat = ChatAuthorAvatarView.size, fallback: Fallback = .monogram) { + self.fallback = fallback + super.init(frame: .zero) - layer.cornerRadius = Self.size / 2 + layer.cornerRadius = size / 2 layer.masksToBounds = true // The same two stops as `LinearGradient.avatarPlaceholder`, so a monogram in the transcript @@ -43,20 +55,31 @@ final class ChatAuthorAvatarView: UIView { layer.addSublayer(gradient) // Scales the 44pt avatar's 16pt monogram down to this size, the way `ContactAvatarView` does. - monogram.font = .default(size: Self.size * 16 / 44, weight: .bold) + monogram.font = .default(size: size * 16 / 44, weight: .bold) monogram.textColor = UIColor(Color.textMain) monogram.textAlignment = .center monogram.translatesAutoresizingMaskIntoConstraints = false addSubview(monogram) + personGlyph.tintColor = .white + personGlyph.contentMode = .scaleAspectFit + personGlyph.isHidden = true + personGlyph.translatesAutoresizingMaskIntoConstraints = false + addSubview(personGlyph) + imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageView.translatesAutoresizingMaskIntoConstraints = false addSubview(imageView) NSLayoutConstraint.activate([ - widthAnchor.constraint(equalToConstant: Self.size), - heightAnchor.constraint(equalToConstant: Self.size), + widthAnchor.constraint(equalToConstant: size), + heightAnchor.constraint(equalToConstant: size), + // Android pads its Person icon 5dp inside the circle. + personGlyph.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 5), + personGlyph.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -5), + personGlyph.topAnchor.constraint(equalTo: topAnchor, constant: 5), + personGlyph.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5), monogram.centerXAnchor.constraint(equalTo: centerXAnchor), monogram.centerYAnchor.constraint(equalTo: centerYAnchor), imageView.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -88,13 +111,19 @@ final class ChatAuthorAvatarView: UIView { } else { imageView.image = BlurHashCache.shared.image(for: author.blurhash) } - switch ContactAvatarView.monogram(for: author.name) { - case .initials(let text): - monogram.text = text - case .placeholder: - // No letters to work with — the gradient alone stands in, rather than the people glyph - // the full-size avatar draws, which is illegible at 24pt. + switch fallback { + case .monogram: + switch ContactAvatarView.monogram(for: author.name) { + case .initials(let text): + monogram.text = text + case .placeholder: + // No letters to work with — the gradient alone stands in, rather than the people glyph + // the full-size avatar draws, which is illegible at 24pt. + monogram.text = nil + } + case .personGlyph: monogram.text = nil + personGlyph.isHidden = imageView.image != nil } isAccessibilityElement = true accessibilityTraits = .image @@ -105,6 +134,7 @@ final class ChatAuthorAvatarView: UIView { func reset() { imageView.image = nil monogram.text = nil + personGlyph.isHidden = true accessibilityLabel = nil } } 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..861295a37 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,23 @@ public final class ChatTypingIndicatorCell: UICollectionViewCell { private static let dotSize: CGFloat = 7 + // Android's `staticGrid.x8` / `staticGrid.x4` (the fixed 5pt grid) and `grid.x2` on a phone. + private static let avatarSize: CGFloat = 40 + private static let avatarOverlap: CGFloat = 20 + private static let avatarRowGap: CGFloat = 10 + /// Android pads the avatar row 4dp above and below. + private static let avatarRowInset: CGFloat = 4 + private let bubble = BubbleBackgroundView() private let dotsRow = UIStackView() private var dots: [UIView] = [] + private let avatarRow = UIView() + private var avatarRowWidth: NSLayoutConstraint! + private var avatarRowHeight: NSLayoutConstraint! + /// The avatars on screen, oldest typist first — the order they are drawn left to right. + private var avatars: [(id: UserID, view: ChatAuthorAvatarView)] = [] + public override init(frame: CGRect) { super.init(frame: frame) @@ -64,12 +79,26 @@ public final class ChatTypingIndicatorCell: UICollectionViewCell { return dot } + // Overlapping avatars draw past each other's bounds, and one fading out draws past the row's. + avatarRow.clipsToBounds = false + avatarRow.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(avatarRow) + avatarRowWidth = avatarRow.widthAnchor.constraint(equalToConstant: 0) + // The row only claims height while it holds avatars, so a DM's cell stays the bubble's height. + avatarRowHeight = contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 0) + let trailing = bubble.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -12) trailing.priority = .defaultHigh NSLayoutConstraint.activate([ - bubble.topAnchor.constraint(equalTo: contentView.topAnchor), - bubble.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - bubble.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), + avatarRow.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), + avatarRow.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + avatarRow.heightAnchor.constraint(equalToConstant: Self.avatarSize), + avatarRowWidth, + avatarRowHeight, + bubble.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor), + bubble.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor), + bubble.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + 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 +114,109 @@ 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() + avatars.forEach { $0.view.removeFromSuperview() } + avatars = [] + applyRowSize(count: 0) + } + + /// Draws `typists` as overlapping avatars ahead of the dots, oldest first, reading their pictures + /// out of `imageData`. An empty list draws the dots alone. A reconfigure of the same cell animates + /// the avatars that joined or left and the row's width; the first configure after a dequeue + /// snaps, since the row itself is animating in. + public func configure(typists: [ChatAuthor], imageData: [UserID: Data]) { + let animated = window != nil && !avatars.isEmpty + let shown = Array(typists.suffix(ChatItem.maxTypingAvatars)) + let keep = Set(shown.map(\.id)) + + let leaving = avatars.filter { !keep.contains($0.id) } + var existing: [UserID: ChatAuthorAvatarView] = [:] + for avatar in avatars where keep.contains(avatar.id) { + existing[avatar.id] = avatar.view + } + + var joining: [ChatAuthorAvatarView] = [] + avatars = shown.map { author in + let view: ChatAuthorAvatarView + if let kept = existing[author.id] { + view = kept + } else { + view = ChatAuthorAvatarView(size: Self.avatarSize, fallback: .personGlyph) + view.translatesAutoresizingMaskIntoConstraints = true + self.avatarRow.addSubview(view) + joining.append(view) + } + view.configure(with: author, imageData: imageData[author.id]) + return (id: author.id, view: view) + } + + // New avatars start where they will land, so they only fade and grow in place. + for (index, avatar) in avatars.enumerated() { + // The earlier typist sits on top, as Android's `zIndex = count - index` does. + avatar.view.layer.zPosition = CGFloat(avatars.count - index) + if joining.contains(where: { $0 === avatar.view }) { + avatar.view.frame = Self.avatarFrame(at: index) + if animated { + avatar.view.alpha = 0 + avatar.view.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) + } + } + } + + let layout = { + for (index, avatar) in self.avatars.enumerated() { + avatar.view.bounds.size = CGSize(width: Self.avatarSize, height: Self.avatarSize) + avatar.view.center = CGPoint( + x: Self.avatarFrame(at: index).midX, + y: Self.avatarSize / 2 + ) + avatar.view.alpha = 1 + avatar.view.transform = .identity + } + for avatar in leaving { + avatar.view.alpha = 0 + avatar.view.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) + } + self.applyRowSize(count: self.avatars.count) + self.contentView.layoutIfNeeded() + } + + guard animated else { + leaving.forEach { $0.view.removeFromSuperview() } + UIView.performWithoutAnimation(layout) + return + } + UIView.animate( + withDuration: 0.35, + delay: 0, + usingSpringWithDamping: 1, + initialSpringVelocity: 0, + options: [.beginFromCurrentState, .allowUserInteraction], + animations: layout, + completion: { _ in leaving.forEach { $0.view.removeFromSuperview() } } + ) + } + + private static func avatarFrame(at index: Int) -> CGRect { + CGRect( + x: CGFloat(index) * (avatarSize - avatarOverlap), + y: 0, + width: avatarSize, + height: avatarSize + ) + } + + private func applyRowSize(count: Int) { + guard count > 0 else { + avatarRowWidth.constant = 0 + avatarRowHeight.constant = 0 + return + } + avatarRowWidth.constant = Self.avatarSize + CGFloat(count - 1) * (Self.avatarSize - Self.avatarOverlap) + Self.avatarRowGap + avatarRowHeight.constant = Self.avatarSize + 2 * Self.avatarRowInset + } + @objc private func restartAnimationIfVisible() { if window != nil { startAnimating() } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index eea9b1a1a..eacc69e03 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -77,16 +77,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, .profileCard, .groupCard: + continue + } } } } @@ -384,8 +391,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( From 036d0e99256ab54cb3536e145168f2c3487f4150 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 16:29:22 -0400 Subject: [PATCH 2/2] fix(chat): match the typing avatar stack to Android's code-android-app#1544 settled on a different stack from the one the first commit ported. This follows it: - 24pt avatars, the transcript's sender avatar, overlapping by 8pt with a 4pt gap to the bubble. A member without a picture shows initials. - The newest 3 typists, oldest at the back on the left and newest on top at the right. - Each avatar has a 2pt transparent ring cut where the one above it sits. - A newcomer grows from 0.4 scale at its left edge in its final slot. A leaver drops beneath the stack as its neighbours slide over it. The width follows the animated count, so the bubble slides. The slots, presence and count run on a display-link spring at Compose's StiffnessMediumLow with no bounce, since the rings are recut from the live positions every frame and a UIView animation block cannot drive them. ChatAuthorAvatarView goes back to its single 24pt form. --- .../FlipcashCore/Models/Chat/ChatItem.swift | 2 +- .../ConversationTypingIndicatorTests.swift | 10 +- .../Chat/ChatAuthorAvatarView.swift | 56 +--- .../Chat/ChatTypingIndicatorCell.swift | 317 ++++++++++++------ 4 files changed, 242 insertions(+), 143 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift index e58fc8b2d..46df39be0 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift @@ -37,7 +37,7 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { } /// The most typists a group's typing indicator draws; the newest win. - public static let maxTypingAvatars = 10 + 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. diff --git a/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift b/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift index 2197bff6c..bcce680eb 100644 --- a/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift +++ b/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift @@ -121,11 +121,11 @@ struct ConversationTypingIndicatorTests { #expect(coordinator.attributedMembers.isEmpty) } - @Test("A group with more typists than the cap keeps the newest ten") - func typingRow_overCap_keepsNewestTen() async throws { + @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..<12).map { _ in UUID() } + let typists = (0..<5).map { _ in UUID() } let group = conversation(.group, members: typists.enumerated().map { ConversationMember(userID: $0.element, displayName: "Typist \($0.offset)") }) @@ -138,7 +138,7 @@ struct ConversationTypingIndicatorTests { } try await waitUntil { typingRow(coordinator)?.first?.id == typists[2] } - #expect(ChatItem.maxTypingAvatars == 10) - #expect(typingRow(coordinator)?.map(\.id) == Array(typists.suffix(10))) + #expect(ChatItem.maxTypingAvatars == 3) + #expect(typingRow(coordinator)?.map(\.id) == Array(typists.suffix(3))) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatAuthorAvatarView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatAuthorAvatarView.swift index e18b962a8..a636181c2 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatAuthorAvatarView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatAuthorAvatarView.swift @@ -10,8 +10,7 @@ import UIKit import SwiftUI import FlipcashCore -/// The 24pt circle in the leading gutter of an attributed transcript row, drawn in UIKit. The -/// typing indicator draws larger ones with a person glyph as the fallback. +/// The 24pt circle in the leading gutter of an attributed transcript row, drawn in UIKit. /// /// Deliberately not a `UIHostingController` around ``ContactAvatarView``: the transcript recycles /// its cells, and hosting a SwiftUI view per recycled row costs a view-controller adoption on every @@ -23,25 +22,14 @@ final class ChatAuthorAvatarView: UIView { /// Figma sizes the gutter circle at 24pt (nodes 10125:19169-19184). static let size: CGFloat = 24 - /// What stands in for a picture the author does not have. - enum Fallback { - /// The author's initials, or the bare gradient when the name has none. - case monogram - /// A white person glyph, whatever the name. - case personGlyph - } - - private let fallback: Fallback private let imageView = UIImageView() private let monogram = UILabel() - private let personGlyph = UIImageView(image: UIImage(systemName: "person.fill")) private let gradient = CAGradientLayer() - init(size: CGFloat = ChatAuthorAvatarView.size, fallback: Fallback = .monogram) { - self.fallback = fallback - super.init(frame: .zero) + override init(frame: CGRect) { + super.init(frame: frame) - layer.cornerRadius = size / 2 + layer.cornerRadius = Self.size / 2 layer.masksToBounds = true // The same two stops as `LinearGradient.avatarPlaceholder`, so a monogram in the transcript @@ -55,31 +43,20 @@ final class ChatAuthorAvatarView: UIView { layer.addSublayer(gradient) // Scales the 44pt avatar's 16pt monogram down to this size, the way `ContactAvatarView` does. - monogram.font = .default(size: size * 16 / 44, weight: .bold) + monogram.font = .default(size: Self.size * 16 / 44, weight: .bold) monogram.textColor = UIColor(Color.textMain) monogram.textAlignment = .center monogram.translatesAutoresizingMaskIntoConstraints = false addSubview(monogram) - personGlyph.tintColor = .white - personGlyph.contentMode = .scaleAspectFit - personGlyph.isHidden = true - personGlyph.translatesAutoresizingMaskIntoConstraints = false - addSubview(personGlyph) - imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageView.translatesAutoresizingMaskIntoConstraints = false addSubview(imageView) NSLayoutConstraint.activate([ - widthAnchor.constraint(equalToConstant: size), - heightAnchor.constraint(equalToConstant: size), - // Android pads its Person icon 5dp inside the circle. - personGlyph.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 5), - personGlyph.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -5), - personGlyph.topAnchor.constraint(equalTo: topAnchor, constant: 5), - personGlyph.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5), + widthAnchor.constraint(equalToConstant: Self.size), + heightAnchor.constraint(equalToConstant: Self.size), monogram.centerXAnchor.constraint(equalTo: centerXAnchor), monogram.centerYAnchor.constraint(equalTo: centerYAnchor), imageView.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -111,19 +88,13 @@ final class ChatAuthorAvatarView: UIView { } else { imageView.image = BlurHashCache.shared.image(for: author.blurhash) } - switch fallback { - case .monogram: - switch ContactAvatarView.monogram(for: author.name) { - case .initials(let text): - monogram.text = text - case .placeholder: - // No letters to work with — the gradient alone stands in, rather than the people glyph - // the full-size avatar draws, which is illegible at 24pt. - monogram.text = nil - } - case .personGlyph: + switch ContactAvatarView.monogram(for: author.name) { + case .initials(let text): + monogram.text = text + case .placeholder: + // No letters to work with — the gradient alone stands in, rather than the people glyph + // the full-size avatar draws, which is illegible at 24pt. monogram.text = nil - personGlyph.isHidden = imageView.image != nil } isAccessibilityElement = true accessibilityTraits = .image @@ -134,7 +105,6 @@ final class ChatAuthorAvatarView: UIView { func reset() { imageView.image = nil monogram.text = nil - personGlyph.isHidden = true accessibilityLabel = nil } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift index 861295a37..6e54579cc 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatTypingIndicatorCell.swift @@ -35,12 +35,15 @@ public final class ChatTypingIndicatorCell: UICollectionViewCell { private static let dotSize: CGFloat = 7 - // Android's `staticGrid.x8` / `staticGrid.x4` (the fixed 5pt grid) and `grid.x2` on a phone. - private static let avatarSize: CGFloat = 40 - private static let avatarOverlap: CGFloat = 20 - private static let avatarRowGap: CGFloat = 10 - /// Android pads the avatar row 4dp above and below. - private static let avatarRowInset: CGFloat = 4 + // 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() @@ -48,9 +51,14 @@ public final class ChatTypingIndicatorCell: UICollectionViewCell { private let avatarRow = UIView() private var avatarRowWidth: NSLayoutConstraint! - private var avatarRowHeight: NSLayoutConstraint! - /// The avatars on screen, oldest typist first — the order they are drawn left to right. - private var avatars: [(id: UserID, view: ChatAuthorAvatarView)] = [] + /// 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) @@ -79,13 +87,11 @@ public final class ChatTypingIndicatorCell: UICollectionViewCell { return dot } - // Overlapping avatars draw past each other's bounds, and one fading out draws past the row's. + // 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) - // The row only claims height while it holds avatars, so a DM's cell stays the bubble's height. - avatarRowHeight = contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 0) let trailing = bubble.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -12) trailing.priority = .defaultHigh @@ -94,10 +100,8 @@ public final class ChatTypingIndicatorCell: UICollectionViewCell { avatarRow.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), avatarRow.heightAnchor.constraint(equalToConstant: Self.avatarSize), avatarRowWidth, - avatarRowHeight, - bubble.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor), - bubble.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor), - bubble.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + bubble.topAnchor.constraint(equalTo: contentView.topAnchor), + bubble.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), 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. @@ -116,105 +120,221 @@ public final class ChatTypingIndicatorCell: UICollectionViewCell { public override func prepareForReuse() { super.prepareForReuse() - avatars.forEach { $0.view.removeFromSuperview() } - avatars = [] - applyRowSize(count: 0) + stopAvatarMotion() + entries.forEach { $0.view.removeFromSuperview() } + entries = [] + count = Spring(0) + hasConfigured = false + layoutAvatars() } - /// Draws `typists` as overlapping avatars ahead of the dots, oldest first, reading their pictures - /// out of `imageData`. An empty list draws the dots alone. A reconfigure of the same cell animates - /// the avatars that joined or left and the row's width; the first configure after a dequeue - /// snaps, since the row itself is animating in. + /// 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 = window != nil && !avatars.isEmpty + let animated = hasConfigured && window != nil && !UIAccessibility.isReduceMotionEnabled + hasConfigured = true let shown = Array(typists.suffix(ChatItem.maxTypingAvatars)) - let keep = Set(shown.map(\.id)) + let shownIDs = Set(shown.map(\.id)) - let leaving = avatars.filter { !keep.contains($0.id) } - var existing: [UserID: ChatAuthorAvatarView] = [:] - for avatar in avatars where keep.contains(avatar.id) { - existing[avatar.id] = avatar.view - } - - var joining: [ChatAuthorAvatarView] = [] - avatars = shown.map { author in - let view: ChatAuthorAvatarView - if let kept = existing[author.id] { - view = kept + 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 { - view = ChatAuthorAvatarView(size: Self.avatarSize, fallback: .personGlyph) - view.translatesAutoresizingMaskIntoConstraints = true - self.avatarRow.addSubview(view) - joining.append(view) + 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) } - view.configure(with: author, imageData: imageData[author.id]) - return (id: author.id, view: view) + 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) - // New avatars start where they will land, so they only fade and grow in place. - for (index, avatar) in avatars.enumerated() { - // The earlier typist sits on top, as Android's `zIndex = count - index` does. - avatar.view.layer.zPosition = CGFloat(avatars.count - index) - if joining.contains(where: { $0 === avatar.view }) { - avatar.view.frame = Self.avatarFrame(at: index) - if animated { - avatar.view.alpha = 0 - avatar.view.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) - } - } + if animated { + startAvatarMotion() + } else { + stopAvatarMotion() + entries.forEach { $0.settle() } + count.settle() + layoutAvatars() } + } - let layout = { - for (index, avatar) in self.avatars.enumerated() { - avatar.view.bounds.size = CGSize(width: Self.avatarSize, height: Self.avatarSize) - avatar.view.center = CGPoint( - x: Self.avatarFrame(at: index).midX, - y: Self.avatarSize / 2 - ) - avatar.view.alpha = 1 - avatar.view.transform = .identity - } - for avatar in leaving { - avatar.view.alpha = 0 - avatar.view.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) + // 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 } - self.applyRowSize(count: self.avatars.count) - self.contentView.layoutIfNeeded() + if isSettled { settle() } } + } - guard animated else { - leaving.forEach { $0.view.removeFromSuperview() } - UIView.performWithoutAnimation(layout) - return + 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() } - UIView.animate( - withDuration: 0.35, - delay: 0, - usingSpringWithDamping: 1, - initialSpringVelocity: 0, - options: [.beginFromCurrentState, .allowUserInteraction], - animations: layout, - completion: { _ in leaving.forEach { $0.view.removeFromSuperview() } } - ) } - private static func avatarFrame(at index: Int) -> CGRect { - CGRect( - x: CGFloat(index) * (avatarSize - avatarOverlap), - y: 0, - width: avatarSize, - height: avatarSize - ) + /// 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() } - private func applyRowSize(count: Int) { - guard count > 0 else { - avatarRowWidth.constant = 0 - avatarRowHeight.constant = 0 + /// 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 } - avatarRowWidth.constant = Self.avatarSize + CGFloat(count - 1) * (Self.avatarSize - Self.avatarOverlap) + Self.avatarRowGap - avatarRowHeight.constant = Self.avatarSize + 2 * Self.avatarRowInset + 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() { @@ -251,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