diff --git a/whitenoise-mac/Core/MarmotMapping.swift b/whitenoise-mac/Core/MarmotMapping.swift index d2a1647..6b497f2 100644 --- a/whitenoise-mac/Core/MarmotMapping.swift +++ b/whitenoise-mac/Core/MarmotMapping.swift @@ -38,9 +38,9 @@ extension ChatItem { directPeer: ChatPeerProfile? = nil, groupAvatarURL: String? = nil ) { - let groupName = row.groupName.trimmingCharacters(in: .whitespacesAndNewlines) - let peerName = directPeer?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) - let projectedTitle = row.title.trimmingCharacters(in: .whitespacesAndNewlines) + let groupName = PeerDisplayText.sanitize(row.groupName) ?? "" + let peerName = PeerDisplayText.sanitize(directPeer?.displayName) + let projectedTitle = PeerDisplayText.sanitize(row.title) ?? "" let title: String if let peerName, !peerName.isEmpty { title = peerName @@ -115,13 +115,13 @@ extension ChatItem { } guard presentation.isChatBubble, preview.sender != activeAccountIdHex, - let senderName = preview.senderDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines), + let senderName = PeerDisplayText.sanitize(preview.senderDisplayName), !senderName.isEmpty else { return body } - return "\(senderName): \(body)" + return "\(PeerDisplayText.templateFragment(senderName)): \(body)" } } @@ -401,7 +401,7 @@ nonisolated extension MessageItem { break } - return nonBlank(event.text) ?? groupSystemFallback(event.systemType) + return PeerDisplayText.sanitize(event.text) ?? groupSystemFallback(event.systemType) } private static func memberAddedText( @@ -595,25 +595,35 @@ nonisolated extension MessageItem { activeAccountIdHex: String?, senderProfiles: [String: ChatPeerProfile] ) -> String? { - guard let name = nonBlank(event.name) else { return nil } + guard let name = PeerDisplayText.sanitize(event.name) else { return nil } let actorName = systemAccountName( event.actorAccountIdHex, activeAccountIdHex: activeAccountIdHex, senderProfiles: senderProfiles, position: .subject ) - let oldName = nonBlank(event.oldName) + let oldName = PeerDisplayText.sanitize(event.oldName) + let isolatedName = PeerDisplayText.templateFragment(name) if let actorName, let oldName { - return String(format: L10n.string("%@ renamed the group from \"%@\" to \"%@\""), actorName, oldName, name) + return String( + format: L10n.string("%@ renamed the group from \"%@\" to \"%@\""), + actorName, + PeerDisplayText.templateFragment(oldName), + isolatedName + ) } if let actorName { - return String(format: L10n.string("%@ renamed the group to \"%@\""), actorName, name) + return String(format: L10n.string("%@ renamed the group to \"%@\""), actorName, isolatedName) } if let oldName { - return String(format: L10n.string("The group was renamed from \"%@\" to \"%@\""), oldName, name) + return String( + format: L10n.string("The group was renamed from \"%@\" to \"%@\""), + PeerDisplayText.templateFragment(oldName), + isolatedName + ) } - return String(format: L10n.string("The group was renamed to \"%@\""), name) + return String(format: L10n.string("The group was renamed to \"%@\""), isolatedName) } private static func groupAvatarChangedText( @@ -685,7 +695,9 @@ nonisolated extension MessageItem { return L10n.string("you") } } - return displayName(for: accountIdHex, profile: senderProfiles[accountIdHex]) + return PeerDisplayText.templateFragment( + displayName(for: accountIdHex, profile: senderProfiles[accountIdHex]) + ) } private enum SystemAccountNamePosition { @@ -834,11 +846,11 @@ nonisolated extension MessageItem { return L10n.string("Agent operation") case .groupSystem: let payload = TimelinePayload.decode(from: body) - if let text = firstNonBlank([payload?.text]) { + if let text = PeerDisplayText.sanitize(payload?.text) { return text } - if payload == nil, !body.isEmpty { - return body + if payload == nil, let text = PeerDisplayText.sanitize(body) { + return text } return groupSystemFallback(payload?.systemType ?? tagValue("system", in: tags)) case .unsupported: @@ -887,7 +899,7 @@ nonisolated extension MessageItem { } private static func displayName(for sender: String, profile: ChatPeerProfile?) -> String { - firstNonBlank([profile?.displayName]) ?? DisplayText.short(sender) + PeerDisplayText.sanitize(profile?.displayName) ?? DisplayText.short(sender) } private static func tagValue(_ name: String, in tags: [MessageTagFfi]) -> String? { diff --git a/whitenoise-mac/Core/PeerDisplayText.swift b/whitenoise-mac/Core/PeerDisplayText.swift new file mode 100644 index 0000000..6c5c416 --- /dev/null +++ b/whitenoise-mac/Core/PeerDisplayText.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Sanitization for peer-controlled strings shown as display names or group titles. +nonisolated enum PeerDisplayText { + private static let firstStrongIsolate = "\u{2068}" + private static let popDirectionalIsolate = "\u{2069}" + + /// Strips Unicode format controls (`Cf`), including bidi embedding/override/isolate + /// scalars (U+202A–U+202E, U+2066–U+2069), then treats an all-blank result as absent. + static func sanitize(_ text: String?) -> String? { + guard let text else { return nil } + let filtered = String( + String.UnicodeScalarView(text.unicodeScalars.filter { $0.properties.generalCategory != .format }) + ) + return filtered.nilIfBlank + } + + /// Sanitized text wrapped in first-strong isolate and PDI for safe `%@` interpolation. + static func templateFragment(_ text: String) -> String { + guard let core = sanitize(text) else { return "" } + return firstStrongIsolate + core + popDirectionalIsolate + } +} diff --git a/whitenoise-mac/Core/WorkspaceState+NewChat.swift b/whitenoise-mac/Core/WorkspaceState+NewChat.swift index 18b304e..09b93af 100644 --- a/whitenoise-mac/Core/WorkspaceState+NewChat.swift +++ b/whitenoise-mac/Core/WorkspaceState+NewChat.swift @@ -80,9 +80,9 @@ extension WorkspaceState { ) } let displayName = firstNonBlank([ - resolved?.profileDisplayName, - resolved?.profileName, - resolved?.directoryDisplayName, + PeerDisplayText.sanitize(resolved?.profileDisplayName), + PeerDisplayText.sanitize(resolved?.profileName), + PeerDisplayText.sanitize(resolved?.directoryDisplayName), ]) return NewChatRecipient( sourceQuery: query, diff --git a/whitenoise-mac/Core/WorkspaceState+Notifications.swift b/whitenoise-mac/Core/WorkspaceState+Notifications.swift index 16bcb9a..23facd6 100644 --- a/whitenoise-mac/Core/WorkspaceState+Notifications.swift +++ b/whitenoise-mac/Core/WorkspaceState+Notifications.swift @@ -210,10 +210,11 @@ extension WorkspaceState { func localNotificationRequest(for update: NotificationUpdateFfi) -> LocalNotificationRequest { let senderName = - firstNonBlank([ - update.sender.displayName, - update.sender.accountIdHex, - ]) ?? L10n.string("Someone") + PeerDisplayText.sanitize(update.sender.displayName) + ?? PeerDisplayText.sanitize(update.sender.accountIdHex) + ?? L10n.string("Someone") + let senderTemplateName = PeerDisplayText.templateFragment(senderName) + let groupName = PeerDisplayText.sanitize(update.groupName) let previewText = firstNonBlank([update.previewText]) ?? L10n.string("New message") // For an E2EE messenger, notification content is rendered as banners, @@ -233,7 +234,7 @@ extension WorkspaceState { body = L10n.string("New group invite") } else { title = L10n.string("Group invite") - body = firstNonBlank([update.groupName, senderName]) ?? L10n.string("New group invite") + body = groupName ?? senderName } case .newMessage: switch previewMode { @@ -242,15 +243,15 @@ extension WorkspaceState { title = senderName body = previewText } else { - title = firstNonBlank([update.groupName]) ?? L10n.string("New message") - body = "\(senderName): \(previewText)" + title = groupName ?? L10n.string("New message") + body = "\(senderTemplateName): \(previewText)" } case .senderOnly: if update.isDm { title = senderName body = genericBody } else { - title = firstNonBlank([update.groupName]) ?? L10n.string("New message") + title = groupName ?? L10n.string("New message") body = senderName } case .hidden: diff --git a/whitenoise-mac/Core/WorkspaceState.swift b/whitenoise-mac/Core/WorkspaceState.swift index 954ffd3..20266e2 100644 --- a/whitenoise-mac/Core/WorkspaceState.swift +++ b/whitenoise-mac/Core/WorkspaceState.swift @@ -1660,12 +1660,16 @@ final class WorkspaceState { let members = details.members .map { member in let action = actionByMemberId[member.memberIdHex] + let displayName = + firstNonBlank([ + PeerDisplayText.sanitize(member.displayName), + PeerDisplayText.sanitize(member.account), + ]) ?? DisplayText.short(member.npub, head: 12, tail: 8) return GroupMemberItem( id: member.memberIdHex, - displayName: firstNonBlank([member.displayName, member.account]) - ?? DisplayText.short(member.npub, head: 12, tail: 8), + displayName: displayName, npub: member.npub, - accountLabel: member.account, + accountLabel: PeerDisplayText.sanitize(member.account), isLocal: member.local, isAdmin: member.isAdmin, isSelf: member.isSelf, @@ -1684,7 +1688,7 @@ final class WorkspaceState { return GroupDetailsSnapshot( groupIdHex: details.group.groupIdHex, endpoint: details.group.endpoint, - name: firstNonBlank([details.group.name]) ?? L10n.string("Unnamed group"), + name: PeerDisplayText.sanitize(details.group.name) ?? L10n.string("Unnamed group"), description: details.group.description, avatarURL: avatarURL, sanitizedAvatarURL: RemoteImageURLPolicy.sanitizedURL(from: avatarURL), diff --git a/whitenoise-mac/Models/MessengerModels.swift b/whitenoise-mac/Models/MessengerModels.swift index e5df2e0..7a71571 100644 --- a/whitenoise-mac/Models/MessengerModels.swift +++ b/whitenoise-mac/Models/MessengerModels.swift @@ -202,7 +202,7 @@ struct GroupMemberItem: Identifiable, Hashable { if isSelf { return L10n.string("You") } - if let accountLabel, !accountLabel.isEmpty { + if let accountLabel = PeerDisplayText.sanitize(accountLabel) { return accountLabel } return DisplayText.short(npub, head: 12, tail: 8) @@ -2204,17 +2204,13 @@ struct NewChatRecipient: Equatable { self.memberRef = memberRef self.accountIdHex = accountIdHex self.npub = npub - self.displayName = displayName + self.displayName = PeerDisplayText.sanitize(displayName) self.pictureURL = pictureURL self.sanitizedPictureURL = RemoteImageURLPolicy.sanitizedURL(from: pictureURL) } var title: String { - guard let displayName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines), - !displayName.isEmpty - else { return DisplayText.short(accountIdHex) } - - return displayName + displayName ?? DisplayText.short(accountIdHex) } var subtitle: String { diff --git a/whitenoise-mac/Views/GroupViews.swift b/whitenoise-mac/Views/GroupViews.swift index 09638da..37419eb 100644 --- a/whitenoise-mac/Views/GroupViews.swift +++ b/whitenoise-mac/Views/GroupViews.swift @@ -577,7 +577,7 @@ struct GroupMemberRow: View { .disabled(workspace.hasInFlightGroupDetailsMutation) Button("Cancel", role: .cancel) {} } message: { - Text("This removes \(member.displayName) from the group.") + Text("This removes \(PeerDisplayText.templateFragment(member.displayName)) from the group.") } } } diff --git a/whitenoise-macTests/PeerDisplayTextTests.swift b/whitenoise-macTests/PeerDisplayTextTests.swift new file mode 100644 index 0000000..f0d84f3 --- /dev/null +++ b/whitenoise-macTests/PeerDisplayTextTests.swift @@ -0,0 +1,236 @@ +// +// PeerDisplayTextTests.swift +// whitenoise-macTests +// + +import Foundation +import MarmotKit +import Testing + +@testable import whitenoise_mac + +private let fsi = "\u{2068}" +private let pdi = "\u{2069}" +private let rtlOverride = "\u{202E}" +private let ltrIsolate = "\u{2066}" + +private func isolated(_ text: String) -> String { + fsi + text + pdi +} + +@Suite(.serialized) +struct PeerDisplayTextTests { + @Test func sanitizeStripsBidiAndFormatControls() async throws { + let malicious = "\(rtlOverride)Alice\(ltrIsolate)" + #expect(PeerDisplayText.sanitize(malicious) == "Alice") + #expect(PeerDisplayText.sanitize("\(fsi) Bob \(pdi)") == "Bob") + #expect(PeerDisplayText.sanitize("\u{200B}trimmed\u{200B}") == "trimmed") + #expect(PeerDisplayText.sanitize(nil) == nil) + #expect(PeerDisplayText.sanitize(rtlOverride) == nil) + } + + @Test func templateFragmentWrapsSanitizedTextInFirstStrongIsolate() async throws { + let wrapped = PeerDisplayText.templateFragment("\(rtlOverride)Carol\(pdi)") + #expect(wrapped == isolated("Carol")) + #expect(!wrapped.unicodeScalars.contains { $0.value == 0x202E }) + #expect(wrapped.hasPrefix(fsi)) + #expect(wrapped.hasSuffix(pdi)) + } + + @Test func newChatRecipientTitleSanitizesPeerControlledDisplayName() async throws { + let recipient = NewChatRecipient( + sourceQuery: "npub1recipient", + memberRef: "npub1recipient", + accountIdHex: "def456", + npub: "npub1recipient", + displayName: "\(rtlOverride)Trusted Admin\(ltrIsolate)", + pictureURL: nil + ) + + #expect(recipient.displayName == "Trusted Admin") + #expect(recipient.title == "Trusted Admin") + #expect(!recipient.title.unicodeScalars.contains { $0.properties.generalCategory == .format }) + } + + @Test func chatListRowsSanitizePeerControlledTitlesAndPreviewSenderNames() async throws { + let row = ChatListRowFfi( + groupIdHex: "group", + archived: false, + pendingConfirmation: false, + title: "\(rtlOverride)Planning\(ltrIsolate)", + groupName: "\(ltrIsolate)Planning\(rtlOverride)", + avatarUrl: nil, + avatar: nil, + lastMessage: ChatListMessagePreviewFfi( + messageIdHex: "message-1", + sender: "alice", + senderDisplayName: "\(rtlOverride)Alice\(ltrIsolate)", + plaintext: "Welcome in", + contentTokens: MarkdownDocumentFfi(blocks: [], truncated: false), + kind: 9, + timelineAt: 1_700_000_000, + deleted: false + ), + unreadCount: 0, + hasUnread: false, + unreadMentionCount: 0, + unreadMention: false, + firstUnreadMessageIdHex: nil, + lastReadMessageIdHex: nil, + lastReadTimelineAt: nil, + updatedAt: 1_700_000_000, + selfMembership: .member + ) + + let chat = ChatItem(row: row, activeAccountIdHex: "self") + #expect(chat.title == "Planning") + #expect(chat.subtitle == "Planning") + #expect(chat.preview == "\(isolated("Alice")): Welcome in") + #expect(!chat.title.unicodeScalars.contains { $0.properties.generalCategory == .format }) + #expect(!chat.subtitle.unicodeScalars.contains { $0.properties.generalCategory == .format }) + } + + @Test func timelineMappingStripsBidiFromPeerDisplayNames() async throws { + let alice = String(repeating: "a", count: 64) + let spoofedName = "\(rtlOverride)Trusted Admin\(ltrIsolate)" + let profiles = [ + alice: ChatPeerProfile(accountIdHex: alice, displayName: spoofedName, pictureURL: nil) + ] + let page = TimelinePageFfi( + messages: [ + timelineMessage( + id: "chat-1", + groupIdHex: "group", + sender: alice, + plaintext: "hello", + recordedAt: 1_700_000_000 + ) + ], + hasMoreBefore: false, + hasMoreAfter: false + ) + + let messages = MessageItem.timeline(from: page, activeAccountIdHex: "self", senderProfiles: profiles) + #expect(messages.count == 1) + #expect(messages[0].senderName == "Trusted Admin") + #expect(!messages[0].senderName.unicodeScalars.contains { $0.properties.generalCategory == .format }) + } + + @Test func timelineMappingSanitizesGroupRenameNamesAndIsolatesTemplateFragments() async throws { + let alice = String(repeating: "a", count: 64) + let profiles = [ + alice: ChatPeerProfile(accountIdHex: alice, displayName: "Alice", pictureURL: nil) + ] + let maliciousOld = "\(rtlOverride)Team One\(ltrIsolate)" + let maliciousNew = "\(ltrIsolate)Team Two\(rtlOverride)" + let page = TimelinePageFfi( + messages: [ + timelineMessage( + id: "group-renamed", + groupIdHex: "group", + sender: "", + plaintext: "", + kind: 1210, + recordedAt: 1_700_000_000, + groupSystem: groupSystemEvent( + systemType: "group_renamed", + text: "Group renamed", + actorAccountIdHex: alice, + name: maliciousNew, + oldName: maliciousOld + ) + ) + ], + hasMoreBefore: false, + hasMoreAfter: false + ) + + let messages = MessageItem.timeline(from: page, activeAccountIdHex: "self", senderProfiles: profiles) + #expect(messages.count == 1) + + let body = messages[0].body + let expectedBody = + "\(isolated("Alice")) renamed the group from \"\(isolated("Team One"))\" " + + "to \"\(isolated("Team Two"))\"" + #expect(body == expectedBody) + #expect(!body.unicodeScalars.contains { $0.value == 0x202E }) + #expect(!body.unicodeScalars.contains { $0.value == 0x2066 }) + #expect(body.contains(isolated("Team One"))) + #expect(body.contains(isolated("Team Two"))) + } + + @Test func legacyGroupSystemPayloadTextIsSanitized() async throws { + let page = TimelinePageFfi( + messages: [ + timelineMessage( + id: "legacy-system", + groupIdHex: "group", + sender: "", + plaintext: #"{"v":1,"system_type":"group_renamed","text":"\u202EGroup renamed\u2066"}"#, + kind: 1210, + recordedAt: 1_700_000_000 + ) + ], + hasMoreBefore: false, + hasMoreAfter: false + ) + + let messages = MessageItem.timeline(from: page, activeAccountIdHex: "self") + #expect(messages.count == 1) + #expect(messages[0].body == "Group renamed") + #expect(!messages[0].body.unicodeScalars.contains { $0.properties.generalCategory == .format }) + } +} + +private func timelineMessage( + id: String, + groupIdHex: String, + sender: String, + plaintext: String, + kind: UInt64 = 9, + recordedAt: UInt64, + groupSystem: GroupSystemEventFfi? = nil +) -> TimelineMessageRecordFfi { + TimelineMessageRecordFfi( + messageIdHex: id, + sourceMessageIdHex: nil, + direction: "inbound", + groupIdHex: groupIdHex, + sender: sender, + plaintext: plaintext, + contentTokens: MarkdownDocumentFfi(blocks: [], truncated: false), + kind: kind, + tags: [], + timelineAt: recordedAt, + receivedAt: recordedAt, + replyToMessageIdHex: nil, + replyPreview: nil, + mediaJson: nil, + media: [], + agentTextStreamJson: nil, + groupSystem: groupSystem, + reactions: TimelineReactionSummaryFfi(byEmoji: [], userReactions: []), + deleted: false, + deletedByMessageIdHex: nil, + invalidationStatus: nil + ) +} + +private func groupSystemEvent( + systemType: String, + text: String, + actorAccountIdHex: String? = nil, + name: String? = nil, + oldName: String? = nil +) -> GroupSystemEventFfi { + GroupSystemEventFfi( + systemType: systemType, + text: text, + actorAccountIdHex: actorAccountIdHex, + subjectAccountIdHex: nil, + name: name, + oldName: oldName, + oldRetentionSeconds: nil, + newRetentionSeconds: nil + ) +} diff --git a/whitenoise-macTests/PureValueTests.swift b/whitenoise-macTests/PureValueTests.swift index 0661d0b..f1952c4 100644 --- a/whitenoise-macTests/PureValueTests.swift +++ b/whitenoise-macTests/PureValueTests.swift @@ -582,6 +582,78 @@ struct PureValueTests { #expect(member.canDemote) } + @MainActor + @Test func groupDetailsSnapshotSanitizesPeerControlledNames() async throws { + let rtlOverride = "\u{202E}" + let ltrIsolate = "\u{2066}" + let memberIdHex = "member1234567890member1234567890member1234567890member1234" + let group = AppGroupRecordFfi( + groupIdHex: "group", + endpoint: "", + name: "\(rtlOverride)Ops Team\(ltrIsolate)", + description: "", + admins: [memberIdHex], + relays: [], + nostrGroupIdHex: "", + avatarUrl: nil, + avatarDim: nil, + avatarThumbhash: nil, + imageHashHex: nil, + encryptedMedia: AppGroupEncryptedMediaComponentFfi( + componentId: 0, + component: "", + required: false, + mediaFormat: "", + allowedLocatorKinds: [], + defaultBlobEndpoints: [] + ), + disappearingMessageSecs: 0, + archived: false, + pendingConfirmation: false, + selfMembership: .member, + welcomerAccountIdHex: nil, + viaWelcomeMessageIdHex: nil + ) + let details = GroupDetailsFfi( + group: group, + members: [ + GroupMemberDetailsFfi( + memberIdHex: memberIdHex, + account: "\(rtlOverride)member@example.test\(ltrIsolate)", + local: false, + isAdmin: true, + isSelf: false, + npub: "npub1member", + displayName: "\(ltrIsolate)Trusted Admin\(rtlOverride)" + ) + ] + ) + let managementState = GroupManagementStateFfi( + myAccountIdHex: memberIdHex, + isSelfAdmin: true, + isLastAdmin: false, + canInvite: true, + canLeave: true, + requiresSelfDemoteBeforeLeave: false, + memberActions: [] + ) + + let state = WorkspaceState( + localNotificationCenter: NoopLocalNotificationCenter(), + appActivityProvider: { false }, + conversationWindowVisibilityProvider: { false } + ) + let snapshot = state.groupDetailsSnapshot(from: details, managementState: managementState) + let member = try #require(snapshot.members.first { $0.id == memberIdHex }) + + #expect(snapshot.name == "Ops Team") + #expect(member.displayName == "Trusted Admin") + #expect(member.detailLabel == "member@example.test") + #expect(!snapshot.name.unicodeScalars.contains { $0.properties.generalCategory == .format }) + #expect(!member.displayName.unicodeScalars.contains { $0.properties.generalCategory == .format }) + #expect(!member.detailLabel.unicodeScalars.contains { $0.properties.generalCategory == .format }) + } + @MainActor @Test func groupDetailsSnapshotMapsSelfMembershipVariants() async throws { let variants: [(SelfMembershipFfi, ChatSelfMembership)] = [ diff --git a/whitenoise-macTests/whitenoise_macTests.swift b/whitenoise-macTests/whitenoise_macTests.swift index 9c52f32..90f15a0 100644 --- a/whitenoise-macTests/whitenoise_macTests.swift +++ b/whitenoise-macTests/whitenoise_macTests.swift @@ -3152,7 +3152,7 @@ struct whitenoise_macTests { #expect(chat.pendingConfirmation) #expect(chat.subtitle == "Planning") - #expect(chat.preview == "Alice: Welcome in") + #expect(chat.preview == "\(isolated("Alice")): Welcome in") #expect(chat.selfMembership == .member) #expect(!chat.isNoLongerMember) } @@ -3877,29 +3877,35 @@ struct whitenoise_macTests { activeAccountIdHex: bob, senderProfiles: profiles ) + let remoteRenameBody = + #"\#(isolated("Alice")) renamed the group from "\#(isolated("Team One"))" "# + + #"to "\#(isolated("Team Two"))""# + let aliceRenameBody = + #"You renamed the group from "\#(isolated("Team One"))" "# + + #"to "\#(isolated("Team Two"))""# #expect( remoteMessages.map(\.body) == [ - "Alice added Bob", - "Alice removed Bob", - "Carol left", - "Alice made Bob an admin", - "Bob was made an admin", - "Bob removed Carol as admin", - #"Alice renamed the group from "Team One" to "Team Two""#, - "Bob changed the group avatar", - "Alice changed disappearing messages from off to 1 week", + "\(isolated("Alice")) added \(isolated("Bob"))", + "\(isolated("Alice")) removed \(isolated("Bob"))", + "\(isolated("Carol")) left", + "\(isolated("Alice")) made \(isolated("Bob")) an admin", + "\(isolated("Bob")) was made an admin", + "\(isolated("Bob")) removed \(isolated("Carol")) as admin", + remoteRenameBody, + "\(isolated("Bob")) changed the group avatar", + "\(isolated("Alice")) changed disappearing messages from off to 1 week", ]) - #expect(aliceLocalMessages[0].body == "You added Bob") - #expect(aliceLocalMessages[3].body == "You made Bob an admin") - #expect(aliceLocalMessages[4].body == "Bob was made an admin") - #expect(aliceLocalMessages[6].body == #"You renamed the group from "Team One" to "Team Two""#) + #expect(aliceLocalMessages[0].body == "You added \(isolated("Bob"))") + #expect(aliceLocalMessages[3].body == "You made \(isolated("Bob")) an admin") + #expect(aliceLocalMessages[4].body == "\(isolated("Bob")) was made an admin") + #expect(aliceLocalMessages[6].body == aliceRenameBody) #expect(aliceLocalMessages[8].body == "You changed disappearing messages from off to 1 week") - #expect(bobLocalMessages[0].body == "Alice added you") - #expect(bobLocalMessages[1].body == "You were removed from the group by Alice") - #expect(bobLocalMessages[3].body == "Alice made you an admin") + #expect(bobLocalMessages[0].body == "\(isolated("Alice")) added you") + #expect(bobLocalMessages[1].body == "You were removed from the group by \(isolated("Alice"))") + #expect(bobLocalMessages[3].body == "\(isolated("Alice")) made you an admin") #expect(bobLocalMessages[4].body == "You were made an admin") - #expect(bobLocalMessages[5].body == "You removed Carol as admin") + #expect(bobLocalMessages[5].body == "You removed \(isolated("Carol")) as admin") #expect(bobLocalMessages[7].body == "You changed the group avatar") } @@ -4041,7 +4047,7 @@ struct whitenoise_macTests { ) let directChat = ChatItem(row: directRow, activeAccountIdHex: "self") - #expect(directChat.preview == "Alice: Attachment") + #expect(directChat.preview == "\(isolated("Alice")): Attachment") let groupRow = ChatListRowFfi( groupIdHex: "group", @@ -4073,7 +4079,7 @@ struct whitenoise_macTests { ) let groupChat = ChatItem(row: groupRow, activeAccountIdHex: "self") - #expect(groupChat.preview == "Alice: Attachment") + #expect(groupChat.preview == "\(isolated("Alice")): Attachment") } @MainActor @@ -10462,7 +10468,7 @@ struct whitenoise_macTests { #expect(state.activeChats.first?.avatarSeed == aliceId) #expect(state.activeChats.first?.pictureURL == "https://example.com/alice.png") #expect(state.activeChats.first?.isDirect == true) - #expect(state.activeChats.first?.preview == "Alice Actual: Latest message") + #expect(state.activeChats.first?.preview == "\(isolated("Alice Actual")): Latest message") } @MainActor @@ -15630,15 +15636,15 @@ struct whitenoise_macTests { account: account, notificationKey: "group-notice", groupIdHex: "team-group", - senderName: "Bob", + senderName: "\u{202E}Bob\u{2066}", previewText: "The launch plan is ready.", isDm: false, - groupName: "Engineering" + groupName: "\u{202E}Engineering\u{2066}" )) #expect(notificationCenter.postedRequests.count == 1) #expect(notificationCenter.postedRequests.first?.title == "Engineering") - #expect(notificationCenter.postedRequests.first?.body == "Bob: The launch plan is ready.") + #expect(notificationCenter.postedRequests.first?.body == "\(isolated("Bob")): The launch plan is ready.") } @MainActor @@ -18474,6 +18480,10 @@ private func pagedTimeline( ) } +private func isolated(_ text: String) -> String { + "\u{2068}\(text)\u{2069}" +} + private func timelineMessage( id: String, sourceMessageIdHex: String? = nil,