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/App/RecentChatPreview.swift b/bitchat/App/RecentChatPreview.swift new file mode 100644 index 0000000000..24777ede44 --- /dev/null +++ b/bitchat/App/RecentChatPreview.swift @@ -0,0 +1,86 @@ +// +// RecentChatPreview.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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..