mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-10-18 07:33:46 +00:00
feat: clear unread badge for a single conversation
Adds a mark-as-read action to the mesh, groups and chats rows in the people sheet, and to the header of an open DM. Clearing the badge sends no read receipt — the unread state is local, so dismissing it should stay local. Badge clearing is alias-aware: ChatUnreadStateResolver.matchingUnreadPeerIDs collects the noise-key, nostr and geoDM-nickname peer IDs that resolve to the same conversation, mirroring hasUnreadMessages. Clearing only the routing peer ID left ghost badges behind on the aliases. Rebased onto current main. The catalog carries only the three new keys, spliced in Xcode's serialization; main's 520 existing keys are untouched. The chats section main gained in #1598 gets the same action.
This commit is contained in:
parent
9b84b36122
commit
bc20ed6add
@ -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)")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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`).
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
100
bitchatTests/ClearUnreadConversationTests.swift
Normal file
100
bitchatTests/ClearUnreadConversationTests.swift
Normal 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()
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user