diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index d406e9805..85d10e250 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -139,7 +139,7 @@ final class ConversationController { /// A marked chat with nothing unread is the no-count unread state; a known count wins over the mark. private func chatListUnread(for conversation: Conversation, isMarked: Bool) -> ChatListUnread { - guard conversation.hasUnread(for: selfUserID) else { return isMarked ? .unknown : .count(0) } + guard hasUnreadMessages(conversation) else { return isMarked ? .unknown : .count(0) } return unreadCount(for: conversation).map(ChatListUnread.count) ?? .unknown } @@ -197,7 +197,7 @@ final class ConversationController { /// Unread messages or a mark: what decides between Mark as Read and Mark as Unread. A pending /// join request doesn't count; it is not something reading the chat clears. func isUnread(_ conversation: Conversation) -> Bool { - conversation.hasUnread(for: selfUserID) || isMarkedUnread(conversation.id) + hasUnreadMessages(conversation) || isMarkedUnread(conversation.id) } /// The read action a chat's swipe, menu and selection offer; nil for a chat the user hasn't @@ -232,7 +232,7 @@ final class ConversationController { func markRead(_ id: ConversationID, source: ChatReadSource) { chatMarkedUnread?.clear(id) guard let conversation = conversation(withID: id), - conversation.hasUnread(for: selfUserID), + hasUnreadMessages(conversation), let newest = conversation.lastMessage?.id else { return } advanceReadPointer(to: newest, in: id) @@ -287,16 +287,48 @@ final class ConversationController { chatMarkedUnread?.clear(id) } + /// Whether `conversation` holds a message that is unread for the viewer. A share payment is + /// unread only for the member it paid, so one to someone else is looked past. + func hasUnreadMessages(_ conversation: Conversation) -> Bool { + conversation.hasUnread(for: selfUserID, countedThrough: newestCountingAsUnread(in: conversation)) + } + /// The unread count a conversation row shows; nil when the chat is unread but its READ /// watermark's message is neither stored nor fetched yet. See - /// ``Conversation/unreadCount(for:unreadSeqAt:)`` and ``resolveUnreadCount(for:)``. + /// ``Conversation/unreadCount(for:countedThrough:uncounted:unreadSeqAt:)`` and + /// ``resolveUnreadCount(for:)``. func unreadCount(for conversation: Conversation) -> Int? { - conversation.unreadCount(for: selfUserID) { pointer in + let newest = newestCountingAsUnread(in: conversation) + return conversation.unreadCount( + for: selfUserID, + countedThrough: newest, + uncounted: sharePaymentsToOthers(in: conversation, through: newest) + ) { pointer in storedUnreadSeq(of: pointer, in: conversation.id) ?? watermarkStamps.stamp(for: .init(conversationID: conversation.id, messageID: pointer)) } } + /// The newest message that can be unread for the viewer: the chat's latest, or the newest + /// stored one under it when the latest is a share payment to someone else. + private func newestCountingAsUnread(in conversation: Conversation) -> ConversationMessage? { + guard let latest = conversation.lastMessage else { return nil } + if latest.countsAsUnread(for: selfUserID) { return latest } + _ = messageRevision // observe: the message under the payment can land after the row draws + return (try? database.newestMessageCountingAsUnread(conversationID: conversation.id, for: selfUserID)) ?? nil + } + + /// The stored share payments to someone else between the viewer's READ watermark and `newest`. + /// Only a group holds any, so a DM skips the read. + private func sharePaymentsToOthers(in conversation: Conversation, through newest: ConversationMessage?) -> Int { + guard conversation.type == .group, let newest else { return 0 } + _ = messageRevision + let after = conversation.selfReadPointer(for: selfUserID) ?? MessageID(value: 0) + return (try? database.sharePaymentsToOthersCount( + conversationID: conversation.id, after: after, through: newest.id, for: selfUserID + )) ?? 0 + } + /// Fetches the READ watermark's message when ``unreadCount(for:)`` can't be known without it, /// so the row's count fills in. A no-op for a chat whose count is already known. func resolveUnreadCount(for conversation: Conversation) async { @@ -1300,7 +1332,7 @@ final class ConversationController { /// ``FeedReconcileTiming/unreadCap``; the lookups keep going and fill their rows in as they land. private func resolveUnreadCounts(in conversations: [Conversation]) async { unresolvedUnreadQueue = conversations - .filter { $0.hasUnread(for: selfUserID) && unreadCount(for: $0) == nil } + .filter { hasUnreadMessages($0) && unreadCount(for: $0) == nil } guard !unresolvedUnreadQueue.isEmpty else { return } watermarkStamps.hold() let wait = BoundedWait(expecting: unresolvedUnreadQueue.count) @@ -1991,6 +2023,19 @@ final class ConversationController { guard let senderName else { return text } return "\(senderName): \(text)" + case .cash(let amount) where message.isSharePayment: + // A share of a request: who paid whom, with the request's title when it can be read. + let paid = viewerAmount(amount).formattedDroppingZeroFraction() + let subject = message.paymentRequestID.flatMap(requestTitle).map { " for \($0)" } ?? "" + if isFromSelf { return "You paid \(paid)\(subject)" } + let payer = message.senderID.flatMap { noticeUserName(for: $0, in: conversation, knownAuthors: knownAuthors) } ?? "Someone" + // No receiver means no creator to check the request against, so no title either. + guard let receiver = message.cashReceiverID else { return "\(payer) paid \(paid)" } + let creator = receiver == selfUserID + ? "you" + : noticeUserName(for: receiver, in: conversation, knownAuthors: knownAuthors) ?? "Someone" + return "\(payer) paid \(creator) \(paid)\(subject)" + case .cash(let amount): let formatted = viewerAmount(amount).formatted() // The reserve would read "$1.00 of Dollars" — the amount alone already says it. diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 0ac3b73a0..7628cf7a4 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -113,6 +113,10 @@ extension ChatItem { paymentRequest: (ConversationMessage) -> PaymentRequestCardState = { _ in .loading }, /// The Send Back row under a cash message; nil draws none. See ``SendBackRow/resolve``. sendBack: (ConversationMessage) -> ChatCashContent.SendBack? = { _ in nil }, + /// The chat's stored, readable payment requests, resolved by the caller from the request + /// store. They title a share payment's row and are the source of the marked-paid and + /// closed rows, which have no message behind them. + requests: [PaymentRequestID: RequestTimeline] = [:], /// Where the viewer's unread messages began at open; the divider heads the first message /// after it that someone else sent. unreadBoundary: UnreadBoundary = .none, @@ -202,9 +206,14 @@ extension ChatItem { func isSystem(_ message: ConversationMessage) -> Bool { if case .system = message.content { true } else { false } } + // A share payment draws as a centred line too, with no sender to group a bubble under. + func isNotice(_ message: ConversationMessage) -> Bool { + isSystem(message) || message.isSharePayment + } let dividerHeadID: String? = { var older: ConversationMessage? - for message in messages where !isSystem(message) { + // A share payment paid to someone else is never unread, so the divider passes it too. + for message in messages where !isSystem(message) && message.countsAsUnread(for: selfUserID) { if let older, unreadBoundary.dividerBetween(newer: message, older: older, selfUserID: selfUserID) { return message.stableID } @@ -261,14 +270,50 @@ extension ChatItem { } } + // The marked-paid and closed rows, each placed after the last message at or before its + // time. One older than the oldest loaded message waits for paging to reach it. + var storedAfter: [Int: [RequestOccurrence]] = [:] + if let oldest = messages.first?.date { + for occurrence in RequestOccurrence.stored(in: requests) where occurrence.date >= oldest { + guard let index = messages.lastIndex(where: { $0.date <= occurrence.date }) else { continue } + storedAfter[index, default: []].append(occurrence) + } + } + // What each request row was built from, so a run of them can collapse once every row is placed. + var occurrences: [String: RequestOccurrence] = [:] + // The date of the row drawn last. It is not always the previous message's: a stored row + // sits between two messages at a time of its own. + var lastDrawn: Date? + func separates(_ date: Date, fromDrawn earlier: Date) -> Bool { + date.timeIntervalSince(earlier) > gap || !calendar.isDate(date, inSameDayAs: earlier) + } + var items: [ChatItem] = [] + func appendRequestRow(_ occurrence: RequestOccurrence, showsSeparator: Bool) { + guard let row = RequestOccurrence.row(for: [occurrence], request: requests[occurrence.requestID], selfUserID: selfUserID, nameOf: nameOf) else { return } + if showsSeparator { + items.append(.dateSeparator(id: "sep-\(row.id)", text: occurrence.date.formattedChatSeparator())) + } + occurrences[row.id] = occurrence + items.append(.requestEvent(row)) + lastDrawn = occurrence.date + } for (index, message) in messages.enumerated() { let isFromSelf = message.isFromSelf(selfUserID) let previous = index > 0 ? messages[index - 1] : nil let next = index + 1 < messages.count ? messages[index + 1] : nil + // Rows with no message behind them, drawn between this message and its neighbours. + let followsStored = index > 0 && storedAfter[index - 1] != nil + let stored = storedAfter[index] ?? [] + defer { + lastDrawn = message.date + for occurrence in stored { + appendRequestRow(occurrence, showsSeparator: lastDrawn.map { separates(occurrence.date, fromDrawn: $0) } ?? true) + } + } // A separator opens the transcript and breaks any run longer than the gap. - let showsSeparator = previous.map { separates(message, from: $0) } ?? true + let showsSeparator = lastDrawn.map { separates(message.date, fromDrawn: $0) } ?? true let showsMarker = index == markerIndex if showsMarker { items.append(.encryptionMarker) @@ -292,11 +337,12 @@ extension ChatItem { // separator is already the heading of what follows it, so a second, shorter threshold // would flatten runs the transcript still draws as continuous. let groupedAbove = previous.map { - $0.senderID == message.senderID && !isSystem($0) && !showsSeparator && !showsDivider && !showsMarker + $0.senderID == message.senderID && !isNotice($0) && !showsSeparator && !showsDivider && !showsMarker + && !followsStored } ?? false let groupedBelow = next.map { - $0.senderID == message.senderID && !isSystem($0) && !separates($0, from: message) && !divides($0, from: message) - && index + 1 != markerIndex + $0.senderID == message.senderID && !isNotice($0) && !separates($0, from: message) && !divides($0, from: message) + && index + 1 != markerIndex && stored.isEmpty } ?? false // The bubble run, which is not the author run. A bubble stacked above a bare emoji would @@ -324,6 +370,21 @@ extension ChatItem { continue case .text(let text): content = .text(text) + case .cash(let fiat) where message.isSharePayment: + guard let requestID = message.paymentRequestID else { continue } + // The separator above already went in with the loop's own; the row adds none. + appendRequestRow( + RequestOccurrence( + kind: .sharePayment(message: message, amount: viewerAmount(fiat)), + requestID: requestID, + // The payment names who it paid, so the row reads right before the request loads. + creator: message.cashReceiverID ?? requests[requestID]?.request.creator, + member: message.senderID, + date: message.date + ), + showsSeparator: false + ) + continue case .cash(let fiat): let branding = cashBranding(fiat) let isTip = message.cashAction == .tipped @@ -524,7 +585,45 @@ extension ChatItem { ))) } } - return items + return collapsingRequestRows(items, occurrences: occurrences, requests: requests, selfUserID: selfUserID, nameOf: nameOf) + } + + /// Joins request rows that sit directly under one another, for one request and of one kind, + /// into a single row. Anything drawn between two of them, a date header included, keeps them apart. + nonisolated private static func collapsingRequestRows( + _ items: [ChatItem], + occurrences: [String: RequestOccurrence], + requests: [PaymentRequestID: RequestTimeline], + selfUserID: UserID, + nameOf: (UserID) -> String? + ) -> [ChatItem] { + guard !occurrences.isEmpty else { return items } + var collapsed: [ChatItem] = [] + var run: [RequestOccurrence] = [] + func flush() { + guard let first = run.first else { return } + if let row = RequestOccurrence.row(for: run, request: requests[first.requestID], selfUserID: selfUserID, nameOf: nameOf) { + collapsed.append(.requestEvent(row)) + } + run = [] + } + for item in items { + switch item { + case .requestEvent(let event): + guard let occurrence = occurrences[event.id] else { + flush() + collapsed.append(item) + continue + } + if let last = run.last, !last.collapses(with: occurrence) { flush() } + run.append(occurrence) + case .message, .dateSeparator, .unreadDivider, .typingIndicator, .encryptionMarker, .systemMessage, .inviteCard: + flush() + collapsed.append(item) + } + } + flush() + return collapsed } /// The pin bar's quote of a pinned `message`: the same author, snippet and kind a reply's quote diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+RequestEvents.swift b/Flipcash/Core/Screens/Conversation/ChatItem+RequestEvents.swift new file mode 100644 index 000000000..67e78981a --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatItem+RequestEvents.swift @@ -0,0 +1,193 @@ +// +// ChatItem+RequestEvents.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// A stored payment request a transcript row can name: the request and its title as the viewer +/// reads it. +nonisolated struct RequestTimeline: Hashable, Sendable { + var request: GroupPaymentRequest + var title: String +} + +/// One thing that happened to a request, before a run of them is collapsed into a row. +nonisolated struct RequestOccurrence { + + enum Kind: Hashable { + /// A share paid in Flipcash, drawn from `message`. + case sharePayment(message: ConversationMessage, amount: FiatAmount) + /// A share the creator marked paid. + case markedPaid + /// The request closed by its creator. + case closed + } + + var kind: Kind + var requestID: PaymentRequestID + /// The request's creator: who was paid, who marked, who closed. + var creator: UserID? + /// Who paid, or whose share was marked; nil for a close. + var member: UserID? + var date: Date + + /// The id of the row a run starting at this occurrence draws as. + var rowID: String { + switch kind { + case .sharePayment(let message, _): "req-paid-\(message.stableID)" + case .markedPaid: "req-marked-\(requestID.data.hexString())-\(member?.uuidString ?? "")" + case .closed: "req-closed-\(requestID.data.hexString())" + } + } + + /// Whether `other`, drawn directly below, joins this one's row. + func collapses(with other: RequestOccurrence) -> Bool { + guard requestID == other.requestID else { return false } + switch (kind, other.kind) { + case (.sharePayment, .sharePayment), (.markedPaid, .markedPaid): return true + case (.sharePayment, _), (.markedPaid, _), (.closed, _): return false + } + } +} + +nonisolated extension RequestOccurrence { + + /// The marked-paid and closed occurrences `requests` hold, oldest first. Neither has a message + /// behind it: a mark is a share settled by hand at its `paidAt`, a close a `closed` status at + /// `closedAt`. + static func stored(in requests: [PaymentRequestID: RequestTimeline]) -> [RequestOccurrence] { + var occurrences: [RequestOccurrence] = [] + for timeline in requests.values { + let request = timeline.request + for share in request.split?.shares ?? [] { + guard let paidAt = share.paidAt, let userID = share.userID else { continue } + switch share.settlement { + case .manual: + occurrences.append(RequestOccurrence(kind: .markedPaid, requestID: request.id, creator: request.creator, member: userID, date: paidAt)) + case .intent, .creator, .unknown, nil: + continue + } + } + switch request.status { + case .closed: + if let closedAt = request.closedAt { + occurrences.append(RequestOccurrence(kind: .closed, requestID: request.id, creator: request.creator, member: nil, date: closedAt)) + } + case .open, .completed, .expired, .unknown: + break + } + } + // Ties are ordered by id so a remap of the same store draws the same rows. + return occurrences.sorted { ($0.date, $0.rowID) < ($1.date, $1.rowID) } + } + + /// The row a run of occurrences that collapse together draws as. `request` is the run's request + /// when it is stored and readable; without it a share payment draws no title or ring and does + /// not tap through. + static func row( + for run: [RequestOccurrence], + request: RequestTimeline?, + selfUserID: UserID, + nameOf: (UserID) -> String? + ) -> ChatRequestEvent? { + guard let first = run.first, let last = run.last else { return nil } + // "You" is semibold only on a single share payment's row, where it stands beside a name. + func name(_ id: UserID?, emphasizingViewer: Bool = false) -> ChatRequestEvent.Span { + if id == selfUserID { return emphasizingViewer ? .viewer("you") : ChatRequestEvent.Span("you") } + return .name(id.flatMap(nameOf).flatMap { $0.isEmpty ? nil : $0 } ?? "Someone") + } + func leading(_ id: UserID?) -> ChatRequestEvent.Span { + id == selfUserID ? ChatRequestEvent.Span("You") : name(id) + } + let members = run.map(\.member) + let viewerIsMember = members.contains(selfUserID) + func others(_ count: Int) -> String { "\(count) \(count == 1 ? "other" : "others")" } + typealias Span = ChatRequestEvent.Span + + switch first.kind { + case .sharePayment: + var messages: [ConversationMessage] = [] + var total: FiatAmount? + for occurrence in run { + guard case .sharePayment(let message, let amount) = occurrence.kind else { continue } + messages.append(message) + // One viewer currency per transcript, so the run's amounts add. + total = total.map { $0.currency == amount.currency ? $0 + amount : $0 } ?? amount + } + let caption: [Span] + if run.count == 1 { + caption = if first.member == selfUserID { + [.viewer("You"), Span(" paid")] + } else if first.creator == nil { + // The message names no receiver and the request isn't stored to say who. + [name(first.member), Span(" paid")] + } else { + [name(first.member), Span(" paid "), name(first.creator, emphasizingViewer: true)] + } + } else if viewerIsMember { + caption = [Span("You and \(others(run.count - 1)) paid")] + } else if first.creator == selfUserID { + caption = [Span("\(run.count) people paid you")] + } else { + caption = [Span("\(run.count) people paid")] + } + // The newest confirmed payment is what the READ pointer moves past. + let serverID = messages.last { $0.status == .sent }?.id + return ChatRequestEvent( + id: first.rowID, + kind: .sharePayment, + caption: caption, + amount: total?.formattedDroppingZeroFraction(), + title: request?.title, + ringFraction: request.map { $0.request.paidFraction(asOfShareOf: last.member, fallback: last.date) }, + requestID: request.map { $0.request.id.data }, + serverID: serverID, + messageIDs: messages.map(\.stableID) + ) + + case .markedPaid: + guard let request else { return nil } + let caption: [Span] + if run.count == 1 { + caption = [leading(first.creator), Span(" marked "), name(first.member), Span(" as paid")] + } else if viewerIsMember { + caption = [leading(first.creator), Span(" marked "), Span("you and \(others(run.count - 1)) as paid")] + } else { + caption = [leading(first.creator), Span(" marked \(run.count) people as paid")] + } + return ChatRequestEvent( + id: first.rowID, + kind: .markedPaid, + caption: caption, + title: request.title, + ringFraction: request.request.paidFraction(asOf: last.date), + requestID: request.request.id.data + ) + + case .closed: + guard let request else { return nil } + return ChatRequestEvent( + id: first.rowID, + kind: .closed, + caption: [leading(first.creator), Span(" closed")], + title: request.title, + requestID: request.request.id.data + ) + } + } +} + +nonisolated extension GroupPaymentRequest { + + /// The paid fraction as of `payer`'s payment: every share paid at or before their share's + /// `paidAt`. A copy that doesn't show the share paid yet counts it at `fallback`, the payment + /// message's date, so the row never draws a ring short of its own payment. + func paidFraction(asOfShareOf payer: UserID?, fallback: Date) -> Double { + let paidAt = payer.flatMap { share(for: $0) }?.paidAt + return paidFraction(asOf: paidAt ?? fallback, counting: payer) + } +} diff --git a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift index 4b2b4e51c..c0eb27a5a 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift @@ -328,12 +328,18 @@ final class ConversationLoadCoordinator { } // Read inside the observation scope, so a fetched or refetched request re-maps its row. var paymentRequestCards: [UInt64: PaymentRequestCardState] = [:] + var requestTimelines: [PaymentRequestID: RequestTimeline] = [:] if let paymentRequests { + requestTimelines = paymentRequests.timelines(in: conversationID) let copy = PaymentRequestCopy(session.displayCurrency) for message in window { - if let id = message.paymentRequestID, message.date > openedAt, - refetchedPayments.insert(message.id.value).inserted { - Task { _ = try? await paymentRequests.refresh(id) } + if let id = message.paymentRequestID { + if message.date > openedAt, refetchedPayments.insert(message.id.value).inserted { + Task { _ = try? await paymentRequests.refresh(id) } + } else if paymentRequests.request(id) == nil { + // An older share payment's row needs its request for the title and ring. + Task { await paymentRequests.fetchIfMissing(id) } + } } guard case .widget(.paymentRequest(let id)) = message.content else { continue } let state = paymentRequests.cardState(for: id, in: conversationID, sentBy: message.senderID, copy: copy, now: capabilityClock) @@ -385,6 +391,7 @@ final class ConversationLoadCoordinator { headsHistory: headsHistory, unreadBoundary: unreadBoundary(in: window), paymentRequestCards: paymentRequestCards, + requests: requestTimelines, sendBackRows: sendBackRows, // Read inside the observation scope, so a pin or unpin re-maps the menus. pinnedIDs: controller.pins.pinnedIDs(in: conversationID) @@ -455,6 +462,7 @@ final class ConversationLoadCoordinator { linkCard: { links in classifier.firstCard(in: links) }, paymentRequest: { inputs.paymentRequestCards[$0.id.value] ?? .loading }, sendBack: { inputs.sendBackRows[$0.id.value] }, + requests: inputs.requests, unreadBoundary: inputs.unreadBoundary, headsHistory: inputs.headsHistory ) @@ -572,6 +580,9 @@ final class ConversationLoadCoordinator { var unreadBoundary: UnreadBoundary /// Each request widget's card in the window, keyed by raw message id. var paymentRequestCards: [UInt64: PaymentRequestCardState] = [:] + /// The chat's stored, readable requests, which title share payment rows and are the + /// source of the marked-paid and closed rows. + var requests: [PaymentRequestID: RequestTimeline] = [:] /// Each cash message's Send Back row, keyed by raw message id; absent draws none. var sendBackRows: [UInt64: ChatCashContent.SendBack] = [:] /// The chat's pinned messages, which decide Pin or Unpin on each row's menu. diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 0d2ff29ba..68082db42 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -487,7 +487,7 @@ struct TipConversationRow: View { let counterpart = conversation.counterpart(excluding: conversationController.selfUserID) let title = conversationController.displayName(for: conversation) let joinRequests = ChatRowJoinRequests(conversationController.joinRequests(for: conversation.id)) - let hasUnreadMessages = conversation.hasUnread(for: conversationController.selfUserID) + let hasUnreadMessages = conversationController.hasUnreadMessages(conversation) let unreadMessageCount = hasUnreadMessages ? conversationController.unreadCount(for: conversation) : nil let subtitle = joinRequests.subtitle(preview: self.subtitle, hasUnreadMessages: hasUnreadMessages) // Marked unread with nothing unread draws the no-count state; a known count still wins. @@ -549,8 +549,10 @@ struct TipConversationRow: View { } private var lastRequestID: PaymentRequestID? { - guard case .widget(.paymentRequest(let id)) = conversation.lastMessage?.content else { return nil } - return id + guard let message = conversation.lastMessage else { return nil } + if case .widget(.paymentRequest(let id)) = message.content { return id } + // A share payment's preview names its request too. + return message.paymentRequestID } /// The preview line: the last message, or an italic placeholder for a chat with no message diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift index 47bbfdab6..c286c68ce 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift @@ -154,6 +154,17 @@ final class PaymentRequests { requests[id] } + /// `conversationID`'s stored requests whose title the viewer can read, keyed by id: the ones a + /// transcript row may name. + func timelines(in conversationID: ConversationID) -> [PaymentRequestID: RequestTimeline] { + var timelines: [PaymentRequestID: RequestTimeline] = [:] + for request in requests.values where request.chatID == conversationID && !unavailable.contains(request.id) { + guard let title = text(of: request)?.title else { continue } + timelines[request.id] = RequestTimeline(request: request, title: title) + } + return timelines + } + /// `conversationID`'s requests the deck shows to `viewer` as of `now`, in deck order. func deck(for conversationID: ConversationID, viewer: UserID?, at now: Date) -> [GroupPaymentRequest] { GroupPaymentRequest.deckOrder(requests.values.filter { $0.chatID == conversationID }, viewer: viewer, at: now) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift index cbdd733a0..715bc8871 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift @@ -29,6 +29,8 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { /// fallback text, shown unmodified. `serverID` is the notice's message id, which the READ pointer /// moves past like any inbound message's. `userLink` is the name in `text` that opens a profile. case systemMessage(id: String, text: String, serverID: MessageID?, userLink: SystemNoticeUserLink? = nil) + /// A payment request's share paid, share marked paid, or close, drawn as a centred line. + case requestEvent(ChatRequestEvent) /// The "Your Group Is Ready" card at the head of a group the viewer is alone in. Client-only: it /// is never a message, so nothing stores or syncs it. case inviteCard(groupName: String) @@ -46,6 +48,7 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { case .typingIndicator: "typing-indicator" case .encryptionMarker: "encryption-marker" case .systemMessage(let id, _, _, _): id + case .requestEvent(let event): event.id // Independent of `isLeaving`, so the exit reconfigures the row in place. case .inviteCard: "invite-card" } @@ -59,10 +62,21 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { public var messageID: String? { switch self { case .message(let message): message.messageID + case .requestEvent(let event): event.messageIDs.first case .dateSeparator, .unreadDivider, .typingIndicator, .encryptionMarker, .systemMessage, .inviteCard: nil } } + /// Whether this row draws the message with stable id `messageID`. A collapsed run of share + /// payments draws several, so a reply quote or a jump to any of them lands on the one row. + public func draws(messageID: String) -> Bool { + switch self { + case .requestEvent(let event): event.messageIDs.contains(messageID) + case .message, .dateSeparator, .unreadDivider, .typingIndicator, .encryptionMarker, .systemMessage, .inviteCard: + self.messageID == messageID + } + } + /// The unread divider's label: "1 Unread Message", "12 Unread Messages", and "99+ Unread /// Messages" past 99, as Android shows it. public static func unreadDividerText(count: Int) -> String { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatRequestEvent.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatRequestEvent.swift new file mode 100644 index 000000000..818f109ad --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatRequestEvent.swift @@ -0,0 +1,183 @@ +// +// ChatRequestEvent.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// Something that happened to a payment request, drawn as one centred line in the transcript: a +/// share paid in Flipcash, a share the creator marked paid, or the request closed. +public struct ChatRequestEvent: Hashable, Sendable, Codable { + + public enum Kind: String, Hashable, Sendable, Codable { + /// One or more members paid their share; backed by `.cash` messages. + case sharePayment + /// The creator marked one or more shares paid; built from the stored request. + case markedPaid + /// The creator closed the request; built from the stored request. + case closed + } + + /// A piece of the caption. + public struct Span: Hashable, Sendable, Codable { + public var text: String + /// Drawn semibold, as a system row draws a user's name. + public var isEmphasized: Bool + /// A member's display name, which the fit rule may shorten. + public var isName: Bool + + public init(_ text: String, isEmphasized: Bool = false, isName: Bool = false) { + self.text = text + self.isEmphasized = isEmphasized + self.isName = isName + } + + /// A member's display name. + public static func name(_ text: String) -> Span { Span(text, isEmphasized: true, isName: true) } + /// "You" or "you", standing in for the viewer's name. + public static func viewer(_ text: String) -> Span { Span(text, isEmphasized: true) } + } + + public let id: String + public let kind: Kind + /// Who did what, without the amount or the title: "Jordan paid Alex". + public let caption: [Span] + /// The amount paid, formatted for the viewer; `nil` for a row where no money moved. + public let amount: String? + /// The request's title; `nil` while the request can't be shown. + public let title: String? + /// The request's paid fraction as of this event; `nil` draws no ring. + public let ringFraction: Double? + /// The request Request details opens for; `nil` leaves the row inert. + public let requestID: Data? + /// The newest message this row draws, which the READ pointer moves past; `nil` for a row with + /// no message behind it. + public let serverID: MessageID? + /// The stable ids of the messages this row draws, oldest first; empty for a row with none. + public let messageIDs: [String] + + public init( + id: String, + kind: Kind, + caption: [Span], + amount: String? = nil, + title: String? = nil, + ringFraction: Double? = nil, + requestID: Data? = nil, + serverID: MessageID? = nil, + messageIDs: [String] = [] + ) { + self.id = id + self.kind = kind + self.caption = caption + self.amount = amount + self.title = title + self.ringFraction = ringFraction + self.requestID = requestID + self.serverID = serverID + self.messageIDs = messageIDs + } + + /// What joins the title to the text before it: a closed row reads "Alex closed Tahoe cabin", + /// the others "… · Tahoe cabin". + public var titleSeparator: String { + switch kind { + case .sharePayment, .markedPaid: " · " + case .closed: " " + } + } + + /// The whole row as one string, for VoiceOver. + public var spokenText: String { + var text = caption.map(\.text).joined() + if let amount { text += " \(amount)" } + if let title { text += titleSeparator + title } + return text + } +} + +// MARK: - Fit + +extension ChatRequestEvent { + + /// How a row's text is cut to fit its width. + public struct Fit: Hashable, Sendable { + /// The caption, with names shortened where the row needed the room. + public var caption: [Span] + /// The title as drawn. On a wrapped row it is the whole title, which the second line + /// truncates at its end. + public var title: String? + /// Whether the title, and the ring with it, moves to a second line. + public var wraps: Bool + } + + /// The fewest characters a shortened title keeps, ellipsis included. + public static let shortestTitle = 8 + /// The fewest characters a shortened name keeps, ellipsis included. + public static let shortestName = 6 + + /// Cuts the row to `maxWidth`: the title shortens first, then each name, and only then the title + /// wraps to a second line. The amount is never cut. + /// + /// `width` measures one line holding the given caption, the amount, and the title with its + /// ring when a title is passed. + public func fit(maxWidth: Double, width: (_ caption: [Span], _ title: String?) -> Double) -> Fit { + if width(caption, title) <= maxWidth { + return Fit(caption: caption, title: title, wraps: false) + } + + var shortTitle = title + if let title { + for candidate in Self.shortenings(of: title, floor: Self.shortestTitle) { + shortTitle = candidate + if width(caption, candidate) <= maxWidth { + return Fit(caption: caption, title: candidate, wraps: false) + } + } + } + + let shortNames = Self.shorteningNames(in: caption) { width($0, shortTitle) <= maxWidth } + if shortNames.fits || title == nil { + return Fit(caption: shortNames.caption, title: shortTitle, wraps: false) + } + + // The first line holds the caption and amount alone, so the names get back what fits there. + let firstLine = Self.shorteningNames(in: caption) { width($0, nil) <= maxWidth } + return Fit(caption: firstLine.caption, title: title, wraps: true) + } + + /// `text` cut one character shorter at a time, each ending in an ellipsis, down to `floor` + /// characters; empty when `text` is already that short. + static func shortenings(of text: String, floor: Int) -> [String] { + let characters = Array(text) + guard characters.count > floor else { return [] } + return stride(from: characters.count - 2, through: floor - 1, by: -1).map { kept in + String(characters.prefix(kept)).trimmingCharacters(in: .whitespaces) + "…" + } + } + + /// Shortens the longest name a character at a time until `fits` passes or every name is down to + /// ``shortestName``. + private static func shorteningNames(in caption: [Span], until fits: ([Span]) -> Bool) -> (caption: [Span], fits: Bool) { + var current = caption + if fits(current) { return (current, true) } + // Each name's remaining cuts, shortest last. + var remaining: [Int: [String]] = [:] + for (index, span) in caption.enumerated() where span.isName { + remaining[index] = shortenings(of: span.text, floor: shortestName) + } + while true { + let longest = remaining + .filter { !$0.value.isEmpty } + .max { lhs, rhs in + let left = current[lhs.key].text.count, right = current[rhs.key].text.count + return left == right ? lhs.key > rhs.key : left < right + } + guard let longest else { return (current, false) } + current[longest.key].text = remaining[longest.key]!.removeFirst() + if fits(current) { return (current, true) } + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift index e35f9d509..3943f589e 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift @@ -233,36 +233,52 @@ extension Conversation { /// row. A DM with no watermark counts from zero. A group whose paged roster omits the viewer's /// row reads as read: its pointer is unknown, not zero. Android's `FeedProjection.unreadCount` /// applies the same rule. - public func hasUnread(for selfUserID: UserID?) -> Bool { - guard let lastMessage, lastMessage.senderID != selfUserID else { return false } + /// + /// `newest` is the newest message that can be unread for the viewer. It defaults to + /// ``lastMessage``, which is right unless that is a share payment to someone else (see + /// ``ConversationMessage/countsAsUnread(for:)``): then the caller passes the newest message + /// under it that counts, and without one the chat reads as read. + public func hasUnread(for selfUserID: UserID?, countedThrough newest: ConversationMessage? = nil) -> Bool { + let latest = newest ?? lastMessage.flatMap { $0.countsAsUnread(for: selfUserID) ? $0 : nil } + guard let latest, latest.senderID != selfUserID else { return false } let member = members.first { $0.userID == selfUserID } switch type { case .group: guard let member else { return false } - return (member.readPointer?.value ?? 0) < lastMessage.id.value + return (member.readPointer?.value ?? 0) < latest.id.value case .contactDm, .tipDm: - return (member?.readPointer?.value ?? 0) < lastMessage.id.value + return (member?.readPointer?.value ?? 0) < latest.id.value } } /// How many unread-eligible messages follow the signed-in user's READ watermark: 0 when - /// ``hasUnread(for:)`` is false, nil when the chat is unread but the count can't be known. + /// ``hasUnread(for:countedThrough:)`` is false, nil when the chat is unread but the count can't be known. /// /// The server stamps every message with a running `unreadSeq`, so the count is the newest /// message's stamp minus the stamp of the message at the watermark. The feed reports the /// watermark as a message id only, so `unreadSeqAt` supplies that message's stamp from local /// storage; a watermark whose message isn't stored yields nil. A DM with no watermark counts - /// from zero, as ``hasUnread(for:)`` does. - public func unreadCount(for selfUserID: UserID?, unreadSeqAt: (MessageID) -> UInt64?) -> Int? { - guard let lastMessage, hasUnread(for: selfUserID) else { return 0 } + /// from zero, as ``hasUnread(for:countedThrough:)`` does. + /// + /// The stamp is one value for every member, so it counts a share payment for all of them. + /// `uncounted` is how many share payments to someone else sit between the watermark and + /// `newest`; they come off the count, which never drops below one for an unread chat. + public func unreadCount( + for selfUserID: UserID?, + countedThrough newest: ConversationMessage? = nil, + uncounted: Int = 0, + unreadSeqAt: (MessageID) -> UInt64? + ) -> Int? { + let latest = newest ?? lastMessage.flatMap { $0.countsAsUnread(for: selfUserID) ? $0 : nil } + guard let latest, hasUnread(for: selfUserID, countedThrough: latest) else { return 0 } var readSeq: UInt64 = 0 if let pointer = selfReadPointer(for: selfUserID), pointer.value > 0 { guard let seq = unreadSeqAt(pointer) else { return nil } readSeq = seq } // Unread by id but not by stamp: the newest message doesn't count toward unread. - guard lastMessage.unreadSeq > readSeq else { return nil } - return Int(lastMessage.unreadSeq - readSeq) + guard latest.unreadSeq > readSeq else { return nil } + return max(1, Int(latest.unreadSeq - readSeq) - uncounted) } /// Whether the signed-in user has this chat muted as of `date`. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index d05515629..2f3a80980 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -154,7 +154,7 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { /// Persisted, so a group send's bubble keeps its recipients across a relaunch. public let cashPayments: [CashPayment] /// The payment request whose share this `.cash` message paid; `nil` for any other cash message and - /// for non-cash content. Not persisted, so a message read back from the local store has `nil`. + /// for non-cash content. Persisted, so a share payment keeps its row across a relaunch. public let paymentRequestID: PaymentRequestID? /// A `.cash` message's part in a return: whether its receiver is offered one, and the message it /// returns, if any. `nil` for a cash message that is neither and for non-cash content. Persisted, @@ -385,6 +385,18 @@ extension ConversationMessage { senderID == selfUserID } + /// Whether this message is a share payment: a `.cash` message that paid a share of a request. + public var isSharePayment: Bool { + paymentRequestID != nil + } + + /// Whether this message can be unread for the given user. A share payment is unread only for + /// the member it paid, the request's creator; every other message can be unread for anyone. + public func countsAsUnread(for selfUserID: UserID?) -> Bool { + guard isSharePayment else { return true } + return selfUserID != nil && cashReceiverID == selfUserID + } + /// Whether arriving live in the open chat earns the receive haptic: someone else's message that is /// not a server notice. public func buzzesOnReceive(selfUserID: UserID) -> Bool { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift index fcee5ebc4..79b708e6c 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/UnreadBoundary.swift @@ -30,7 +30,7 @@ public enum UnreadBoundary: Hashable, Sendable { /// A missing pointer resolves to `.none`, not to "everything is unread": a group's roster is /// paged, so the viewer's own member row can be absent, and treating that as a pointer of zero /// would head the whole transcript with the divider. This is deliberately not - /// ``Conversation/hasUnread(for:)``, which counts a DM's missing pointer as zero. + /// ``Conversation/hasUnread(for:countedThrough:)``, which counts a DM's missing pointer as zero. /// /// `hasStored` answers whether any message at or below a given id is stored. When none is, the /// store has a gap below the unread run and can't know how many unread messages precede it, so diff --git a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift index ff4c76c97..c5b893b17 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Lifecycle.swift @@ -59,6 +59,24 @@ extension GroupPaymentRequest { return min(1, (paidNative / requested).doubleValue) } + /// The paid share of the total as of `date`, from 0 to 1: the creator's own share, which is paid + /// from creation, plus every share paid at or before `date`. `counting` is a member whose share + /// counts whatever its `paidAt`, for a payment this copy of the request doesn't show yet. + /// + /// Unlike ``paidFraction`` a COMPLETED request gets no shortcut to 1: this reads a moment in + /// the request's past. + public func paidFraction(asOf date: Date, counting payer: UserID? = nil) -> Double { + let requested = requestedNative + guard requested > 0 else { return 0 } + let paid = (split?.shares ?? []).filter { share in + if share.settlement == .creator { return true } + if let payer, share.userID == payer { return true } + guard share.isPaid, let paidAt = share.paidAt else { return false } + return paidAt <= date + }.reduce(Decimal.zero) { $0 + Decimal($1.nativeAmount) } + return min(1, (paid / requested).doubleValue) + } + /// The share owed by `userID`, if any. public func share(for userID: UserID) -> Share? { split?.shares.first { $0.userID == userID } diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index f39b151ec..f01f90195 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -288,6 +288,42 @@ nonisolated extension Database { && m.kind != 2 && m.kind != 6 && (m.senderId == nil || m.senderId != selfUserID) + && Self.countsAsUnread(m, for: selfUserID) + ).count + ) + } + + /// The SQL form of ``ConversationMessage/countsAsUnread(for:)``: every row but a share payment + /// to someone other than `selfUserID`. Written NULL-safe, since most rows have neither column. + private static func countsAsUnread(_ m: ConversationMessageTable, for selfUserID: UUID) -> Expression { + m.paymentRequestId == nil || (m.cashReceiverId != nil && m.cashReceiverId == selfUserID) + } + + /// The newest stored message that can be unread for `selfUserID`, or nil when none is stored: + /// the message a chat's unread state reads when its latest is a share payment to someone else. + public func newestMessageCountingAsUnread(conversationID: ConversationID, for selfUserID: UUID) throws -> ConversationMessage? { + let m = ConversationMessageTable() + guard let row = try reader.pluck( + m.table.filter( + m.conversationId == conversationID.data + && m.id >= (try historyFloor(conversationID)) + && Self.countsAsUnread(m, for: selfUserID) + ).order(m.id.desc) + ) else { return nil } + return conversationMessage(from: row) + } + + /// How many stored share payments to someone other than `selfUserID` lie after `after` and at + /// or before `through`: the messages the server's unread stamp counts that this viewer doesn't. + public func sharePaymentsToOthersCount(conversationID: ConversationID, after: MessageID, through: MessageID, for selfUserID: UUID) throws -> Int { + let m = ConversationMessageTable() + return try reader.scalar( + m.table.filter( + m.conversationId == conversationID.data + && m.id > after.value + && m.id <= through.value + && m.paymentRequestId != nil + && (m.cashReceiverId == nil || m.cashReceiverId != selfUserID) ).count ) } @@ -316,6 +352,7 @@ nonisolated extension Database { m.conversationId == conversationID.data && m.kind != 6 && (m.senderId == nil || m.senderId != selfUserID) + && Self.countsAsUnread(m, for: selfUserID) ).order(m.id.desc) ).map { MessageID(value: $0[m.id]) } } @@ -333,6 +370,7 @@ nonisolated extension Database { && m.id >= (try historyFloor(conversationID)) && m.kind != 6 && (m.senderId == nil || m.senderId != selfUserID) + && Self.countsAsUnread(m, for: selfUserID) ).order(m.id.asc) ).map { $0[m.id] } } @@ -1000,6 +1038,7 @@ nonisolated extension Database { column(m.cashReceiverId, message.cashReceiverID), column(m.cashReturnOffer, message.cashReturn.map(\.hasOffer)), column(m.cashReturnOfId, message.cashReturn?.returnOf?.messageID.value), + column(m.paymentRequestId, message.paymentRequestID?.data), ] return (columns, kind) } @@ -1272,6 +1311,7 @@ nonisolated extension Database { cashAction: cashAction, cashReceiverID: row[m.cashReceiverId], cashPayments: Self.decodeCashPayments(row[m.cashPaymentsJson]), + paymentRequestID: row[m.paymentRequestId].map(PaymentRequestID.init(data:)), cashReturn: Self.decodeCashReturn(offer: row[m.cashReturnOffer], returnOf: row[m.cashReturnOfId]), date: date, unreadSeq: row[m.unreadSeq], diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index 5f7bc94c9..f7493666b 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 66 + public static let schemaVersion = 67 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index 713527396..a204d51ce 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -498,6 +498,9 @@ nonisolated public struct ConversationMessageTable: Sendable { // returns. Both nil for a cash row that is neither, and for every other row. public let cashReturnOffer = Expression ("cashReturnOffer") public let cashReturnOfId = Expression ("cashReturnOfId") + // The payment request a cash row pays a share of (`CashContent.payment_request_id`); nil for + // every other row. + public let paymentRequestId = Expression ("paymentRequestId") } @@ -854,6 +857,7 @@ nonisolated extension Database { t.column(conversationMessageTable.cashReceiverId) t.column(conversationMessageTable.cashReturnOffer) t.column(conversationMessageTable.cashReturnOfId) + t.column(conversationMessageTable.paymentRequestId) t.primaryKey(conversationMessageTable.conversationId, conversationMessageTable.id) }) } diff --git a/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift b/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift index f3608d766..c265b11cc 100644 --- a/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift +++ b/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift @@ -69,7 +69,7 @@ struct ConversationTypingIndicatorTests { switch item { case .typingIndicator(let typists): return typists - case .message, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage, .inviteCard: + case .message, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage, .requestEvent, .inviteCard: continue } } @@ -175,7 +175,7 @@ struct ConversationTypingIndicatorTests { for item in items { switch item { case .typingIndicator(let typists): return typists - case .message, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage, .inviteCard: continue + case .message, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage, .requestEvent, .inviteCard: continue } } return nil diff --git a/FlipcashTests/Chat/RequestEventRowsTests.swift b/FlipcashTests/Chat/RequestEventRowsTests.swift new file mode 100644 index 000000000..e02c6cf32 --- /dev/null +++ b/FlipcashTests/Chat/RequestEventRowsTests.swift @@ -0,0 +1,603 @@ +// +// RequestEventRowsTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +// MARK: - Fixtures - + +private let alex = UserID() +private let jordan = UserID() +private let sam = UserID() +private let chat = ConversationID.test(7) +private let tahoe = PaymentRequestID(data: Data([1])) +private let names: [UserID: String] = [alex: "Alex", jordan: "Jordan", sam: "Sam"] + +/// A fixed local noon, so a fixture's offsets stay inside one day unless a test crosses midnight. +private let noon: Date = Calendar.current.date(from: DateComponents(year: 2026, month: 3, day: 10, hour: 12))! + +private func dollars(_ value: Decimal) -> ExchangedFiat { + ExchangedFiat( + onChainAmount: TokenAmount(quarks: UInt64(truncating: (value * 1_000_000) as NSDecimalNumber), mint: .usdf), + nativeAmount: FiatAmount(value: value, currency: .usd), + currencyRate: Rate(fx: 1, currency: .usd) + ) +} + +/// A share of `request` paid by `payer` to `creator`, `minutes` after noon. +private func payment( + _ id: UInt64, + by payer: UserID, + to creator: UserID? = alex, + _ amount: Decimal = 40, + for request: PaymentRequestID? = tahoe, + minutes: Double? = nil +) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: id), + senderID: payer, + content: .cash(dollars(amount)), + cashReceiverID: creator, + paymentRequestID: request, + date: noon.addingTimeInterval((minutes ?? Double(id)) * 60), + unreadSeq: id + ) +} + +private func text(_ id: UInt64, from sender: UserID, minutes: Double? = nil) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: id), + senderID: sender, + content: .text("m\(id)"), + date: noon.addingTimeInterval((minutes ?? Double(id)) * 60), + unreadSeq: id + ) +} + +private typealias Share = GroupPaymentRequest.Share + +/// A request by Alex titled `title`, with the creator's own share paid at creation. +private func request( + _ id: PaymentRequestID = tahoe, + title: String = "Tahoe cabin", + shares: [Share], + status: GroupPaymentRequest.Status = .open, + closedAt: Date? = nil +) -> RequestTimeline { + RequestTimeline( + request: GroupPaymentRequest( + id: id, + creator: alex, + createdAt: noon.addingTimeInterval(-3600), + status: status, + closedAt: closedAt, + version: 1, + chatID: chat, + split: .init(text: .plaintext(title: title, description: ""), currency: "usd", endsAt: nil, shares: shares) + ), + title: title + ) +} + +private func creatorShare(_ amount: Double = 10) -> Share { + Share(userID: alex, nativeAmount: amount, paidAt: noon.addingTimeInterval(-3600), settlement: .creator) +} + +private func paid(_ user: UserID, _ amount: Double = 10, minutes: Double) -> Share { + Share(userID: user, nativeAmount: amount, paidAt: noon.addingTimeInterval(minutes * 60), settlement: .intent(Data([1]))) +} + +private func marked(_ user: UserID, _ amount: Double = 10, minutes: Double) -> Share { + Share(userID: user, nativeAmount: amount, paidAt: noon.addingTimeInterval(minutes * 60), settlement: .manual) +} + +private func owed(_ user: UserID, _ amount: Double = 10) -> Share { + Share(userID: user, nativeAmount: amount) +} + +private func rows( + _ messages: [ConversationMessage], + viewer: UserID, + requests: [RequestTimeline] = [], + extraNames: [UserID: String] = [:] +) -> [ChatRequestEvent] { + items(messages, viewer: viewer, requests: requests, extraNames: extraNames).compactMap { + if case .requestEvent(let event) = $0 { event } else { nil } + } +} + +private func items( + _ messages: [ConversationMessage], + viewer: UserID, + requests: [RequestTimeline] = [], + extraNames: [UserID: String] = [:] +) -> [ChatItem] { + ChatItem.from( + messages, + selfUserID: viewer, + nameOf: { names[$0] ?? extraNames[$0] }, + requests: Dictionary(uniqueKeysWithValues: requests.map { ($0.request.id, $0) }) + ) +} + +private func line(_ event: ChatRequestEvent) -> String { event.spokenText } + +/// The semibold parts of a row's caption. +private func emphasized(_ event: ChatRequestEvent) -> [String] { + event.caption.filter(\.isEmphasized).map(\.text) +} + +// MARK: - Share payment rows - + +@Suite("Share payment rows (spec cases 63-74)") +struct SharePaymentRowTests { + + private var tahoeCabin: RequestTimeline { + request(shares: [creatorShare(40), paid(jordan, 40, minutes: 1), owed(sam, 40)]) + } + + @Test("63: the row reads per viewer", arguments: [ + (jordan, "You paid $40 · Tahoe cabin", ["You"]), + (alex, "Jordan paid you $40 · Tahoe cabin", ["Jordan", "you"]), + (sam, "Jordan paid Alex $40 · Tahoe cabin", ["Jordan", "Alex"]), + ]) + func copyPerViewer(viewer: UserID, expected: String, semibold: [String]) throws { + let row = try #require(rows([payment(1, by: jordan)], viewer: viewer, requests: [tahoeCabin]).first) + #expect(line(row) == expected) + #expect(emphasized(row) == semibold) + #expect(row.kind == .sharePayment) + #expect(row.amount == "$40") + #expect(row.requestID == tahoe.data) + } + + @Test("63: a payment that names no receiver, for a request not stored, reads without one") + func noReceiver() throws { + let row = try #require(rows([payment(1, by: jordan, to: nil)], viewer: sam, requests: []).first) + #expect(line(row) == "Jordan paid $40") + } + + @Test("63: a share payment draws no cash bubble") + func replacesTheCashBubble() { + let drawn = items([payment(1, by: jordan)], viewer: sam, requests: [tahoeCabin]) + #expect(!drawn.contains { if case .message = $0 { true } else { false } }) + } + + @Test("64: each row's ring is the paid fraction as of that payment") + func ringAsOfPayment() { + let b = UserID(), c = UserID(), d = UserID() + let four = request(shares: [creatorShare(), paid(b, minutes: 1), paid(c, minutes: 3), owed(d)]) + // A text between them keeps the two payments from collapsing. + let drawn = rows([payment(1, by: b, 10), text(2, from: sam), payment(3, by: c, 10)], viewer: sam, requests: [four]) + #expect(drawn.map(\.ringFraction) == [0.5, 0.75]) + } + + @Test("65: a later mark leaves earlier rings alone") + func laterMarkLeavesRings() { + let b = UserID(), c = UserID(), d = UserID() + let four = request(shares: [creatorShare(), paid(b, minutes: 1), paid(c, minutes: 3), marked(d, minutes: 5)]) + let drawn = rows([payment(1, by: b, 10), text(2, from: sam), payment(3, by: c, 10)], viewer: sam, requests: [four]) + #expect(drawn.filter { $0.kind == .sharePayment }.map(\.ringFraction) == [0.5, 0.75]) + } + + @Test("64: a payment the stored request doesn't show yet still counts itself") + func ringCountsOwnPaymentBeforeRefetch() throws { + let b = UserID() + let stale = request(shares: [creatorShare(), owed(b), owed(sam), owed(jordan)]) + let row = try #require(rows([payment(1, by: b, 10)], viewer: sam, requests: [stale]).first) + #expect(row.ringFraction == 0.5) + } + + @Test("66: a run for one request collapses; a message between ends the run") + func collapsesARun() { + let b = UserID(), c = UserID(), d = UserID(), e = UserID() + let five = request(title: "Pizza", shares: [creatorShare(), paid(b, minutes: 1), paid(c, minutes: 2), paid(d, minutes: 3), paid(e, minutes: 5)]) + let drawn = rows( + [payment(1, by: b, 10), payment(2, by: c, 10), payment(3, by: d, 10), text(4, from: sam), payment(5, by: e, 10)], + viewer: sam, requests: [five], extraNames: [e: "Eve"] + ) + #expect(drawn.map(line) == ["3 people paid $30 · Pizza", "Eve paid Alex $10 · Pizza"]) + // The collapsed row's ring is as of its last payment. + #expect(drawn.first?.ringFraction == 0.8) + #expect(drawn.first?.messageIDs.count == 3) + #expect(drawn.first?.serverID == MessageID(value: 3)) + } + + @Test("66: payments for different requests don't collapse") + func differentRequestsStayApart() { + let other = PaymentRequestID(data: Data([2])) + let drawn = rows( + [payment(1, by: jordan), payment(2, by: sam, for: other)], + viewer: alex, + requests: [tahoeCabin, request(other, title: "Gas", shares: [creatorShare(40), paid(sam, 40, minutes: 2)])] + ) + #expect(drawn.map(line) == ["Jordan paid you $40 · Tahoe cabin", "Sam paid you $40 · Gas"]) + } + + @Test("66: the creator's collapsed row names them as the payee") + func collapsedForCreator() { + let drawn = rows( + [payment(1, by: jordan), payment(2, by: sam)], + viewer: alex, + requests: [request(shares: [creatorShare(40), paid(jordan, 40, minutes: 1), paid(sam, 40, minutes: 2)])] + ) + #expect(drawn.map(line) == ["2 people paid you $80 · Tahoe cabin"]) + } + + @Test("67: a date header between two payments keeps them apart") + func midnightSplitsARun() { + let midnight = Calendar.current.startOfDay(for: noon.addingTimeInterval(24 * 3600)) + let before = (midnight.timeIntervalSince(noon) - 60) / 60 + let after = (midnight.timeIntervalSince(noon) + 60) / 60 + let drawn = items( + [payment(1, by: jordan, minutes: before), payment(2, by: sam, minutes: after)], + viewer: alex, + requests: [request(shares: [creatorShare(40), paid(jordan, 40, minutes: before), paid(sam, 40, minutes: after)])] + ) + let kinds: [String] = drawn.map { + switch $0 { + case .dateSeparator: "date" + case .requestEvent: "row" + case .message, .unreadDivider, .typingIndicator, .encryptionMarker, .systemMessage, .inviteCard: "other" + } + } + #expect(kinds == ["date", "row", "date", "row"]) + } + + @Test("68: the viewer in a run of seven") + func viewerInARun() { + let others = (0..<6).map { _ in UserID() } + let payers = Array(others.prefix(3)) + [sam] + Array(others.suffix(3)) + let shares = [creatorShare()] + payers.enumerated().map { paid($1, minutes: Double($0 + 1)) } + let messages = payers.enumerated().map { payment(UInt64($0 + 1), by: $1, 10) } + let drawn = rows(messages, viewer: sam, requests: [request(title: "Pizza", shares: shares)]) + #expect(drawn.map(line) == ["You and 6 others paid $70 · Pizza"]) + } + + @Test("68: the viewer and one other") + func viewerAndOneOther() { + let drawn = rows( + [payment(1, by: jordan), payment(2, by: sam)], + viewer: sam, + requests: [request(shares: [creatorShare(40), paid(jordan, 40, minutes: 1), paid(sam, 40, minutes: 2)])] + ) + #expect(drawn.map(line) == ["You and 1 other paid $80 · Tahoe cabin"]) + // "You" is regular weight on a collapsed row. + #expect(drawn.flatMap(emphasized).isEmpty) + } + + @Test("73: a request that isn't stored draws no title or ring and doesn't tap through") + func missingRequest() throws { + let row = try #require(rows([payment(1, by: jordan)], viewer: sam).first) + #expect(line(row) == "Jordan paid Alex $40") + #expect(row.title == nil) + #expect(row.ringFraction == nil) + #expect(row.requestID == nil) + } + + @Test("A share payment heads the unread divider only for the member it paid") + func unreadDivider() { + let messages = [text(1, from: alex), payment(2, by: jordan), text(3, from: alex)] + func drawn(for viewer: UserID) -> [String] { + ChatItem.from( + messages, selfUserID: viewer, nameOf: { names[$0] }, + requests: [tahoe: tahoeCabin], + unreadBoundary: .at(readThrough: MessageID(value: 1), count: 1) + ).compactMap { + switch $0 { + case .unreadDivider: "divider" + case .requestEvent: "row" + case .message: "message" + case .dateSeparator, .typingIndicator, .encryptionMarker, .systemMessage, .inviteCard: nil + } + } + } + // Sam's divider sits under the payment row, above the text that follows it. + #expect(drawn(for: sam) == ["message", "row", "divider", "message"]) + // Jordan paid Alex, so for Alex the payment is the first unread row. + #expect(drawn(for: jordan).firstIndex(of: "divider") == 2) + } +} + +// MARK: - Fit - + +@Suite("Share payment row fit (spec cases 69-70)") +struct RequestEventFitTests { + + /// One unit per character, plus a ring the width of two. + private func width(_ event: ChatRequestEvent) -> ([ChatRequestEvent.Span], String?) -> Double { + { caption, title in + var count = caption.map(\.text.count).reduce(0, +) + if let amount = event.amount { count += 1 + amount.count } + if let title { count += event.titleSeparator.count + title.count + 2 } + return Double(count) + } + } + + private func event(payer: String, creator: String, title: String?) -> ChatRequestEvent { + ChatRequestEvent( + id: "row", kind: .sharePayment, + caption: [.name(payer), .init(" paid "), .name(creator)], + amount: "$40", title: title, ringFraction: title == nil ? nil : 0.5 + ) + } + + @Test("A row that fits is left whole") + func fits() { + let row = event(payer: "Jordan", creator: "Alex", title: "Tahoe cabin") + let fit = row.fit(maxWidth: 100, width: width(row)) + #expect(fit.caption == row.caption) + #expect(fit.title == "Tahoe cabin") + #expect(!fit.wraps) + } + + @Test("69: a long title shortens with an ellipsis and stays on one line") + func shortensTitle() { + let row = event(payer: "Jordan", creator: "Alex", title: "Tahoe cabin weekend with everyone") + // Caption 16 + amount 4 + separator 3 + ring 2 leaves 15 for the title. + let fit = row.fit(maxWidth: 40, width: width(row)) + #expect(!fit.wraps) + #expect(fit.caption == row.caption) + #expect(fit.title == "Tahoe cabin we…") + } + + @Test("69: the title never shortens under eight characters") + func titleFloor() throws { + let row = event(payer: "Maximiliana", creator: "Bartholomew", title: "Tahoe cabin weekend") + // Title at its floor (8) costs 13 with separator and ring; amount 4; " paid " 6. 37 leaves + // 14 for two names, which fit only once both are cut. + let fit = row.fit(maxWidth: 37, width: width(row)) + #expect(!fit.wraps) + #expect(fit.title == "Tahoe c…") + let cut = fit.caption.filter(\.isName).map(\.text) + #expect(cut.allSatisfy { $0.count >= ChatRequestEvent.shortestName && $0.hasSuffix("…") }) + #expect(cut.map(\.count).reduce(0, +) == 14) + } + + @Test("70: what still doesn't fit wraps the title and ring to a second line") + func wraps() { + let row = event(payer: "Maximiliana", creator: "Bartholomew", title: "Tahoe cabin weekend") + // Both names at their floor (6 + 6) still leave the line at 35. + let fit = row.fit(maxWidth: 30, width: width(row)) + #expect(fit.wraps) + // The whole title moves down; the second line truncates it at its own end. + #expect(fit.title == "Tahoe cabin weekend") + // The first line holds caption and amount alone, so the names get back what fits in 30. + let first = fit.caption.map(\.text).joined() + #expect(first.count + 4 <= 30) + #expect(fit.caption.filter(\.isName).map(\.text.count).reduce(0, +) == 20) + } + + @Test("A row with no title only shortens names") + func noTitle() { + let row = event(payer: "Maximiliana", creator: "Bartholomew", title: nil) + let fit = row.fit(maxWidth: 24, width: width(row)) + #expect(!fit.wraps) + #expect(fit.title == nil) + } +} + +// MARK: - Marked paid and closed - + +@Suite("Marked-paid and closed rows (spec cases 75-82)") +struct MarkedPaidAndClosedRowTests { + + @Test("75: the marked-paid row reads per viewer, at the share's paid_at", arguments: [ + (alex, "You marked Sam as paid · Tahoe cabin", ["Sam"]), + (sam, "Alex marked you as paid · Tahoe cabin", ["Alex"]), + (jordan, "Alex marked Sam as paid · Tahoe cabin", ["Alex", "Sam"]), + ]) + func markedCopy(viewer: UserID, expected: String, semibold: [String]) { + let stored = request(shares: [creatorShare(40), marked(sam, 40, minutes: 2), owed(jordan, 40)]) + let drawn = items([text(1, from: alex), text(3, from: jordan)], viewer: viewer, requests: [stored]) + let index = drawn.firstIndex { if case .requestEvent = $0 { true } else { false } } + guard let index, case .requestEvent(let row) = drawn[index] else { + Issue.record("no marked-paid row") + return + } + #expect(line(row) == expected) + #expect(emphasized(row) == semibold) + #expect(row.kind == .markedPaid) + #expect(row.amount == nil) + #expect(row.serverID == nil) + // Between the message before its time and the one after. + #expect(drawn[index - 1].messageID == text(1, from: alex).stableID) + #expect(drawn[index + 1].messageID == text(3, from: jordan).stableID) + } + + @Test("76: the marked-paid row's ring counts the mark; the payment row before it doesn't") + func ringAcrossPaymentAndMark() { + let b = UserID(), c = UserID(), d = UserID() + let four = request(shares: [creatorShare(), paid(b, minutes: 1), marked(c, minutes: 2), owed(d)]) + let drawn = rows([payment(1, by: b, 10), text(3, from: sam)], viewer: sam, requests: [four], extraNames: [c: "Cam"]) + #expect(drawn.map(\.kind) == [.sharePayment, .markedPaid]) + #expect(drawn.map(\.ringFraction) == [0.5, 0.75]) + #expect(drawn.last?.amount == nil) + } + + @Test("77: a payment that replaces the mark leaves only the payment row") + func paymentReplacesMark() { + let stored = request(shares: [creatorShare(40), paid(sam, 40, minutes: 2), owed(jordan, 40)]) + let drawn = rows([text(1, from: alex), payment(2, by: sam)], viewer: jordan, requests: [stored]) + #expect(drawn.map(line) == ["Sam paid Alex $40 · Tahoe cabin"]) + } + + @Test("78: the closed row reads per viewer, at closed_at, with no ring", arguments: [ + (alex, "You closed Tahoe cabin"), + (sam, "Alex closed Tahoe cabin"), + ]) + func closedCopy(viewer: UserID, expected: String) throws { + let stored = request(shares: [creatorShare(40), owed(sam, 40)], status: .closed, closedAt: noon.addingTimeInterval(120)) + let row = try #require(rows([text(1, from: alex)], viewer: viewer, requests: [stored]).first) + #expect(line(row) == expected) + #expect(row.kind == .closed) + #expect(row.ringFraction == nil) + #expect(row.requestID == tahoe.data) + } + + @Test("79: a completed or expired request draws no closed row", arguments: [GroupPaymentRequest.Status.completed, .expired]) + func noRowForOtherEndings(status: GroupPaymentRequest.Status) { + let stored = request(shares: [creatorShare(40), owed(sam, 40)], status: status, closedAt: noon.addingTimeInterval(120)) + #expect(rows([text(1, from: alex)], viewer: sam, requests: [stored]).isEmpty) + } + + @Test("80: marks in a row collapse") + func marksCollapse() { + let b = UserID(), c = UserID() + let stored = request(shares: [creatorShare(), marked(b, minutes: 2), marked(c, minutes: 3), marked(jordan, minutes: 4), owed(sam)]) + #expect(rows([text(1, from: alex)], viewer: sam, requests: [stored]).map(line) == ["Alex marked 3 people as paid · Tahoe cabin"]) + #expect(rows([text(1, from: alex)], viewer: jordan, requests: [stored]).map(line) == ["Alex marked you and 2 others as paid · Tahoe cabin"]) + #expect(rows([text(1, from: alex)], viewer: alex, requests: [stored]).map(line) == ["You marked 3 people as paid · Tahoe cabin"]) + } + + @Test("80: a mark and a close for one request stay two rows") + func markThenClose() { + let stored = request(shares: [creatorShare(40), marked(sam, 40, minutes: 2)], status: .closed, closedAt: noon.addingTimeInterval(180)) + #expect(rows([text(1, from: alex)], viewer: jordan, requests: [stored]).map(\.kind) == [.markedPaid, .closed]) + } + + @Test("81: a stored row is no message: it carries nothing the read pointer or a jump can land on") + func storedRowIsNotAMessage() throws { + let stored = request(shares: [creatorShare(40), marked(sam, 40, minutes: 2)]) + let row = try #require(rows([text(1, from: alex)], viewer: jordan, requests: [stored]).first) + #expect(row.serverID == nil) + #expect(row.messageIDs.isEmpty) + #expect(ChatItem.requestEvent(row).messageID == nil) + } + + @Test("82: a mark older than the oldest loaded message draws no row until paging reaches it") + func olderThanTheWindow() { + let stored = request(shares: [creatorShare(40), marked(sam, 40, minutes: 2)]) + #expect(rows([text(5, from: alex), text(6, from: jordan)], viewer: jordan, requests: [stored]).isEmpty) + #expect(rows([text(1, from: alex), text(5, from: alex), text(6, from: jordan)], viewer: jordan, requests: [stored]).count == 1) + } + + @Test("A stored row on a later day gets its own date header") + func storedRowDateHeader() { + let stored = request(shares: [creatorShare(40), marked(sam, 40, minutes: 26 * 60)]) + let drawn = items([text(1, from: alex)], viewer: jordan, requests: [stored]) + guard drawn.count == 4, case .dateSeparator = drawn[2], case .requestEvent = drawn[3] else { + Issue.record("expected date, message, date, row; got \(drawn.count) items") + return + } + } +} + +// MARK: - Preview, unread and persistence - + +@MainActor +@Suite("Share payment preview, unread and persistence (spec cases 71, 72, 74, 81)") +struct SharePaymentListTests { + + private func makeController(viewer: UserID, database: Database) -> ConversationController { + let mock = MockConversations() + return ConversationController( + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, + contactNaming: MockDMContactNaming(), + database: database, + owner: .generate()!, selfUserID: viewer + ) + } + + /// A group whose latest message is Jordan's share payment (id 3, stamp 3), over Alex's text + /// (id 2, stamp 2), with the viewer's READ watermark at `read`. + private func group(viewer: UserID, read: UInt64, latest: ConversationMessage? = nil) -> Conversation { + Conversation( + id: chat, + members: [alex, jordan, sam].map { + ConversationMember(userID: $0, displayName: names[$0]!, readPointer: $0 == viewer ? MessageID(value: read) : nil) + }, + lastMessage: latest ?? payment(3, by: jordan), + lastActivity: noon, + type: .group + ) + } + + private func preview(_ controller: ConversationController, _ conversation: Conversation, title: String? = "Tahoe cabin") -> String? { + controller.lastMessagePreview(for: conversation, requestTitle: { _ in title }) { _ in nil } + } + + @Test("71, 72: the preview reads per viewer, with 'for' in place of the dot", arguments: [ + (alex, "Jordan paid you $40 for Tahoe cabin"), + (sam, "Jordan paid Alex $40 for Tahoe cabin"), + (jordan, "You paid $40 for Tahoe cabin"), + ]) + func previewPerViewer(viewer: UserID, expected: String) throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = makeController(viewer: viewer, database: database) + #expect(preview(controller, group(viewer: viewer, read: 2)) == expected) + } + + @Test("The preview drops 'for ' when the title can't be read") + func previewWithoutTitle() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = makeController(viewer: sam, database: database) + #expect(preview(controller, group(viewer: sam, read: 2), title: nil) == "Jordan paid Alex $40") + } + + @Test("71: the creator's chat is unread, and counts the payment") + func unreadForCreator() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = makeController(viewer: alex, database: database) + let conversation = group(viewer: alex, read: 2) + try database.upsertConversation(conversation) + try database.upsertConversationMessages([text(2, from: alex), payment(3, by: jordan)], conversationID: chat) + #expect(controller.hasUnreadMessages(conversation)) + #expect(controller.unreadCount(for: conversation) == 1) + } + + @Test("72: another member's chat stays read when the payment is all that's new") + func readForOtherMember() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = makeController(viewer: sam, database: database) + let conversation = group(viewer: sam, read: 2) + try database.upsertConversation(conversation) + try database.upsertConversationMessages([text(2, from: alex), payment(3, by: jordan)], conversationID: chat) + #expect(!controller.hasUnreadMessages(conversation)) + #expect(controller.unreadCount(for: conversation) == 0) + } + + @Test("72: another member's count leaves out a payment that sits among unread messages") + func countSkipsPaymentForOtherMember() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let controller = makeController(viewer: sam, database: database) + // Read through 1; then Alex's text (2), Jordan's payment (3), Alex's text (4), and the + // payment again as the latest (5). + let conversation = group(viewer: sam, read: 1, latest: payment(5, by: jordan)) + try database.upsertConversation(conversation) + try database.upsertConversationMessages( + [text(1, from: alex), text(2, from: alex), payment(3, by: jordan), text(4, from: alex), payment(5, by: jordan)], + conversationID: chat + ) + #expect(controller.hasUnreadMessages(conversation)) + // Stamps 2...4 are three messages; one is a payment to Alex. + #expect(controller.unreadCount(for: conversation) == 2) + #expect(preview(controller, conversation) == "Jordan paid Alex $40 for Tahoe cabin") + } + + @Test("74: a stored share payment reads back with its request id") + func persistsRequestID() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + try database.upsertConversation(group(viewer: sam, read: 0)) + try database.upsertConversationMessages([payment(3, by: jordan), text(4, from: alex)], conversationID: chat) + let stored = try database.messages(conversationID: chat, from: 0) + let reloaded = try #require(stored.first { $0.id.value == 3 }) + #expect(reloaded.paymentRequestID == tahoe) + #expect(reloaded.isSharePayment) + #expect(stored.first { $0.id.value == 4 }?.paymentRequestID == nil) + // And it still draws as a share payment row. + #expect(rows(stored, viewer: sam).first?.kind == .sharePayment) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift index 3599a7b53..41400e51f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift @@ -35,6 +35,8 @@ extension ChatItem { ChatEncryptionMarkerCell.reuseIdentifier case .systemMessage: ChatSystemMessageCell.reuseIdentifier + case .requestEvent: + ChatRequestEventCell.reuseIdentifier case .inviteCard: ChatInviteCardCell.reuseIdentifier case .message(let message): diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatRequestEventCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatRequestEventCell.swift new file mode 100644 index 000000000..ee9c24d80 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatRequestEventCell.swift @@ -0,0 +1,229 @@ +// +// ChatRequestEventCell.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import SwiftUI +import UIKit +import FlipcashCore + +/// A payment request's share paid, share marked paid, or close, as one centred line in the +/// transcript with the system row's metrics. A share payment sits between two fading rules and +/// shows its amount in the success colour; the other two are plain. A tap on the row opens the +/// request's details, which the collection view's selection delivers. +public final class ChatRequestEventCell: UICollectionViewCell { + + public static let reuseIdentifier = "ChatRequestEventCell" + + /// The system row's metrics (`ChatSystemMessageCell`). + private static let lineHeight: CGFloat = 18 + private static let verticalPadding: CGFloat = 6 + static let textMaxWidth: CGFloat = 250 + private static let textColor = UIColor(Color.textSecondary) + private static let amountColor = UIColor(Color.textSuccess) + private static let font = UIFont.appTextCaption + private static let emphasisFont = UIFont.default(size: 12, weight: .bold) + + static let ringDiameter: CGFloat = 14 + private static let ringGap: CGFloat = 4 + private static let ruleGap: CGFloat = 8 + private static let ruleMinWidth: CGFloat = 16 + private static let ruleOpacity: Float = 0.6 + + private let leadingRule = FadingRuleView(fadesToward: .leading) + private let trailingRule = FadingRuleView(fadesToward: .trailing) + private let firstLabel = UILabel() + private let secondLabel = UILabel() + private let firstLine = UIStackView() + private let secondLine = UIStackView() + private let ring: any UIView & UIContentView + private var captionWidth: NSLayoutConstraint! + + public override init(frame: CGRect) { + ring = UIHostingConfiguration { RequestEventRing(fraction: 0, rowID: "") } + .margins(.all, 0) + .makeContentView() + super.init(frame: frame) + + for label in [firstLabel, secondLabel] { + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + label.setContentHuggingPriority(.required, for: .horizontal) + label.heightAnchor.constraint(equalToConstant: Self.lineHeight).isActive = true + } + ring.translatesAutoresizingMaskIntoConstraints = false + ring.setContentCompressionResistancePriority(.required, for: .horizontal) + NSLayoutConstraint.activate([ + ring.widthAnchor.constraint(equalToConstant: Self.ringDiameter), + ring.heightAnchor.constraint(equalToConstant: Self.ringDiameter), + ]) + + for line in [firstLine, secondLine] { + line.axis = .horizontal + line.alignment = .center + line.spacing = Self.ringGap + } + firstLine.addArrangedSubview(firstLabel) + secondLine.addArrangedSubview(secondLabel) + + let caption = UIStackView(arrangedSubviews: [firstLine, secondLine]) + caption.axis = .vertical + caption.alignment = .center + caption.setContentHuggingPriority(.required, for: .horizontal) + + let row = UIStackView(arrangedSubviews: [leadingRule, caption, trailingRule]) + row.axis = .horizontal + // Top-aligned, with each rule one line tall: a caption that wraps keeps its rules beside + // line 1. + row.alignment = .top + row.spacing = Self.ruleGap + row.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(row) + + captionWidth = caption.widthAnchor.constraint(lessThanOrEqualToConstant: Self.textMaxWidth) + NSLayoutConstraint.activate([ + row.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: ChatColumnCell.rowInset), + row.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -ChatColumnCell.rowInset), + row.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Self.verticalPadding), + row.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Self.verticalPadding), + leadingRule.widthAnchor.constraint(equalTo: trailingRule.widthAnchor), + leadingRule.widthAnchor.constraint(greaterThanOrEqualToConstant: Self.ruleMinWidth), + leadingRule.heightAnchor.constraint(equalToConstant: Self.lineHeight), + trailingRule.heightAnchor.constraint(equalToConstant: Self.lineHeight), + captionWidth, + ]) + leadingRule.layer.opacity = Self.ruleOpacity + trailingRule.layer.opacity = Self.ruleOpacity + + isAccessibilityElement = true + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// Draws `event`, cut to fit the narrower of the system row's 250 points and `maxWidth`, the + /// room the row has between its rules. + public func configure(with event: ChatRequestEvent, maxWidth: CGFloat) { + let width = min(Self.textMaxWidth, maxWidth) + captionWidth.constant = width + let showsRing = event.ringFraction != nil + let fit = event.fit(maxWidth: Double(width)) { caption, title in + Double(Self.width(of: Self.line(caption: caption, amount: event.amount, title: title, separator: event.titleSeparator), ring: showsRing && title != nil)) + } + + let showsRules: Bool = switch event.kind { + case .sharePayment: true + case .markedPaid, .closed: false + } + leadingRule.alpha = showsRules ? 1 : 0 + trailingRule.alpha = showsRules ? 1 : 0 + + if fit.wraps { + firstLabel.attributedText = Self.line(caption: fit.caption, amount: event.amount, title: nil, separator: "") + secondLabel.attributedText = fit.title.map { Self.text($0, font: Self.font, color: Self.textColor) } + } else { + firstLabel.attributedText = Self.line(caption: fit.caption, amount: event.amount, title: fit.title, separator: event.titleSeparator) + secondLabel.attributedText = nil + } + secondLine.isHidden = !fit.wraps + + // The ring follows the title onto whichever line holds it. + ring.removeFromSuperview() + if let fraction = event.ringFraction, fit.title != nil { + (fit.wraps ? secondLine : firstLine).addArrangedSubview(ring) + // Keyed by row, so a recycled cell shows the new row's ring without sweeping to it. + ring.configuration = UIHostingConfiguration { RequestEventRing(fraction: fraction, rowID: event.id) } + .margins(.all, 0) + } + + accessibilityLabel = event.spokenText + accessibilityTraits = event.requestID == nil ? .staticText : [.staticText, .button] + } + + /// One line of the row: the caption, the amount, and the title after `separator`. + private static func line(caption: [ChatRequestEvent.Span], amount: String?, title: String?, separator: String) -> NSAttributedString { + let line = NSMutableAttributedString() + for span in caption { + line.append(text(span.text, font: span.isEmphasized ? emphasisFont : font, color: textColor)) + } + if let amount { + line.append(text(" ", font: font, color: textColor)) + line.append(text(amount, font: emphasisFont, color: amountColor)) + } + if let title { + line.append(text(separator + title, font: font, color: textColor)) + } + return line + } + + private static func text(_ string: String, font: UIFont, color: UIColor) -> NSAttributedString { + NSAttributedString(string: string, attributes: [.font: font, .foregroundColor: color]) + } + + private static func width(of line: NSAttributedString, ring: Bool) -> CGFloat { + ceil(line.size().width) + (ring ? ringGap + ringDiameter : 0) + } +} + +/// The request's progress ring at the row's size: the deck's ring, scaled down whole so its stroke +/// and "$" shrink with it. +private struct RequestEventRing: View { + + let fraction: Double + let rowID: String + + /// The ring's own smallest size, which the row scales from. + private static let sourceDiameter: CGFloat = 26 + + var body: some View { + PaymentRequestRing(fraction: fraction, diameter: Self.sourceDiameter) + .scaleEffect(ChatRequestEventCell.ringDiameter / Self.sourceDiameter) + .frame(width: ChatRequestEventCell.ringDiameter, height: ChatRequestEventCell.ringDiameter) + .id(rowID) + } +} + +/// A one-point rule in the success colour, drawn through the middle of the view: solid for the +/// half beside the text, then fading to clear at its outer end. +private final class FadingRuleView: UIView { + + enum Edge { case leading, trailing } + + private let gradient = CAGradientLayer() + + init(fadesToward edge: Edge) { + super.init(frame: .zero) + let color = UIColor(Color.textSuccess) + layer.addSublayer(gradient) + gradient.startPoint = CGPoint(x: 0, y: 0.5) + gradient.endPoint = CGPoint(x: 1, y: 0.5) + let clear = color.withAlphaComponent(0).cgColor + switch edge { + case .leading: + gradient.colors = [clear, color.cgColor, color.cgColor] + gradient.locations = [0, 0.5, 1] + case .trailing: + gradient.colors = [color.cgColor, color.cgColor, clear] + gradient.locations = [0, 0.5, 1] + } + setContentHuggingPriority(.defaultLow, for: .horizontal) + setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + isUserInteractionEnabled = false + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + CATransaction.begin() + CATransaction.setDisableActions(true) + gradient.frame = CGRect(x: 0, y: (bounds.height - 1) / 2, width: bounds.width, height: 1) + CATransaction.commit() + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index ab4a2b2d7..f0fcd05d7 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -159,6 +159,12 @@ public final class ChatViewController: UICollectionViewController { static let maxCardLayoutWidth: CGFloat = 440 /// The transcript width a card is sized against, given the collection view's `width`. + /// The room a request row's text has in a transcript `width` wide, once its two rules have + /// their shortest length and their gaps. + static func requestEventTextWidth(_ width: CGFloat) -> CGFloat { + width - 2 * (ChatColumnCell.rowInset + 16 + 8) + } + static func cardLayoutWidth(_ width: CGFloat) -> CGFloat { min(width, maxCardLayoutWidth) } @@ -198,7 +204,7 @@ public final class ChatViewController: UICollectionViewController { configure(cell, with: message) case .typingIndicator(let typists): (cell as? ChatTypingIndicatorCell)?.configure(typists: typists, imageData: authorAvatars) - case .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage, .inviteCard: + case .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage, .requestEvent, .inviteCard: continue } } @@ -429,6 +435,7 @@ public final class ChatViewController: UICollectionViewController { collectionView.register(ChatUnreadDividerCell.self, forCellWithReuseIdentifier: ChatUnreadDividerCell.reuseIdentifier) collectionView.register(ChatEncryptionMarkerCell.self, forCellWithReuseIdentifier: ChatEncryptionMarkerCell.reuseIdentifier) collectionView.register(ChatSystemMessageCell.self, forCellWithReuseIdentifier: ChatSystemMessageCell.reuseIdentifier) + collectionView.register(ChatRequestEventCell.self, forCellWithReuseIdentifier: ChatRequestEventCell.reuseIdentifier) collectionView.register(ChatInviteCardCell.self, forCellWithReuseIdentifier: ChatInviteCardCell.reuseIdentifier) collectionView.register(ChatTypingIndicatorCell.self, forCellWithReuseIdentifier: ChatTypingIndicatorCell.reuseIdentifier) @@ -633,7 +640,7 @@ public final class ChatViewController: UICollectionViewController { let arrivals = (start..<new.count).compactMap { row -> (row: Int, message: ChatMessage)? in switch new[row] { case .message(let message): (row, message) - case .typingIndicator, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage, .inviteCard: nil + case .typingIndicator, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage, .requestEvent, .inviteCard: nil } } guard let taker = TypingDotsHandoff.takerIndex( @@ -660,7 +667,7 @@ public final class ChatViewController: UICollectionViewController { switch item { case .typingIndicator(let typists): typists.isEmpty ? [.counterpart] : Set(typists.map { .member($0.id) }) - case .message, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage, .inviteCard: + case .message, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage, .requestEvent, .inviteCard: nil } } @@ -676,7 +683,7 @@ public final class ChatViewController: UICollectionViewController { guard items.indices.contains(index) else { return nil } switch items[index] { case .message(let message): return message - case .typingIndicator, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage, .inviteCard: return nil + case .typingIndicator, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage, .requestEvent, .inviteCard: return nil } } @@ -744,6 +751,9 @@ public final class ChatViewController: UICollectionViewController { case .systemMessage(_, let text, _, let userLink): let tap: ((UserID) -> Void)? = onAuthorTap == nil ? nil : { [weak self] in self?.onAuthorTap?($0) } (cell as! ChatSystemMessageCell).configure(text: text, userLink: userLink, onUserTap: tap) + case .requestEvent(let event): + let width = collectionView.bounds.width > 0 ? collectionView.bounds.width : UIScreen.main.bounds.width + (cell as! ChatRequestEventCell).configure(with: event, maxWidth: Self.requestEventTextWidth(width)) case .inviteCard(let groupName): let width = collectionView.bounds.width > 0 ? collectionView.bounds.width : UIScreen.main.bounds.width let card = cell as! ChatInviteCardCell @@ -990,11 +1000,16 @@ public final class ChatViewController: UICollectionViewController { /// The id of the payment request whose card is at `indexPath`, or nil if that row isn't one or /// its message failed to send (the tap retries it instead). private func paymentRequestID(at indexPath: IndexPath) -> Data? { - guard items.indices.contains(indexPath.item), - case .message(let message) = items[indexPath.item], - case .paymentRequest(let request) = message.content, - !message.isFailed else { return nil } - return request.requestID + guard items.indices.contains(indexPath.item) else { return nil } + switch items[indexPath.item] { + case .message(let message): + guard case .paymentRequest(let request) = message.content, !message.isFailed else { return nil } + return request.requestID + case .requestEvent(let event): + return event.requestID + case .typingIndicator, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage, .inviteCard: + return nil + } } /// The stable id of the cash message at `indexPath`, or nil if that row isn't a cash card. The @@ -1132,7 +1147,7 @@ public final class ChatViewController: UICollectionViewController { /// Scrolls the given row into view, or waits for the update that brings it in. public func scrollToMessage(id: String) { - guard items.contains(where: { $0.messageID == id }) else { + guard items.contains(where: { $0.draws(messageID: id) }) else { pendingScrollTargetID = id return } @@ -1147,7 +1162,7 @@ public final class ChatViewController: UICollectionViewController { @discardableResult private func performPendingScrollIfLanded() -> Bool { guard let target = pendingScrollTargetID, - items.contains(where: { $0.messageID == target }) else { return false } + items.contains(where: { $0.draws(messageID: target) }) else { return false } pendingScrollTargetID = nil needsInitialScroll = false scrollToRow(id: target, animated: false) @@ -1165,7 +1180,7 @@ public final class ChatViewController: UICollectionViewController { /// settled by re-anchoring on the target until the rows around it have self-sized, then the scroll /// runs to that measured offset. private func scrollToRow(id: String, animated: Bool) { - guard let index = items.firstIndex(where: { $0.messageID == id }) else { return } + guard let index = items.firstIndex(where: { $0.draws(messageID: id) }) else { return } let indexPath = IndexPath(item: index, section: 0) positionClaim += 1 let claim = positionClaim @@ -1245,7 +1260,7 @@ public final class ChatViewController: UICollectionViewController { for cell in bubbleCells(forStableID: stableID) { cell.flashAttention(startedAt: now) } for indexPath in collectionView.indexPathsForVisibleItems { guard let cell = collectionView.cellForItem(at: indexPath) else { continue } - markAttention(cell, isTarget: items.indices.contains(indexPath.item) && items[indexPath.item].messageID == stableID) + markAttention(cell, isTarget: items.indices.contains(indexPath.item) && items[indexPath.item].draws(messageID: stableID)) } } @@ -1266,7 +1281,7 @@ public final class ChatViewController: UICollectionViewController { private func reattachAttention(to cell: UICollectionViewCell, at indexPath: IndexPath) { guard let attention, items.indices.contains(indexPath.item), - items[indexPath.item].messageID == attention.id else { + items[indexPath.item].draws(messageID: attention.id) else { markAttention(cell, isTarget: false) return } @@ -1357,7 +1372,7 @@ public final class ChatViewController: UICollectionViewController { /// quote jump's scroll and flash, or to the newest message once there are none. func jumpFromJumpButton() { let items = items - if let origin = replyReturns.popOrigin(exists: { id in items.contains { $0.messageID == id } }) { + if let origin = replyReturns.popOrigin(exists: { id in items.contains { $0.draws(messageID: id) } }) { scrollToRow(id: origin, animated: true) return } @@ -1378,7 +1393,7 @@ public final class ChatViewController: UICollectionViewController { guard !replyReturns.isEmpty, !isUpdating else { return } let visibleBounds = chatLayout.visibleBounds replyReturns.dropReached { id in - guard let index = items.firstIndex(where: { $0.messageID == id }), + guard let index = items.firstIndex(where: { $0.draws(messageID: id) }), let frame = chatLayout.layoutAttributesForItem(at: IndexPath(item: index, section: 0))?.frame else { return false } return Self.isReached(frame, in: visibleBounds) @@ -1546,6 +1561,8 @@ public final class ChatViewController: UICollectionViewController { return ReadProgress.VisibleMessage(id: serverID, isFromSelf: isFromSelf) case .systemMessage(_, _, let serverID, _): return serverID.map { ReadProgress.VisibleMessage(id: $0, isFromSelf: false) } + case .requestEvent(let event): + return event.serverID.map { ReadProgress.VisibleMessage(id: $0, isFromSelf: false) } case .typingIndicator, .dateSeparator, .unreadDivider, .encryptionMarker, .inviteCard: return nil } @@ -1791,7 +1808,7 @@ extension ChatViewController: ChatLayoutDelegate { guard items.indices.contains(indexPath.item) else { return false } switch items[indexPath.item] { case .dateSeparator, .unreadDivider, .encryptionMarker: return true - case .message, .typingIndicator, .systemMessage, .inviteCard: return false + case .message, .typingIndicator, .systemMessage, .requestEvent, .inviteCard: return false } } @@ -1808,7 +1825,7 @@ extension ChatViewController: ChatLayoutDelegate { switch items[indexPath.item] { case .message(let message): return message.sender case .typingIndicator: return .other - case .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage, .inviteCard: return nil + case .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage, .requestEvent, .inviteCard: return nil } } } @@ -2051,7 +2068,7 @@ extension ChatViewController { /// The on-screen cell drawing the first row of the message with `stableID` — the row a split /// message's quote heads, and the one an edit spotlights. private func bubbleCell(forStableID stableID: String) -> BubbleCarrying? { - guard let item = items.firstIndex(where: { $0.messageID == stableID }) else { return nil } + guard let item = items.firstIndex(where: { $0.draws(messageID: stableID) }) else { return nil } return collectionView.cellForItem(at: IndexPath(item: item, section: 0)) as? BubbleCarrying } @@ -2059,7 +2076,7 @@ extension ChatViewController { /// of a message that was split around its card. private func bubbleCells(forStableID stableID: String) -> [BubbleCarrying] { items.indices - .filter { items[$0].messageID == stableID } + .filter { items[$0].draws(messageID: stableID) } .compactMap { collectionView.cellForItem(at: IndexPath(item: $0, section: 0)) as? BubbleCarrying } } } diff --git a/NotificationContent/NotificationTranscriptView.swift b/NotificationContent/NotificationTranscriptView.swift index 5c52b7b61..c0faddda6 100644 --- a/NotificationContent/NotificationTranscriptView.swift +++ b/NotificationContent/NotificationTranscriptView.swift @@ -35,7 +35,7 @@ struct NotificationTranscriptView: View { case .dateSeparator(_, let text): NotificationDateSeparator(text: text) .transition(.opacity) - case .typingIndicator, .unreadDivider, .encryptionMarker, .systemMessage, .inviteCard: + case .typingIndicator, .unreadDivider, .encryptionMarker, .systemMessage, .requestEvent, .inviteCard: // Typing is live, ephemeral state; the profile card and the // unread divider are in-app chrome — none belongs in a static // notification snapshot and the preview mapping never emits