Merge bc20ed6add5fe1663e8c46bc8303e7a26444007c into 9b84b361225facd8e623f25d76f889d3dc54a879

This commit is contained in:
Taksh Kothari 2026-08-11 23:55:48 +00:00 committed by GitHub
commit 8fa9b8925b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 848 additions and 3 deletions

View File

@ -67,6 +67,12 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendMessage("/clear")
}
/// Dismisses the unread indicator for a conversation the user has not
/// opened; does not emit read receipts.
func clearUnread(for peerID: PeerID) {
chatViewModel.markPrivateChatRead(peerID)
}
func sendHug(to sender: String) {
chatViewModel.sendMessage("/hug @\(sender)")
}

View File

@ -106,6 +106,12 @@ final class PeerListModel: ObservableObject {
chatViewModel.startPrivateChat(with: peerID)
}
/// Clears the unread badge for one direct or group conversation without
/// opening it or sending read receipts.
func clearUnread(for peerID: PeerID) {
chatViewModel.markPrivateChatRead(peerID)
}
func toggleFavorite(peerID: PeerID) {
chatViewModel.toggleFavorite(peerID: peerID)
}

View File

@ -183,6 +183,13 @@ final class PrivateConversationModel: ObservableObject {
chatViewModel.markPrivateMessagesAsRead(from: peerID)
}
/// Clears the unread badge for the selected conversation without sending
/// read receipts — for when new messages arrive while the thread is open.
func clearUnreadForSelectedConversation() {
guard let peerID = selectedPeerID else { return }
chatViewModel.markPrivateChatRead(peerID)
}
private func bind() {
conversations.$selectedPrivatePeerID
.receive(on: DispatchQueue.main)

View File

@ -60220,6 +60220,564 @@
}
}
},
"conversation.accessibility.mark_read" : {
"comment" : "Accessibility label for clearing unread on the open conversation",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "تحديد المحادثة كمقروءة"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "কথোপকথন পঠিত হিসেবে চিহ্নিত করুন"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "unterhaltung als gelesen markieren"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "mark conversation as read"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "marcar la conversación como leída"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "علامت‌گذاری گفتگو به‌عنوان خوانده‌شده"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "markahan ang pag-uusap bilang nabasa"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "marquer la conversation comme lue"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "סמן את השיחה כנקראה"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "बातचीत को पढ़ा हुआ चिह्नित करें"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "tandai percakapan sudah dibaca"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "segna la conversazione come letta"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "会話を既読にする"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "대화를 읽음으로 표시"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "tandakan perbualan sudah dibaca"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "कुराकानीलाई पढिएको चिन्ह लगाउनुहोस्"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "gesprek markeren als gelezen"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "oznacz rozmowę jako przeczytaną"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "marcar a conversa como lida"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "marcar a conversa como lida"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "отметить разговор как прочитанный"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "markera konversationen som läst"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "உரையாடலைப் படித்ததாகக் குறி"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ทำเครื่องหมายว่าอ่านการสนทนาแล้ว"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "sohbeti okundu olarak işaretle"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "позначити розмову як прочитану"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "گفتگو کو پڑھا ہوا نشان زد کریں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "đánh dấu cuộc trò chuyện đã đọc"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "将对话标记为已读"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "將對話標記為已讀"
}
}
}
},
"conversation.action.mark_read" : {
"comment" : "Context menu action that clears the unread badge for one conversation",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "تحديد كمقروء"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "পঠিত হিসেবে চিহ্নিত করুন"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "als gelesen markieren"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "mark as read"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "marcar como leído"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "علامت‌گذاری به‌عنوان خوانده‌شده"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "markahan bilang nabasa"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "marquer comme lu"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "סמן כנקרא"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "पढ़ा हुआ चिह्नित करें"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "tandai sudah dibaca"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "segna come letto"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "既読にする"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "읽음으로 표시"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "tandakan sudah dibaca"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "पढिएको चिन्ह लगाउनुहोस्"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "markeren als gelezen"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "oznacz jako przeczytane"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "marcar como lido"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "marcar como lida"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "отметить как прочитанное"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "markera som läst"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "படித்ததாகக் குறி"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ทำเครื่องหมายว่าอ่านแล้ว"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "okundu olarak işaretle"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "позначити як прочитане"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "پڑھا ہوا نشان زد کریں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "đánh dấu đã đọc"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "标记为已读"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "標記為已讀"
}
}
}
},
"conversation.tooltip.mark_read" : {
"comment" : "Tooltip for clearing unread on the open conversation",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "إزالة شارة غير المقروء دون إرسال إيصالات القراءة"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "পঠিত রসিদ না পাঠিয়ে অপঠিত ব্যাজ মুছুন"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "ungelesen-markierung entfernen, ohne lesebestätigungen zu senden"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "clear unread badge without sending read receipts"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "quitar la marca de no leído sin enviar confirmaciones de lectura"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "حذف نشان خوانده‌نشده بدون ارسال رسید خواندن"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "alisin ang badge na hindi pa nabasa nang hindi nagpapadala ng read receipt"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "retirer le badge non lu sans envoyer d'accusé de lecture"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "הסרת סימון לא נקרא בלי לשלוח אישורי קריאה"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "पठन रसीद भेजे बिना अपठित बैज हटाएँ"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "hapus tanda belum dibaca tanpa mengirim tanda terima baca"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "rimuovi il badge non letto senza inviare conferme di lettura"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "開封確認を送らずに未読バッジを消す"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "읽음 확인을 보내지 않고 안 읽음 표시 지우기"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "buang lencana belum dibaca tanpa menghantar resit bacaan"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "पढेको रसिद नपठाई नपढिएको ब्याज हटाउनुहोस्"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "ongelezen-markering wissen zonder leesbevestigingen te sturen"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "usuń oznaczenie nieprzeczytanych bez wysyłania potwierdzeń odczytu"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "remover a marca de não lido sem enviar confirmações de leitura"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "remover a marca de não lida sem enviar confirmações de leitura"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "убрать метку непрочитанного без отправки уведомлений о прочтении"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "ta bort oläst-markeringen utan att skicka läskvitton"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "படித்த ரசீதுகளை அனுப்பாமல் படிக்காத குறியை அகற்று"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ล้างป้ายยังไม่ได้อ่านโดยไม่ส่งการแจ้งอ่าน"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "okundu bilgisi göndermeden okunmadı rozetini kaldır"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "прибрати позначку непрочитаного без надсилання сповіщень про прочитання"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "پڑھنے کی رسید بھیجے بغیر نہ پڑھے کا نشان ہٹائیں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "xóa dấu chưa đọc mà không gửi báo đã đọc"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "清除未读标记,不发送已读回执"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "清除未讀標記,不傳送已讀回條"
}
}
}
},
"encryption.accessibility.establishing" : {
"extractionState" : "manual",
"localizations" : {

View File

@ -256,7 +256,7 @@ final class ChatPeerIdentityCoordinator {
}
@MainActor
func hasUnreadMessages(for peerID: PeerID) -> Bool {
func unreadPeerContext(for peerID: PeerID) -> ChatUnreadPeerContext {
var noiseKeyPeerID: PeerID?
var nostrPeerID: PeerID?
@ -267,12 +267,26 @@ final class ChatPeerIdentityCoordinator {
}
}
let unreadContext = ChatUnreadPeerContext(
return ChatUnreadPeerContext(
peerID: peerID,
noiseKeyPeerID: noiseKeyPeerID,
nostrPeerID: nostrPeerID,
nickname: context.peerNickname(for: peerID)
)
}
@MainActor
func unreadPeerIDsMatching(for peerID: PeerID) -> Set<PeerID> {
ChatUnreadStateResolver.matchingUnreadPeerIDs(
for: unreadPeerContext(for: peerID),
unreadPrivateMessages: context.unreadPrivateMessages,
privateChats: context.privateChats
)
}
@MainActor
func hasUnreadMessages(for peerID: PeerID) -> Bool {
let unreadContext = unreadPeerContext(for: peerID)
return ChatUnreadStateResolver.hasUnreadMessages(
for: unreadContext,

View File

@ -40,4 +40,39 @@ enum ChatUnreadStateResolver {
return firstMessage.sender.lowercased() == peerNickname
}
}
/// Peer IDs in `unreadPrivateMessages` that resolve to the same
/// conversation badge as `hasUnreadMessages(for:)`.
static func matchingUnreadPeerIDs(
for context: ChatUnreadPeerContext,
unreadPrivateMessages: Set<PeerID>,
privateChats: [PeerID: [BitchatMessage]]
) -> Set<PeerID> {
var matching = Set<PeerID>()
if unreadPrivateMessages.contains(context.peerID) {
matching.insert(context.peerID)
}
if let noiseKeyPeerID = context.noiseKeyPeerID,
unreadPrivateMessages.contains(noiseKeyPeerID) {
matching.insert(noiseKeyPeerID)
}
if let nostrPeerID = context.nostrPeerID,
unreadPrivateMessages.contains(nostrPeerID) {
matching.insert(nostrPeerID)
}
if let peerNickname = context.nickname?.lowercased(), !peerNickname.isEmpty {
for unreadPeerID in unreadPrivateMessages where unreadPeerID.isGeoDM {
guard let firstMessage = privateChats[unreadPeerID]?.first else { continue }
if firstMessage.sender.lowercased() == peerNickname {
matching.insert(unreadPeerID)
}
}
}
return matching
}
}

View File

@ -660,7 +660,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
/// sending stays in `PrivateChatManager.markAsRead`).
@MainActor
func markPrivateChatRead(_ peerID: PeerID) {
conversations.markRead(.directPeer(peerID))
var idsToClear = peerIdentityCoordinator.unreadPeerIDsMatching(for: peerID)
idsToClear.insert(peerID)
for id in idsToClear {
conversations.markRead(.directPeer(id))
}
}
/// Empties the peer's chat but keeps the conversation alive (`/clear`).

View File

@ -388,6 +388,9 @@ private struct ContentPeopleListView: View {
onTapChat: { peerID in
peerListModel.startConversation(with: peerID)
showSidebar = true
},
onClearUnread: { peerID in
peerListModel.clearUnread(for: peerID)
}
)
} else {
@ -413,6 +416,9 @@ private struct ContentPeopleListView: View {
} else {
conversationUIModel.block(peerID: peer.peerID, displayName: peer.displayName)
}
},
onClearUnread: { peerID in
peerListModel.clearUnread(for: peerID)
}
)
// People in this area but beyond radio range, and
@ -423,6 +429,9 @@ private struct ContentPeopleListView: View {
onTapGroup: { peerID in
peerListModel.startConversation(with: peerID)
showSidebar = true
},
onClearUnread: { peerID in
peerListModel.clearUnread(for: peerID)
}
)
// Conversations with people no roster above lists
@ -462,6 +471,7 @@ private extension ContentPeopleListView {
private struct ContentPrivateChatSheetView: View {
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var privateInboxModel: PrivateInboxModel
@Binding var showSidebar: Bool
@Binding var messageText: String
@ -533,6 +543,26 @@ private struct ContentPrivateChatSheetView: View {
: String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
)
}
if let peerID = privateConversationModel.selectedPeerID,
privateInboxModel.unreadPeerIDs.contains(peerID) {
Button(action: {
privateConversationModel.clearUnreadForSelectedConversation()
}) {
Image(systemName: "envelope.open.fill")
.font(.bitchatSystem(size: 14))
.foregroundColor(.orange)
.frame(width: 32, height: 32)
.contentShape(Rectangle().inset(by: -6))
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "conversation.accessibility.mark_read", comment: "Accessibility label for clearing unread on the open conversation")
)
.help(
String(localized: "conversation.tooltip.mark_read", comment: "Tooltip for clearing unread on the open conversation")
)
}
}
.frame(maxWidth: .infinity)

