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
51 changes: 42 additions & 9 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1677,9 +1677,15 @@ final class ConversationController {
case .deleted:
return nil

case .system(let text):
case .system(let fallbackText, let event):
// Server-authored, so no sender prefix; a blank one previews as nothing, like an empty body.
return text.isEmpty ? nil : text
guard !fallbackText.isEmpty else { return nil }
return SystemNoticeStrings.text(
for: event,
fallbackText: fallbackText,
selfID: selfUserID,
nameOf: { noticeUserName(for: $0, in: conversation, knownAuthors: knownAuthors) }
)

case .widget(.shareProfile):
// The server sends it; no user is the sender to name.
Expand All @@ -1697,24 +1703,51 @@ final class ConversationController {

/// The group senders of `conversations`' last messages that neither their chat's roster nor
/// `knownAuthors` can name, in feed order and without repeats — what the Chats list asks
/// ``KnownAuthorDirectory/resolve(_:)`` for.
/// ``KnownAuthorDirectory/resolve(_:)`` for. A system notice has no sender, so the users it names
/// count in their place.
func unnamedLastMessageSenders(
in conversations: [Conversation],
knownAuthors: KnownAuthorDirectory.Snapshot
) -> [UserID] {
var seen: Set<UserID> = []
return conversations.compactMap { conversation in
guard conversation.type == .group,
let message = conversation.lastMessage,
let senderID = message.senderID,
return conversations.flatMap { conversation -> [UserID] in
guard conversation.type == .group, let message = conversation.lastMessage else { return [] }
let noticeIDs = message.systemNoticeUserIDs(excluding: selfUserID).filter { userID in
let member = noticeMember(for: userID, in: conversation, knownAuthors: knownAuthors)
return member?.displayName.isEmpty ?? true
}
if !noticeIDs.isEmpty { return noticeIDs.filter { seen.insert($0).inserted } }
guard let senderID = message.senderID,
!message.isFromSelf(selfUserID),
groupSenderName(for: message, in: conversation, isFromSelf: false, knownAuthors: knownAuthors) == nil,
seen.insert(senderID).inserted
else { return nil }
return senderID
else { return [] }
return [senderID]
}
}

/// The name a system notice calls `userID`, or `nil` when neither the chat's roster nor
/// `knownAuthors` has them; a member with no name or handle gets the generic fallback.
private func noticeUserName(
for userID: UserID,
in conversation: Conversation,
knownAuthors: KnownAuthorDirectory.Snapshot
) -> String? {
noticeMember(for: userID, in: conversation, knownAuthors: knownAuthors)
.map(ConversationLoadCoordinator.attributionName(for:))
}

/// The roster's member for `userID`, else the directory's, preferring whichever has a name.
private func noticeMember(
for userID: UserID,
in conversation: Conversation,
knownAuthors: KnownAuthorDirectory.Snapshot
) -> ConversationMember? {
let rostered = conversation.members.first { $0.userID == userID }
if let rostered, !rostered.displayName.isEmpty { return rostered }
return knownAuthors.membersByUserID[userID] ?? rostered
}

/// The name a group row attributes its last message to, or `nil` when it should carry no
/// attribution: the viewer's own message is covered by "You", and a DM's other party is what
/// the row is already titled after. A sender the chat's roster subset omits is named from
Expand Down
11 changes: 10 additions & 1 deletion Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ extension ChatItem {
quotedMessage: (MessageID) -> ConversationMessage? = { _ in nil },
author: (ConversationMessage) -> ChatAuthor? = { _ in nil },
namesAuthors: Bool = false,
/// The display name of a user a system notice names, or nil when none is known yet, which
/// leaves the notice on the server's fallback text.
nameOf: (UserID) -> String? = { _ in nil },
/// Whether the viewer may add or remove a reaction — false for someone previewing a group
/// they have not joined, or who fails a reaction-blocking speaker rule (see `ChatMessage.canReact`).
canReact: Bool = true,
Expand Down Expand Up @@ -264,7 +267,13 @@ extension ChatItem {

let content: ChatMessage.Content
switch message.content {
case .system(let text):
case .system(let fallbackText, let event):
let text = SystemNoticeStrings.text(
for: event,
fallbackText: fallbackText,
selfID: selfUserID,
nameOf: nameOf
)
items.append(.systemMessage(id: "sys-\(message.stableID)", text: text))
continue
case .text(let text):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ final class ConversationLoadCoordinator {
// draws as it does in a DM rather than under a blank name.
author: { message in message.senderID.flatMap { authors[$0] } },
namesAuthors: inputs.namesAuthors,
nameOf: { authors[$0]?.name },
canReact: inputs.canReact,
// Classification is pure and host-gated, and that is all mapping does with a link: the
// card is the link's identity, and the card view looks it up for itself. So nothing
Expand Down Expand Up @@ -372,7 +373,9 @@ final class ConversationLoadCoordinator {
var seen: Set<UserID> = []
var members: [ConversationMember] = []
var unnamed: [UserID] = []
for senderID in inputs.messages.compactMap(\.senderID) + inputs.typists {
// A system notice has no sender but names the users it is about, so those need profiles too.
let noticeIDs = inputs.messages.flatMap { $0.systemNoticeUserIDs(excluding: inputs.selfUserID) }
for senderID in inputs.messages.compactMap(\.senderID) + noticeIDs + inputs.typists {
guard seen.insert(senderID).inserted else { continue }
// The viewer's own rows are never attributed, so their id is not worth a round trip.
guard senderID != inputs.selfUserID else { continue }
Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Spotlight/ChatSpotlightItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +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 .system(let text, _): text.isEmpty ? nil : text
case .deleted, .encrypted, .widget(.unrecognized), nil: nil
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// SystemNoticeStrings.swift
// FlipcashCore
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import Foundation

/// The words a system notice is drawn in wherever it names the users it is about: the transcript row
/// and the chat list's last-message line. The wording matches Android's.
public enum SystemNoticeStrings {

/// The notice's text: a sentence naming the users `event` involves, or `fallbackText` when there
/// is no event or `nameOf` cannot name a user the chosen sentence needs.
///
/// The viewer reads as "You" only through their own sentences, never in a name slot, and a
/// `nil` `selfID` means nobody is the viewer. An admitter who is also the joiner is no admitter.
public static func text(
for event: ConversationMessage.SystemEvent?,
fallbackText: String,
selfID: UserID?,
nameOf: (UserID) -> String?
) -> String {
guard let event else { return fallbackText }
switch event {
case .userJoined(let joiner, let admittedBy):
let admitter = admittedBy == joiner ? nil : admittedBy
let isViewerJoiner = joiner == selfID

guard let admitter else {
if isViewerJoiner { return "You joined the group" }
return nameOf(joiner).map { "\($0) joined the group" } ?? fallbackText
}
if admitter == selfID {
return nameOf(joiner).map { "You added \($0)" } ?? fallbackText
}
guard let admitterName = nameOf(admitter) else { return fallbackText }
if isViewerJoiner { return "\(admitterName) added you" }
return nameOf(joiner).map { "\(admitterName) added \($0)" } ?? fallbackText
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,28 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable {
/// ``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)
/// rather than a bubble. Blank text is stored as `""` and draws nothing. `event` is the
/// structured fact behind the notice, when the server sent one this build understands.
case system(fallbackText: String, event: SystemEvent?)
}

/// The structured fact a system notice reports, which lets the client name the users involved
/// instead of showing the server's fallback text.
public enum SystemEvent: Hashable, Sendable {
/// `userID` joined the group, directly or, when `admittedBy` is set, admitted by that user.
case userJoined(userID: UserID, admittedBy: UserID?)

/// The users this event names, in reading order, without duplicates and without `selfID`.
public func namedUserIDs(excluding selfID: UserID?) -> [UserID] {
switch self {
case .userJoined(let userID, let admittedBy):
var ids: [UserID] = []
for id in [admittedBy, userID].compactMap({ $0 }) where id != selfID && !ids.contains(id) {
ids.append(id)
}
return ids
}
}
}

/// The widget a `.widget` message carries. Sent in the clear only: the E2EE allow-list is text,
Expand Down Expand Up @@ -298,7 +318,17 @@ 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 }
if case .system("", _) = content { true } else { false }
}

/// The users a system notice names, deduplicated and without `selfID`; empty for every other message.
public func systemNoticeUserIDs(excluding selfID: UserID?) -> [UserID] {
switch content {
case .system(_, let event):
event?.namedUserIDs(excluding: selfID) ?? []
case .text, .cash, .deleted, .encrypted, .widget, .media:
[]
}
}
}

Expand Down Expand Up @@ -476,11 +506,31 @@ extension ConversationMessage.Content {
}

/// 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. Only `fallback_text` is read, so
/// every `type` variant (`UserJoined` today) and an unset one render as their fallback text.
/// message still anchors the read pointer and the unread divider. A `user_joined` with a valid
/// `user_id` becomes an event; an unset or unknown `type`, or an invalid `user_id`, leaves only the
/// fallback text.
init(_ proto: Flipcash_Messaging_V1_SystemContent) {
let text = proto.fallbackText
self = .system(fallbackText: text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "" : text)
self = .system(
fallbackText: text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "" : text,
event: Self.event(for: proto)
)
}

private static func event(for proto: Flipcash_Messaging_V1_SystemContent) -> ConversationMessage.SystemEvent? {
switch proto.type {
case .userJoined(let joined):
guard joined.hasUserID, let userID = try? UUID(data: joined.userID.value) else { return nil }
switch joined.method {
case .admitted(let admitted):
let admitter = admitted.hasAdmittedBy ? try? UUID(data: admitted.admittedBy.value) : nil
return .userJoined(userID: userID, admittedBy: admitter)
case .direct, .none:
return .userJoined(userID: userID, admittedBy: nil)
}
case .none:
return nil
}
}

/// The `.media` content `proto` describes, or `nil` when its first item has no ORIGINAL rendition.
Expand Down
20 changes: 17 additions & 3 deletions FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,8 @@ nonisolated extension Database {
var encryptedCiphertext: Data?
var mediaJson: Data?
var linkPreviewJson: Data?
var systemUserId: UUID?
var systemAdmittedBy: UUID?
if let sealed = message.sealed {
encryptedScheme = sealed.scheme
encryptedNonce = sealed.nonce
Expand Down Expand Up @@ -645,9 +647,16 @@ nonisolated extension Database {
case .shareProfile(let share): text = share.username.value
case .unrecognized: text = nil
}
case .system(let fallbackText):
case .system(let fallbackText, let event):
kind = 6
text = fallbackText
switch event {
case .userJoined(let userID, let admittedBy):
systemUserId = userID
systemAdmittedBy = admittedBy
case nil:
break
}
case .media(let attachments, let caption):
kind = 5
mediaJson = try JSONEncoder().encode(
Expand Down Expand Up @@ -688,7 +697,9 @@ nonisolated extension Database {
m.decryptFailure <- message.decryptFailure?.rawValue,
m.reactionsJson <- Self.encodeReactions(message.reactionState),
m.mediaJson <- mediaJson,
m.linkPreviewJson <- linkPreviewJson
m.linkPreviewJson <- linkPreviewJson,
m.systemUserId <- systemUserId,
m.systemAdmittedBy <- systemAdmittedBy
)
)
}
Expand Down Expand Up @@ -908,7 +919,10 @@ nonisolated extension Database {
redacted = media.redacted
case 6:
guard let text = row[m.text] else { return nil }
content = .system(fallbackText: text)
let event = row[m.systemUserId].map {
ConversationMessage.SystemEvent.userJoined(userID: $0, admittedBy: row[m.systemAdmittedBy])
}
content = .system(fallbackText: text, event: event)
default:
return nil
}
Expand Down
2 changes: 1 addition & 1 deletion FlipcashCore/Sources/FlipcashStore/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 52
public static let schemaVersion = 53

/// Removes the store and the write-ahead log files beside it.
///
Expand Down
7 changes: 7 additions & 0 deletions FlipcashCore/Sources/FlipcashStore/Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,11 @@ nonisolated public struct ConversationMessageTable: Sendable {
public let mediaJson = Expression <Data?> ("mediaJson")
// JSON-encoded `MessageLinkPreview` of a `.text` row; nil when it carries none.
public let linkPreviewJson = Expression <Data?> ("linkPreviewJson")
// A `kind` 6 row's `SystemEvent.userJoined`: the user who joined and, when admitted, who admitted
// them. Both nil for plain system text and every other row; a row decodes an event only when
// `systemUserId` is set.
public let systemUserId = Expression <UUID?> ("systemUserId")
public let systemAdmittedBy = Expression <UUID?> ("systemAdmittedBy")
}


Expand Down Expand Up @@ -666,6 +671,8 @@ nonisolated extension Database {
t.column(conversationMessageTable.reactionsJson)
t.column(conversationMessageTable.mediaJson)
t.column(conversationMessageTable.linkPreviewJson)
t.column(conversationMessageTable.systemUserId)
t.column(conversationMessageTable.systemAdmittedBy)
t.primaryKey(conversationMessageTable.conversationId, conversationMessageTable.id)
})
}
Expand Down
Loading
Loading