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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions .claude/plans/2026-10-14-chat-system-messages.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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
}
}
Expand Down
42 changes: 32 additions & 10 deletions Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -154,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
Expand All @@ -164,9 +181,11 @@ 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 (see `isSystem`).
func rendersBare(_ message: ConversationMessage) -> Bool {
message.repliedTo == nil && isEmojiOnlyBody(message)
}
Expand All @@ -177,7 +196,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
Expand Down Expand Up @@ -227,10 +246,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

Expand All @@ -247,6 +266,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):
Expand Down Expand Up @@ -464,9 +486,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,
Expand Down
12 changes: 7 additions & 5 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
}
}
Expand Down
1 change: 1 addition & 0 deletions Flipcash/Core/Spotlight/ChatSpotlightItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
6 changes: 5 additions & 1 deletion FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}

Expand All @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
Loading
Loading