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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 27 additions & 7 deletions Flipcash/Core/Controllers/ConversationTyping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Void, Never>?

@ObservationIgnored private var isSelfTyping = false
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 }
Expand All @@ -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)",
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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 ([], []) }
Expand All @@ -348,8 +356,8 @@ final class ConversationLoadCoordinator {
var seen: Set<UserID> = []
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 {
Expand Down Expand Up @@ -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?
Expand Down
8 changes: 7 additions & 1 deletion FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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? {
Expand Down
6 changes: 3 additions & 3 deletions FlipcashTests/Chat/ChatChangesetFlatteningTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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())
Expand Down
2 changes: 1 addition & 1 deletion FlipcashTests/Chat/ChatTranscriptDiffFuzzTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ struct ChatTranscriptDiffFuzzTests {
case .separator(let id):
.dateSeparator(id: "sep\(id)", text: "Day \(id)")
case .typing:
.typingIndicator
.typingIndicator(typists: [])
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions FlipcashTests/Chat/ChatViewControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
144 changes: 144 additions & 0 deletions FlipcashTests/Chat/ConversationTypingIndicatorTests.swift
Original file line number Diff line number Diff line change
@@ -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)))
}
}
Loading
Loading