From 232d25a0f4818e6071168a2ee7ac95a0760d6a9a Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 08:59:57 +0530 Subject: [PATCH 1/3] Add RecentChatPreview for the chats-row snippet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Media messages carry their payload as "[image] " — the marker is part of the message content, not decoration the view adds. Rendering that verbatim in a list row would put an opaque on-disk filename in front of the reader, which says nothing about the conversation. Keep the marker, drop the filename, collapse text to one line and bound its length. Matching against MimeType.Category's own messagePrefix rather than three literals means adding a category cannot leave a path here still printing a filename. No caller yet; this commit is the rules and their tests. --- bitchat/App/RecentChatPreview.swift | 87 ++++++++++++++++++ bitchatTests/RecentChatPreviewTests.swift | 103 ++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 bitchat/App/RecentChatPreview.swift create mode 100644 bitchatTests/RecentChatPreviewTests.swift diff --git a/bitchat/App/RecentChatPreview.swift b/bitchat/App/RecentChatPreview.swift new file mode 100644 index 0000000000..4f4ab6d5d2 --- /dev/null +++ b/bitchat/App/RecentChatPreview.swift @@ -0,0 +1,87 @@ +// +// RecentChatPreview.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation + +/// Builds the one-line snippet shown under a name in the "chats" section. +/// +/// Media messages carry their payload as `"[image] "` — the marker +/// is part of the message content, not decoration added by the view (see +/// `MimeType.Category.messagePrefix` and `BitchatMessage.mediaAttachment`). +/// Rendering that content verbatim in a list row would put an opaque +/// on-disk filename in front of the reader, which says nothing about the +/// conversation. Keeping the marker and dropping the filename is what makes +/// the row readable, and it matches how the thread itself presents the +/// message. +/// +/// Framework-free on purpose: this is the part of the row with rules worth +/// pinning, and it stays testable without a view or a view model. +enum RecentChatPreview { + /// Long enough to distinguish two conversations at a glance, short + /// enough that the row never wraps at the narrowest sheet width. + static let maxLength = 80 + + /// A snippet for `content`, or nil when there is nothing worth showing. + /// + /// Nil rather than empty string: an absent preview and a preview of "" + /// are different states, and the caller decides whether to reserve the + /// line. Whitespace-only content is treated as absent, since a row of + /// blank space reads as a rendering bug. + static func snippet(for content: String) -> String? { + if let marker = mediaMarker(for: content) { + return marker + } + + // Newlines and runs of spaces would otherwise render as a ragged gap + // mid-row; a multi-line message has to become one line to fit at all. + let collapsed = content + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + guard !collapsed.isEmpty else { return nil } + return truncated(collapsed) + } + + /// The bare marker for a media message (`[image]`, `[voice]`, `[file]`), + /// or nil when the content is not media. + /// + /// Matched against the shared `messagePrefix` rather than a literal, so + /// adding a category cannot leave this behind still emitting a filename. + static func mediaMarker(for content: String) -> String? { + for category in mediaCategories { + let prefix = category.messagePrefix + guard content.hasPrefix(prefix) else { continue } + // `messagePrefix` ends with a space; the marker is what precedes + // it. A prefix with no filename after it is still that kind of + // message, so this does not require a non-empty remainder. + return String(prefix.dropLast()) + } + return nil + } + + /// Every category `mediaAttachment` can classify, plus `.file`, which it + /// does not resolve to an attachment but which still arrives as content + /// with a prefix — and so would otherwise fall through to the text path + /// and print its filename. + private static let mediaCategories: [MimeType.Category] = [.audio, .image, .file] + + private static func truncated(_ text: String) -> String { + guard text.count > maxLength else { return text } + // Cut on a word boundary when one is close to the limit, so the + // snippet does not end mid-word for the sake of four characters. + let hardCut = text.prefix(maxLength) + let cut: Substring + if let lastSpace = hardCut.lastIndex(of: " "), + hardCut.distance(from: lastSpace, to: hardCut.endIndex) < 12 { + cut = hardCut[hardCut.startIndex.. Date: Sat, 15 Aug 2026 09:05:13 +0530 Subject: [PATCH 2/3] Show the newest message and drop the row for the open thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups jack asked for on #1576, on top of what landed. Each chats row gains a second line with the newest message: the marker alone for media, one collapsed line for text. Rows are also suppressed for the conversation currently on screen, which the sheet was otherwise offering to open. Suppression needs its own binding. `selectedPeer` was not among the publishers PeerListModel observes, so without it the row would linger until some unrelated change triggered a refresh. The integration test took 30s waiting for that before the binding existed and 0.07s after. Preview is threaded into the accessibility label too — the row ignores its children, so a line left out there is one sighted users can read and VoiceOver cannot. --- bitchat/App/PeerListModel.swift | 53 ++++++++++++++++- bitchat/Views/RecentChatList.swift | 46 ++++++++++----- bitchatTests/AppArchitectureTests.swift | 78 +++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 17 deletions(-) diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 4f1fc633d5..a88e646db4 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -49,8 +49,26 @@ struct RecentChatRow: Identifiable, Equatable { let displayName: String let hasUnread: Bool let lastActivity: Date + /// One-line snippet of the newest message, or nil when there is nothing + /// worth showing (see `RecentChatPreview`). Nil rather than "" so the + /// row can collapse to a single line instead of reserving blank space. + let preview: String? var id: String { peerID.id } + + init( + peerID: PeerID, + displayName: String, + hasUnread: Bool, + lastActivity: Date, + preview: String? = nil + ) { + self.peerID = peerID + self.displayName = displayName + self.hasUnread = hasUnread + self.lastActivity = lastActivity + self.preview = preview + } } @MainActor @@ -169,6 +187,17 @@ final class PeerListModel: ObservableObject { } .store(in: &cancellables) + // Opening or closing a direct conversation adds and removes its chat + // row. Without this the suppression would only take effect on the + // next unrelated refresh, so the sheet would keep offering to open + // the thread already on screen until something else changed. + chatViewModel.privateChatManager.$selectedPeer + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + chatViewModel.groupStore.$groups .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -315,9 +344,22 @@ final class PeerListModel: ObservableObject { visibleIdentities.insert(PeerID(nostr_: person.id).id) } + // The thread already on screen does not need a row pointing at it. + // Compare through the same identity keys used above: a conversation + // opened under the stable Noise ID must still suppress the row keyed + // by its ephemeral alias, or closing the sheet reopens what is open. + var openIdentities = Set() + if let openPeerID = chatViewModel.selectedPrivateChatPeer { + openIdentities.insert(openPeerID.id) + if let fingerprint = chatViewModel.getFingerprint(for: openPeerID) { + openIdentities.insert(fingerprint) + } + } + struct Candidate { let peerID: PeerID let lastActivity: Date + let preview: String? } var bestByIdentity: [String: Candidate] = [:] @@ -332,13 +374,19 @@ final class PeerListModel: ObservableObject { let fingerprint = chatViewModel.getFingerprint(for: peerID) if visibleIdentities.contains(peerID.id) { continue } if let fingerprint, visibleIdentities.contains(fingerprint) { continue } + if openIdentities.contains(peerID.id) { continue } + if let fingerprint, openIdentities.contains(fingerprint) { continue } let identityKey = fingerprint ?? peerID.id if let existing = bestByIdentity[identityKey], existing.lastActivity >= lastMessage.timestamp { continue } - bestByIdentity[identityKey] = Candidate(peerID: peerID, lastActivity: lastMessage.timestamp) + bestByIdentity[identityKey] = Candidate( + peerID: peerID, + lastActivity: lastMessage.timestamp, + preview: RecentChatPreview.snippet(for: lastMessage.content) + ) } return bestByIdentity.values @@ -348,7 +396,8 @@ final class PeerListModel: ObservableObject { peerID: candidate.peerID, displayName: displayName(for: candidate.peerID), hasUnread: chatViewModel.hasUnreadMessages(for: candidate.peerID), - lastActivity: candidate.lastActivity + lastActivity: candidate.lastActivity, + preview: candidate.preview ) } } diff --git a/bitchat/Views/RecentChatList.swift b/bitchat/Views/RecentChatList.swift index 85180eb48d..9da070781b 100644 --- a/bitchat/Views/RecentChatList.swift +++ b/bitchat/Views/RecentChatList.swift @@ -45,24 +45,37 @@ struct RecentChatList: View { ) ForEach(chats) { chat in - HStack(spacing: 4) { - Text(verbatim: chat.displayName) - .bitchatFont(size: 14) - .foregroundColor(palette.primary) - .lineLimit(1) - .truncationMode(.tail) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + Text(verbatim: chat.displayName) + .bitchatFont(size: 14) + .foregroundColor(palette.primary) + .lineLimit(1) + .truncationMode(.tail) - Text(verbatim: Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date())) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary.opacity(0.8)) + Text(verbatim: Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date())) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary.opacity(0.8)) - Spacer() + Spacer() - if chat.hasUnread { - Image(systemName: "envelope.fill") - .font(.bitchatSystem(size: 10)) - .foregroundColor(.orange) - .help(Strings.newMessagesTooltip) + if chat.hasUnread { + Image(systemName: "envelope.fill") + .font(.bitchatSystem(size: 10)) + .foregroundColor(.orange) + .help(Strings.newMessagesTooltip) + } + } + + // Only when there is something to show: an empty + // second line reserves height and reads as a + // rendering fault rather than an empty conversation. + if let preview = chat.preview { + Text(verbatim: preview) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + .lineLimit(1) + .truncationMode(.tail) } } .padding(.horizontal) @@ -83,6 +96,9 @@ struct RecentChatList: View { chat.displayName, Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date()) ] + // The row ignores its children for accessibility, so a preview left + // out here is a line sighted users can read and VoiceOver cannot. + if let preview = chat.preview { parts.append(preview) } if chat.hasUnread { parts.append(Strings.unread) } return parts.joined(separator: ", ") } diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 747e737b0f..f8637304f6 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -602,6 +602,84 @@ struct AppArchitectureTests { #expect(peerListModel.recentChatRows.map(\.peerID) == [offlinePeerID]) } + @Test("Recent chat rows preview the newest message and hide the open thread") + @MainActor + func peerListModelPreviewsRecentChatsAndHidesTheOpenThread() async { + let viewModel = makeArchitectureViewModel() + let textPeerID = PeerID(str: "00000000000000b1") + let mediaPeerID = PeerID(str: "00000000000000b2") + + viewModel.seedPrivateChat([ + BitchatMessage( + id: "dm-text-1", + sender: "passerby", + content: "older message", + timestamp: Date(timeIntervalSince1970: 100), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: textPeerID + ), + BitchatMessage( + id: "dm-text-2", + sender: "passerby", + content: "meet me\nby the bridge", + timestamp: Date(timeIntervalSince1970: 200), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: textPeerID + ) + ], for: textPeerID) + + // Media content carries its on-disk filename. The row must show the + // marker alone — the filename is meaningless to the reader. + viewModel.seedPrivateChat([ + BitchatMessage( + id: "dm-media-1", + sender: "stranger", + content: MimeType.Category.image.messagePrefix + "9F2A7C41-B0E3.jpg", + timestamp: Date(timeIntervalSince1970: 300), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: mediaPeerID + ) + ], for: mediaPeerID) + + let peerListModel = PeerListModel( + chatViewModel: viewModel, + conversations: viewModel.conversations + ) + + await waitUntil { + peerListModel.recentChatRows.contains { $0.peerID == textPeerID } + && peerListModel.recentChatRows.contains { $0.peerID == mediaPeerID } + } + + // Newest message wins, and it is flattened to one line. + let textRow = peerListModel.recentChatRows.first { $0.peerID == textPeerID } + #expect(textRow?.preview == "meet me by the bridge") + + let mediaRow = peerListModel.recentChatRows.first { $0.peerID == mediaPeerID } + #expect(mediaRow?.preview == "[image]") + + // A row pointing at the conversation already on screen is noise: the + // sheet offers to open what is open. + viewModel.selectedPrivateChatPeer = mediaPeerID + await waitUntil { + !peerListModel.recentChatRows.contains { $0.peerID == mediaPeerID } + } + #expect(peerListModel.recentChatRows.contains { $0.peerID == textPeerID }) + + // Closing it brings the row back — the suppression is a view of the + // current selection, not a durable removal. + viewModel.selectedPrivateChatPeer = nil + await waitUntil { + peerListModel.recentChatRows.contains { $0.peerID == mediaPeerID } + } + } + @Test("PrivateConversationModel resolves canonical header state for the selected DM") @MainActor func privateConversationModelResolvesSelectedHeaderState() async { From 4ea4935202ebb6c392137fb28f0c5e8fc9ed1d32 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 09:41:14 +0530 Subject: [PATCH 3/3] Drop the unused BitFoundation imports Periphery runs as a build gate and flags an unused import as an issue. RecentChatPreview names only app-target types. --- bitchat/App/RecentChatPreview.swift | 1 - bitchatTests/RecentChatPreviewTests.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/bitchat/App/RecentChatPreview.swift b/bitchat/App/RecentChatPreview.swift index 4f4ab6d5d2..24777ede44 100644 --- a/bitchat/App/RecentChatPreview.swift +++ b/bitchat/App/RecentChatPreview.swift @@ -6,7 +6,6 @@ // For more information, see // -import BitFoundation import Foundation /// Builds the one-line snippet shown under a name in the "chats" section. diff --git a/bitchatTests/RecentChatPreviewTests.swift b/bitchatTests/RecentChatPreviewTests.swift index c42910a686..ed350ee1f7 100644 --- a/bitchatTests/RecentChatPreviewTests.swift +++ b/bitchatTests/RecentChatPreviewTests.swift @@ -1,4 +1,3 @@ -import BitFoundation import Foundation import Testing