View File

@ -8,6 +8,7 @@ struct GroupChatList: View {
let groups: [GroupChatRow]
let onTapGroup: (PeerID) -> Void
var onClearUnread: ((PeerID) -> Void)? = nil
private enum Strings {
static let header = String(localized: "groups.section.header", comment: "Section header above the private groups list")
@ -16,6 +17,7 @@ struct GroupChatList: View {
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
static let openGroupHint = String(localized: "groups.accessibility.open_group_hint", comment: "Accessibility hint on a group row explaining activation opens the group chat")
static let memberCountFormat = String(localized: "groups.member_count %@", comment: "Member count shown next to a group name; placeholder is the count")
static let markRead = String(localized: "conversation.action.mark_read", comment: "Context menu action that clears the unread badge for one conversation")
}
var body: some View {
@ -60,6 +62,13 @@ struct GroupChatList: View {
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { onTapGroup(group.peerID) }
.contextMenu {
if group.hasUnread, let onClearUnread {
Button(Strings.markRead) {
onClearUnread(group.peerID)
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription(for: group))
.accessibilityAddTraits(.isButton)

View File

@ -10,6 +10,8 @@ struct MeshPeerList: View {
/// Optional so existing call sites (and previews/tests) keep compiling;
/// when absent the block/unblock context-menu entry is hidden.
var onToggleBlock: ((MeshPeerRow) -> Void)? = nil
/// Clears the unread badge without opening the conversation.
var onClearUnread: ((PeerID) -> Void)? = nil
@Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = []
@ -34,6 +36,7 @@ struct MeshPeerList: View {
static let directMessage = String(localized: "content.actions.direct_message", comment: "Action that opens a private chat with the person")
static let block = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
static let unblock = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
static let markRead = String(localized: "conversation.action.mark_read", comment: "Context menu action that clears the unread badge for one conversation")
}
var body: some View {
@ -186,6 +189,11 @@ struct MeshPeerList: View {
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
.contextMenu {
if !isMe {
if peer.hasUnread, let onClearUnread {
Button(Strings.markRead) {
onClearUnread(peer.peerID)
}
}
Button(Strings.directMessage) {
onTapPeer(peer.peerID)
}
@ -214,6 +222,11 @@ struct MeshPeerList: View {
.accessibilityHint(isMe ? "" : Strings.openDMHint)
.accessibilityActions {
if !isMe {
if peer.hasUnread, let onClearUnread {
Button(Strings.markRead) {
onClearUnread(peer.peerID)
}
}
Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) {
onToggleFavorite(peer.peerID)
}

View File

@ -19,11 +19,14 @@ struct RecentChatList: View {
let chats: [RecentChatRow]
let onTapChat: (PeerID) -> Void
/// Clears the unread badge without opening the conversation.
var onClearUnread: ((PeerID) -> Void)? = nil
private enum Strings {
static let header = String(localized: "chats.section.header", defaultValue: "chats", comment: "Section header above recent direct conversations in the people sheet")
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
static let markRead = String(localized: "conversation.action.mark_read", comment: "Context menu action that clears the unread badge for one conversation")
static let openChatHint = String(localized: "chats.accessibility.open_hint", defaultValue: "opens this conversation", comment: "Accessibility hint on a recent chat row explaining activation opens the direct conversation")
}
@ -69,10 +72,24 @@ struct RecentChatList: View {
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { onTapChat(chat.peerID) }
.contextMenu {
if chat.hasUnread, let onClearUnread {
Button(Strings.markRead) {
onClearUnread(chat.peerID)
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription(for: chat))
.accessibilityAddTraits(.isButton)
.accessibilityHint(Strings.openChatHint)
.accessibilityActions {
if chat.hasUnread, let onClearUnread {
Button(Strings.markRead) {
onClearUnread(chat.peerID)
}
}
}
}
}
}

View File

@ -85,6 +85,52 @@ struct ChatUnreadStateResolverTests {
))
}
@Test
func matchingUnreadPeerIDsIncludesStableNoiseKeyAlias() {
let peerID = PeerID(str: "ephemeral")
let stableID = PeerID(str: "stable")
let context = ChatUnreadPeerContext(
peerID: peerID,
noiseKeyPeerID: stableID,
nostrPeerID: nil,
nickname: nil
)
#expect(ChatUnreadStateResolver.matchingUnreadPeerIDs(
for: context,
unreadPrivateMessages: [stableID],
privateChats: [:]
) == [stableID])
}
@Test
func matchingUnreadPeerIDsIncludesGeoDMAlias() {
let peerID = PeerID(str: "mesh-peer")
let geoDM = PeerID(nostr_: "0123456789abcdef")
let context = ChatUnreadPeerContext(
peerID: peerID,
noiseKeyPeerID: nil,
nostrPeerID: nil,
nickname: "Alice"
)
let message = BitchatMessage(
sender: "alice",
content: "hi",
timestamp: Date(timeIntervalSince1970: 1),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "me",
senderPeerID: geoDM
)
#expect(ChatUnreadStateResolver.matchingUnreadPeerIDs(
for: context,
unreadPrivateMessages: [geoDM],
privateChats: [geoDM: [message]]
) == [geoDM])
}
@Test
func unmatchedUnreadStateReturnsFalse() {
let peerID = PeerID(str: "mesh-peer")

View File

@ -0,0 +1,100 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@MainActor
struct ClearUnreadConversationTests {
@Test("markPrivateChatRead clears unread without removing messages")
func markPrivateChatRead_clearsBadgeOnly() {
let viewModel = makeViewModel()
let peerID = PeerID(str: "0011223344556677")
let message = BitchatMessage(
sender: "alice",
content: "ping",
timestamp: Date(),
isRelay: false,
isPrivate: true,
senderPeerID: peerID
)
viewModel.appendPrivateMessage(message, to: peerID)
viewModel.markPrivateChatUnread(peerID)
#expect(viewModel.hasUnreadMessages(for: peerID))
#expect(viewModel.privateMessages(for: peerID).count == 1)
viewModel.markPrivateChatRead(peerID)
#expect(!viewModel.hasUnreadMessages(for: peerID))
#expect(viewModel.privateMessages(for: peerID).count == 1)
}
@Test("PeerListModel.clearUnread forwards to the conversation store")
func peerListModel_clearUnread() {
let viewModel = makeViewModel()
let conversations = viewModel.conversations
let locationChannelsModel = LocationChannelsModel()
let peerListModel = PeerListModel(
chatViewModel: viewModel,
conversations: conversations,
locationChannelsModel: locationChannelsModel
)
let peerID = PeerID(str: "0011223344556677")
conversations.markUnread(.directPeer(peerID))
#expect(viewModel.hasUnreadMessages(for: peerID))
peerListModel.clearUnread(for: peerID)
#expect(!viewModel.hasUnreadMessages(for: peerID))
}
@Test("ConversationUIModel.clearUnread clears one peer badge")
func conversationUIModel_clearUnread() {
let viewModel = makeViewModel()
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversations: viewModel.conversations
)
let uiModel = ConversationUIModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel,
conversations: viewModel.conversations
)
let peerID = PeerID(str: "0011223344556677")
viewModel.markPrivateChatUnread(peerID)
uiModel.clearUnread(for: peerID)
#expect(!viewModel.hasUnreadMessages(for: peerID))
}
@Test("markPrivateChatRead is a no-op when the conversation is already read")
func markPrivateChatRead_idempotent() {
let viewModel = makeViewModel()
let peerID = PeerID(str: "0011223344556677")
viewModel.markPrivateChatRead(peerID)
viewModel.markPrivateChatUnread(peerID)
viewModel.markPrivateChatRead(peerID)
viewModel.markPrivateChatRead(peerID)
#expect(!viewModel.hasUnreadMessages(for: peerID))
}
}
@MainActor
private func makeViewModel() -> ChatViewModel {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
return ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: MockTransport()
)
}