From ebfb935bba323ae85b8f35a1c59c6213e91ca581 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 07:02:16 -0400 Subject: [PATCH 1/5] fix(chat): keep server system messages and render them as an info row Server SystemContent messages map to Content.system(fallbackText:), stored as kind 6 (schemaVersion 51). Blank text is stored as "" and draws no row; the chat-list preview skips blank notices. A notice never counts as unread, never anchors a receipt, and does not trigger the receive haptic. --- .../Controllers/ConversationController.swift | 8 +- .../Conversation/ChatItem+Conversation.swift | 27 ++++-- .../Conversation/ConversationScreen.swift | 12 ++- .../Core/Spotlight/ChatSpotlightItem.swift | 1 + .../FlipcashCore/Models/Chat/ChatItem.swift | 6 +- .../Models/Chat/ChatPreviewMapping.swift | 11 ++- .../Conversation/ConversationMessage.swift | 37 +++++++- .../Conversation/ConversationStore.swift | 6 +- .../Conversation/MessageCapability.swift | 2 +- .../Push/NotificationPayload.swift | 2 +- .../Database+Conversations.swift | 22 ++++- .../Sources/FlipcashStore/Database.swift | 2 +- .../ConversationModelMappingTests.swift | 66 +++++++++++++ .../ConversationStoreMutationTests.swift | 1 + .../Push/NotificationPayloadTests.swift | 2 +- .../Chat/ConversationMediaSendTests.swift | 6 +- .../ConversationTypingIndicatorTests.swift | 4 +- .../Database+ConversationsTests.swift | 2 +- .../Chat/ChatItem+Differentiable.swift | 2 + .../Chat/ChatSystemMessageCell.swift | 94 +++++++++++++++++++ .../FlipcashUI/Chat/ChatViewController.swift | 22 +++-- .../NotificationTranscriptView.swift | 2 +- 22 files changed, 292 insertions(+), 45 deletions(-) create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 218369ec9..60094bb3d 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1677,6 +1677,10 @@ final class ConversationController { case .deleted: return nil + case .system(let text): + // Server-authored, so no sender prefix; a blank one previews as nothing, like an empty body. + return text.isEmpty ? nil : text + case .widget(.shareProfile): // The server sends it; no user is the sender to name. return "Shared a profile" @@ -2124,7 +2128,7 @@ final class ConversationController { _ = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: pending.repliedTo, to: conversationID) case .media(_, let caption): await retryMedia(clientMessageID: clientMessageID, caption: caption, repliedTo: pending.repliedTo, in: conversationID) - case .encrypted, .cash, .deleted, .widget: + case .encrypted, .cash, .deleted, .widget, .system: if case .failure(let error) = Result(catching: { try pending.content.asProto() }) { logger.error("Cannot retry a send this client has no path to re-send", metadata: [ "conversationID": "\(conversationID)", @@ -2400,7 +2404,7 @@ final class ConversationController { switch message.content { case .media(let attachments, _): return attachments.contains { $0.blobID == blobID } - case .text, .cash, .deleted, .encrypted, .widget: + case .text, .cash, .deleted, .encrypted, .widget, .system: return false } } diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 7630e9bd9..5d38e37f3 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -110,10 +110,11 @@ extension ChatItem { // Tombstoned (deleted) messages are retained in the store for gapless ordering. Under // `.hidden` they are dropped up front so they never skew a date separator, group an adjacent // bubble to an invisible row, or steal the "Delivered"/"Read" receipt anchor below. - // A message still waiting on the peer's key is left out until it decrypts. + // A message still waiting on the peer's key is left out until it decrypts, and a blank + // system message draws nothing, so it must not head a separator either. let messages: [ConversationMessage] = switch deletedPresentation { - case .hidden: messages.filter { !$0.isDeleted && !$0.isAwaitingDecryption } - case .placeholder: messages.filter { !$0.isAwaitingDecryption } + case .hidden: messages.filter { !$0.isDeleted && !$0.isAwaitingDecryption && !$0.isBlankSystem } + case .placeholder: messages.filter { !$0.isAwaitingDecryption && !$0.isBlankSystem } } // The marker goes where the ciphertext starts: above the first encrypted message with @@ -164,9 +165,14 @@ extension ChatItem { func isEmojiOnlyBody(_ message: ConversationMessage) -> Bool { switch message.content { case .text(let text): EmojiOnlyDetector.isEmojiOnly(text) - case .cash, .deleted, .encrypted, .widget, .media: false + case .cash, .deleted, .encrypted, .widget, .media, .system: false } } + // A system row has no sender, like a widget, so the author comparison below would otherwise + // join a bubble to it. + func isSystem(_ message: ConversationMessage) -> Bool { + if case .system = message.content { true } else { false } + } func rendersBare(_ message: ConversationMessage) -> Bool { message.repliedTo == nil && isEmojiOnlyBody(message) } @@ -177,7 +183,7 @@ extension ChatItem { let layouts = messages.map { message in switch message.content { case .text(let text): Self.rows(for: text, preview: detectedLink(in: text, card: linkCard)) - case .cash, .deleted, .encrypted, .widget, .media: [RowLayout(part: nil, text: nil, preview: nil)] + case .cash, .deleted, .encrypted, .widget, .media, .system: [RowLayout(part: nil, text: nil, preview: nil)] } } // A status line under a message sits between it and the next bubble, so it ends the bubble @@ -227,10 +233,10 @@ 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 && !showsSeparator && !showsDivider && !showsMarker + $0.senderID == message.senderID && !isSystem($0) && !showsSeparator && !showsDivider && !showsMarker } ?? false let groupedBelow = next.map { - $0.senderID == message.senderID && !separates($0, from: message) && !divides($0, from: message) + $0.senderID == message.senderID && !isSystem($0) && !separates($0, from: message) && !divides($0, from: message) && index + 1 != markerIndex } ?? false @@ -247,6 +253,9 @@ extension ChatItem { let content: ChatMessage.Content switch message.content { + case .system(let text): + items.append(.systemMessage(id: "sys-\(message.stableID)", text: text)) + continue case .text(let text): content = .text(text) case .cash(let fiat): @@ -464,9 +473,9 @@ extension ChatItem { kind: .media(thumbnailBlobID: original.redacted ? nil : attachments.first?.blobID, sealed: attachments.first?.sealed), authorID: original.senderID ) - case .encrypted: + case .encrypted, .system: // No plaintext to preview -- same unavailable treatment as a quote whose original the - // local database never saw. + // local database never saw. A notice offers no Reply, so a quote of one is unreachable. return ChatQuote( stableID: nil, authorName: authorName, diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 6abea8a33..2481d58dd 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -366,7 +366,8 @@ struct ConversationScreen: View { } } - /// The newest server-confirmed message — what the receive buzz and mark-read track. Optimistic + /// The newest server-confirmed message — what the receive buzz and mark-read track. A system + /// message is included; ``ConversationMessage/buzzesOnReceive(selfUserID:)`` withholds only the buzz. Optimistic /// pending sends render after the confirmed run, so they must not drive these signals (an unresolved /// send would otherwise sit at the transcript's tail and mask incoming messages). private var latestConfirmedMessage: ConversationMessage? { @@ -829,11 +830,12 @@ struct ConversationScreen: View { } // Buzz on a live message from the other side while this conversation is on screen. `old != nil` // and `didInitialRead` skip the opening history load; the sender and visibility checks skip the - // user's own sends and arrivals in a chat they've navigated away from. + // user's own sends and arrivals in a chat they've navigated away from. A server notice doesn't + // buzz, though `latestConfirmedMessage` still includes it so mark-read sees it. .sensoryFeedback(.impact(weight: .light), trigger: latestConfirmedMessage?.stableID) { old, _ in guard old != nil, didInitialRead, let last = latestConfirmedMessage, - !last.isFromSelf(conversationController.selfUserID), + last.buzzesOnReceive(selfUserID: conversationController.selfUserID), conversationController.visibleConversationID == conversationID else { return false } return true @@ -1111,7 +1113,7 @@ struct ConversationScreen: View { (ChatQuote.deletedSnippet, .unavailable) case .widget(.shareProfile): (ChatQuote.sharedProfileSnippet, .text) - case .encrypted, .widget(.unrecognized): + case .encrypted, .widget(.unrecognized), .system: (ChatQuote.unavailableSnippet, .unavailable) } } @@ -1174,7 +1176,7 @@ struct ConversationScreen: View { case .cash(let fiat): Analytics.tokenInfoOpened(from: .openedFromChat, mint: fiat.mint) router.push(.currencyInfo(fiat.mint)) - case .text, .deleted, .encrypted, .widget, .media: + case .text, .deleted, .encrypted, .widget, .media, .system: break } } diff --git a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift index 1167e363e..c635c6fed 100644 --- a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift +++ b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift @@ -61,6 +61,7 @@ nonisolated struct ChatSpotlightItem { case .cash(let amount): "Cash · \(amount.nativeAmount.formatted())" case .widget(.shareProfile): "Shared a profile" case .media(_, let caption): ChatMediaStrings.listPreview(caption: caption) + case .system(let text): text.isEmpty ? nil : text case .deleted, .encrypted, .widget(.unrecognized), nil: nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift index 19f719c9d..529a9e978 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift @@ -25,6 +25,9 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { /// The "Encrypted" line above the first end-to-end-encrypted message in the transcript, or at /// its head when every message is encrypted. At most one per transcript. case encryptionMarker + /// A server-authored notice drawn as a leading-aligned line, not a bubble. `text` is the server's + /// fallback text, shown unmodified. + case systemMessage(id: String, text: String) public var id: String { switch self { @@ -34,6 +37,7 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { // Independent of `typists`, so a typist joining or leaving updates the row in place. case .typingIndicator: "typing-indicator" case .encryptionMarker: "encryption-marker" + case .systemMessage(let id, _): id } } @@ -45,7 +49,7 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { public var messageID: String? { switch self { case .message(let message): message.messageID - case .dateSeparator, .unreadDivider, .typingIndicator, .encryptionMarker: nil + case .dateSeparator, .unreadDivider, .typingIndicator, .encryptionMarker, .systemMessage: nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift index c8180173d..b529f21aa 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift @@ -48,7 +48,12 @@ extension ChatItem { // preview) and leave an orphaned leading separator. let sorted = messages // Nor messages still waiting on the peer's key, which the transcript doesn't show either. - .filter { if case .deleted = $0.content { false } else { !$0.isAwaitingDecryption } } + .filter { + switch $0.content { + case .deleted, .system: false // a notice has no preview row, so it must not open a separator + case .text, .cash, .encrypted, .widget, .media: !$0.isAwaitingDecryption + } + } .sorted { $0.id < $1.id } let slice = sorted.suffix(limit) @@ -105,6 +110,8 @@ extension ChatItem { )) case .deleted: continue // filtered out above; unreachable, kept for switch exhaustiveness + case .system: + continue // filtered out above; unreachable, kept for switch exhaustiveness } // The same body test the transcript runs, so a notification preview draws a bare emoji @@ -113,7 +120,7 @@ extension ChatItem { let isEmojiOnly: Bool switch message.content { case .text(let text): isEmojiOnly = EmojiOnlyDetector.isEmojiOnly(text) - case .cash, .deleted, .encrypted, .widget, .media: isEmojiOnly = false + case .cash, .deleted, .encrypted, .widget, .media, .system: isEmojiOnly = false } items.append(.message(ChatMessage( diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index da5ec3130..3081b392b 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -59,6 +59,9 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { /// redacted copy keeps its dimensions and blurhash and stays `.media`, with /// ``ConversationMessage/redacted`` saying its bytes may not be fetched. case media([MediaAttachment], caption: String?) + /// A server-authored notice (for example "Alex joined the group"), drawn as a transcript row + /// rather than a bubble. Blank text is stored as `""` and draws nothing. + case system(fallbackText: String) } /// The widget a `.widget` message carries. Sent in the clear only: the E2EE allow-list is text, @@ -272,11 +275,29 @@ extension ConversationMessage { } } +extension ConversationMessage { + /// `true` for a system message with no text, which draws nothing and has nothing to preview. + public var isBlankSystem: Bool { + if case .system("") = content { true } else { false } + } +} + extension ConversationMessage { /// `true` when this message was sent by the given user. public func isFromSelf(_ selfUserID: UserID) -> Bool { senderID == 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 { + switch content { + case .system: + false + case .text, .cash, .deleted, .encrypted, .widget, .media: + !isFromSelf(selfUserID) + } + } } extension ConversationMessage { @@ -346,8 +367,11 @@ extension ConversationMessage { self.content = .widget(widgetContent.shareProfile.map(Widget.shareProfile) ?? .unrecognized) self.cashAction = nil repliedTo = nil - // `.system` is dropped by design: the message is not stored and not shown. - case .system, .none: + case .system(let systemContent): + self.content = Content(systemContent) + self.cashAction = nil + repliedTo = nil + case .none: return nil } @@ -410,11 +434,18 @@ extension ConversationMessage.Content { } }) } - case .cash, .deleted, .widget: + case .cash, .deleted, .widget, .system: throw ConversationMessageContentEncodingError.unsupported(self) } } + /// The `.system` content `proto` describes. Blank text becomes `""` and is kept, not dropped, so the + /// message still anchors the read pointer and the unread divider. + init(_ proto: Flipcash_Messaging_V1_SystemContent) { + let text = proto.fallbackText + self = .system(fallbackText: text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "" : text) + } + /// The `.media` content `proto` describes, or `nil` when its first item has no ORIGINAL rendition. init?(_ proto: Flipcash_Messaging_V1_MediaContent) { guard let attachment = proto.items.first.flatMap(MediaAttachment.init) else { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index 3fa164e9d..de569791d 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -622,13 +622,15 @@ public struct ConversationStore: Sendable { /// `conversation` with a feed preview the row can actually draw: the newest message that still /// has content. Server metadata reports the newest message whatever its state, so a delete /// arrives here as a tombstone — which renders as a blank row with an unread splat beside it. - /// It falls back to the visible preview the row already carries, and to nothing when there is + /// A blank system message has nothing to preview either. It falls back to the visible preview the row already carries, and to nothing when there is /// none; ``setFeedPreview(_:in:force:)`` then fills it from the database. /// /// The fallback is skipped when the tombstone replaces the very message the row was previewing, /// which would leave deleted content on screen. private func seated(_ conversation: Conversation) -> Conversation { - guard let tombstone = conversation.lastMessage, tombstone.isDeleted else { return conversation } + guard let tombstone = conversation.lastMessage, tombstone.isDeleted || tombstone.isBlankSystem else { + return conversation + } var conversation = conversation let current = conversations.first { $0.id == conversation.id }?.lastMessage conversation.lastMessage = current.flatMap { current in diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index d82a2c98d..387c4db75 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -96,7 +96,7 @@ extension MessageCapability { // Media carries no text, so it offers neither copy nor edit — matching Android's resolver. let hasText: Bool switch message.content { - case .deleted, .encrypted: + case .deleted, .encrypted, .system: // Nothing is left to act on: a tombstone must not be re-deleted, and this client has no // plaintext to copy, quote, or edit for an encrypted message it cannot decrypt. return [] diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index 17d2ae10a..a602344be 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -94,7 +94,7 @@ public enum NotificationPayload { switch message.content { case .text(let text): return text - case .cash, .deleted, .encrypted, .widget, .media: + case .cash, .deleted, .encrypted, .widget, .media, .system: return nil } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 977c4db12..595f9bfcb 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -124,8 +124,14 @@ nonisolated extension Database { private func latestMessage(conversationId: Data) throws -> ConversationMessage? { let m = ConversationMessageTable() guard let row = try reader.pluck( - // Neither a tombstone nor an encrypted row still waiting on the peer's key. - m.table.filter(m.conversationId == conversationId && m.kind != 2 && (m.kind != 3 || m.decryptFailure != nil)).order(m.id.desc) + // Neither a tombstone, an encrypted row still waiting on the peer's key, nor a blank system + // message, which has nothing to preview. + m.table.filter( + m.conversationId == conversationId + && m.kind != 2 + && (m.kind != 3 || m.decryptFailure != nil) + && (m.kind != 6 || m.text != "") + ).order(m.id.desc) ) else { return nil } @@ -238,7 +244,7 @@ nonisolated extension Database { /// The number of stored messages after `after` that someone other than `selfUserID` sent and /// that are not deleted — the count the unread divider shows. A row with no sender counts as - /// someone else's. Index-backed by the composite `(conversationId, id)` primary key. + /// someone else's, except a system message, which is never unread. Index-backed by the composite `(conversationId, id)` primary key. public func inboundMessageCount(conversationID: ConversationID, after: MessageID, excludingSender selfUserID: UUID) throws -> Int { let m = ConversationMessageTable() return try reader.scalar( @@ -246,6 +252,7 @@ nonisolated extension Database { m.conversationId == conversationID.data && m.id > after.value && m.kind != 2 + && m.kind != 6 && (m.senderId == nil || m.senderId != selfUserID) ).count ) @@ -267,13 +274,14 @@ nonisolated extension Database { /// The oldest stored message id after `after` that someone other than `selfUserID` sent, deleted /// or not, or nil when there is none — where the unread divider goes. A row with no sender counts - /// as someone else's. + /// as someone else's, except a system message, so the divider never lands above one. public func firstInboundMessageID(conversationID: ConversationID, after: MessageID, excludingSender selfUserID: UUID) throws -> UInt64? { let m = ConversationMessageTable() return try reader.pluck( m.table.filter( m.conversationId == conversationID.data && m.id > after.value + && m.kind != 6 && (m.senderId == nil || m.senderId != selfUserID) ).order(m.id.asc) ).map { $0[m.id] } @@ -621,6 +629,9 @@ nonisolated extension Database { case .shareProfile(let share): text = share.username.value case .unrecognized: text = nil } + case .system(let fallbackText): + kind = 6 + text = fallbackText case .media(let attachments, let caption): kind = 5 mediaJson = try JSONEncoder().encode( @@ -876,6 +887,9 @@ nonisolated extension Database { } content = .media(media.attachments, caption: media.caption) redacted = media.redacted + case 6: + guard let text = row[m.text] else { return nil } + content = .system(fallbackText: text) default: return nil } diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index 2bf057aa9..7a7e62f3c 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 = 50 + public static let schemaVersion = 51 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 9a16b6aea..520f27eb1 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -32,6 +32,72 @@ struct ConversationModelMappingTests { #expect(message.unreadSeq == 3) } + @Test("System content maps to .system with its fallback text and no sender") + func systemMessageParses() throws { + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 9 } + $0.content = [.with { $0.system = .with { $0.fallbackText = "Alex joined the group" } }] + } + + let message = try #require(ConversationMessage(proto)) + #expect(message.content == .system(fallbackText: "Alex joined the group")) + #expect(message.senderID == nil) + #expect(message.isBlankSystem == false) + } + + @Test("Blank system text maps to an empty .system rather than dropping the message", arguments: ["", " ", "\n\t", "\u{85}", "\u{A0}"]) + func blankSystemMessageIsKeptEmpty(text: String) throws { + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 9 } + $0.content = [.with { $0.system = .with { $0.fallbackText = text } }] + } + + let message = try #require(ConversationMessage(proto)) + #expect(message.content == .system(fallbackText: "")) + #expect(message.isBlankSystem) + } + + @Test("A control character is not whitespace, so that system text is kept as-is") + func controlCharacterSystemTextIsKept() throws { + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 9 } + $0.content = [.with { $0.system = .with { $0.fallbackText = "\u{1F}" } }] + } + + let message = try #require(ConversationMessage(proto)) + #expect(message.content == .system(fallbackText: "\u{1F}")) + } + + @Test("Non-blank system text is not trimmed") + func systemTextIsNotTrimmed() throws { + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 9 } + $0.content = [.with { $0.system = .with { $0.fallbackText = " Alex joined \n" } }] + } + + #expect(try #require(ConversationMessage(proto)).content == .system(fallbackText: " Alex joined \n")) + } + + @Test("A system message never buzzes; someone else's text does, your own does not") + func receiveBuzz() { + let me = UserID() + let them = UUID() + func message(_ content: ConversationMessage.Content, from sender: UUID?) -> ConversationMessage { + ConversationMessage(id: MessageID(value: 1), senderID: sender, content: content, date: .now, unreadSeq: 1) + } + #expect(message(.system(fallbackText: "Alex joined the group"), from: nil).buzzesOnReceive(selfUserID: me) == false) + #expect(message(.system(fallbackText: "Alex joined the group"), from: them).buzzesOnReceive(selfUserID: me) == false) + #expect(message(.text("hi"), from: them).buzzesOnReceive(selfUserID: me)) + #expect(message(.text("hi"), from: me).buzzesOnReceive(selfUserID: me) == false) + } + + @Test("System content cannot be re-encoded for sending") + func systemContentHasNoProto() { + #expect(throws: ConversationMessageContentEncodingError.self) { + try ConversationMessage.Content.system(fallbackText: "x").asProto() + } + } + @Test("Cash message maps the payment amount") func cashMessageParses() throws { let mintBytes = Data(repeating: 0x02, count: 32) diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift index 7f3376a1f..5223d8c26 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift @@ -34,6 +34,7 @@ struct ConversationStoreMutationTests { case .encrypted: "" case .widget: "" case .media: "" + case .system: "" } } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift index b2fe28a0d..0d6b7fb88 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift @@ -238,7 +238,7 @@ struct NotificationPayloadTests { func chatMessageNilForUnrepresentableContent() throws { let embedded = Flipcash_Messaging_V1_Message.with { $0.messageID = .with { $0.value = 42 } - $0.content = [.with { $0.system = .with { _ in } }] + $0.content = [] } #expect(NotificationPayload.chatMessage(try Self.chatPush(message: embedded)) == nil) } diff --git a/FlipcashTests/Chat/ConversationMediaSendTests.swift b/FlipcashTests/Chat/ConversationMediaSendTests.swift index 3c68d8985..da3e3e42f 100644 --- a/FlipcashTests/Chat/ConversationMediaSendTests.swift +++ b/FlipcashTests/Chat/ConversationMediaSendTests.swift @@ -68,7 +68,7 @@ struct ConversationMediaSendTests { controller.messages(for: conversationID).filter { switch $0.content { case .media: $0.clientMessageID != nil && $0.id == .unassigned - case .text, .cash, .deleted, .encrypted, .widget: false + case .text, .cash, .deleted, .encrypted, .widget, .system: false } } } @@ -76,7 +76,7 @@ struct ConversationMediaSendTests { private func attachment(of message: ConversationMessage) -> MediaAttachment? { switch message.content { case .media(let attachments, _): attachments.first - case .text, .cash, .deleted, .encrypted, .widget: nil + case .text, .cash, .deleted, .encrypted, .widget, .system: nil } } @@ -373,7 +373,7 @@ private extension ConversationMessage.Content { var mediaCaption: String? { switch self { case .media(_, let caption): caption - case .text, .cash, .deleted, .encrypted, .widget: nil + case .text, .cash, .deleted, .encrypted, .widget, .system: nil } } } diff --git a/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift b/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift index cbee9ab43..044155788 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: + case .message, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage: continue } } @@ -175,7 +175,7 @@ struct ConversationTypingIndicatorTests { for item in items { switch item { case .typingIndicator(let typists): return typists - case .message, .dateSeparator, .unreadDivider, .encryptionMarker: continue + case .message, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage: continue } } return nil diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 1d70d5b28..93b56ba84 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -103,7 +103,7 @@ struct DatabaseConversationsTests { switch loadedMessage.content { case .cash(let loadedExchanged): #expect(loadedExchanged.nativeAmount.value == amount) - case .text, .deleted, .encrypted, .widget, .media: + case .text, .deleted, .encrypted, .widget, .media, .system: Issue.record("Expected cash message content") } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift index 944f0ca4f..bb3589150 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift @@ -33,6 +33,8 @@ extension ChatItem { ChatUnreadDividerCell.reuseIdentifier case .encryptionMarker: ChatEncryptionMarkerCell.reuseIdentifier + case .systemMessage: + ChatSystemMessageCell.reuseIdentifier case .message(let message): switch message.content { case .text: diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift new file mode 100644 index 000000000..53dd803a0 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift @@ -0,0 +1,94 @@ +// +// ChatSystemMessageCell.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit + +/// A server-authored notice, drawn as a leading-aligned line: a ringed info glyph in the member-avatar +/// gutter, then the text at the member bubble column. No bubble, no fill, no sender, no gestures. +public final class ChatSystemMessageCell: UICollectionViewCell { + + public static let reuseIdentifier = "ChatSystemMessageCell" + + private static let slotSize = ChatAuthorAvatarView.size + private static let lineHeight: CGFloat = 16 + private static let verticalPadding: CGFloat = 6 + private static let textColor = UIColor.white.withAlphaComponent(0.5) + private static let font = UIFont.default(size: 13, weight: .regular) + + private let label = UILabel() + private let textWidth: NSLayoutConstraint + + public override init(frame: CGRect) { + textWidth = label.widthAnchor.constraint(equalToConstant: 0) + super.init(frame: frame) + + let slot = UIView() + slot.layer.cornerRadius = Self.slotSize / 2 + slot.layer.borderWidth = 1 / UITraitCollection.current.displayScale + slot.layer.borderColor = UIColor.white.withAlphaComponent(0.1).cgColor + slot.translatesAutoresizingMaskIntoConstraints = false + + let glyph = UIImageView(image: UIImage(systemName: "info.circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14))) + glyph.tintColor = Self.textColor + glyph.contentMode = .center + glyph.isAccessibilityElement = false + glyph.translatesAutoresizingMaskIntoConstraints = false + slot.addSubview(glyph) + + label.numberOfLines = 0 + label.textAlignment = .left + label.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(slot) + contentView.addSubview(label) + + // Below the width pin so a row narrower than the column squeezes the text, not the layout. + textWidth.priority = .defaultHigh + isAccessibilityElement = true + accessibilityTraits = .staticText + + let textLeading = ChatColumnCell.rowInset + ChatColumnCell.authorGutterWidth + NSLayoutConstraint.activate([ + slot.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: ChatColumnCell.rowInset), + slot.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Self.verticalPadding), + slot.widthAnchor.constraint(equalToConstant: Self.slotSize), + slot.heightAnchor.constraint(equalToConstant: Self.slotSize), + slot.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -Self.verticalPadding), + glyph.centerXAnchor.constraint(equalTo: slot.centerXAnchor), + glyph.centerYAnchor.constraint(equalTo: slot.centerYAnchor), + + // The first line centres on the slot: its 16pt box starts (24 - 16) / 2 below the slot top. + label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: textLeading), + label.topAnchor.constraint(equalTo: slot.topAnchor, constant: (Self.slotSize - Self.lineHeight) / 2), + label.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -ChatColumnCell.rowInset), + label.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -Self.verticalPadding), + textWidth, + ]) + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// Shows `text` unmodified, wrapped to `maxWidth` points. + public func configure(text: String, maxWidth: CGFloat) { + let style = NSMutableParagraphStyle() + style.minimumLineHeight = Self.lineHeight + style.maximumLineHeight = Self.lineHeight + style.alignment = .left + label.attributedText = NSAttributedString(string: text, attributes: [ + .font: Self.font, + .foregroundColor: Self.textColor, + .paragraphStyle: style, + // Fixed line boxes sit the glyphs on the bottom; this centres them in the 16pt box. + .baselineOffset: (Self.lineHeight - Self.font.lineHeight) / 2, + ]) + textWidth.constant = maxWidth + accessibilityLabel = text + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index d87cb6053..6a2925da6 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -161,7 +161,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: + case .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage: continue } } @@ -351,6 +351,7 @@ public final class ChatViewController: UICollectionViewController { collectionView.register(ChatDateSeparatorCell.self, forCellWithReuseIdentifier: ChatDateSeparatorCell.reuseIdentifier) collectionView.register(ChatUnreadDividerCell.self, forCellWithReuseIdentifier: ChatUnreadDividerCell.reuseIdentifier) collectionView.register(ChatEncryptionMarkerCell.self, forCellWithReuseIdentifier: ChatEncryptionMarkerCell.reuseIdentifier) + collectionView.register(ChatSystemMessageCell.self, forCellWithReuseIdentifier: ChatSystemMessageCell.reuseIdentifier) collectionView.register(ChatTypingIndicatorCell.self, forCellWithReuseIdentifier: ChatTypingIndicatorCell.reuseIdentifier) swipeToReply.isBlocked = { [weak self] in @@ -537,7 +538,7 @@ public final class ChatViewController: UICollectionViewController { let arrivals = (start.. (row: Int, message: ChatMessage)? in switch new[row] { case .message(let message): (row, message) - case .typingIndicator, .dateSeparator, .unreadDivider, .encryptionMarker: nil + case .typingIndicator, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage: nil } } guard let taker = TypingDotsHandoff.takerIndex( @@ -564,7 +565,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: + case .message, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage: nil } } @@ -580,7 +581,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: return nil + case .typingIndicator, .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage: return nil } } @@ -621,12 +622,21 @@ public final class ChatViewController: UICollectionViewController { case .encryptionMarker: let tap: (() -> Void)? = onEncryptionMarkerTap == nil ? nil : { [weak self] in self?.onEncryptionMarkerTap?() } (cell as! ChatEncryptionMarkerCell).configure(onTap: tap) + case .systemMessage(_, let text): + (cell as! ChatSystemMessageCell).configure(text: text, maxWidth: memberBubbleMaxWidth()) case .message(let message): configure(cell, with: message) } return cell } + /// The widest an attributed member's bubble gets: the same fraction of the row less the avatar + /// gutter that ``configure(_:with:)`` applies. A system row's text column matches it. + private func memberBubbleMaxWidth() -> CGFloat { + let width = collectionView.bounds.width > 0 ? collectionView.bounds.width : UIScreen.main.bounds.width + return (width - ChatColumnCell.authorGutterWidth) * Self.maxBubbleWidthFraction + } + /// Fills a message cell. Split out of `cellForItemAt` because an avatar landing re-runs it for /// the rows already on screen, which must render identically to a fresh dequeue. private func configure(_ cell: UICollectionViewCell, with message: ChatMessage) { @@ -1326,7 +1336,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: return false + case .message, .typingIndicator, .systemMessage: return false } } @@ -1343,7 +1353,7 @@ extension ChatViewController: ChatLayoutDelegate { switch items[indexPath.item] { case .message(let message): return message.sender case .typingIndicator: return .other - case .dateSeparator, .unreadDivider, .encryptionMarker: return nil + case .dateSeparator, .unreadDivider, .encryptionMarker, .systemMessage: return nil } } } diff --git a/NotificationContent/NotificationTranscriptView.swift b/NotificationContent/NotificationTranscriptView.swift index 62ad6030b..fd47c843d 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: + case .typingIndicator, .unreadDivider, .encryptionMarker, .systemMessage: // 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 From 09aaf7a588f5626b603612e8f9c69c0baf7a9726 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 07:02:17 -0400 Subject: [PATCH 2/5] docs(plans): record the chat system messages plan --- .../plans/2026-10-14-chat-system-messages.md | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 .claude/plans/2026-10-14-chat-system-messages.md diff --git a/.claude/plans/2026-10-14-chat-system-messages.md b/.claude/plans/2026-10-14-chat-system-messages.md new file mode 100644 index 000000000..12cb69a72 --- /dev/null +++ b/.claude/plans/2026-10-14-chat-system-messages.md @@ -0,0 +1,136 @@ +# Plan: render server system messages in private group chats (iOS) + +Repo `/Users/bmc/dev/bmcreations/code/code-ios-app`, base `main` (HEAD `7fab7a59` when read). Read-only plan. +Abbreviations: `C/` = `FlipcashCore/Sources/FlipcashCore`, `S/` = `FlipcashCore/Sources/FlipcashStore`, `UI/` = `FlipcashUI/Sources/FlipcashUI`. + +## Context +Server `SystemContent { fallback_text }` messages are mapped to nil and dropped today +(`C/Models/Conversation/ConversationMessage.swift:333-335`). Goal: keep them, persist them, show them as a +non-bubble transcript row and as the chat-list preview, and keep them out of client-computed unread. Shared +spec with Android. Working visual default is now **Option D** (Figma node 11721:918), not A. + +## Steps + +### 1. Model + mapping (FlipcashCore) +- `C/Models/Conversation/ConversationMessage.swift:47-70`: add `case system(fallbackText: String)` to `Content` with a `///` contract doc. +- `:333-335`: split `case .system, .none`. New arm `case .system(let system): guard let content = Content(system: system) else { return nil }`. +- Add `init?(system: Flipcash_Messaging_V1_SystemContent)` (fileprivate/internal, same file). Body today: trim `fallbackText` with `.whitespacesAndNewlines`; nil if empty (spec 6), else `.system(fallbackText:)`. This is the single place a future `switch system.event { case known: localized; default: fallback }` goes. Inline comment states only that constraint. +- `senderID` is already optional (`:106`, `:342`), so no sender workaround. `.widget` is the precedent for a sender-less message. +- `:308` (nested reply): leave `.system` returning nil. A reply never wraps server content. +- `:375` `asProto()`: add `.system` to the throwing `unsupported` arm (`.cash, .deleted, .widget`). +- `C/Models/Conversation/MessageCapability.swift:98-120`: `.system` returns `[]` (spec 5, parity with Android `MessageCapability`). +- Other exhaustive `Content` switches that need an arm (compiler will flag all): + - `C/Models/Chat/ChatPreviewMapping.swift:~72-107` and `:116` (notification preview): same arm as `.widget(.unrecognized)`/`.deleted`; emoji check `false`. + - `C/Push/NotificationPayload.swift:97` `decryptedBody`: nil (non-text). + - `FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift:~30-36` and `FlipcashTests/Chat/ConversationMediaSendTests.swift:372` test helpers. +- No change to `ChatMessagingService.swift:51,89,138,226,275,316`, `ConversationStreamEvent.swift:294-300`, or `Conversation.swift:170`. They un-drop automatically once the init returns non-nil. `ConversationStreamEvent` is the live arrival path. + +### 2. Persistence (FlipcashStore) +- `S/Database+Conversations.swift:~575-606` write: `case .system(let text): kind = 6; text = text`. No new column. +- `:805-852` read: `case 6: guard let text = row[m.text] else { return nil }; return .system(fallbackText: text)`. Same pattern as kind 0. +- `S/Database.swift:224`: bump `schemaVersion` 48 → 49 (hard rule: changed write encoding). Rebuild runs at `Flipcash/Core/Session/DatabaseStore.swift:75-82`. +- Older builds reading kind 6 (confirmed safe): + - The unknown-kind `default: return nil` drops it. + - List reads use `compactMap` (`:179,192,203,217`), so there is no crash and paging still works. + - The notification extension refuses any version mismatch (`S/ExtensionStore.swift:68`). + - Residual: a downgrade opens the newer DB as-is (rebuild fires only when `current > stored`). It has two visible effects, and both are transient because the old build never writes kind 6, so the next normal message clears them: + - (a) `latestMessage` (`:127`, `pluck` of one row) returns nil, so that chat's feed preview goes blank. + - (b) `newestMessage` (`:145`) returns nil, so `lastConfirmedMessage` (`ConversationController.swift:1866-1872`) loses its receive-buzz and mark-read anchor for that chat. + - No crash and no data loss. Accepted. The only fix would be in the old build. + +### 3. Unread (spec 8) +- `S/Database+Conversations.swift:241-251` `inboundMessageCount` (sole caller `ConversationController.swift:1838`, the divider count): add `&& m.kind != 6`. Today `senderId == nil` rows count as inbound. +- `:256-266` `firstInboundMessageID` (divider position; currently filters only id + sender and includes tombstones): add `&& m.kind != 6` and update its doc comment ("A row with no sender counts as someone else's" → except system rows), so the divider never lands directly above a system row. With S between the read pointer and B, `readThrough = B-1`, and `UnreadBoundary.dividerBetween` (`C/Models/Conversation/UnreadBoundary.swift:57-63`) puts the divider below S and above B. When only system messages are unread, there is no divider. +- How unread works today: + - (a) Divider position and count are local SQL over the stored READ pointer (above). + - (b) The chat-list badge is `Conversation.unreadCount` (`C/.../Conversation.swift:239-249`) = `lastMessage.unreadSeq − readSeq`. `unreadSeq` is **server-stamped**, so leave it alone. + - (c) `Conversation.hasUnread` (`:219-229`) is a client boolean: `readPointer < lastMessage.id` unless the last message is the viewer's own. See Open questions. + +### 4. Transcript row (spec 3, 5, 6) +- `C/Models/Chat/ChatItem.swift:14-27`: add `case systemMessage(id: String, text: String)` (doc'd). `id` (`:29-37`) returns `id`. Add it to the nil arm at `:48`. +- `Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift` (`ChatItem.from`, loop `:186-373`): + - Separator/marker/divider emission at `:192-205` stays first, so a system message on a new day still gets its date header. + - In the `message.content` switch at `:236`, the `.system(let text)` arm does `items.append(.systemMessage(id: "sys-\(message.stableID)", text: text)); continue`. Mirrors `"sep-\(message.stableID)"`. + - Break grouping: add `&& !$0.isSystem` (or the `if case .system` equivalent) to the `groupedAbove`/`groupedBelow` closures at `:216-222`. Grouping keys on `senderID` of the adjacent *message*, and system and `.widget` rows both have a nil sender, so without this a share-profile widget would group with a system row. Add it to `rendersBare`/`carriesReceipt` neighbours at `:230-233` so a receipt never anchors to a system row. + - Other switches in the file: `:154` `isEmojiOnlyBody` → false; `:167` link layouts → none; `:~395-460` reply-quote → same arm as an unavailable original (unreachable, no reply capability). +- `UI/Chat/ChatItem+Differentiable.swift:26-35`: `case .systemMessage: ChatSystemMessageCell.reuseIdentifier`. Identity is `"\(reuseId):\(id)"` and equality is synthesized `==` (`:17-18`), so no other diff code is needed. The `sys-` id is stable across reloads because it derives from `stableID`. +- `UI/Chat/ChatViewController.swift`: + - Register the cell at `:327-335`. + - Configure it in `cellForItemAt` at `:590-608`. + - Add `.systemMessage` to the non-message arms at `:142-149` (continue), `:519-522`, `:545-550`, `:562-565`, and `sender(at:)` `:1302-1309` (nil). + - Add it to `isHeading(at:)` `:1286-1292` so spacing matches separators. + - Interaction (spec 5): no new gating needed. Long-press (`:800-807`, `:1320`), context menu (`:1358`), double-tap reaction (`:817`, `:1317`) and swipe-to-reply (`:341-352`) all require `.message` plus `BubbleCarrying`/`ChatColumnCell`. The new cell is a plain `UICollectionViewCell`, so it is excluded. Scroll anchoring at `:845-856` is unaffected. + +### 5. Cell, Option D (spec 4, revised) +New `UI/Chat/ChatSystemMessageCell.swift`, a plain UIKit `UICollectionViewCell` that copies `ChatEncryptionMarkerCell`/`ChatUnreadDividerCell` structure (`static let reuseIdentifier`, `configure(text:)`). All styling lives in this file: +- Glyph: `UIImageView(image: UIImage(systemName: "info.circle", withConfiguration: .init(pointSize: 14)))`, the same SF Symbol as `UI/Theme/Image+Symbols.swift:61` (`case info = "info.circle"`). UIKit precedent: `ChatEncryptionMarkerCell.swift:25-26`. +- 6pt gap, then a `UILabel` with `numberOfLines = 0` and centered text. +- Font: no 13pt token exists. `UI/Theme/AppFonts.swift:28-47` has 12/14/16/20/22 only. Use `.default(size: 13, weight: .regular)` (`UI/Theme/FontBook.swift`) inline. Line height 16 via `NSParagraphStyle` min/max line height in an attributed string. +- Color "text/dim" (white 50%): no token. `Color.textSecondary` is opaque rgb 123 (`UI/Theme/Colors.swift:15`). Use `UIColor.white.withAlphaComponent(0.5)` as `ChatDateSeparatorCell.swift:22` does, for both glyph and text. +- Layout: a horizontal stack (glyph top-aligned to the first line), centered with `centerXAnchor`, `width <= 250`, `leading >= 16`, top/bottom 8 like the divider cell. No fill, no hairlines. +- Accessibility: the cell is one element. `accessibilityLabel = text`, traits `.staticText`, glyph hidden from VoiceOver. +- Text is a single server string, so actor names can't be bolded. +- Option E would need, inside the same file: + - Leading-aligned layout: glyph slot at `ChatColumnCell.rowInset` (12, `UI/Chat/ChatColumnCell.swift:167`), 24pt wide (`ChatAuthorAvatarView.size`, `ChatAuthorAvatarView.swift:23`), a hairline ring of white 0.1 (`Colors.swift:41` `rowSeparator`, or `LinkCardMetrics.borderOpacity`). + - 8pt gap, then text at x = 44 = the group bubble column (`ChatColumnCell.swift:180,299`), width 272. + - Caveat: the 44pt column only exists when `reservesAuthorGutter` is true (groups). In a DM the row would sit 32pt right of the bubbles. + +### 6. Chat-list preview (spec 7) +- `Flipcash/Core/Controllers/ConversationController.swift:~1568-1617` `lastMessagePreview`: `case .system(let text): return text`, with no prefix (`groupSenderName` `:1645` is nil for a nil sender anyway). +- Other preview call sites: `Flipcash/Core/Spotlight/ChatSpotlightItem.swift:~58-65` returns the text. `ConversationScreen.swift:1003-1021` → nil arm. `ConversationScreen.swift:1080` → `break` arm. `ConversationController.swift:2039` → the log-and-bail arm. `ConversationController.swift:2314` → not-sent arm. +- `S/Database+Conversations.swift:127` needs **no edit**: it is an exclusion filter (`kind != 2 && (kind != 3 || decryptFailure != nil)`), so kind 6 already qualifies as the latest message. `:247` is changed in step 3. + +### 7. Encrypted path (spec 9) +Server system messages always arrive as plaintext `Content.system`, never through `ChatSeal`: +- `ChatSeal.open` (`C/Models/Conversation/ChatSeal.swift:127-`) only handles `.encrypted` content. +- The key comes from the chat key plus the sender/peer public keys (`:26`, `:33-39`), which the server does not hold, so it cannot produce a sealed system payload. +- `readable(senderID:)` `:249-269` returning nil for `.system` (`:263`, `:266`) should **stay**. A `.system` inside a sealed payload could only be member-authored and would let a member forge "X joined" notices. +- I found no "First Private Chat" string in the repo. The existing lock notice is the client-derived `.encryptionMarker` (`ChatItem+Conversation.swift:108`, `:194-196`). Any server "First Private Chat" notice would take the plaintext `Content.system` path above. +- Optional one-line comment at `:266` stating the forgery constraint. + +### 8. Tests (Swift Testing, existing suites) +- `FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift` (`@Suite("Conversation model proto mapping")` `:13`). Add: + - "System content maps to .system with its fallback text": proto `content = [.with { $0.system = .with { $0.fallbackText = "Alex joined the group" } }]`, no sender. Expect `.system(fallbackText:)` and `senderID == nil`. + - Blank/whitespace-only `fallbackText` → `ConversationMessage(proto) == nil` (parameterized `["", " ", "\n"]`). +- `FlipcashTests/Chat/ChatMessageMappingTests.swift` (`@Suite("ChatItem mapping from conversation")`, fixtures `text(_:_:_:after:)` `:24`). Add: + - A system message yields exactly one `.systemMessage(id: "sys-…", text: "Alex joined the group")`. + - Two bubbles from the same sender on either side of a system row are not grouped (`isContinuationFromPrevious == false`). + - End-to-end proto → `ConversationMessage` → `ChatItem.from` for the spec example. + - A blank proto produces no row. +- `FlipcashTests/Chat/UnreadDividerTests.swift` (`@Suite("Unread divider")`): a system message after the pointer is excluded from the count, and only-system-unread gives no divider. +- `FlipcashTests/Database/Database+ConversationsTests.swift` (`@Suite("Conversation offline cache round-trip")`, fixture `textMessage(id:at:)` `:21`): kind-6 write/read round-trip equality with nil sender. `latestMessage` returns the system row. `inboundMessageCount` excludes it. +- **Existing test to change:** `FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift:237-244` uses `system = .with { _ in }` as the "unrepresentable" fixture. It still passes (blank → nil), but its intent is now the blank rule. Swap the fixture to `content = []` (no content), or rename the test. +- Optional: a `ChatSystemMessageCell` layout test following `FlipcashTests/Chat/ChatBubbleViewTests.swift:173` (`systemLayoutSizeFitting`): a 256-char string wraps (height > one line) and does not truncate. + +## Parity deviations +- None to the spec's shape. Two interpretation notes for Android: + - (1) The blank rule is applied at proto mapping, so blank system messages are never stored or used as `lastMessage`. Android should match by dropping at mapping, not only at render. + - (2) Spec 7's "update the filter at `:127`" is a no-op on iOS (exclusion filter). Only `:247` and `:256` change. + - (3) Divider *position* also skips system rows (`firstInboundMessageID`). They are treated as read, and the divider lands below them. The spec only says "not counted", so Android must match this or the transcripts diverge. +- The visual default changed from A to D per the updated Figma direction (node 11721:918). The spec text still says A. + +## Open questions / risks +1. **`hasUnread` dot** (`Conversation.swift:219-229`) is client-computed from `lastMessage.id`. A system last message lights the unread dot. Suppressing it would hide a real unread message that precedes it. Default: leave it, since the badge count is server `unreadSeq`. Ask the server whether it stamps `unreadSeq` on system messages. If it doesn't, `unreadCount` returns nil when the newest message is system (`:247` guard). Confirm with Android's `FeedProjection.unreadCount`, which the code comment says mirrors iOS. +2. **Receive buzz**: `lastConfirmedMessage` (`ConversationController.swift:1866-1872`, `newestMessage`) would buzz when a system message arrives. Default: allow it. Exclude `.system` if product says no. +3. **Push**: whether the server pushes system messages is unknown. With this change `NotificationPayload.chatMessage` returns a message whose `decryptedBody` is nil, so it falls back to the APS alert. +3b. **Notification rich preview**: the plan maps `.system` to the nil arm in `ChatPreviewMapping.swift:~72-107`, so a push shows no rich content. The alternative is `.text(fallbackText)`. The spec doesn't cover push, so this needs a decision. +3c. **Option D glyph alignment** for multi-line text (top vs vertically centred) isn't specified in the description of node 11721:918. It should be checked against the node. +4. **Future `event` oneof**: kind 6 stores only resolved text. A client-localized event will need its own stored data and another schema bump. That is acceptable under the rebuild-from-server rule. +5. Diff identity `"ChatSystemMessageCell:sys-"` cannot collide with message or separator ids. +6. No `.claude/plans/2026-10-13-system-messages.md` was written, because this run was read-only. Copy this plan there when implementing (repo CLAUDE.md convention). + +## Verification (narrowest first) +- Step 1: `./Scripts/test.sh FlipcashCoreTests/ConversationModelMappingTests FlipcashCoreTests/NotificationPayloadTests` +- Step 2–3: `./Scripts/test.sh FlipcashTests/DatabaseConversationsTests FlipcashTests/UnreadDividerTests` +- Step 4–6: `./Scripts/test.sh FlipcashTests/ChatMessageMappingTests`, then `./Scripts/build.sh` (exhaustive-switch fallout in Flipcash/FlipcashUI). +- Visual: run on a simulator via loupe, open a group chat that has a system message, and check that a 256-char string wraps centered, long-press and swipe do nothing, and VoiceOver reads the text. +- Final: the user runs the `AllTargets` plan (test.sh refuses it). + +## Overrides (2026-10-14) + +- Visual is Figma Option E (nodes 11721:934 / 11721:942): ringed `info.circle` slot in the avatar gutter, text at the member bubble column. +- No receive haptic for a system message: the `.sensoryFeedback` closure guards it (`ConversationMessage.buzzesOnReceive(selfUserID:)`); mark-read is untouched and still sees the system message. +- Blank text is stored as `""` in kind 6 and rendered as no row. This replaces dropping it at mapping. +- The chat-list preview query skips blank kind 6 rows, so the preview falls back to the previous message. +- The read pointer may land on a system message id. +- Row key is `sys-`, since a message carries a single content. From bc130821c2cd30cd78f7c11319949940b0b56ab9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 07:24:06 -0400 Subject: [PATCH 3/5] fix(chat): keep the unread divider off system rows The divider headed the first non-self message after the read-through, so a system message in the unread run could take it. Place it on the first real message instead, comparing against the nearest real message above. --- .../Conversation/ChatItem+Conversation.swift | 23 ++++++++++---- FlipcashTests/Chat/UnreadDividerTests.swift | 30 +++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 5d38e37f3..95982699b 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -155,8 +155,24 @@ extension ChatItem { // The unread divider heads `message` when the read-through falls between it and `earlier`. // Like a separator it ends the run above it: a bubble must not join one across the divider. + // + // System rows are skipped when placing it: the divider heads the first real message after the + // read-through, comparing against the nearest real message above, never a system row. + func isSystem(_ message: ConversationMessage) -> Bool { + if case .system = message.content { true } else { false } + } + let dividerHeadID: String? = { + var older: ConversationMessage? + for message in messages where !isSystem(message) { + if let older, unreadBoundary.dividerBetween(newer: message, older: older, selfUserID: selfUserID) { + return message.stableID + } + older = message + } + return nil + }() func divides(_ message: ConversationMessage, from earlier: ConversationMessage) -> Bool { - unreadBoundary.dividerBetween(newer: message, older: earlier, selfUserID: selfUserID) + message.stableID == dividerHeadID } // What breaks the bubble run: a text body of one to three emoji, on a row that is not a @@ -169,10 +185,7 @@ extension ChatItem { } } // A system row has no sender, like a widget, so the author comparison below would otherwise - // join a bubble to it. - func isSystem(_ message: ConversationMessage) -> Bool { - if case .system = message.content { true } else { false } - } + // join a bubble to it (see `isSystem`). func rendersBare(_ message: ConversationMessage) -> Bool { message.repliedTo == nil && isEmojiOnlyBody(message) } diff --git a/FlipcashTests/Chat/UnreadDividerTests.swift b/FlipcashTests/Chat/UnreadDividerTests.swift index 1622e430b..8fe9c7378 100644 --- a/FlipcashTests/Chat/UnreadDividerTests.swift +++ b/FlipcashTests/Chat/UnreadDividerTests.swift @@ -149,6 +149,36 @@ struct UnreadDividerTests { #expect(dividerCount(items) == (shared.dividerAbove == nil ? 0 : 1)) } + // MARK: - System messages - + + @Test("a system message in the unread run is neither counted nor headed by the divider") + func systemMessage_isSkippedByDividerCountAndPlacement() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let notice = ConversationMessage( + id: MessageID(value: 2), + senderID: nil, + content: .system(fallbackText: "Alex joined"), + date: base.addingTimeInterval(120), + unreadSeq: 2 + ) + let messages = [message(1, fromSelf: false), notice, message(3, fromSelf: false)] + let controller = try await hydratedController(messages: messages, pointer: 1, database: database) + + let boundary = controller.unreadBoundary(for: .test(1)) + #expect(boundary.count == 1) + + let items = ChatItem.from( + messages, + selfUserID: Self.me, + deletedPresentation: .placeholder, + unreadBoundary: boundary + ) + #expect(messageUnderDivider(items) == "3") + #expect(dividerCount(items) == 1) + #expect(items.contains { if case .systemMessage(let id, _) = $0 { id == "sys-2" } else { false } }) + } + // MARK: - Resolution - @Test("a missing pointer resolves to none without asking for a count") From 5783cd17a7377d3452f0295b6b26680222c5ecb1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 08:39:15 -0400 Subject: [PATCH 4/5] fix(chat): style system notices with the caption and theme colour tokens --- .../FlipcashUI/Chat/ChatSystemMessageCell.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift index 53dd803a0..19a51cbab 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift @@ -6,6 +6,7 @@ // #if canImport(UIKit) +import SwiftUI import UIKit /// A server-authored notice, drawn as a leading-aligned line: a ringed info glyph in the member-avatar @@ -15,10 +16,10 @@ public final class ChatSystemMessageCell: UICollectionViewCell { public static let reuseIdentifier = "ChatSystemMessageCell" private static let slotSize = ChatAuthorAvatarView.size - private static let lineHeight: CGFloat = 16 + private static let lineHeight: CGFloat = 18 private static let verticalPadding: CGFloat = 6 - private static let textColor = UIColor.white.withAlphaComponent(0.5) - private static let font = UIFont.default(size: 13, weight: .regular) + private static let textColor = UIColor(Color.textSecondary) + private static let font = UIFont.appTextCaption private let label = UILabel() private let textWidth: NSLayoutConstraint @@ -30,7 +31,7 @@ public final class ChatSystemMessageCell: UICollectionViewCell { let slot = UIView() slot.layer.cornerRadius = Self.slotSize / 2 slot.layer.borderWidth = 1 / UITraitCollection.current.displayScale - slot.layer.borderColor = UIColor.white.withAlphaComponent(0.1).cgColor + slot.layer.borderColor = UIColor(Color.rowSeparator).cgColor slot.translatesAutoresizingMaskIntoConstraints = false let glyph = UIImageView(image: UIImage(systemName: "info.circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14))) @@ -62,7 +63,7 @@ public final class ChatSystemMessageCell: UICollectionViewCell { glyph.centerXAnchor.constraint(equalTo: slot.centerXAnchor), glyph.centerYAnchor.constraint(equalTo: slot.centerYAnchor), - // The first line centres on the slot: its 16pt box starts (24 - 16) / 2 below the slot top. + // The first line centres on the slot: its 18pt box starts (24 - 18) / 2 below the slot top. label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: textLeading), label.topAnchor.constraint(equalTo: slot.topAnchor, constant: (Self.slotSize - Self.lineHeight) / 2), label.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -ChatColumnCell.rowInset), @@ -84,7 +85,7 @@ public final class ChatSystemMessageCell: UICollectionViewCell { .font: Self.font, .foregroundColor: Self.textColor, .paragraphStyle: style, - // Fixed line boxes sit the glyphs on the bottom; this centres them in the 16pt box. + // Fixed line boxes sit the glyphs on the bottom; this centres them in the 18pt box. .baselineOffset: (Self.lineHeight - Self.font.lineHeight) / 2, ]) textWidth.constant = maxWidth From 7697e3d3ae25a56c1ab6366e20e0922d1532df6e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 08:46:46 -0400 Subject: [PATCH 5/5] fix(chat): centre system notices with the info glyph beside the text --- .../Chat/ChatSystemMessageCell.swift | 67 +++++++++---------- .../FlipcashUI/Chat/ChatViewController.swift | 2 +- 2 files changed, 33 insertions(+), 36 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift index 19a51cbab..cd4a35d44 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSystemMessageCell.swift @@ -9,78 +9,76 @@ import SwiftUI import UIKit -/// A server-authored notice, drawn as a leading-aligned line: a ringed info glyph in the member-avatar -/// gutter, then the text at the member bubble column. No bubble, no fill, no sender, no gestures. +/// A server-authored notice, centred in the transcript (node 11721:879, option D): an info glyph, +/// then the text. No bubble, no fill, no sender, no gestures. The glyph is what tells it apart from +/// the date separator, which is also centred caption text. public final class ChatSystemMessageCell: UICollectionViewCell { public static let reuseIdentifier = "ChatSystemMessageCell" - private static let slotSize = ChatAuthorAvatarView.size private static let lineHeight: CGFloat = 18 private static let verticalPadding: CGFloat = 6 + private static let glyphGap: CGFloat = 6 + private static let textMaxWidth: CGFloat = 250 private static let textColor = UIColor(Color.textSecondary) private static let font = UIFont.appTextCaption private let label = UILabel() - private let textWidth: NSLayoutConstraint public override init(frame: CGRect) { - textWidth = label.widthAnchor.constraint(equalToConstant: 0) super.init(frame: frame) - let slot = UIView() - slot.layer.cornerRadius = Self.slotSize / 2 - slot.layer.borderWidth = 1 / UITraitCollection.current.displayScale - slot.layer.borderColor = UIColor(Color.rowSeparator).cgColor - slot.translatesAutoresizingMaskIntoConstraints = false - let glyph = UIImageView(image: UIImage(systemName: "info.circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14))) glyph.tintColor = Self.textColor glyph.contentMode = .center glyph.isAccessibilityElement = false + glyph.setContentHuggingPriority(.required, for: .horizontal) + glyph.setContentCompressionResistancePriority(.required, for: .horizontal) glyph.translatesAutoresizingMaskIntoConstraints = false - slot.addSubview(glyph) label.numberOfLines = 0 - label.textAlignment = .left + label.textAlignment = .center + label.preferredMaxLayoutWidth = Self.textMaxWidth label.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(slot) - contentView.addSubview(label) + // Glyph and text are centred as one group, so a short notice sits in the middle of the row. + let group = UIView() + group.translatesAutoresizingMaskIntoConstraints = false + group.addSubview(glyph) + group.addSubview(label) + contentView.addSubview(group) - // Below the width pin so a row narrower than the column squeezes the text, not the layout. - textWidth.priority = .defaultHigh isAccessibilityElement = true accessibilityTraits = .staticText - let textLeading = ChatColumnCell.rowInset + ChatColumnCell.authorGutterWidth NSLayoutConstraint.activate([ - slot.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: ChatColumnCell.rowInset), - slot.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Self.verticalPadding), - slot.widthAnchor.constraint(equalToConstant: Self.slotSize), - slot.heightAnchor.constraint(equalToConstant: Self.slotSize), - slot.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -Self.verticalPadding), - glyph.centerXAnchor.constraint(equalTo: slot.centerXAnchor), - glyph.centerYAnchor.constraint(equalTo: slot.centerYAnchor), + group.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + group.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: ChatColumnCell.rowInset), + group.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Self.verticalPadding), + group.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Self.verticalPadding), + + // One line tall, so the glyph centres on the first line however many the text wraps to. + glyph.leadingAnchor.constraint(equalTo: group.leadingAnchor), + glyph.topAnchor.constraint(equalTo: group.topAnchor), + glyph.heightAnchor.constraint(equalToConstant: Self.lineHeight), - // The first line centres on the slot: its 18pt box starts (24 - 18) / 2 below the slot top. - label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: textLeading), - label.topAnchor.constraint(equalTo: slot.topAnchor, constant: (Self.slotSize - Self.lineHeight) / 2), - label.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -ChatColumnCell.rowInset), - label.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -Self.verticalPadding), - textWidth, + label.leadingAnchor.constraint(equalTo: glyph.trailingAnchor, constant: Self.glyphGap), + label.trailingAnchor.constraint(equalTo: group.trailingAnchor), + label.topAnchor.constraint(equalTo: group.topAnchor), + label.bottomAnchor.constraint(equalTo: group.bottomAnchor), + label.widthAnchor.constraint(lessThanOrEqualToConstant: Self.textMaxWidth), ]) } @available(*, unavailable) public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - /// Shows `text` unmodified, wrapped to `maxWidth` points. - public func configure(text: String, maxWidth: CGFloat) { + /// Shows `text` unmodified, centred and wrapped at 250 points. + public func configure(text: String) { let style = NSMutableParagraphStyle() style.minimumLineHeight = Self.lineHeight style.maximumLineHeight = Self.lineHeight - style.alignment = .left + style.alignment = .center label.attributedText = NSAttributedString(string: text, attributes: [ .font: Self.font, .foregroundColor: Self.textColor, @@ -88,7 +86,6 @@ public final class ChatSystemMessageCell: UICollectionViewCell { // Fixed line boxes sit the glyphs on the bottom; this centres them in the 18pt box. .baselineOffset: (Self.lineHeight - Self.font.lineHeight) / 2, ]) - textWidth.constant = maxWidth accessibilityLabel = text } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 6a2925da6..ce7e138ee 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -623,7 +623,7 @@ public final class ChatViewController: UICollectionViewController { let tap: (() -> Void)? = onEncryptionMarkerTap == nil ? nil : { [weak self] in self?.onEncryptionMarkerTap?() } (cell as! ChatEncryptionMarkerCell).configure(onTap: tap) case .systemMessage(_, let text): - (cell as! ChatSystemMessageCell).configure(text: text, maxWidth: memberBubbleMaxWidth()) + (cell as! ChatSystemMessageCell).configure(text: text) case .message(let message): configure(cell, with: message) }