Skip to content
Open
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
53 changes: 51 additions & 2 deletions bitchat/App/PeerListModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String>()
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] = [:]

Expand All @@ -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
Expand All @@ -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
)
}
}
Expand Down
86 changes: 86 additions & 0 deletions bitchat/App/RecentChatPreview.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//
// RecentChatPreview.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//

import Foundation

/// Builds the one-line snippet shown under a name in the "chats" section.
///
/// Media messages carry their payload as `"[image] <filename>"` — 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..<lastSpace]
} else {
cut = hardCut
}
return cut + "…"
}
}
46 changes: 31 additions & 15 deletions bitchat/Views/RecentChatList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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: ", ")
}
Expand Down
78 changes: 78 additions & 0 deletions bitchatTests/AppArchitectureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading