mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-11-01 07:53:26 +00:00
Merge bc20ed6add5fe1663e8c46bc8303e7a26444007c into 9b84b361225facd8e623f25d76f889d3dc54a879
This commit is contained in:
commit
8fa9b8925b
@ -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