diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 4883a04c..e1d6581f 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -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)") } diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 4f1fc633..197f5d54 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -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) } diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index 80d5cf29..0b4db03f 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -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) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index e498a20e..64e24275 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -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" : { diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index e2d0fae8..8057c59c 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -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 { + 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, diff --git a/bitchat/ViewModels/ChatUnreadStateResolver.swift b/bitchat/ViewModels/ChatUnreadStateResolver.swift index 378f6f2f..cab0372a 100644 --- a/bitchat/ViewModels/ChatUnreadStateResolver.swift +++ b/bitchat/ViewModels/ChatUnreadStateResolver.swift @@ -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, + privateChats: [PeerID: [BitchatMessage]] + ) -> Set { + var matching = Set() + + 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 + } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 63fba0f8..ef12dd71 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -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`). diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 88be040f..a4797e59 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -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) diff --git a/bitchat/Views/GroupChatList.swift b/bitchat/Views/GroupChatList.swift index 4dff5c1d..71dccf88 100644 --- a/bitchat/Views/GroupChatList.swift +++ b/bitchat/Views/GroupChatList.swift @@ -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) diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 1d94af5f..68149b9b 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -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) } diff --git a/bitchat/Views/RecentChatList.swift b/bitchat/Views/RecentChatList.swift index 85180eb4..20cd6ca5 100644 --- a/bitchat/Views/RecentChatList.swift +++ b/bitchat/Views/RecentChatList.swift @@ -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) + } + } + } } } } diff --git a/bitchatTests/ChatUnreadStateResolverTests.swift b/bitchatTests/ChatUnreadStateResolverTests.swift index 37b80b57..8094b725 100644 --- a/bitchatTests/ChatUnreadStateResolverTests.swift +++ b/bitchatTests/ChatUnreadStateResolverTests.swift @@ -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") diff --git a/bitchatTests/ClearUnreadConversationTests.swift b/bitchatTests/ClearUnreadConversationTests.swift new file mode 100644 index 00000000..1b30e422 --- /dev/null +++ b/bitchatTests/ClearUnreadConversationTests.swift @@ -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() + ) +}