diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 88cacfb0e..45920c4c3 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -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. @@ -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 = [] - 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 diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 168e4ef40..98454a4f9 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -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, @@ -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): diff --git a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift index aab7c3cae..a92cb71f4 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift @@ -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 @@ -372,7 +373,9 @@ final class ConversationLoadCoordinator { var seen: Set = [] 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 } diff --git a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift index c635c6fed..a0e71d0f0 100644 --- a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift +++ b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift @@ -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 } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/SystemNoticeStrings.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/SystemNoticeStrings.swift new file mode 100644 index 000000000..89cac6ca5 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/SystemNoticeStrings.swift @@ -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 + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 6ae1d6706..96400cc0e 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -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, @@ -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: + [] + } } } @@ -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. diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 2a74c4eb7..84438a443 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -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 @@ -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( @@ -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 ) ) } @@ -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 } diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index c047d9771..e54cdf0cc 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 = 52 + public static let schemaVersion = 53 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index 247415352..00dd36b89 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -384,6 +384,11 @@ nonisolated public struct ConversationMessageTable: Sendable { public let mediaJson = Expression ("mediaJson") // JSON-encoded `MessageLinkPreview` of a `.text` row; nil when it carries none. public let linkPreviewJson = Expression ("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 ("systemUserId") + public let systemAdmittedBy = Expression ("systemAdmittedBy") } @@ -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) }) } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index c8928d621..228d5b514 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -40,11 +40,92 @@ struct ConversationModelMappingTests { } let message = try #require(ConversationMessage(proto)) - #expect(message.content == .system(fallbackText: "Alex joined the group")) + #expect(message.content == .system(fallbackText: "Alex joined the group", event: nil)) #expect(message.senderID == nil) #expect(message.isBlankSystem == false) } + private func systemMessage(_ build: @escaping @Sendable (inout Flipcash_Messaging_V1_SystemContent) -> Void) throws -> ConversationMessage { + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 9 } + $0.content = [.with { $0.system = .with { build(&$0); $0.fallbackText = "fallback" } }] + } + return try #require(ConversationMessage(proto)) + } + + @Test("A direct join maps to a userJoined event with no admitter") + func directJoin() throws { + let joiner = UUID() + let message = try systemMessage { + $0.userJoined = .with { $0.userID = .with { $0.value = joiner.data }; $0.direct = .init() } + } + #expect(message.content == .system(fallbackText: "fallback", event: .userJoined(userID: joiner, admittedBy: nil))) + } + + @Test("An admitted join maps to a userJoined event carrying the admitter") + func admittedJoin() throws { + let joiner = UUID() + let admitter = UUID() + let message = try systemMessage { + $0.userJoined = .with { + $0.userID = .with { $0.value = joiner.data } + $0.admitted = .with { $0.admittedBy = .with { $0.value = admitter.data } } + } + } + #expect(message.content == .system(fallbackText: "fallback", event: .userJoined(userID: joiner, admittedBy: admitter))) + } + + @Test("A join with no method is a direct join") + func joinWithoutMethod() throws { + let joiner = UUID() + let message = try systemMessage { + $0.userJoined = .with { $0.userID = .with { $0.value = joiner.data } } + } + #expect(message.content == .system(fallbackText: "fallback", event: .userJoined(userID: joiner, admittedBy: nil))) + } + + @Test("An admitted join without admitted_by is a direct join") + func admittedWithoutAdmitter() throws { + let joiner = UUID() + let message = try systemMessage { + $0.userJoined = .with { + $0.userID = .with { $0.value = joiner.data } + $0.admitted = .init() + } + } + #expect(message.content == .system(fallbackText: "fallback", event: .userJoined(userID: joiner, admittedBy: nil))) + } + + @Test("An unset type carries no event") + func unsetType() throws { + let message = try systemMessage { _ in } + #expect(message.content == .system(fallbackText: "fallback", event: nil)) + } + + @Test("A join with a missing or invalid user_id carries no event") + func joinWithoutUserID() throws { + let missing = try systemMessage { $0.userJoined = .with { $0.direct = .init() } } + #expect(missing.content == .system(fallbackText: "fallback", event: nil)) + let invalid = try systemMessage { + $0.userJoined = .with { $0.userID = .with { $0.value = Data([1, 2, 3]) } } + } + #expect(invalid.content == .system(fallbackText: "fallback", event: nil)) + } + + @Test("A notice names its users once, in reading order, without the viewer") + func noticeUserIDs() { + let me = UUID(), a = UUID(), b = UUID() + func ids(_ content: ConversationMessage.Content) -> [UUID] { + ConversationMessage(id: MessageID(value: 1), senderID: nil, content: content, date: .now, unreadSeq: 1) + .systemNoticeUserIDs(excluding: me) + } + #expect(ids(.system(fallbackText: "x", event: .userJoined(userID: b, admittedBy: a))) == [a, b]) + #expect(ids(.system(fallbackText: "x", event: .userJoined(userID: a, admittedBy: a))) == [a]) + #expect(ids(.system(fallbackText: "x", event: .userJoined(userID: me, admittedBy: a))) == [a]) + #expect(ids(.system(fallbackText: "x", event: nil)).isEmpty) + #expect(ids(.text("hi")).isEmpty) + } + @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 { @@ -53,7 +134,7 @@ struct ConversationModelMappingTests { } let message = try #require(ConversationMessage(proto)) - #expect(message.content == .system(fallbackText: "")) + #expect(message.content == .system(fallbackText: "", event: nil)) #expect(message.isBlankSystem) } @@ -65,7 +146,7 @@ struct ConversationModelMappingTests { } let message = try #require(ConversationMessage(proto)) - #expect(message.content == .system(fallbackText: "\u{1F}")) + #expect(message.content == .system(fallbackText: "\u{1F}", event: nil)) } @Test("Non-blank system text is not trimmed") @@ -75,7 +156,7 @@ struct ConversationModelMappingTests { $0.content = [.with { $0.system = .with { $0.fallbackText = " Alex joined \n" } }] } - #expect(try #require(ConversationMessage(proto)).content == .system(fallbackText: " Alex joined \n")) + #expect(try #require(ConversationMessage(proto)).content == .system(fallbackText: " Alex joined \n", event: nil)) } @Test("A system message never buzzes; someone else's text does, your own does not") @@ -85,8 +166,8 @@ struct ConversationModelMappingTests { 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(.system(fallbackText: "Alex joined the group", event: nil), from: nil).buzzesOnReceive(selfUserID: me) == false) + #expect(message(.system(fallbackText: "Alex joined the group", event: nil), 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) } @@ -94,7 +175,7 @@ struct ConversationModelMappingTests { @Test("System content cannot be re-encoded for sending") func systemContentHasNoProto() { #expect(throws: ConversationMessageContentEncodingError.self) { - try ConversationMessage.Content.system(fallbackText: "x").asProto() + try ConversationMessage.Content.system(fallbackText: "x", event: nil).asProto() } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift index f43a86837..c04c25093 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift @@ -72,7 +72,7 @@ struct MessageCapabilityTests { func systemNoticeCantBeForwarded() { let notice = ConversationMessage( id: MessageID(value: 7), senderID: nil, - content: .system(fallbackText: "Alex joined"), date: now, unreadSeq: 1, eventSequence: 2 + content: .system(fallbackText: "Alex joined", event: nil), date: now, unreadSeq: 1, eventSequence: 2 ) #expect(resolve(notice).isEmpty) } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/SystemNoticeStringsTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/SystemNoticeStringsTests.swift new file mode 100644 index 000000000..0b800e331 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/SystemNoticeStringsTests.swift @@ -0,0 +1,80 @@ +// +// SystemNoticeStringsTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +@testable import FlipcashCore + +@Suite("System notice wording") +struct SystemNoticeStringsTests { + + private let me = UUID() + private let ada = UUID() + private let bob = UUID() + + private func text( + _ event: ConversationMessage.SystemEvent?, + selfID: UUID? = nil, + known: [UUID: String] = [:] + ) -> String { + SystemNoticeStrings.text(for: event, fallbackText: "fallback", selfID: selfID ?? me, nameOf: { known[$0] }) + } + + @Test("A direct join names the joiner") + func directJoin() { + #expect(text(.userJoined(userID: ada, admittedBy: nil), known: [ada: "Ada"]) == "Ada joined the group") + } + + @Test("The viewer joining reads as You") + func viewerJoined() { + #expect(text(.userJoined(userID: me, admittedBy: nil)) == "You joined the group") + } + + @Test("An admitted join names both users") + func admitted() { + #expect(text(.userJoined(userID: bob, admittedBy: ada), known: [ada: "Ada", bob: "Bob"]) == "Ada added Bob") + } + + @Test("The viewer being admitted reads as added you") + func viewerAdmitted() { + #expect(text(.userJoined(userID: me, admittedBy: ada), known: [ada: "Ada"]) == "Ada added you") + } + + @Test("The viewer admitting reads as You added") + func viewerAdmitting() { + #expect(text(.userJoined(userID: bob, admittedBy: me), known: [bob: "Bob"]) == "You added Bob") + } + + @Test("An admitter who is the joiner is no admitter") + func selfAdmit() { + #expect(text(.userJoined(userID: ada, admittedBy: ada), known: [ada: "Ada"]) == "Ada joined the group") + #expect(text(.userJoined(userID: me, admittedBy: me)) == "You joined the group") + } + + @Test("Any name the sentence needs being unknown falls back") + func unknownNames() { + #expect(text(.userJoined(userID: ada, admittedBy: nil)) == "fallback") + #expect(text(.userJoined(userID: bob, admittedBy: ada), known: [bob: "Bob"]) == "fallback") + #expect(text(.userJoined(userID: bob, admittedBy: ada), known: [ada: "Ada"]) == "fallback") + #expect(text(.userJoined(userID: bob, admittedBy: ada)) == "fallback") + #expect(text(.userJoined(userID: me, admittedBy: ada)) == "fallback") + #expect(text(.userJoined(userID: bob, admittedBy: me)) == "fallback") + } + + @Test("A notice with no event shows its fallback text") + func noEvent() { + #expect(text(nil) == "fallback") + } + + @Test("Nobody is the viewer when the viewer is unknown") + func noSelf() { + let result = SystemNoticeStrings.text( + for: .userJoined(userID: me, admittedBy: nil), fallbackText: "fallback", selfID: nil, nameOf: { _ in "Me" } + ) + #expect(result == "Me joined the group") + } +} diff --git a/FlipcashTests/Chat/ChatMessageMappingTests.swift b/FlipcashTests/Chat/ChatMessageMappingTests.swift index 31214007d..6778555eb 100644 --- a/FlipcashTests/Chat/ChatMessageMappingTests.swift +++ b/FlipcashTests/Chat/ChatMessageMappingTests.swift @@ -126,6 +126,40 @@ struct ChatMessageMappingTests { #expect(messageRows(items).map(\.content) == [.unavailable(.updateApp)]) } + private func joinNotice(_ event: ConversationMessage.SystemEvent?) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: 1), senderID: nil, + content: .system(fallbackText: "Server fallback", event: event), + date: base, unreadSeq: 1 + ) + } + + private func systemTexts(_ items: [ChatItem]) -> [String] { + items.compactMap { if case .systemMessage(_, let text) = $0 { text } else { nil } } + } + + @Test("a join notice renders the names it is about") + func systemJoinRendersNames() { + let items = ChatItem.from( + [joinNotice(.userJoined(userID: them, admittedBy: nil))], + selfUserID: me, + nameOf: { $0 == self.them ? "Ada" : nil } + ) + #expect(systemTexts(items) == ["Ada joined the group"]) + } + + @Test("a join notice with an unknown name renders the server's fallback text") + func systemJoinFallsBack() { + let items = ChatItem.from([joinNotice(.userJoined(userID: them, admittedBy: nil))], selfUserID: me) + #expect(systemTexts(items) == ["Server fallback"]) + } + + @Test("the viewer's own join reads as You") + func systemJoinViewer() { + let items = ChatItem.from([joinNotice(.userJoined(userID: me, admittedBy: nil))], selfUserID: me) + #expect(systemTexts(items) == ["You joined the group"]) + } + @Test("a group's resend hint names the message's author, not the empty DM counterpart") func decryptFailureHintNamesGroupAuthor() { let items = ChatItem.from( diff --git a/FlipcashTests/Chat/UnreadBelowCountTests.swift b/FlipcashTests/Chat/UnreadBelowCountTests.swift index 852eef26b..ff4469c76 100644 --- a/FlipcashTests/Chat/UnreadBelowCountTests.swift +++ b/FlipcashTests/Chat/UnreadBelowCountTests.swift @@ -138,7 +138,7 @@ struct UnreadBelowCountTests { let notice = ConversationMessage( id: MessageID(value: 4), senderID: nil, - content: .system(fallbackText: "Alex joined"), + content: .system(fallbackText: "Alex joined", event: nil), date: UnreadFixtures.base.addingTimeInterval(240), unreadSeq: 4 ) diff --git a/FlipcashTests/Chat/UnreadDividerTests.swift b/FlipcashTests/Chat/UnreadDividerTests.swift index ca04d2163..855eb11b8 100644 --- a/FlipcashTests/Chat/UnreadDividerTests.swift +++ b/FlipcashTests/Chat/UnreadDividerTests.swift @@ -126,7 +126,7 @@ struct UnreadDividerTests { let notice = ConversationMessage( id: MessageID(value: 2), senderID: nil, - content: .system(fallbackText: "Alex joined"), + content: .system(fallbackText: "Alex joined", event: nil), date: UnreadFixtures.base.addingTimeInterval(120), unreadSeq: 2 ) diff --git a/FlipcashTests/ConversationControllerTests.swift b/FlipcashTests/ConversationControllerTests.swift index 607ef69d8..35ae4434a 100644 --- a/FlipcashTests/ConversationControllerTests.swift +++ b/FlipcashTests/ConversationControllerTests.swift @@ -1756,6 +1756,68 @@ struct ConversationControllerTests { #expect(controller.unnamedLastMessageSenders(in: conversations, knownAuthors: knownAuthors) == [unknown]) } + private func noticeConversation( + _ event: ConversationMessage.SystemEvent?, + members: [ConversationMember] = [] + ) -> Conversation { + Conversation( + id: ConversationID.test(1), + members: members, + lastMessage: ConversationMessage( + id: MessageID(value: 1), + senderID: nil, + content: .system(fallbackText: "Someone joined", event: event), + date: Date(timeIntervalSince1970: 1), + unreadSeq: 1 + ), + lastActivity: Date(timeIntervalSince1970: 1), + type: .group + ) + } + + @Test("a join notice previews with the names of the users it is about") + func systemJoinPreviewNamesUsers() { + let me = UUID(), ada = UUID(), bob = UUID() + let controller = makeController(MockConversations(), selfUserID: me) + let roster = [member(ada, named: "Ada")] + let known = KnownAuthorDirectory.Snapshot(membersByUserID: [bob: member(bob, named: "Bob")]) + + let direct = noticeConversation(.userJoined(userID: ada, admittedBy: nil), members: roster) + #expect(controller.lastMessagePreview(for: direct, knownAuthors: known) { _ in nil } == "Ada joined the group") + + let admitted = noticeConversation(.userJoined(userID: bob, admittedBy: ada), members: roster) + #expect(controller.lastMessagePreview(for: admitted, knownAuthors: known) { _ in nil } == "Ada added Bob") + } + + @Test("a join notice with an unknown name previews the server's fallback text") + func systemJoinPreviewFallsBack() { + let controller = makeController(MockConversations(), selfUserID: UUID()) + let conversation = noticeConversation(.userJoined(userID: UUID(), admittedBy: nil)) + #expect(controller.lastMessagePreview(for: conversation) { _ in nil } == "Someone joined") + } + + @Test("a joiner with no name or handle previews as the generic Flipcash User") + func systemJoinPreviewBlankProfile() { + let joiner = UUID() + let controller = makeController(MockConversations(), selfUserID: UUID()) + let conversation = noticeConversation( + .userJoined(userID: joiner, admittedBy: nil), members: [member(joiner, named: "")] + ) + #expect(controller.lastMessagePreview(for: conversation) { _ in nil } == "Flipcash User joined the group") + } + + @Test("a join notice asks for the users nothing can name, never the viewer") + func unnamedLastMessageSendersIncludesJoinNotices() { + let me = UUID(), known = UUID(), unknown = UUID() + let controller = makeController(MockConversations(), selfUserID: me) + let knownAuthors = KnownAuthorDirectory.Snapshot(membersByUserID: [known: member(known, named: "Bob")]) + let conversations = [ + noticeConversation(.userJoined(userID: unknown, admittedBy: known)), + noticeConversation(.userJoined(userID: me, admittedBy: unknown)), + ] + #expect(controller.unnamedLastMessageSenders(in: conversations, knownAuthors: knownAuthors) == [unknown]) + } + @Test("an empty body has nothing to preview, rather than a bare prefix") func textPreviewSkipsEmptyBody() { let me = UUID() diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 8a606db6f..91c3eef07 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -500,6 +500,31 @@ struct DatabaseConversationsTests { #expect(ciphertext == Data([0x01, 0x02, 0x03, 0x04])) } + @Test("System messages round-trip their join event, and plain system text reads back with none") + func systemMessageRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let id = ConversationID.test(1) + let joiner = UUID() + let admitter = UUID() + func system(_ n: UInt64, _ event: ConversationMessage.SystemEvent?) -> ConversationMessage { + ConversationMessage(id: MessageID(value: n), senderID: nil, content: .system(fallbackText: "notice \(n)", event: event), + date: Date(timeIntervalSince1970: TimeInterval(n)), unreadSeq: n) + } + try database.upsertConversationMessages( + [system(1, .userJoined(userID: joiner, admittedBy: nil)), + system(2, .userJoined(userID: joiner, admittedBy: admitter)), + system(3, nil)], + conversationID: id + ) + let loaded = try database.getConversationMessages(conversationID: id) + #expect(loaded.map(\.content) == [ + .system(fallbackText: "notice 1", event: .userJoined(userID: joiner, admittedBy: nil)), + .system(fallbackText: "notice 2", event: .userJoined(userID: joiner, admittedBy: admitter)), + .system(fallbackText: "notice 3", event: nil), + ]) + } + @Test("A media message round-trips its attachment, caption, and reply target") func mediaMessageRoundTrip() throws { let (database, url) = try Database.makeTemp()