diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index a706cfac..de096e8e 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -28,6 +28,7 @@ final class AppRuntime: ObservableObject { let locationChannelsModel: LocationChannelsModel let peerListModel: PeerListModel let appChromeModel: AppChromeModel + let boardAlertsModel: BoardAlertsModel private let idBridge: NostrIdentityBridge private var cancellables = Set() @@ -91,6 +92,24 @@ final class AppRuntime: ObservableObject { chatViewModel: self.chatViewModel, privateInboxModel: self.privateInboxModel ) + let chatViewModel = self.chatViewModel + self.boardAlertsModel = BoardAlertsModel( + arrivals: BoardStore.shared.postArrivals.eraseToAnyPublisher(), + wipes: BoardStore.shared.didWipe.eraseToAnyPublisher(), + dependencies: BoardAlertsModel.Dependencies( + isOwnPost: { post in + let key = chatViewModel.meshService.noiseSigningPublicKeyData() + return !key.isEmpty && key == post.authorSigningKey + }, + emitSystemLine: { content, geohash in + if geohash.isEmpty { + chatViewModel.addMeshOnlySystemMessage(content) + } else { + chatViewModel.addGeohashSystemMessage(content, geohash: geohash) + } + } + ) + ) GeoRelayDirectory.shared.prefetchIfNeeded() bindRuntimeObservers() diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index e606506f..41a01f6d 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -40,6 +40,7 @@ struct BitchatApp: App { .environmentObject(runtime.locationChannelsModel) .environmentObject(runtime.peerListModel) .environmentObject(runtime.appChromeModel) + .environmentObject(runtime.boardAlertsModel) .onAppear { appDelegate.runtime = runtime runtime.start() diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index fdabb822..c2ef1442 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "#%@" : { + + }, "%@" : { "comment" : "Non-localizable symbol used in code", "extractionState" : "manual", @@ -5019,12 +5022,6 @@ "comment" : "Legend entry for the nosign glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "blocked" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -5043,6 +5040,12 @@ "value" : "blockiert" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocked" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -5175,12 +5178,6 @@ "comment" : "Legend entry for the lock glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "end-to-end encrypted session" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -5199,6 +5196,12 @@ "value" : "end-to-end-verschlüsselte sitzung" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "end-to-end encrypted session" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -5331,12 +5334,6 @@ "comment" : "Legend entry for the failed-encryption lock-slash glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "encryption failed — messages not secured" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -5355,6 +5352,12 @@ "value" : "verschlüsselung fehlgeschlagen — nachrichten nicht gesichert" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encryption failed — messages not secured" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -5487,12 +5490,6 @@ "comment" : "Legend entry for the star glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "favorite — enables offline messages via nostr when mutual" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -5511,6 +5508,12 @@ "value" : "favorit — ermöglicht offline-nachrichten über nostr, wenn beidseitig" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorite — enables offline messages via nostr when mutual" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -5643,12 +5646,6 @@ "comment" : "Legend entry for the map pin glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "physically in this location channel's area" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -5667,6 +5664,12 @@ "value" : "physisch im gebiet dieses standortkanals" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "physically in this location channel's area" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -5799,12 +5802,6 @@ "comment" : "Legend entry for the antenna glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "connected directly over bluetooth" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -5823,6 +5820,12 @@ "value" : "direkt über bluetooth verbunden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "connected directly over bluetooth" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -5955,12 +5958,6 @@ "comment" : "Legend entry for the relayed-mesh glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "reachable through the mesh, relayed by others" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -5979,6 +5976,12 @@ "value" : "über das mesh erreichbar, von anderen weitergeleitet" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reachable through the mesh, relayed by others" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -6111,12 +6114,6 @@ "comment" : "Legend entry for the globe glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "reachable over the internet (nostr) — mutual favorites only" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -6135,6 +6132,12 @@ "value" : "über das internet erreichbar (nostr) — nur bei beidseitigen favoriten" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reachable over the internet (nostr) — mutual favorites only" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -6267,12 +6270,6 @@ "comment" : "Legend entry for the offline person glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "offline — not currently reachable" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -6291,6 +6288,12 @@ "value" : "offline — derzeit nicht erreichbar" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "offline — not currently reachable" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -6423,12 +6426,6 @@ "comment" : "Legend entry for the teleported glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "teleported — joined the channel from somewhere else" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -6447,6 +6444,12 @@ "value" : "teleportiert — dem kanal von woanders beigetreten" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "teleported — joined the channel from somewhere else" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -6579,12 +6582,6 @@ "comment" : "Section header for the symbols legend in app info", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "SYMBOLS" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -6603,6 +6600,12 @@ "value" : "SYMBOLE" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SYMBOLS" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -6735,12 +6738,6 @@ "comment" : "Legend entry for the envelope glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "unread private messages" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -6759,6 +6756,12 @@ "value" : "ungelesene private nachrichten" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unread private messages" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -6891,12 +6894,6 @@ "comment" : "Legend entry for the verified seal glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "identity verified" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -6915,6 +6912,12 @@ "value" : "identität verifiziert" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "identity verified" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -7047,12 +7050,6 @@ "comment" : "Section header for network diagnostics in the app info sheet", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "NETWORK" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -7071,6 +7068,12 @@ "value" : "NETZWERK" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NETWORK" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -7203,12 +7206,6 @@ "comment" : "Row description for the mesh topology map", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "map of peers and links learned from mesh announces" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -7227,6 +7224,12 @@ "value" : "karte der peers und verbindungen, aus mesh-announces gelernt" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "map of peers and links learned from mesh announces" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -7359,12 +7362,6 @@ "comment" : "Accessibility hint for the mesh topology row", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "opens the mesh topology map" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -7383,6 +7380,12 @@ "value" : "öffnet die mesh-topologie-karte" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens the mesh topology map" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -7515,12 +7518,6 @@ "comment" : "Row title opening the mesh topology map", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh topology" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -7539,6 +7536,12 @@ "value" : "mesh-topologie" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh topology" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -9457,6 +9460,186 @@ } } }, + "Choose an image" : { + "comment" : "A label displayed above a button that allows the user to choose an image to send.", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اختر صورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "একটি ছবি নির্বাচন করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bild auswählen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose an image" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige una imagen" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumili ng larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisir une image" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בחר תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "एक चित्र चुनें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilih gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli un’immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像を選択" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이미지를 선택하세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilih imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "एउटा तस्वीर चयन गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kies een afbeelding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wybierz obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolher uma imagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha uma imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Välj en bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ஒரு படத்தைத் தேர்ந்தெடுக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เลือกภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir görüntü seç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виберіть зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ایک تصویر منتخب کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn một hình ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择图像" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇圖像" + } + } + } + }, "close" : { "comment" : "Button to dismiss fullscreen media viewer", "localizations" : { @@ -11072,12 +11255,6 @@ "comment" : "Accessibility hint on the bitchat/ logo explaining a tap opens app info", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "shows app info" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -11096,6 +11273,12 @@ "value" : "zeigt app-infos" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "shows app info" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -11228,12 +11411,6 @@ "comment" : "Accessibility label for the photo attachment button", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "attach photo" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -11252,6 +11429,12 @@ "value" : "foto anhängen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "attach photo" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -11384,12 +11567,6 @@ "comment" : "Accessibility hint explaining the attachment button opens the photo library", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "opens the photo library; use the take photo action for the camera" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -11408,6 +11585,12 @@ "value" : "öffnet die fotobibliothek; nutze die aktion foto aufnehmen für die kamera" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens the photo library; use the take photo action for the camera" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -11898,12 +12081,6 @@ "comment" : "Accessibility label for the macOS photo picker button", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "choose photo" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -11922,6 +12099,12 @@ "value" : "foto auswählen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "choose photo" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -12233,12 +12416,6 @@ "comment" : "Accessibility hint for the delivery status glyph explaining a tap reveals details", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "tap to show delivery details" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -12257,6 +12434,12 @@ "value" : "tippe, um zustelldetails anzuzeigen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tap to show delivery details" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -12568,12 +12751,6 @@ "comment" : "Accessibility label for the internet gateway indicator", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Internet gateway active, sharing your connection with the mesh" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -12592,6 +12769,12 @@ "value" : "Internet-gateway aktiv, teilt deine verbindung mit dem mesh" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Internet gateway active, sharing your connection with the mesh" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -12723,12 +12906,6 @@ "content.accessibility.group_chat" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group chat" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -12747,6 +12924,12 @@ "value" : "Gruppenchat" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group chat" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -12879,12 +13062,6 @@ "comment" : "Accessibility label for the jump to latest messages button", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "jump to latest messages" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -12903,6 +13080,12 @@ "value" : "zu den neuesten nachrichten springen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "jump to latest messages" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -13389,6 +13572,366 @@ } } }, + "content.accessibility.notices" : { + "comment" : "Accessibility label for the notices button", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إعلانات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hinweise" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notices" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mga paunawa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annonces" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengumuman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvisi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengumuman" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाहरू" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mededelingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ogłoszenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Объявления" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anslag" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ประกาศ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duyurular" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оголошення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلانات" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thông báo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告" + } + } + } + }, + "content.accessibility.notices_new" : { + "comment" : "Accessibility value for the notices button when unseen pins arrived", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld جديد" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldটি নতুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld neu" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld new" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nuevos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld bago" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nouvelles" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld חדשות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld नई" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld baru" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nuovi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld件の新着" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld개 새 항목" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld baharu" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld नयाँ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nieuw" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nowych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld novos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld novos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld новых" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nya" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld புதியவை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ใหม่ %lld รายการ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld yeni" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld нових" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld نئے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld mới" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 条新公告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 則新公告" + } + } + } + }, "content.accessibility.open_unread_private_chat" : { "extractionState" : "manual", "localizations" : { @@ -13572,12 +14115,6 @@ "comment" : "Accessibility value when peers are reachable", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "connected" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -13596,6 +14133,12 @@ "value" : "verbunden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "connected" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -13728,12 +14271,6 @@ "comment" : "Accessibility value when no peers are reachable", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "no one reachable" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -13752,6 +14289,12 @@ "value" : "niemand erreichbar" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no one reachable" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -14805,12 +15348,6 @@ "comment" : "Accessibility hint explaining double-tap toggles voice recording", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "double-tap to start recording, double-tap again to send" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -14829,6 +15366,12 @@ "value" : "doppeltippen zum aufnehmen, erneut doppeltippen zum senden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "double-tap to start recording, double-tap again to send" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -14961,12 +15504,6 @@ "comment" : "Accessibility label for the voice note button", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "record voice note" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -14985,6 +15522,12 @@ "value" : "sprachnachricht aufnehmen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "record voice note" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -15117,12 +15660,6 @@ "comment" : "Accessibility value announced while a voice note is recording", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "recording" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -15141,6 +15678,12 @@ "value" : "aufnahme" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "recording" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -15989,12 +16532,6 @@ "comment" : "Accessibility action name for taking a photo with the camera", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "take photo with camera" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -16013,6 +16550,12 @@ "value" : "foto mit kamera aufnehmen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "take photo with camera" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -16503,12 +17046,6 @@ "comment" : "Accessibility label for the verification QR button", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "verify encryption" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -16527,6 +17064,12 @@ "value" : "verschlüsselung verifizieren" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "verify encryption" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -17554,12 +18097,6 @@ "comment" : "Context menu action that resends a failed private message", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "resend" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -17578,6 +18115,12 @@ "value" : "erneut senden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "resend" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -19321,12 +19864,6 @@ "comment" : "Destructive confirmation button that clears the current chat", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "clear chat" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -19345,6 +19882,12 @@ "value" : "chat leeren" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "clear chat" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -19477,12 +20020,6 @@ "comment" : "Title of the confirmation dialog shown before clearing the current chat", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "clear this chat?" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -19501,6 +20038,12 @@ "value" : "diesen chat leeren?" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "clear this chat?" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -20169,12 +20712,6 @@ "content.commands.group" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "create or manage private groups" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -20193,6 +20730,12 @@ "value" : "private gruppen erstellen oder verwalten" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "create or manage private groups" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -20325,12 +20868,6 @@ "comment" : "Description of the /help command in the suggestions panel", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "show available commands" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -20349,6 +20886,12 @@ "value" : "verfügbare befehle anzeigen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "show available commands" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -20839,12 +21382,6 @@ "comment" : "Autocomplete description for the /pay command that sends a Cashu ecash token", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "send a cashu ecash token in this chat" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -20863,6 +21400,12 @@ "value" : "einen cashu-ecash-token in diesem chat senden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "send a cashu ecash token in this chat" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -20995,12 +21538,6 @@ "comment" : "Description of the /ping command in the suggestions panel", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "measure round-trip time to a mesh peer" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -21019,6 +21556,12 @@ "value" : "die roundtrip-zeit zu einem mesh-peer messen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "measure round-trip time to a mesh peer" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -21330,12 +21873,6 @@ "comment" : "Description of the /trace command in the suggestions panel", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "estimate the mesh path to a peer" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -21354,6 +21891,12 @@ "value" : "den mesh-pfad zu einem peer schätzen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimate the mesh path to a peer" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -22914,172 +23457,10 @@ } } }, - "content.delivery.reason.not_delivered" : { - "comment" : "Failure reason shown when the router gave up delivering a message", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "not delivered" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "لم يُسلَّم" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "পৌঁছায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nicht zugestellt" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no entregado" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "non livré" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "לא נמסר" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "डिलीवर नहीं हुआ" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak terkirim" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "non consegnato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "未配信" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "전송되지 않음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak dihantar" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "डेलिभर भएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "niet bezorgd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie dostarczono" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não entregue" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не доставлено" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "inte levererat" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "வழங்கப்படவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ยังไม่ได้ส่ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teslim edilmedi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не доставлено" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "نہیں پہنچا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chưa gửi được" - } - } - } - }, "content.delivery.reason.encryption_failed" : { "comment" : "Failure reason shown when a message could not be encrypted for the peer", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "encryption failed" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -23098,6 +23479,12 @@ "value" : "verschlüsselung fehlgeschlagen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encryption failed" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -23226,314 +23613,158 @@ } } }, - "content.delivery.reason.voice_too_large" : { - "comment" : "Failure reason shown when a voice note exceeds the size limit", + "content.delivery.reason.not_delivered" : { + "comment" : "Failure reason shown when the router gave up delivering a message", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "voice note too large" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", - "value" : "الملاحظة الصوتية كبيرة جدًا" + "value" : "لم يُسلَّم" } }, "bn" : { "stringUnit" : { "state" : "needs_review", - "value" : "ভয়েস নোট খুব বড়" + "value" : "পৌঁছায়নি" } }, "de" : { "stringUnit" : { "state" : "needs_review", - "value" : "sprachnachricht zu groß" + "value" : "nicht zugestellt" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "nota de voz demasiado grande" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "note vocale trop volumineuse" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ההערה הקולית גדולה מדי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "वॉइस नोट बहुत बड़ा" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "catatan suara terlalu besar" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nota vocale troppo grande" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ボイスメモが大きすぎます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "음성 메모가 너무 큼" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nota suara terlalu besar" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "भ्वाइस नोट धेरै ठूलो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "spraakbericht te groot" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "notatka głosowa zbyt duża" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nota de voz demasiado grande" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "голосовое сообщение слишком большое" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "röstmeddelandet är för stort" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குரல் குறிப்பு மிகப் பெரியது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ข้อความเสียงใหญ่เกินไป" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sesli not çok büyük" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "голосова нотатка завелика" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "وائس نوٹ بہت بڑا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ghi chú giọng nói quá lớn" - } - } - } - }, - "content.delivery.reason.voice_send_failed" : { - "comment" : "Failure reason shown when a voice note could not be sent", - "extractionState" : "manual", - "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "voice note failed to send" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "فشل إرسال الملاحظة الصوتية" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ভয়েস নোট পাঠানো যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sprachnachricht konnte nicht gesendet werden" + "value" : "not delivered" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "no se pudo enviar la nota de voz" + "value" : "no entregado" } }, "fr" : { "stringUnit" : { "state" : "needs_review", - "value" : "échec de l'envoi de la note vocale" + "value" : "non livré" } }, "he" : { "stringUnit" : { "state" : "needs_review", - "value" : "שליחת ההערה הקולית נכשלה" + "value" : "לא נמסר" } }, "hi" : { "stringUnit" : { "state" : "needs_review", - "value" : "वॉइस नोट भेजने में विफल" + "value" : "डिलीवर नहीं हुआ" } }, "id" : { "stringUnit" : { "state" : "needs_review", - "value" : "catatan suara gagal dikirim" + "value" : "tidak terkirim" } }, "it" : { "stringUnit" : { "state" : "needs_review", - "value" : "invio della nota vocale non riuscito" + "value" : "non consegnato" } }, "ja" : { "stringUnit" : { "state" : "needs_review", - "value" : "ボイスメモの送信に失敗" + "value" : "未配信" } }, "ko" : { "stringUnit" : { "state" : "needs_review", - "value" : "음성 메모 전송 실패" + "value" : "전송되지 않음" } }, "ms" : { "stringUnit" : { "state" : "needs_review", - "value" : "nota suara gagal dihantar" + "value" : "tidak dihantar" } }, "ne" : { "stringUnit" : { "state" : "needs_review", - "value" : "भ्वाइस नोट पठाउन असफल" + "value" : "डेलिभर भएन" } }, "nl" : { "stringUnit" : { "state" : "needs_review", - "value" : "spraakbericht kon niet worden verzonden" + "value" : "niet bezorgd" } }, "pl" : { "stringUnit" : { "state" : "needs_review", - "value" : "nie udało się wysłać notatki głosowej" + "value" : "nie dostarczono" } }, "pt" : { "stringUnit" : { "state" : "needs_review", - "value" : "falha ao enviar a nota de voz" + "value" : "não entregue" } }, "ru" : { "stringUnit" : { "state" : "needs_review", - "value" : "не удалось отправить голосовое сообщение" + "value" : "не доставлено" } }, "sv" : { "stringUnit" : { "state" : "needs_review", - "value" : "röstmeddelandet kunde inte skickas" + "value" : "inte levererat" } }, "ta" : { "stringUnit" : { "state" : "needs_review", - "value" : "குரல் குறிப்பு அனுப்ப முடியவில்லை" + "value" : "வழங்கப்படவில்லை" } }, "th" : { "stringUnit" : { "state" : "needs_review", - "value" : "ส่งข้อความเสียงไม่สำเร็จ" + "value" : "ยังไม่ได้ส่ง" } }, "tr" : { "stringUnit" : { "state" : "needs_review", - "value" : "sesli not gönderilemedi" + "value" : "teslim edilmedi" } }, "uk" : { "stringUnit" : { "state" : "needs_review", - "value" : "не вдалося надіслати голосову нотатку" + "value" : "не доставлено" } }, "ur" : { "stringUnit" : { "state" : "needs_review", - "value" : "وائس نوٹ بھیجنے میں ناکام" + "value" : "نہیں پہنچا" } }, "vi" : { "stringUnit" : { "state" : "needs_review", - "value" : "gửi ghi chú giọng nói thất bại" + "value" : "chưa gửi được" } } } @@ -24254,16 +24485,322 @@ } } }, + "content.delivery.reason.voice_send_failed" : { + "comment" : "Failure reason shown when a voice note could not be sent", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "فشل إرسال الملاحظة الصوتية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ভয়েস নোট পাঠানো যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sprachnachricht konnte nicht gesendet werden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "voice note failed to send" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo enviar la nota de voz" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "échec de l'envoi de la note vocale" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שליחת ההערה הקולית נכשלה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉइस नोट भेजने में विफल" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "catatan suara gagal dikirim" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "invio della nota vocale non riuscito" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ボイスメモの送信に失敗" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음성 메모 전송 실패" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota suara gagal dihantar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भ्वाइस नोट पठाउन असफल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spraakbericht kon niet worden verzonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie udało się wysłać notatki głosowej" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "falha ao enviar a nota de voz" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось отправить голосовое сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "röstmeddelandet kunde inte skickas" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குரல் குறிப்பு அனுப்ப முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความเสียงไม่สำเร็จ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesli not gönderilemedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не вдалося надіслати голосову нотатку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "وائس نوٹ بھیجنے میں ناکام" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gửi ghi chú giọng nói thất bại" + } + } + } + }, + "content.delivery.reason.voice_too_large" : { + "comment" : "Failure reason shown when a voice note exceeds the size limit", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الملاحظة الصوتية كبيرة جدًا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ভয়েস নোট খুব বড়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sprachnachricht zu groß" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "voice note too large" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "nota de voz demasiado grande" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "note vocale trop volumineuse" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ההערה הקולית גדולה מדי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉइस नोट बहुत बड़ा" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "catatan suara terlalu besar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota vocale troppo grande" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ボイスメモが大きすぎます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음성 메모가 너무 큼" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota suara terlalu besar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भ्वाइस नोट धेरै ठूलो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spraakbericht te groot" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "notatka głosowa zbyt duża" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota de voz demasiado grande" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "голосовое сообщение слишком большое" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "röstmeddelandet är för stort" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குரல் குறிப்பு மிகப் பெரியது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ข้อความเสียงใหญ่เกินไป" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesli not çok büyük" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "голосова нотатка завелика" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "وائس نوٹ بہت بڑا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ghi chú giọng nói quá lớn" + } + } + } + }, "content.delivery.sending" : { "comment" : "Delivery status description while a private message is being sent", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "sending..." - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -24282,6 +24819,12 @@ "value" : "wird gesendet ..." } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sending..." + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -24414,12 +24957,6 @@ "comment" : "Delivery status description for a sent but not yet confirmed private message", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "sent — no delivery confirmation yet" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -24438,6 +24975,12 @@ "value" : "gesendet — noch keine zustellbestätigung" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sent — no delivery confirmation yet" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -24570,12 +25113,6 @@ "comment" : "First line of an empty geohash timeline naming the channel", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you're in #%@ — a public location channel over the internet" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -24594,6 +25131,12 @@ "value" : "du bist in #%@ — ein öffentlicher standortkanal über das internet" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you're in #%@ — a public location channel over the internet" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -24726,12 +25269,6 @@ "comment" : "First line of the empty mesh timeline explaining what the mesh channel is", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you're on #mesh — reaches people within bluetooth range" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -24750,6 +25287,12 @@ "value" : "du bist auf #mesh — erreicht menschen in bluetooth-reichweite" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you're on #mesh — reaches people within bluetooth range" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -24882,12 +25425,6 @@ "comment" : "Second line of the empty mesh timeline saying no peers are in range yet", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "nobody in range yet... messages appear here" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -24906,6 +25443,12 @@ "value" : "noch niemand in reichweite ... nachrichten erscheinen hier" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "nobody in range yet... messages appear here" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -25038,12 +25581,6 @@ "comment" : "Empty timeline hint pointing at the channel switcher and the help screen", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "tap the channel name above to switch · tap bitchat/ for help" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -25062,6 +25599,12 @@ "value" : "tippe oben auf den kanalnamen zum wechseln · tippe bitchat/ für hilfe" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tap the channel name above to switch · tap bitchat/ for help" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -25194,12 +25737,6 @@ "comment" : "Tooltip for the internet gateway indicator", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sharing your internet connection with nearby mesh peers" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -25218,6 +25755,12 @@ "value" : "teilt deine internetverbindung mit nahen mesh-peers" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sharing your internet connection with nearby mesh peers" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -25346,6 +25889,186 @@ } } }, + "content.header.notices" : { + "comment" : "Tooltip for the notices button", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إعلانات: منشورات مثبتة لهذه المنطقة وشبكة mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশ: এই এলাকা ও মেশের জন্য পিন করা পোস্ট" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hinweise: angeheftete Beiträge für diese Gegend und das Mesh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notices: pinned posts for this area and the mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos: publicaciones fijadas para esta zona y el mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mga paunawa: naka-pin na post para sa lugar na ito at sa mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annonces : publications épinglées pour cette zone et le mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מודעות: פוסטים נעוצים לאזור הזה ול-mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाएँ: इस क्षेत्र और मेश के लिए पिन की गई पोस्ट" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengumuman: kiriman yang disematkan untuk area ini dan mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvisi: post appuntati per questa zona e il mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせ: このエリアとmeshのピン留め投稿" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지: 이 지역과 mesh에 고정된 게시물" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengumuman: kiriman disemat untuk kawasan ini dan mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाहरू: यो क्षेत्र र मेशका लागि पिन गरिएका पोस्ट" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mededelingen: vastgeprikte berichten voor deze omgeving en het mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ogłoszenia: przypięte wpisy dla tej okolicy i mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos: publicações afixadas para esta zona e o mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos: publicações fixadas para esta área e o mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Объявления: закреплённые записи для этого места и mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anslag: uppnålade inlägg för det här området och mesh" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகள்: இந்தப் பகுதி மற்றும் மெஷுக்கான பின் செய்யப்பட்ட பதிவுகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ประกาศ: โพสต์ที่ปักหมุดสำหรับบริเวณนี้และ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duyurular: bu bölge ve mesh için sabitlenmiş gönderiler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оголошення: закріплені дописи для цієї місцевості та mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلانات: اس علاقے اور mesh کیلئے پن شدہ پوسٹس" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thông báo: bài ghim cho khu vực này và mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告:此区域和 mesh 的置顶帖子" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告:此區域和 mesh 的置頂貼文" + } + } + } + }, "content.header.people" : { "extractionState" : "manual", "localizations" : { @@ -25707,12 +26430,6 @@ "content.input.group_placeholder" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "create|invite|leave|list" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -25731,6 +26448,12 @@ "value" : "create|invite|leave|list" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "create|invite|leave|list" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -25859,786 +26582,6 @@ } } }, - "content.input.placeholder.location" : { - "comment" : "Composer placeholder for a public geohash channel, naming it", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "message #%@ — public" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "رسالة #%@ — عام" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@-এ বার্তা — পাবলিক" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nachricht an #%@ — öffentlich" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensaje #%@ — público" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "message vers #%@ — public" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הודעה ל-#%@ — ציבורי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "संदेश #%@ — सार्वजनिक" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan #%@ — publik" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "messaggio a #%@ — pubblico" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@ にメッセージ — 公開" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@ 에 메시지 — 공개" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan #%@ — awam" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@ मा सन्देश — सार्वजनिक" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bericht naar #%@ — openbaar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wiadomość #%@ — publiczna" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensagem para #%@ — público" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "сообщение в #%@ — публичное" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "meddelande #%@ — publikt" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@ க்கு செய்தி — பொது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่งข้อความถึง #%@ — สาธารณะ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesaj #%@ — herkese açık" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "повідомлення #%@ — публічне" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "پیغام #%@ — عوامی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nhắn #%@ — công khai" - } - } - } - }, - "content.input.placeholder.mesh" : { - "comment" : "Composer placeholder for the public mesh channel", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "message #mesh — public, nearby" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "رسالة #mesh — عام، قريب" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh-এ বার্তা — পাবলিক, কাছাকাছি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nachricht an #mesh — öffentlich, in der nähe" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensaje #mesh — público, cerca" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "message vers #mesh — public, à proximité" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הודעה ל-#mesh — ציבורי, קרוב" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "संदेश #mesh — सार्वजनिक, आसपास" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan #mesh — publik, terdekat" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "messaggio a #mesh — pubblico, nelle vicinanze" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh にメッセージ — 公開、近くの人へ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh 에 메시지 — 공개, 근처" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan #mesh — awam, berdekatan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh मा सन्देश — सार्वजनिक, नजिकको" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bericht naar #mesh — openbaar, in de buurt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wiadomość #mesh — publiczna, w pobliżu" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensagem para #mesh — público, próximo" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "сообщение в #mesh — публичное, рядом" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "meddelande #mesh — publikt, i närheten" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh க்கு செய்தி — பொது, அருகில்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่งข้อความถึง #mesh — สาธารณะ, ใกล้เคียง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesaj #mesh — herkese açık, yakında" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "повідомлення #mesh — публічне, поблизу" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "پیغام #mesh — عوامی، قریبی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nhắn #mesh — công khai, gần đây" - } - } - } - }, - "content.input.placeholder.private" : { - "comment" : "Composer placeholder inside a private chat, naming the conversation partner", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "message %@ — private" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "رسالة %@ — خاص" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@-কে বার্তা — ব্যক্তিগত" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nachricht an %@ — privat" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensaje %@ — privado" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "message à %@ — privé" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הודעה ל-%@ — פרטי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "संदेश %@ — निजी" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan %@ — pribadi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "messaggio a %@ — privato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ にメッセージ — プライベート" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ 에게 메시지 — 비공개" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan %@ — peribadi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ लाई सन्देश — निजी" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bericht naar %@ — privé" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wiadomość do %@ — prywatna" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensagem para %@ — privado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "сообщение для %@ — личное" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "meddelande %@ — privat" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ க்கு செய்தி — தனிப்பட்டது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่งข้อความถึง %@ — ส่วนตัว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesaj %@ — özel" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "повідомлення %@ — приватне" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "پیغام %@ — نجی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nhắn %@ — riêng tư" - } - } - } - }, - "content.private.caption" : { - "comment" : "Caption above the private chat composer before encryption is established", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "private conversation" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "محادثة خاصة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ব্যক্তিগত কথোপকথন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privates gespräch" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "conversación privada" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "conversation privée" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "שיחה פרטית" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी बातचीत" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "percakapan pribadi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "conversazione privata" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "プライベートな会話" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "비공개 대화" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "perbualan peribadi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी कुराकानी" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privégesprek" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "rozmowa prywatna" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "conversa privada" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "личный разговор" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privat konversation" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "தனிப்பட்ட உரையாடல்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "การสนทนาส่วนตัว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "özel konuşma" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "приватна розмова" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "نجی گفتگو" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cuộc trò chuyện riêng tư" - } - } - } - }, - "content.private.caption_encrypted" : { - "comment" : "Caption above the private chat composer once the session is end-to-end encrypted", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "private · end-to-end encrypted" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "خاص · مشفّر من طرف إلى طرف" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ব্যক্তিগত · এন্ড-টু-এন্ড এনক্রিপ্টেড" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privat · end-to-end-verschlüsselt" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "privado · cifrado de extremo a extremo" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privé · chiffré de bout en bout" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "פרטי · מוצפן מקצה לקצה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी · एंड-टू-एंड एन्क्रिप्टेड" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pribadi · terenkripsi ujung ke ujung" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privato · cifrato end-to-end" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "プライベート · エンドツーエンド暗号化" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "비공개 · 종단간 암호화" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "peribadi · terenkripsi ujung ke ujung" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी · एन्ड-टु-एन्ड सङ्केत" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privé · end-to-end-versleuteld" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "prywatna · szyfrowana end-to-end" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privado · encriptado ponta a ponta" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "лично · сквозное шифрование" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privat · end-to-end-krypterad" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "தனிப்பட்டது · முனை-முதல்-முனை குறியாக்கம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่วนตัว · เข้ารหัสแบบครบวงจร" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "özel · uçtan uca şifreli" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "приватна · наскрізно зашифрована" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "نجی · اینڈ ٹو اینڈ خفیہ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "riêng tư · mã hóa đầu cuối" - } - } - } - }, "content.input.nickname_placeholder" : { "extractionState" : "manual", "localizations" : { @@ -26818,16 +26761,478 @@ } } }, + "content.input.placeholder.location" : { + "comment" : "Composer placeholder for a public geohash channel, naming it", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسالة #%@ — عام" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@-এ বার্তা — পাবলিক" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nachricht an #%@ — öffentlich" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "message #%@ — public" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensaje #%@ — público" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "message vers #%@ — public" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעה ל-#%@ — ציבורי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "संदेश #%@ — सार्वजनिक" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #%@ — publik" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messaggio a #%@ — pubblico" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ にメッセージ — 公開" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ 에 메시지 — 공개" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #%@ — awam" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ मा सन्देश — सार्वजनिक" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bericht naar #%@ — openbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wiadomość #%@ — publiczna" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para #%@ — público" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сообщение в #%@ — публичное" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "meddelande #%@ — publikt" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ க்கு செய்தி — பொது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความถึง #%@ — สาธารณะ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesaj #%@ — herkese açık" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "повідомлення #%@ — публічне" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پیغام #%@ — عوامی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhắn #%@ — công khai" + } + } + } + }, + "content.input.placeholder.mesh" : { + "comment" : "Composer placeholder for the public mesh channel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسالة #mesh — عام، قريب" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh-এ বার্তা — পাবলিক, কাছাকাছি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nachricht an #mesh — öffentlich, in der nähe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "message #mesh — public, nearby" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensaje #mesh — público, cerca" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "message vers #mesh — public, à proximité" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעה ל-#mesh — ציבורי, קרוב" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "संदेश #mesh — सार्वजनिक, आसपास" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #mesh — publik, terdekat" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messaggio a #mesh — pubblico, nelle vicinanze" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh にメッセージ — 公開、近くの人へ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh 에 메시지 — 공개, 근처" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #mesh — awam, berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh मा सन्देश — सार्वजनिक, नजिकको" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bericht naar #mesh — openbaar, in de buurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wiadomość #mesh — publiczna, w pobliżu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para #mesh — público, próximo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сообщение в #mesh — публичное, рядом" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "meddelande #mesh — publikt, i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh க்கு செய்தி — பொது, அருகில்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความถึง #mesh — สาธารณะ, ใกล้เคียง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesaj #mesh — herkese açık, yakında" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "повідомлення #mesh — публічне, поблизу" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پیغام #mesh — عوامی، قریبی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhắn #mesh — công khai, gần đây" + } + } + } + }, + "content.input.placeholder.private" : { + "comment" : "Composer placeholder inside a private chat, naming the conversation partner", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسالة %@ — خاص" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-কে বার্তা — ব্যক্তিগত" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nachricht an %@ — privat" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "message %@ — private" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensaje %@ — privado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "message à %@ — privé" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעה ל-%@ — פרטי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "संदेश %@ — निजी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan %@ — pribadi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messaggio a %@ — privato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ にメッセージ — プライベート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 에게 메시지 — 비공개" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan %@ — peribadi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ लाई सन्देश — निजी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bericht naar %@ — privé" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wiadomość do %@ — prywatna" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para %@ — privado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сообщение для %@ — личное" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "meddelande %@ — privat" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ க்கு செய்தி — தனிப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความถึง %@ — ส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesaj %@ — özel" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "повідомлення %@ — приватне" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پیغام %@ — نجی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhắn %@ — riêng tư" + } + } + } + }, "content.input.token_placeholder" : { "comment" : "Placeholder shown after /pay in the command suggestion panel", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "token" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -26846,6 +27251,12 @@ "value" : "token" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "token" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -26978,12 +27389,6 @@ "comment" : "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld new" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -27002,6 +27407,12 @@ "value" : "%lld neu" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld new" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -28387,12 +28798,6 @@ "comment" : "Context menu action copying a Cashu token to the pasteboard", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "copy token" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -28411,6 +28816,12 @@ "value" : "token kopieren" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "copy token" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -28722,12 +29133,6 @@ "comment" : "Context menu action opening a Cashu token in an ecash wallet app", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "redeem in wallet" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -28746,6 +29151,12 @@ "value" : "in wallet einlösen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "redeem in wallet" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -28878,12 +29289,6 @@ "comment" : "Context menu action opening a Cashu token in the web redemption page", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "redeem on web" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -28902,6 +29307,12 @@ "value" : "im web einlösen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "redeem on web" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -29030,15 +29441,321 @@ } } }, - "content.private.caption_group" : { + "content.private.caption" : { + "comment" : "Caption above the private chat composer before encryption is established", "extractionState" : "manual", "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "محادثة خاصة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যক্তিগত কথোপকথন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privates gespräch" + } + }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "encrypted group · members only" + "value" : "private conversation" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "conversación privada" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conversation privée" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שיחה פרטית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी बातचीत" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "percakapan pribadi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conversazione privata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "プライベートな会話" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "비공개 대화" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "perbualan peribadi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी कुराकानी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privégesprek" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rozmowa prywatna" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conversa privada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "личный разговор" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privat konversation" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தனிப்பட்ட உரையாடல்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "การสนทนาส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "özel konuşma" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "приватна розмова" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نجی گفتگو" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cuộc trò chuyện riêng tư" + } + } + } + }, + "content.private.caption_encrypted" : { + "comment" : "Caption above the private chat composer once the session is end-to-end encrypted", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "خاص · مشفّر من طرف إلى طرف" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যক্তিগত · এন্ড-টু-এন্ড এনক্রিপ্টেড" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privat · end-to-end-verschlüsselt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "private · end-to-end encrypted" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "privado · cifrado de extremo a extremo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privé · chiffré de bout en bout" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פרטי · מוצפן מקצה לקצה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी · एंड-टू-एंड एन्क्रिप्टेड" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pribadi · terenkripsi ujung ke ujung" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privato · cifrato end-to-end" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "プライベート · エンドツーエンド暗号化" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "비공개 · 종단간 암호화" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "peribadi · terenkripsi ujung ke ujung" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी · एन्ड-टु-एन्ड सङ्केत" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privé · end-to-end-versleuteld" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "prywatna · szyfrowana end-to-end" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privado · encriptado ponta a ponta" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "лично · сквозное шифрование" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privat · end-to-end-krypterad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தனிப்பட்டது · முனை-முதல்-முனை குறியாக்கம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่วนตัว · เข้ารหัสแบบครบวงจร" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "özel · uçtan uca şifreli" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "приватна · наскрізно зашифрована" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نجی · اینڈ ٹو اینڈ خفیہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "riêng tư · mã hóa đầu cuối" + } + } + } + }, + "content.private.caption_group" : { + "extractionState" : "manual", + "localizations" : { "ar" : { "stringUnit" : { "state" : "needs_review", @@ -29057,6 +29774,12 @@ "value" : "verschlüsselte gruppe · nur mitglieder" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encrypted group · members only" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -31695,12 +32418,6 @@ "comment" : "Badge shown when a peer is vouched for by people the user verified but not directly verified", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ VOUCHED" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -31719,6 +32436,12 @@ "value" : "✓ VERBÜRGT" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ VOUCHED" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -32388,6 +33111,24 @@ "comment" : "How many people the user verified have vouched for this peer", "extractionState" : "manual", "localizations" : { + "bn" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি যাচাই করেছেন এমন %d জনের দ্বারা সমর্থিত" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি যাচাই করেছেন এমন %d জনের দ্বারা সমর্থিত" + } + } + } + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -32416,24 +33157,6 @@ } } }, - "bn" : { - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আপনি যাচাই করেছেন এমন %d জনের দ্বারা সমর্থিত" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আপনি যাচাই করেছেন এমন %d জনের দ্বারা সমর্থিত" - } - } - } - } - }, "es" : { "stringUnit" : { "state" : "translated", @@ -32609,12 +33332,6 @@ "formatSpecifier" : "lld", "variations" : { "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d человек" - } - }, "few" : { "stringUnit" : { "state" : "needs_review", @@ -32627,6 +33344,12 @@ "value" : "%d человек" } }, + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d человек" + } + }, "other" : { "stringUnit" : { "state" : "needs_review", @@ -33780,12 +34503,6 @@ "comment" : "State label for someone physically in the location channel's area", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "in this area" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -33804,6 +34521,12 @@ "value" : "in diesem gebiet" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "in this area" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -33936,12 +34659,6 @@ "comment" : "State label for someone who joined the location channel from elsewhere", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "teleported from elsewhere" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -33960,6 +34677,12 @@ "value" : "von woanders teleportiert" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "teleported from elsewhere" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -34092,12 +34815,6 @@ "comment" : "State label marking your own row in the people list", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -34116,6 +34833,12 @@ "value" : "du" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -34605,12 +35328,6 @@ "groups.accessibility.open_group_hint" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Opens the group chat" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -34629,6 +35346,12 @@ "value" : "Öffnet den gruppenchat" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opens the group chat" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -34760,12 +35483,6 @@ "groups.member_count %@" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "(%@)" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -34784,6 +35501,12 @@ "value" : "(%@)" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -34915,12 +35638,6 @@ "groups.section.header" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "groups" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -34939,6 +35656,12 @@ "value" : "gruppen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "groups" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -35070,12 +35793,6 @@ "groups.state.creator" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Creator" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -35094,6 +35811,12 @@ "value" : "Ersteller" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creator" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -35222,16 +35945,189 @@ } } }, + "Images are only available in mesh chats." : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الصور متاحة فقط في محادثات الميش." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ছবি শুধু মেশ চ্যাটে উপলব্ধ।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilder sind nur im Mesh-Chat verfügbar." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Images are only available in mesh chats." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las imágenes solo están disponibles en los chats de mesh." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ang mga larawan ay available lamang sa mga mesh chat." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les images sont uniquement disponibles dans les discussions mesh." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תמונות זמינות רק בצ׳אט של mesh." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "चित्र केवल मेश चैट में ही उपलब्ध हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gambar hanya tersedia di obrolan mesh." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le immagini sono disponibili solo nelle chat mesh." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像はメッシュチャットでのみ利用できます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이미지는 메쉬 채팅에서만 사용할 수 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Imej hanya tersedia dalam sembang mesh." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "तस्बिरहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afbeeldingen zijn alleen beschikbaar in mesh-chats." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obrazy są dostępne tylko na czatach mesh." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "As imagens só estão disponíveis nos chats mesh." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "As imagens só estão disponíveis nos chats mesh." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изображения доступны только в mesh-чатах." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilder är bara tillgängliga i mesh-chattar." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "படங்கள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "รูปภาพใช้งานได้เฉพาะในแชต mesh เท่านั้น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Görseller yalnızca mesh sohbetlerinde kullanılabilir." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зображення доступні лише в mesh-чатах." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تصاویر صرف میش چیٹس میں دستیاب ہیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hình ảnh chỉ khả dụng trong các cuộc trò chuyện mesh." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "图片仅可在 mesh 聊天中使用。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "圖片僅能在 mesh 聊天中使用。" + } + } + } + }, "location_channels.accessibility.add_bookmark" : { "comment" : "Accessibility action name for bookmarking a channel", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "bookmark channel" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -35250,6 +36146,12 @@ "value" : "kanal mit lesezeichen versehen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "bookmark channel" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -35382,12 +36284,6 @@ "comment" : "Accessibility action name for removing a channel bookmark", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "remove bookmark" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -35406,6 +36302,12 @@ "value" : "lesezeichen entfernen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "remove bookmark" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -35538,12 +36440,6 @@ "comment" : "Accessibility hint on a channel row explaining activation switches to it", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "switches to this channel" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -35562,6 +36458,12 @@ "value" : "wechselt zu diesem kanal" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "switches to this channel" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -36947,12 +37849,6 @@ "comment" : "Explanation under the internet gateway toggle", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "share your internet with nearby mesh peers so their geohash messages reach the network" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -36971,6 +37867,12 @@ "value" : "teile dein internet mit nahen mesh-peers, damit ihre geohash-nachrichten das netzwerk erreichen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "share your internet with nearby mesh peers so their geohash messages reach the network" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -37103,12 +38005,6 @@ "comment" : "Title for the internet gateway toggle in the location channels sheet", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "internet gateway" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -37127,6 +38023,12 @@ "value" : "internet-gateway" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "internet gateway" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -43218,12 +44120,6 @@ "comment" : "Accessibility label for the cancel button on an in-flight media send", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cancel sending" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -43242,6 +44138,12 @@ "value" : "senden abbrechen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancel sending" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -43374,12 +44276,6 @@ "comment" : "Accessibility label for a blurred incoming image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "hidden image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -43398,6 +44294,12 @@ "value" : "verstecktes bild" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "hidden image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -43530,12 +44432,6 @@ "comment" : "Accessibility hint for a revealed image; activating it opens the image full screen", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "opens the image full screen" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -43554,6 +44450,12 @@ "value" : "öffnet das bild im vollbild" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens the image full screen" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -43686,12 +44588,6 @@ "comment" : "Accessibility hint for a blurred image; activating it reveals the image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "reveals the image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -43710,6 +44606,12 @@ "value" : "zeigt das bild" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reveals the image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -43842,12 +44744,6 @@ "comment" : "Accessibility label for a revealed image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -43866,6 +44762,12 @@ "value" : "bild" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -43998,12 +44900,6 @@ "comment" : "Accessibility label for an image that is still sending", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "sending image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -44022,6 +44918,12 @@ "value" : "bild wird gesendet" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sending image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -44150,16 +45052,166 @@ } } }, + "media.image.accessibility.unavailable" : { + "comment" : "Accessibility label for an image whose file could not be loaded", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الصورة غير متاحة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি অনুপলব্ধ" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild nicht verfügbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "image unavailable" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "imagen no disponible" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "image indisponible" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "התמונה לא זמינה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र उपलब्ध नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gambar tidak tersedia" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "immagine non disponibile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を利用できません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지를 사용할 수 없음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imej tidak tersedia" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर उपलब्ध छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding niet beschikbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "obraz niedostępny" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imagem indisponível" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "изображение недоступно" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild otillgänglig" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படம் கிடைக்கவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถใช้รูปภาพได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görsel kullanılamıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "зображення недоступне" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر دستیاب نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hình ảnh không khả dụng" + } + } + } + }, "media.image.action.delete" : { "comment" : "Context menu action that deletes a received image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "delete image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -44178,6 +45230,12 @@ "value" : "bild löschen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "delete image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -44310,12 +45368,6 @@ "comment" : "Context menu action that re-blurs a revealed image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "hide image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -44334,6 +45386,12 @@ "value" : "bild verbergen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "hide image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -44466,12 +45524,6 @@ "comment" : "Context menu action that opens an image full screen", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "open image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -44490,6 +45542,12 @@ "value" : "bild öffnen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "open image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -44622,12 +45680,6 @@ "comment" : "Context menu action that reveals a blurred image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "reveal image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -44646,6 +45698,12 @@ "value" : "bild anzeigen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reveal image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -44778,12 +45836,6 @@ "comment" : "Body of the confirmation dialog before deleting a received image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "this cannot be undone — the sender may not be in range to send it again." - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -44802,6 +45854,12 @@ "value" : "das kann nicht rückgängig gemacht werden — der absender ist möglicherweise nicht in reichweite, um es erneut zu senden." } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "this cannot be undone — the sender may not be in range to send it again." + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -44934,12 +45992,6 @@ "comment" : "Title of the confirmation dialog before deleting a received image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "delete this image?" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -44958,6 +46010,12 @@ "value" : "dieses bild löschen?" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "delete this image?" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -45090,12 +46148,6 @@ "comment" : "Caption on a blurred incoming image inviting a tap to reveal it", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "tap to reveal" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -45114,6 +46166,12 @@ "value" : "zum anzeigen tippen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tap to reveal" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -45246,12 +46304,6 @@ "comment" : "Accessibility label for pausing voice note playback", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "pause voice note" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -45270,6 +46322,12 @@ "value" : "sprachnachricht pausieren" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "pause voice note" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -45402,12 +46460,6 @@ "comment" : "Accessibility label for playing a voice note", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "play voice note" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -45426,6 +46478,12 @@ "value" : "sprachnachricht abspielen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "play voice note" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -45558,12 +46616,6 @@ "comment" : "Accessibility hint on a peer row explaining activation opens a private chat", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "opens a private chat" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -45582,6 +46634,12 @@ "value" : "öffnet einen privaten chat" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens a private chat" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -45714,12 +46772,6 @@ "comment" : "Context menu action that shows a peer's fingerprint/verification screen", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "show fingerprint" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -45738,6 +46790,12 @@ "value" : "fingerabdruck anzeigen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "show fingerprint" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -45870,12 +46928,6 @@ "comment" : "State label for a blocked peer", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "blocked" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -45894,6 +46946,12 @@ "value" : "blockiert" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocked" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -46026,12 +47084,6 @@ "comment" : "State label for a favorited peer", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "favorite" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -46050,6 +47102,12 @@ "value" : "favorit" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorite" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -46182,12 +47240,6 @@ "comment" : "State label for a peer that is not currently reachable", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "offline" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -46206,6 +47258,12 @@ "value" : "offline" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "offline" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -46338,12 +47396,6 @@ "comment" : "State label for a peer with unread private messages", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "new messages" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -46362,6 +47414,12 @@ "value" : "neue nachrichten" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "new messages" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -46494,12 +47552,6 @@ "comment" : "State label for a peer vouched for by someone the user verified", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "vouched" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -46518,6 +47570,12 @@ "value" : "verbürgt" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "vouched" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -46829,12 +47887,6 @@ "comment" : "Tooltip for the vouched (unfilled seal) badge next to a peer", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "vouched for by someone you verified" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -46853,6 +47905,12 @@ "value" : "verbürgt von jemandem, den du verifiziert hast" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "vouched for by someone you verified" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -46981,6 +48039,1806 @@ } } }, + "notices.accessibility.close" : { + "comment" : "Accessibility label for the notices close button", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إغلاق الإعلانات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশ বন্ধ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hinweise schließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close notices" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerrar avisos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Isara ang mga paunawa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer les annonces" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סגור מודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाएँ बंद करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tutup pengumuman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chiudi avvisi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせを閉じる" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지 닫기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tutup pengumuman" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचना बन्द गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mededelingen sluiten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamknij ogłoszenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fechar avisos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fechar avisos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть объявления" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stäng anslag" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகளை மூடு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปิดประกาศ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duyuruları kapat" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрити оголошення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلانات بند کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đóng thông báo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭公告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關閉公告" + } + } + } + }, + "notices.accessibility.scope" : { + "comment" : "Accessibility label for the geo/mesh scope toggle", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نطاق الإعلانات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশের পরিধি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereich der Hinweise" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notices scope" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ámbito de los avisos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saklaw ng mga paunawa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Portée des annonces" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "טווח המודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाओं का दायरा" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cakupan pengumuman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ambito degli avvisi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせの範囲" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지 범위" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skop pengumuman" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाको दायरा" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereik van mededelingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zakres ogłoszeń" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Âmbito dos avisos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escopo dos avisos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Раздел объявлений" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anslagens omfattning" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகளின் வரம்பு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ขอบเขตประกาศ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duyuru kapsamı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Розділ оголошень" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلانات کا دائرہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phạm vi thông báo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告范围" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告範圍" + } + } + } + }, + "notices.alert.urgent_collapsed" : { + "comment" : "Local chat line when several urgent notices arrive together", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld إعلانات عاجلة جديدة — اضغط على الدبوس للعرض" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lldটি নতুন জরুরি নোটিশ — দেখতে পিনে ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld neue dringende hinweise — tippe zum ansehen auf den pin" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld new urgent notices — tap the pin to view" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld avisos urgentes nuevos — toca el pin para verlos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld bagong agarang paunawa — i-tap ang pin para makita" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld nouvelles annonces urgentes — touche l'épingle pour voir" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld מודעות דחופות חדשות — הקש על הנעץ לצפייה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld नई ज़रूरी सूचनाएँ — देखने के लिए पिन टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld pengumuman mendesak baru — ketuk pin untuk melihat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld nuovi avvisi urgenti — tocca la puntina per vederli" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 新しい緊急のお知らせが%lld件 — ピンをタップして表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 새 긴급 공지 %lld개 — 핀을 탭하여 확인" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld pengumuman segera baharu — ketik pin untuk melihat" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld नयाँ जरुरी सूचना — हेर्न पिन ट्याप गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld nieuwe dringende mededelingen — tik op de pin om te bekijken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld nowych pilnych ogłoszeń — dotknij pinezki, aby zobaczyć" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld novos avisos urgentes — toca no pin para ver" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld avisos urgentes novos — toque no pin para ver" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld новых срочных объявлений — нажми на булавку, чтобы посмотреть" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld nya brådskande anslag — tryck på nålen för att visa" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld புதிய அவசர அறிவிப்புகள் — பார்க்க பின்னைத் தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 ประกาศด่วนใหม่ %lld รายการ — แตะหมุดเพื่อดู" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld yeni acil duyuru — görmek için raptiyeye dokun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld нових термінових оголошень — натисни на шпильку, щоб переглянути" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld نئے فوری اعلانات — دیکھنے کیلئے پن پر ٹیپ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld thông báo khẩn mới — chạm vào ghim để xem" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld 条新紧急公告 — 点按图钉查看" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld 則新緊急公告 — 點按圖釘查看" + } + } + } + }, + "notices.alert.urgent_single" : { + "comment" : "Local chat line when one urgent notice is pinned nearby", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 إعلان عاجل من @%@: %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@-এর জরুরি নোটিশ: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 dringender hinweis von @%@: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 urgent notice from @%@: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 aviso urgente de @%@: %@" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 agarang paunawa mula kay @%@: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 annonce urgente de @%@ : %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 מודעה דחופה מאת @%@: %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@ की ज़रूरी सूचना: %@" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 pengumuman mendesak dari @%@: %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 avviso urgente da @%@: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@からの緊急のお知らせ: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@님의 긴급 공지: %@" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 pengumuman segera daripada @%@: %@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@को जरुरी सूचना: %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 dringende mededeling van @%@: %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 pilne ogłoszenie od @%@: %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 aviso urgente de @%@: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 aviso urgente de @%@: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 срочное объявление от @%@: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 brådskande anslag från @%@: %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@ இன் அவசர அறிவிப்பு: %@" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 ประกาศด่วนจาก @%@: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@ kişisinden acil duyuru: %@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 термінове оголошення від @%@: %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@ کا فوری اعلان: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 thông báo khẩn từ @%@: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 来自 @%@ 的紧急公告:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 來自 @%@ 的緊急公告:%@" + } + } + } + }, + "notices.description.mesh" : { + "comment" : "Explainer for the mesh tab of the notices sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ثبّت إعلانات قصيرة لمن حولك. تنتقل من هاتف إلى هاتف حتى دون اتصال، وتختفي وحدها بعد أيام قليلة." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আশেপাশের মানুষের জন্য ছোট নোটিশ পিন করুন। অফলাইনেও ফোন থেকে ফোনে পৌঁছে যায় আর কয়েক দিন পরে নিজে থেকেই মুছে যায়।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "hefte kurze hinweise für leute in deiner nähe an. sie springen von handy zu handy, auch offline, und verschwinden nach ein paar tagen von selbst." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "fija avisos cortos para la gente cercana. saltan de teléfono a teléfono, incluso sin conexión, y desaparecen solos después de unos días." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mag-pin ng maiikling paunawa para sa mga taong nasa paligid mo. lumilipat ito mula sa isang telepono patungo sa iba, kahit offline, at kusang nawawala pagkatapos ng ilang araw." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "épingle de courtes annonces pour les gens autour de toi. elles passent de téléphone en téléphone, même hors ligne, et disparaissent d'elles-mêmes après quelques jours." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הצמד מודעות קצרות לאנשים סביבך. הן עוברות מטלפון לטלפון, גם בלי אינטרנט, ונעלמות מעצמן אחרי כמה ימים." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आस-पास के लोगों के लिए छोटी सूचनाएँ पिन करें। ये ऑफ़लाइन भी फ़ोन से फ़ोन तक पहुँचती हैं और कुछ दिनों बाद अपने आप मिट जाती हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "sematkan pengumuman singkat untuk orang di sekitarmu. berpindah dari ponsel ke ponsel, bahkan saat offline, dan hilang sendiri setelah beberapa hari." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "appunta brevi avvisi per chi ti sta intorno. passano da telefono a telefono, anche offline, e spariscono da soli dopo qualche giorno." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くの人に向けて短いお知らせをピン留め。オフラインでもスマホからスマホへ伝わり、数日後に自動的に消えます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주변 사람들을 위해 짧은 공지를 고정하세요. 오프라인에서도 휴대폰에서 휴대폰으로 전달되고 며칠 후 스스로 사라집니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "semat pengumuman ringkas untuk orang di sekeliling anda. ia berpindah dari telefon ke telefon, walaupun di luar talian, dan hilang sendiri selepas beberapa hari." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "वरपरका मानिसहरूका लागि छोटा सूचना पिन गर। अफलाइनमा पनि फोनबाट फोनमा पुग्छन् र केही दिनपछि आफैँ हराउँछन्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "prik korte mededelingen voor mensen om je heen. ze springen van telefoon naar telefoon, ook offline, en verdwijnen vanzelf na een paar dagen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "przypinaj krótkie ogłoszenia dla ludzi w pobliżu. przeskakują z telefonu na telefon, nawet offline, i same znikają po kilku dniach." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "afixa avisos curtos para quem está por perto. saltam de telemóvel em telemóvel, mesmo offline, e desaparecem sozinhos após alguns dias." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "fixe avisos curtos para as pessoas por perto. eles pulam de celular em celular, mesmo offline, e somem sozinhos depois de alguns dias." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "закрепляй короткие объявления для людей рядом. они передаются с телефона на телефон, даже офлайн, и сами исчезают через несколько дней." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "nåla upp korta anslag för folk i närheten. de hoppar från telefon till telefon, även offline, och försvinner av sig själva efter några dagar." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகிலுள்ளவர்களுக்காக சிறிய அறிவிப்புகளைப் பின் செய்யவும். ஆஃப்லைனிலும் ஃபோனிலிருந்து ஃபோனுக்குப் பரவி, சில நாட்களில் தானாக மறைந்துவிடும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปักประกาศสั้น ๆ ให้คนรอบตัว ส่งต่อจากมือถือสู่มือถือได้แม้ออฟไลน์ และหายไปเองหลังผ่านไปสองสามวัน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "çevrendekiler için kısa duyurular sabitle. çevrimdışıyken bile telefondan telefona geçer ve birkaç gün sonra kendiliğinden kaybolur." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "закріплюй короткі оголошення для людей поруч. вони передаються з телефона на телефон, навіть офлайн, і самі зникають за кілька днів." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آس پاس کے لوگوں کیلئے مختصر اعلانات پن کریں۔ یہ آف لائن بھی فون سے فون تک پہنچتے ہیں اور کچھ دنوں بعد خود مٹ جاتے ہیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ghim thông báo ngắn cho những người quanh bạn. chúng truyền từ điện thoại này sang điện thoại khác, kể cả khi ngoại tuyến, và tự biến mất sau vài ngày." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "为周围的人钉上简短公告。即使离线也能在手机间传递,几天后自动消失。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "為周圍的人釘上簡短公告。即使離線也能在手機間傳遞,幾天後自動消失。" + } + } + } + }, + "notices.source.mesh" : { + "comment" : "Source badge for notices carried by the mesh", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + } + } + }, + "notices.source.nostr" : { + "comment" : "Source badge for notices seen on internet relays", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نت" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নেট" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "netz" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "red" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "רשת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नेट" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "rete" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ネット" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "넷" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नेट" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "sieć" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "rede" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "rede" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сеть" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "nät" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நெட்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เน็ต" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ağ" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "мережа" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "نیٹ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mạng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "网络" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "網路" + } + } + } + }, + "notices.tab.geo" : { + "comment" : "Segmented control label for geohash-scoped notices", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جغرافي" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এলাকা" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "géo" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אזור" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "क्षेत्र" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "area" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "エリア" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지역" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kawasan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "क्षेत्र" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "гео" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பகுதி" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "พื้นที่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bölge" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "гео" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "علاقہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "khu vực" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "区域" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "區域" + } + } + } + }, + "notices.tab.mesh" : { + "comment" : "Segmented control label for mesh-local notices", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + } + } + }, + "notices.title" : { + "comment" : "Title prefix of the unified notices sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إعلانات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "hinweise" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "notices" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "avisos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mga paunawa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "annonces" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "pengumuman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "avvisi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "pengumuman" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाहरू" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mededelingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ogłoszenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "avisos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "avisos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "объявления" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "anslag" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ประกาศ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "duyurular" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "оголошення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلانات" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thông báo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告" + } + } + } + }, "recording %@" : { "comment" : "Voice note recording duration indicator", "localizations" : { @@ -48417,12 +51275,6 @@ "comment" : "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "sent via mesh gateway" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -48441,6 +51293,12 @@ "value" : "über mesh-gateway gesendet" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sent via mesh gateway" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -48930,12 +51788,6 @@ "system.group.already_member" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ is already a member" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -48954,6 +51806,12 @@ "value" : "%@ ist bereits mitglied" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is already a member" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -49085,12 +51943,6 @@ "system.group.cannot_remove_creator" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "the creator cannot be removed" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -49109,6 +51961,12 @@ "value" : "der ersteller kann nicht entfernt werden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "the creator cannot be removed" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -49240,12 +52098,6 @@ "system.group.create_failed" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "could not create the group" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -49264,6 +52116,12 @@ "value" : "die gruppe konnte nicht erstellt werden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not create the group" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -49395,12 +52253,6 @@ "system.group.created" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "created group '%@' — use /group invite @name to add people" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -49419,6 +52271,12 @@ "value" : "gruppe '%@' erstellt — nutze /group invite @name, um leute hinzuzufügen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "created group '%@' — use /group invite @name to add people" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -49550,12 +52408,6 @@ "system.group.creator_only" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "only the group creator can do that" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -49574,6 +52426,12 @@ "value" : "nur der gruppenersteller kann das tun" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "only the group creator can do that" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -49705,12 +52563,6 @@ "system.group.full" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "group is full (max %@ members)" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -49729,6 +52581,12 @@ "value" : "gruppe ist voll (max. %@ mitglieder)" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "group is full (max %@ members)" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -49860,12 +52718,6 @@ "system.group.identity_unavailable" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "your identity keys are not ready yet" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -49884,6 +52736,12 @@ "value" : "deine identitätsschlüssel sind noch nicht bereit" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "your identity keys are not ready yet" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -50015,12 +52873,6 @@ "system.group.invite_failed" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "could not build the group invite" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -50039,6 +52891,12 @@ "value" : "die gruppeneinladung konnte nicht erstellt werden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not build the group invite" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -50170,12 +53028,6 @@ "system.group.invited" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "invited %1$@ to '%2$@'" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -50194,6 +53046,12 @@ "value" : "%1$@ zu '%2$@' eingeladen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "invited %1$@ to '%2$@'" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -50325,12 +53183,6 @@ "system.group.joined" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you were added to group '%1$@' by %2$@ — it now appears in your people list" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -50349,6 +53201,12 @@ "value" : "du wurdest von %2$@ zur gruppe '%1$@' hinzugefügt — sie erscheint jetzt in deiner personenliste" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you were added to group '%1$@' by %2$@ — it now appears in your people list" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -50480,12 +53338,6 @@ "system.group.left" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "left group '%@'" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -50504,6 +53356,12 @@ "value" : "gruppe '%@' verlassen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "left group '%@'" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -50635,12 +53493,6 @@ "system.group.list_header" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "your groups:" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -50659,6 +53511,12 @@ "value" : "deine gruppen:" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "your groups:" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -50790,12 +53648,6 @@ "system.group.member_not_found" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' is not a member of this group" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -50814,6 +53666,12 @@ "value" : "'%@' ist kein mitglied dieser gruppe" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' is not a member of this group" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -50945,12 +53803,6 @@ "system.group.name_too_long" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "group names are limited to 40 characters" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -50969,6 +53821,12 @@ "value" : "gruppennamen sind auf 40 zeichen begrenzt" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "group names are limited to 40 characters" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -51100,12 +53958,6 @@ "system.group.none" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you are not in any groups — /group create to start one" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -51124,6 +53976,12 @@ "value" : "du bist in keiner gruppe — /group create , um eine zu starten" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you are not in any groups — /group create to start one" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -51255,12 +54113,6 @@ "system.group.not_in_group" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "open a group chat first" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -51279,6 +54131,12 @@ "value" : "öffne zuerst einen gruppenchat" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "open a group chat first" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -51410,12 +54268,6 @@ "system.group.peer_identity_unknown" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot verify %@'s identity yet — wait for their announce" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -51434,6 +54286,12 @@ "value" : "%@s identität kann noch nicht verifiziert werden — warte auf deren announce" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot verify %@'s identity yet — wait for their announce" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -51565,12 +54423,6 @@ "system.group.peer_not_connected" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ must be connected over mesh to be invited" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -51589,6 +54441,12 @@ "value" : "%@ muss über mesh verbunden sein, um eingeladen zu werden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ must be connected over mesh to be invited" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -51720,12 +54578,6 @@ "system.group.peer_not_found" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' not found" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -51744,6 +54596,12 @@ "value" : "'%@' nicht gefunden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' not found" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -51875,12 +54733,6 @@ "system.group.removed_from" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you were removed from group '%@'" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -51899,6 +54751,12 @@ "value" : "du wurdest aus der gruppe '%@' entfernt" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you were removed from group '%@'" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -52030,12 +54888,6 @@ "system.group.removed_member" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "removed %@ and rotated the group key" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -52054,6 +54906,12 @@ "value" : "%@ entfernt und den gruppenschlüssel rotiert" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "removed %@ and rotated the group key" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -52185,12 +55043,6 @@ "system.group.rotate_failed" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "could not rotate the group key" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -52209,6 +55061,12 @@ "value" : "der gruppenschlüssel konnte nicht rotiert werden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not rotate the group key" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -52340,12 +55198,6 @@ "system.group.send_failed" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "could not encrypt the message" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -52364,6 +55216,12 @@ "value" : "die nachricht konnte nicht verschlüsselt werden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not encrypt the message" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -52495,12 +55353,6 @@ "system.group.unknown" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you are no longer in this group" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -52519,6 +55371,12 @@ "value" : "du bist nicht mehr in dieser gruppe" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you are no longer in this group" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -52650,12 +55508,6 @@ "system.group.usage_create" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage: /group create " - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -52674,6 +55526,12 @@ "value" : "verwendung: /group create " } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group create " + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -52805,12 +55663,6 @@ "system.group.usage_invite" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage: /group invite @name" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -52829,6 +55681,12 @@ "value" : "verwendung: /group invite @name" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group invite @name" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -52960,12 +55818,6 @@ "system.group.usage_remove" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage: /group remove @name" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -52984,6 +55836,12 @@ "value" : "verwendung: /group remove @name" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group remove @name" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -53474,12 +56332,6 @@ "comment" : "System message shown when a mesh peer cannot be blocked", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot block %@: not found or unable to verify identity" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -53498,6 +56350,12 @@ "value" : "%@ kann nicht blockiert werden: nicht gefunden oder identität nicht verifizierbar" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot block %@: not found or unable to verify identity" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -53630,12 +56488,6 @@ "comment" : "System message shown when a mesh peer is blocked", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "blocked %@. you will no longer receive messages from them" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -53654,6 +56506,12 @@ "value" : "%@ blockiert. du erhältst keine nachrichten mehr von ihnen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocked %@. you will no longer receive messages from them" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -53786,12 +56644,6 @@ "comment" : "System message shown when a mesh peer cannot be unblocked", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot unblock %@: not found" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -53810,6 +56662,12 @@ "value" : "%@ kann nicht entsperrt werden: nicht gefunden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot unblock %@: not found" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -53942,12 +56800,6 @@ "comment" : "System message shown when a mesh peer is unblocked", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "unblocked %@" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -53966,6 +56818,12 @@ "value" : "%@ entsperrt" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unblocked %@" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -54993,12 +57851,6 @@ "comment" : "Caption under the mesh topology map", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -55017,6 +57869,12 @@ "value" : "geschätzt aus verbreiteten nachbarlisten (bis zu 10 pro peer) — dein gerät ist hervorgehoben" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -55149,12 +58007,6 @@ "comment" : "Empty state of the mesh topology map", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "no mesh links yet — the map fills in as peer announces arrive" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -55173,6 +58025,12 @@ "value" : "noch keine mesh-verbindungen — die karte füllt sich, sobald peer-announces eintreffen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no mesh links yet — the map fills in as peer announces arrive" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -55305,12 +58163,6 @@ "comment" : "Accessibility label of the topology refresh button", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "refresh topology" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -55329,6 +58181,12 @@ "value" : "topologie aktualisieren" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "refresh topology" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -55461,12 +58319,6 @@ "comment" : "Topology map summary: number of peers and links", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$ld peers · %2$ld links" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -55485,6 +58337,12 @@ "value" : "%1$ld peers · %2$ld verbindungen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$ld peers · %2$ld links" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -55617,12 +58475,6 @@ "comment" : "Title of the mesh topology map sheet", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh topology" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -55641,6 +58493,12 @@ "value" : "mesh-topologie" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh topology" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -57737,522 +60595,7 @@ } } } - }, - "Images are only available in mesh chats." : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الصور متاحة فقط في محادثات الميش." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ছবি শুধু মেশ চ্যাটে উপলব্ধ।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bilder sind nur im Mesh-Chat verfügbar." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Images are only available in mesh chats." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Las imágenes solo están disponibles en los chats de mesh." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ang mga larawan ay available lamang sa mga mesh chat." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Les images sont uniquement disponibles dans les discussions mesh." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "תמונות זמינות רק בצ׳אט של mesh." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "चित्र केवल मेश चैट में ही उपलब्ध हैं।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gambar hanya tersedia di obrolan mesh." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Le immagini sono disponibili solo nelle chat mesh." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "画像はメッシュチャットでのみ利用できます。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이미지는 메쉬 채팅에서만 사용할 수 있습니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Imej hanya tersedia dalam sembang mesh." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "तस्बिरहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Afbeeldingen zijn alleen beschikbaar in mesh-chats." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obrazy są dostępne tylko na czatach mesh." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "As imagens só estão disponíveis nos chats mesh." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "As imagens só estão disponíveis nos chats mesh." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Изображения доступны только в mesh-чатах." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bilder är bara tillgängliga i mesh-chattar." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "படங்கள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "รูปภาพใช้งานได้เฉพาะในแชต mesh เท่านั้น" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Görseller yalnızca mesh sohbetlerinde kullanılabilir." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Зображення доступні лише в mesh-чатах." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "تصاویر صرف میش چیٹس میں دستیاب ہیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hình ảnh chỉ khả dụng trong các cuộc trò chuyện mesh." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "图片仅可在 mesh 聊天中使用。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "圖片僅能在 mesh 聊天中使用。" - } - } - } - }, - "Choose an image" : { - "comment" : "A label displayed above a button that allows the user to choose an image to send.", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "اختر صورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "একটি ছবি নির্বাচন করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bild auswählen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choose an image" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Elige una imagen" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pumili ng larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choisir une image" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "בחר תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "एक चित्र चुनें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pilih gambar" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Scegli un’immagine" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "画像を選択" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이미지를 선택하세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pilih imej" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "एउटा तस्वीर चयन गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kies een afbeelding" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wybierz obraz" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Escolher uma imagem" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Escolha uma imagem" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Выберите изображение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Välj en bild" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "ஒரு படத்தைத் தேர்ந்தெடுக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เลือกภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bir görüntü seç" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Виберіть зображення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ایک تصویر منتخب کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chọn một hình ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "选择图像" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "選擇圖像" - } - } - } - }, - "media.image.accessibility.unavailable" : { - "comment" : "Accessibility label for an image whose file could not be loaded", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "image unavailable" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "الصورة غير متاحة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ছবি অনুপলব্ধ" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bild nicht verfügbar" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "imagen no disponible" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "image indisponible" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "התמונה לא זמינה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "चित्र उपलब्ध नहीं" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gambar tidak tersedia" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "immagine non disponibile" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "画像を利用できません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이미지를 사용할 수 없음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "imej tidak tersedia" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तस्बिर उपलब्ध छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "afbeelding niet beschikbaar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "obraz niedostępny" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "imagem indisponível" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "изображение недоступно" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bild otillgänglig" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "படம் கிடைக்கவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่สามารถใช้รูปภาพได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "görsel kullanılamıyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "зображення недоступне" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تصویر دستیاب نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hình ảnh không khả dụng" - } - } - } } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 83578e2b..7e39bdb4 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -19,6 +19,7 @@ struct NostrProtocol { case giftWrap = 1059 // NIP-59 gift wrap case ephemeralEvent = 20000 case geohashPresence = 20001 + case deletion = 5 // NIP-09 event deletion request } /// Create a NIP-17 private message @@ -256,16 +257,22 @@ struct NostrProtocol { } /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. + /// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays + /// drop the note in step with a bridged board post's expiry. static func createGeohashTextNote( content: String, geohash: String, senderIdentity: NostrIdentity, - nickname: String? = nil + nickname: String? = nil, + expiresAt: Date? = nil ) throws -> NostrEvent { var tags = [["g", geohash]] if let nickname = nickname?.trimmedOrNilIfEmpty { tags.append(["n", nickname]) } + if let expiresAt { + tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))]) + } let event = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), @@ -276,7 +283,25 @@ struct NostrProtocol { let schnorrKey = try senderIdentity.schnorrSigningKey() return try event.sign(with: schnorrKey) } - + + /// Create a NIP-09 deletion request for one of our own events. Relays that + /// honor NIP-09 drop the referenced event; it must be signed by the same + /// key that signed the original. + static func createDeleteEvent( + ofEventID eventID: String, + senderIdentity: NostrIdentity + ) throws -> NostrEvent { + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .deletion, + tags: [["e", eventID]], + content: "" + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) + } + // MARK: - Private Methods private static func createSeal( diff --git a/bitchat/Protocols/Geohash.swift b/bitchat/Protocols/Geohash.swift index f436ea27..6fbb2b90 100644 --- a/bitchat/Protocols/Geohash.swift +++ b/bitchat/Protocols/Geohash.swift @@ -18,6 +18,14 @@ enum Geohash { return geohash.lowercased().allSatisfy { base32Map[$0] != nil } } + /// Validates a geohash string at any channel precision (1-12 characters). + /// - Parameter geohash: The geohash string to validate + /// - Returns: true if a non-empty base32 geohash of at most 12 characters + static func isValidGeohash(_ geohash: String) -> Bool { + guard (1...12).contains(geohash.count) else { return false } + return geohash.lowercased().allSatisfy { base32Map[$0] != nil } + } + /// Encodes the provided coordinates into a geohash string. /// - Parameters: /// - latitude: Latitude in degrees (-90...90) diff --git a/bitchat/Services/Board/BoardAlertsModel.swift b/bitchat/Services/Board/BoardAlertsModel.swift new file mode 100644 index 00000000..35daedfe --- /dev/null +++ b/bitchat/Services/Board/BoardAlertsModel.swift @@ -0,0 +1,160 @@ +// +// BoardAlertsModel.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Combine +import Foundation + +/// Turns newly arriving board posts into local, scope-matched chat alerts. +/// Everything here is derived from posts the mesh already synced — no extra +/// wire traffic, nothing another peer can't already see. +/// +/// - Urgent, recent pins get one system line in the matching chat (geo pin → +/// that geohash's timeline, mesh pin → mesh chat), collapsed when several +/// arrive together. +/// - Every other new pin just marks the header's pin icon until the notices +/// sheet is opened. +@MainActor +final class BoardAlertsModel: ObservableObject { + struct Dependencies { + /// Own posts never alert; the author already knows. + var isOwnPost: @MainActor (BoardPostPacket) -> Bool + /// Appends a local system line to a scope's chat timeline + /// (geohash, or "" for mesh chat). + var emitSystemLine: @MainActor (_ content: String, _ geohash: String) -> Void + var now: () -> Date = Date.init + /// Schedules the collapsed flush of pending urgent alerts; tests + /// inject a synchronous hook. + var scheduleFlush: (_ flush: @escaping @MainActor () -> Void) -> Void = { flush in + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(BoardAlertsModel.collapseDelaySeconds * 1_000_000_000)) + flush() + } + } + } + + /// Posts older than this at arrival are backfilled history carried in by + /// a peer, not something happening now; they badge but never line the chat. + static let inlineRecencyWindow: TimeInterval = 30 * 60 + /// Urgent arrivals within this window collapse into one line. + static let collapseDelaySeconds: TimeInterval = 4 + private static let alertContentMaxChars = 120 + + /// Unseen new pins by postID (hex) → geohash scope, cleared when the + /// notices sheet opens. + @Published private(set) var unseenPostScopes: [String: String] = [:] + + /// PostIDs already handled this session, so store eviction/re-sync churn + /// can't re-alert. Bounded by session wire volume (32-byte strings). + private var handledPostIDs = Set() + private var pendingUrgent: [String: [BoardPostPacket]] = [:] + private var flushScheduled = false + private let dependencies: Dependencies + private var cancellable: AnyCancellable? + private var wipeCancellable: AnyCancellable? + + private enum Strings { + static func urgentSingle(author: String, content: String) -> String { + String( + format: String(localized: "notices.alert.urgent_single", defaultValue: "📌 urgent notice from @%@: %@", comment: "Local chat line when one urgent notice is pinned nearby"), + locale: .current, + author, content + ) + } + + static func urgentCollapsed(_ count: Int) -> String { + String( + format: String(localized: "notices.alert.urgent_collapsed", defaultValue: "📌 %lld new urgent notices — tap the pin to view", comment: "Local chat line when several urgent notices arrive together"), + locale: .current, + count + ) + } + } + + init( + arrivals: AnyPublisher, + wipes: AnyPublisher = Empty(completeImmediately: false).eraseToAnyPublisher(), + dependencies: Dependencies + ) { + self.dependencies = dependencies + cancellable = arrivals + .receive(on: DispatchQueue.main) + .sink { [weak self] post in + self?.handleArrival(post) + } + wipeCancellable = wipes + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.reset() + } + } + + func unseenCount(forGeohash geohash: String) -> Int { + unseenPostScopes.values.reduce(0) { $0 + ($1 == geohash ? 1 : 0) } + } + + /// Marks pins in the given scopes as seen — only the scopes the notices + /// sheet actually shows, so unseen pins for other geohash channels keep + /// their badge until visited. + func markSeen(forScopes scopes: Set) { + guard unseenPostScopes.contains(where: { scopes.contains($0.value) }) else { return } + unseenPostScopes = unseenPostScopes.filter { !scopes.contains($0.value) } + } + + /// Panic wipe: drop everything derived from pre-wipe posts, including + /// urgent lines still waiting on the collapse flush. + func reset() { + pendingUrgent.removeAll() + handledPostIDs.removeAll() + guard !unseenPostScopes.isEmpty else { return } + unseenPostScopes.removeAll() + } + + func handleArrival(_ post: BoardPostPacket) { + let postID = post.postID.hexEncodedString() + guard !handledPostIDs.contains(postID) else { return } + handledPostIDs.insert(postID) + guard !dependencies.isOwnPost(post) else { return } + + unseenPostScopes[postID] = post.geohash + + let createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000) + guard post.isUrgent, + dependencies.now().timeIntervalSince(createdAt) <= Self.inlineRecencyWindow else { + return + } + pendingUrgent[post.geohash, default: []].append(post) + if !flushScheduled { + flushScheduled = true + dependencies.scheduleFlush { [weak self] in + self?.flushPendingUrgent() + } + } + } + + private func flushPendingUrgent() { + flushScheduled = false + let pending = pendingUrgent + pendingUrgent.removeAll() + for (geohash, posts) in pending { + guard let first = posts.first else { continue } + let line: String + if posts.count == 1 { + let author = first.authorNickname.trimmedOrNilIfEmpty ?? "anon" + line = Strings.urgentSingle(author: author, content: Self.truncated(first.content)) + } else { + line = Strings.urgentCollapsed(posts.count) + } + dependencies.emitSystemLine(line, geohash) + } + } + + private static func truncated(_ content: String) -> String { + guard content.count > alertContentMaxChars else { return content } + return content.prefix(alertContentMaxChars) + "…" + } +} diff --git a/bitchat/Services/Board/BoardManager.swift b/bitchat/Services/Board/BoardManager.swift index 989fda34..1f307bd4 100644 --- a/bitchat/Services/Board/BoardManager.swift +++ b/bitchat/Services/Board/BoardManager.swift @@ -20,17 +20,28 @@ final class BoardManager: ObservableObject { private let transport: Transport private let store: BoardStore - private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String) -> Void + /// Publishes a bridged kind-1 note (expiring with the board post via + /// NIP-40) and returns its Nostr event id, or nil when bridging failed or + /// was skipped. + private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64) -> String? + /// Requests NIP-09 deletion of a previously bridged note. + private let deleteFromNostr: (_ eventID: String, _ geohash: String) -> Void + /// Bridged Nostr event ids by postID, for merged deletes. In-memory only: + /// after a relaunch a delete still tombstones the board copy, but the + /// Nostr copy is left to expire with relay retention. + private var bridgedEventIDs: [Data: String] = [:] private var cancellable: AnyCancellable? init( transport: Transport, store: BoardStore = .shared, - publishToNostr: ((String, String, String) -> Void)? = nil + publishToNostr: ((String, String, String, UInt64) -> String?)? = nil, + deleteFromNostr: ((String, String) -> Void)? = nil ) { self.transport = transport self.store = store self.publishToNostr = publishToNostr ?? Self.livePublishToNostr + self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr cancellable = store.$postsSnapshot .receive(on: DispatchQueue.main) .sink { [weak self] snapshot in @@ -115,10 +126,10 @@ final class BoardManager: ObservableObject { ) transport.sendBoardPayload(BoardWire.post(post).encode()) - // One-way Nostr bridge (v1): geohash posts also go out as kind-1 - // location notes so online users see them. No inbound merge yet. - if !geohash.isEmpty { - publishToNostr(trimmed, geohash, cleanNickname) + // Nostr bridge: geohash posts also go out as kind-1 location notes so + // online users see them. Remember the event id for merged deletes. + if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt) { + bridgedEventIDs[postID] = eventID } return true } @@ -140,14 +151,20 @@ final class BoardManager: ObservableObject { signature: signature ) transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode()) + + // Merged delete: also retract the bridged Nostr copy when we still + // know its event id. + if !post.geohash.isEmpty, let eventID = bridgedEventIDs.removeValue(forKey: post.postID) { + deleteFromNostr(eventID, post.geohash) + } return true } - private static func livePublishToNostr(content: String, geohash: String, nickname: String) { + private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64) -> String? { let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) guard !relays.isEmpty else { SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session) - return + return nil } do { let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash) @@ -155,11 +172,26 @@ final class BoardManager: ObservableObject { content: content, geohash: geohash, senderIdentity: identity, - nickname: nickname + nickname: nickname, + expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000) ) NostrRelayManager.shared.sendEvent(event, to: relays) + return event.id } catch { SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session) + return nil + } + } + + private static func liveDeleteFromNostr(eventID: String, geohash: String) { + let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) + guard !relays.isEmpty else { return } + do { + let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash) + let deletion = try NostrProtocol.createDeleteEvent(ofEventID: eventID, senderIdentity: identity) + NostrRelayManager.shared.sendEvent(deletion, to: relays) + } catch { + SecureLogger.error("Board: failed to delete bridged Nostr note: \(error)", category: .session) } } } diff --git a/bitchat/Services/Board/BoardStore.swift b/bitchat/Services/Board/BoardStore.swift index 8ffdf7f3..c96670d0 100644 --- a/bitchat/Services/Board/BoardStore.swift +++ b/bitchat/Services/Board/BoardStore.swift @@ -78,6 +78,16 @@ final class BoardStore { /// Live posts, published on the main thread for the board UI. @Published private(set) var postsSnapshot: [BoardPostPacket] = [] + /// Fires on the main thread for each post newly accepted from the wire + /// (radio, sync, or local echo) — not for disk restores. Drives the + /// local new-pin chat alerts; duplicates never fire twice because the + /// store rejects them. + let postArrivals = PassthroughSubject() + + /// Fires on the main thread after a panic wipe so derived state (pending + /// alerts, unseen badges) is dropped along with the posts themselves. + let didWipe = PassthroughSubject() + private var posts: [StoredPost] = [] private var tombstones: [StoredTombstone] = [] private let queue = DispatchQueue(label: "chat.bitchat.board.store") @@ -104,6 +114,11 @@ final class BoardStore { let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs) if result == .accepted { persistLocked() + if case .post(let post) = wire { + DispatchQueue.main.async { [weak self] in + self?.postArrivals.send(post) + } + } } return result } @@ -149,6 +164,9 @@ final class BoardStore { } publishSnapshotLocked() } + DispatchQueue.main.async { [weak self] in + self?.didWipe.send() + } } // MARK: - Internals (call only on `queue`) diff --git a/bitchat/Services/Board/UnifiedNotices.swift b/bitchat/Services/Board/UnifiedNotices.swift new file mode 100644 index 00000000..16d88a97 --- /dev/null +++ b/bitchat/Services/Board/UnifiedNotices.swift @@ -0,0 +1,88 @@ +// +// UnifiedNotices.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// One row in the unified notices sheet: a mesh board post or a Nostr +/// location note, normalized for display. +struct NoticeItem: Identifiable, Equatable { + enum Source: Equatable { + /// Signed board post carried by the mesh. + case board(BoardPostPacket) + /// Kind-1 location note seen on geo relays. + case nostr(LocationNotesManager.Note) + } + + let id: String + let author: String + let content: String + let createdAt: Date + let isUrgent: Bool + let source: Source + + var isBoardPost: Bool { + if case .board = source { return true } + return false + } + + init(post: BoardPostPacket) { + id = post.postID.hexEncodedString() + author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon" + content = post.content + createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000) + isUrgent = post.isUrgent + source = .board(post) + } + + init(note: LocationNotesManager.Note) { + id = note.id + let display = note.displayName + author = display.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) + .first.map(String.init) ?? display + content = note.content + createdAt = note.createdAt + isUrgent = false + source = .nostr(note) + } +} + +/// Merges mesh board posts and Nostr location notes into one deduplicated +/// list for the notices sheet's geo tab. +enum UnifiedNotices { + /// Board posts on geohash channels are bridged to Nostr as kind-1 notes at + /// post time, so the same notice arrives twice. The copies share content + /// and nickname but are signed by unlinkable keys; match them + /// heuristically by content + author within a time window. + static let bridgeDedupeWindow: TimeInterval = 15 * 60 + + /// Returns board posts and notes as one list, urgent posts first, then + /// newest first. Notes that look like bridged copies of a board post are + /// dropped — the board copy wins because it carries urgency and supports + /// merged deletion. The geohash must match exactly: the notes + /// subscription also surfaces neighboring cells, and a same-text note + /// from a neighbor is not the bridged copy. + static func merge(posts: [BoardPostPacket], notes: [LocationNotesManager.Note]) -> [NoticeItem] { + var items = posts.map(NoticeItem.init(post:)) + for note in notes { + let noteNickname = note.nickname?.trimmedOrNilIfEmpty ?? "anon" + let isBridgedCopy = posts.contains { post in + post.geohash == note.geohash + && post.content == note.content + && (post.authorNickname.trimmedOrNilIfEmpty ?? "anon") == noteNickname + && abs(Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000).timeIntervalSince(note.createdAt)) <= bridgeDedupeWindow + } + if !isBridgedCopy { + items.append(NoticeItem(note: note)) + } + } + return items.sorted { + if $0.isUrgent != $1.isUrgent { return $0.isUrgent } + return $0.createdAt > $1.createdAt + } + } +} diff --git a/bitchat/Services/LocationNotesManager.swift b/bitchat/Services/LocationNotesManager.swift index 910ecced..e95f536a 100644 --- a/bitchat/Services/LocationNotesManager.swift +++ b/bitchat/Services/LocationNotesManager.swift @@ -67,6 +67,9 @@ final class LocationNotesManager: ObservableObject { let content: String let createdAt: Date let nickname: String? + /// The matched `g` tag: the cell the note was posted to, which can be + /// a neighbor of the subscribed geohash. + let geohash: String var displayName: String { let suffix = String(pubkey.suffix(4)) @@ -82,6 +85,8 @@ final class LocationNotesManager: ObservableObject { @Published private(set) var initialLoadComplete: Bool = false @Published private(set) var state: State = .loading @Published private(set) var errorMessage: String? + /// Public key of our per-geohash Nostr identity; identifies our own notes. + private var ownPubkey: String? private var subscriptionID: String? private var noteIDs = Set() // O(1) duplicate detection private var directoryUpdateCancellable: AnyCancellable? @@ -104,10 +109,10 @@ final class LocationNotesManager: ObservableObject { let norm = geohash.lowercased() self.geohash = norm self.dependencies = dependencies - // Validate geohash (building-level precision: 8 chars) - if !Geohash.isValidBuildingGeohash(norm) { - SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session) + if !Geohash.isValidGeohash(norm) { + SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session) } + ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex subscribe() // The relay directory may load after init (remote fetch over Tor); // retry automatically instead of staying stuck on "no relays". @@ -123,9 +128,8 @@ final class LocationNotesManager: ObservableObject { func setGeohash(_ newGeohash: String) { let norm = newGeohash.lowercased() guard norm != geohash else { return } - // Validate geohash (building-level precision: 8 chars) - guard Geohash.isValidBuildingGeohash(norm) else { - SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session) + guard Geohash.isValidGeohash(norm) else { + SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session) return } if let sub = subscriptionID { @@ -137,6 +141,7 @@ final class LocationNotesManager: ObservableObject { initialLoadComplete = false errorMessage = nil geohash = norm + ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex notes.removeAll() noteIDs.removeAll() subscribe() @@ -193,14 +198,14 @@ final class LocationNotesManager: ObservableObject { guard let self = self else { return } guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } // Ensure matching tag - accept any of our 9 geohashes - guard event.tags.contains(where: { tag in + guard let matchedGeohash = event.tags.first(where: { tag in tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased()) - }) else { return } + })?[1].lowercased() else { return } guard !self.noteIDs.contains(event.id) else { return } self.noteIDs.insert(event.id) let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick) + let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash) self.notes.append(note) self.notes.sort { $0.createdAt > $1.createdAt } self.enforceMemoryCap() @@ -239,7 +244,8 @@ final class LocationNotesManager: ObservableObject { pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)), - nickname: nickname + nickname: nickname, + geohash: geohash ) self.noteIDs.insert(event.id) self.notes.insert(echo, at: 0) @@ -252,6 +258,36 @@ final class LocationNotesManager: ObservableObject { } } + /// Whether the note was published by this device's identity for the + /// current geohash (and can therefore be deleted with NIP-09). + func isOwnNote(_ note: Note) -> Bool { + guard let ownPubkey else { return false } + return note.pubkey == ownPubkey + } + + /// Requests NIP-09 deletion of one of our own notes and removes it locally. + @discardableResult + func delete(note: Note) -> Bool { + guard isOwnNote(note) else { return false } + let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) + guard !relays.isEmpty else { + state = .noRelays + errorMessage = Strings.noRelays + return false + } + do { + let identity = try dependencies.deriveIdentity(geohash) + let deletion = try NostrProtocol.createDeleteEvent(ofEventID: note.id, senderIdentity: identity) + dependencies.sendEvent(deletion, relays) + // Keep the id in noteIDs so a relay replay can't resurrect it. + notes.removeAll { $0.id == note.id } + return true + } catch { + SecureLogger.error("LocationNotesManager: failed to delete note: \(error)", category: .session) + return false + } + } + /// Enforces defensive memory cap on notes array (keeps newest). private func enforceMemoryCap() { if notes.count > maxNotesInMemory { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index c0c0d8b1..29140383 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1744,6 +1744,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func addGeohashOnlySystemMessage(_ content: String) { publicConversationCoordinator.addGeohashOnlySystemMessage(content) } + + /// Add a local system message to one specific geohash timeline, active or + /// not. Used by the board's new-pin alerts to scope-match the pin's channel. + @MainActor + func addGeohashSystemMessage(_ content: String, geohash: String) { + let systemMessage = BitchatMessage( + sender: "system", + content: content, + timestamp: Date(), + isRelay: false + ) + appendGeohashMessageIfAbsent(systemMessage, toGeohash: geohash) + } // Send a public message without adding a local user echo. // Used for emotes where we want a local system-style confirmation instead. @MainActor diff --git a/bitchat/Views/BoardView.swift b/bitchat/Views/BoardView.swift deleted file mode 100644 index 8c443847..00000000 --- a/bitchat/Views/BoardView.swift +++ /dev/null @@ -1,270 +0,0 @@ -// -// BoardView.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import SwiftUI - -/// The bulletin board for one context: a geohash channel, or the mesh-local -/// board when `geohash` is empty. Urgent posts pin to the top; own posts can -/// be swipe-deleted, which broadcasts a signed tombstone. -struct BoardView: View { - /// Empty string = mesh-local board. - let geohash: String - let senderNickname: String - @ObservedObject var board: BoardManager - - @ThemedPalette private var palette - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.dismiss) private var dismiss - @State private var draft: String = "" - @State private var urgent = false - @State private var expiryDays = 7 - - private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } - private var posts: [BoardPostPacket] { board.posts(forGeohash: geohash) } - - private enum Strings { - static let boardName = String(localized: "board.title", defaultValue: "board", comment: "Title prefix of the bulletin board sheet") - static let description = String(localized: "board.description", defaultValue: "persistent notices carried by the mesh. posts are signed, spread device-to-device, and expire on their own.", comment: "Explainer under the board sheet title") - static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts") - static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts") - static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts") - static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer") - static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field") - static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button") - static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts") - static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker") - static let closeHint = String(localized: "board.accessibility.close", defaultValue: "Close board", comment: "Accessibility label for the board close button") - - static func expiryDaysOption(_ days: Int) -> String { - String( - format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"), - locale: .current, - days - ) - } - - static func postAccessibilityLabel(author: String, content: String, urgent: Bool) -> String { - let base = String( - format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"), - locale: .current, - author, content - ) - return urgent ? "\(urgentBadge), \(base)" : base - } - } - - var body: some View { - VStack(spacing: 0) { - headerSection - postList - composer - } - .themedSurface() - #if os(macOS) - .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) - #endif - .themedSheetBackground() - } - - private var headerSection: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 12) { - Text(verbatim: geohash.isEmpty ? "\(Strings.boardName) @ #mesh" : "\(Strings.boardName) @ #\(geohash)") - .bitchatFont(size: 18) - Spacer() - SheetCloseButton { dismiss() } - .accessibilityLabel(Strings.closeHint) - } - Text(Strings.description) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 12) - .themedSurface() - } - - private var postList: some View { - Group { - if posts.isEmpty { - ScrollView { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.emptyTitle) - .bitchatFont(size: 13, weight: .semibold) - Text(Strings.emptySubtitle) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - } else { - List { - ForEach(posts, id: \.postID) { post in - postRow(post) - .listRowBackground(palette.background) - .listRowSeparatorTint(palette.divider) - } - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .themedSurface() - } - - private func postRow(_ post: BoardPostPacket) -> some View { - let isOwn = board.isOwnPost(post) - let author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon" - return VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - if post.isUrgent { - Image(systemName: "exclamationmark.triangle.fill") - .font(.bitchatSystem(size: 11)) - .foregroundColor(palette.alertRed) - Text(Strings.urgentBadge) - .bitchatFont(size: 11, weight: .semibold) - .foregroundColor(palette.alertRed) - } - Text(verbatim: "@\(author)") - .bitchatFont(size: 12, weight: .semibold) - Text(timestampText(forMs: post.createdAt)) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) - Spacer() - if isOwn { - Button { - board.deletePost(post) - } label: { - Image(systemName: "trash") - .font(.bitchatSystem(size: 12)) - .foregroundColor(palette.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel(Strings.deleteAction) - } - } - Text(post.content) - .bitchatFont(size: 14) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.vertical, 4) - .accessibilityElement(children: .ignore) - .accessibilityLabel(Strings.postAccessibilityLabel(author: author, content: post.content, urgent: post.isUrgent)) - .accessibilityActions { - if isOwn { - Button(Strings.deleteAction) { board.deletePost(post) } - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if isOwn { - Button(role: .destructive) { - board.deletePost(post) - } label: { - Label(Strings.deleteAction, systemImage: "trash") - } - } - } - } - - private var composer: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 10) { - TextField(Strings.placeholder, text: $draft, axis: .vertical) - .textFieldStyle(.plain) - .bitchatFont(size: 14) - .lineLimit(maxDraftLines, reservesSpace: true) - .padding(.vertical, 6) - Button(action: send) { - Image(systemName: "arrow.up.circle.fill") - .font(.bitchatSystem(size: 20)) - .foregroundColor(sendEnabled ? palette.accent : .secondary) - } - .padding(.top, 2) - .buttonStyle(.plain) - .disabled(!sendEnabled) - .accessibilityLabel(Strings.send) - } - HStack(spacing: 12) { - Toggle(isOn: $urgent) { - Text(Strings.urgentToggle) - .bitchatFont(size: 12) - .foregroundColor(urgent ? palette.alertRed : palette.secondary) - } - .toggleStyle(.switch) - .fixedSize() - .accessibilityLabel(Strings.urgentToggle) - Spacer() - Text(Strings.expiryLabel) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - Picker(Strings.expiryLabel, selection: $expiryDays) { - ForEach([1, 3, 7], id: \.self) { days in - Text(Strings.expiryDaysOption(days)).tag(days) - } - } - .pickerStyle(.segmented) - .fixedSize() - .accessibilityLabel(Strings.expiryLabel) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .themedSurface() - .overlay(Divider(), alignment: .top) - } - - private var sendEnabled: Bool { - let trimmed = draft.trimmed - return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes - } - - private func send() { - guard let content = draft.trimmedOrNilIfEmpty else { return } - let sent = board.createPost( - content: content, - geohash: geohash, - urgent: urgent, - expiryDays: expiryDays, - nickname: senderNickname - ) - if sent { - draft = "" - urgent = false - } - } - - private func timestampText(forMs ms: UInt64) -> String { - let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000) - let now = Date() - if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { - let rel = Self.relativeFormatter.string(from: date, to: now) ?? "" - return rel.isEmpty ? "" : "\(rel) ago" - } - return Self.absDateFormatter.string(from: date) - } - - private static let relativeFormatter: DateComponentsFormatter = { - let f = DateComponentsFormatter() - f.allowedUnits = [.day, .hour, .minute] - f.maximumUnitCount = 1 - f.unitsStyle = .abbreviated - f.collapsesLargestUnit = true - return f - }() - - private static let absDateFormatter: DateFormatter = { - let f = DateFormatter() - f.setLocalizedDateFormatFromTemplate("MMM d") - return f - }() -} diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index 6a260b07..9bac1095 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -1,21 +1,17 @@ import SwiftUI -#if os(iOS) -import UIKit -#endif struct ContentHeaderView: View { @EnvironmentObject private var appChromeModel: AppChromeModel @EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var peerListModel: PeerListModel + @EnvironmentObject private var boardAlertsModel: BoardAlertsModel @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.appTheme) private var theme @ThemedPalette private var palette @Binding var showSidebar: Bool @Binding var showVerifySheet: Bool - @Binding var showLocationNotes: Bool - @Binding var notesGeohash: String? var isNicknameFieldFocused: FocusState.Binding let headerHeight: CGFloat @@ -25,8 +21,13 @@ struct ContentHeaderView: View { /// Courier envelopes this device is carrying for offline third parties. @State private var carriedMailCount = 0 - /// Bulletin board sheet for the current channel context. - @State private var showBoard = false + /// Unified notices sheet (board posts + location notes) for the current + /// channel context. + @State private var showNotices = false + + /// Board posts mirrored from the store so the pin icon can show when the + /// current scope has notices. + @State private var boardPosts: [BoardPostPacket] = [] var body: some View { HStack(spacing: 0) { @@ -137,36 +138,40 @@ struct ContentHeaderView: View { ) } - if case .mesh = locationChannelsModel.selectedChannel, - locationChannelsModel.permissionState == .authorized { - Button(action: { - locationChannelsModel.enableAndRefresh() - notesGeohash = locationChannelsModel.currentBuildingGeohash - showLocationNotes = true - }) { - Image(systemName: "note.text") - .font(.bitchatSystem(size: 12)) - .foregroundColor(Color.orange.opacity(0.8)) - .headerTapTarget() + Button(action: { + var scopes: Set = [""] + if let geoScope = noticesGeoScope { + scopes.insert(geoScope) } - .buttonStyle(.plain) - .accessibilityLabel( - String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button") - ) - } - - Button(action: { showBoard = true }) { - Image(systemName: "pin") + boardAlertsModel.markSeen(forScopes: scopes) + showNotices = true + }) { + // Fill marks unseen new pins; the tint says the current + // scope has notices at all. + Image(systemName: unseenNoticesCount > 0 ? "pin.fill" : "pin") .font(.bitchatSystem(size: 12)) - .foregroundColor(palette.secondary.opacity(0.9)) + .foregroundColor( + scopeHasNotices || unseenNoticesCount > 0 + ? Color.orange.opacity(0.8) + : palette.secondary.opacity(0.9) + ) .headerTapTarget() } .buttonStyle(.plain) .accessibilityLabel( - String(localized: "content.accessibility.board", defaultValue: "Bulletin board", comment: "Accessibility label for the bulletin board button") + String(localized: "content.accessibility.notices", defaultValue: "Notices", comment: "Accessibility label for the notices button") + ) + .accessibilityValue( + unseenNoticesCount > 0 + ? String( + format: String(localized: "content.accessibility.notices_new", defaultValue: "%lld new", comment: "Accessibility value for the notices button when unseen pins arrived"), + locale: .current, + unseenNoticesCount + ) + : "" ) .help( - String(localized: "content.header.board", defaultValue: "Bulletin board: persistent notices for this channel", comment: "Tooltip for the bulletin board button") + String(localized: "content.header.notices", defaultValue: "Notices: pinned posts for this area and the mesh", comment: "Tooltip for the notices button") ) if case .location(let channel) = locationChannelsModel.selectedChannel { @@ -267,54 +272,21 @@ struct ContentHeaderView: View { .onReceive(CourierStore.shared.$carriedCount) { count in carriedMailCount = count } + .onReceive(BoardStore.shared.$postsSnapshot) { posts in + boardPosts = posts + } .sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) { LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) .environmentObject(locationChannelsModel) .environmentObject(peerListModel) } - .sheet(isPresented: $showBoard) { - BoardView( - geohash: boardGeohash, + .sheet(isPresented: $showNotices) { + NoticesView( senderNickname: appChromeModel.nickname, - board: appChromeModel.boardManager + board: appChromeModel.boardManager, + initialTab: initialNoticesTab ) - } - .sheet(isPresented: $showLocationNotes, onDismiss: { - notesGeohash = nil - }) { - Group { - if let geohash = notesGeohash ?? locationChannelsModel.currentBuildingGeohash { - LocationNotesView( - geohash: geohash, - senderNickname: appChromeModel.nickname - ) - .environmentObject(locationChannelsModel) - } else { - ContentLocationNotesUnavailableView( - showLocationNotes: $showLocationNotes, - headerHeight: headerHeight - ) - .environmentObject(locationChannelsModel) - } - } - .onAppear { - locationChannelsModel.enableLocationChannels() - locationChannelsModel.beginLiveRefresh() - } - .onDisappear { - locationChannelsModel.endLiveRefresh() - } - .onChange(of: locationChannelsModel.availableChannels) { channels in - if let current = channels.first(where: { $0.level == .building })?.geohash, - notesGeohash != current { - notesGeohash = current - #if os(iOS) - let generator = UIImpactFeedbackGenerator(style: .light) - generator.prepare() - generator.impactOccurred() - #endif - } - } + .environmentObject(locationChannelsModel) } .onAppear { locationChannelsModel.refreshMeshChannelsIfNeeded() @@ -348,13 +320,34 @@ private extension ContentHeaderView { dynamicTypeSize.isAccessibilitySize ? 2 : 1 } - /// The board scope for the current channel: the geohash channel's board, - /// or the mesh-local board ("") in mesh chat. - var boardGeohash: String { + /// Open the notices sheet on the tab matching the current channel: the + /// geohash channel's notices, or the mesh-local board in mesh chat. + var initialNoticesTab: NoticesView.Tab { + if case .location = locationChannelsModel.selectedChannel { + return .geo + } + return .mesh + } + + /// The geo scope the notices sheet would open on: the selected location + /// channel, or the device's building geohash when chatting on mesh. + var noticesGeoScope: String? { if case .location(let channel) = locationChannelsModel.selectedChannel { return channel.geohash } - return "" + return locationChannelsModel.currentBuildingGeohash + } + + /// Whether either tab of the notices sheet currently has content. + var scopeHasNotices: Bool { + boardPosts.contains { $0.geohash.isEmpty || $0.geohash == noticesGeoScope } + } + + /// New pins in either visible scope since the sheet was last opened. + var unseenNoticesCount: Int { + let meshCount = boardAlertsModel.unseenCount(forGeohash: "") + let geoCount = noticesGeoScope.map { boardAlertsModel.unseenCount(forGeohash: $0) } ?? 0 + return meshCount + geoCount } /// Whether anyone is actually reachable on the current channel — the @@ -380,37 +373,3 @@ private extension ContentHeaderView { } } } - -private struct ContentLocationNotesUnavailableView: View { - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @ThemedPalette private var palette - - @Binding var showLocationNotes: Bool - - let headerHeight: CGFloat - - var body: some View { - VStack(spacing: 12) { - HStack { - Text("content.notes.title") - .bitchatFont(size: 16, weight: .bold) - Spacer() - SheetCloseButton { showLocationNotes = false } - .foregroundColor(palette.primary) - } - .frame(minHeight: headerHeight) - .padding(.horizontal, 12) - .themedChromePanel(edge: .top) - Text("content.notes.location_unavailable") - .bitchatFont(size: 14) - .foregroundColor(palette.secondary) - Button("content.location.enable") { - locationChannelsModel.enableAndRefresh() - } - .buttonStyle(.bordered) - Spacer() - } - .themedSheetBackground() - .foregroundColor(palette.primary) - } -} diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index bd434ad1..901c61b5 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -49,8 +49,6 @@ struct ContentView: View { @State private var isAtBottomPrivate = true @State private var autocompleteDebounceTimer: Timer? @State private var showVerifySheet = false - @State private var showLocationNotes = false - @State private var notesGeohash: String? @State private var imagePreviewURL: URL? #if os(iOS) @State private var showImagePicker = false @@ -269,8 +267,6 @@ struct ContentView: View { ContentHeaderView( showSidebar: $showSidebar, showVerifySheet: $showVerifySheet, - showLocationNotes: $showLocationNotes, - notesGeohash: $notesGeohash, isNicknameFieldFocused: $isNicknameFieldFocused, headerHeight: headerHeight, headerPeerIconSize: headerPeerIconSize, diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift deleted file mode 100644 index d2cd3f16..00000000 --- a/bitchat/Views/LocationNotesView.swift +++ /dev/null @@ -1,304 +0,0 @@ -import SwiftUI - -struct LocationNotesView: View { - @StateObject private var manager: LocationNotesManager - let geohash: String - let senderNickname: String - let onNotesCountChanged: ((Int) -> Void)? - - @ThemedPalette private var palette - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @Environment(\.dismiss) private var dismiss - @State private var draft: String = "" - - init( - geohash: String, - senderNickname: String, - onNotesCountChanged: ((Int) -> Void)? = nil, - manager: LocationNotesManager? = nil - ) { - let gh = geohash.lowercased() - self.geohash = gh - self.senderNickname = senderNickname - self.onNotesCountChanged = onNotesCountChanged - _manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh)) - } - - private var backgroundColor: Color { palette.background } - private var accentGreen: Color { palette.accent } - private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } - - private enum Strings { - static let description: LocalizedStringKey = "location_notes.description" - static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent" - static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused" - static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby" - static let retry: LocalizedStringKey = "location_notes.action.retry" - static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint" - static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes" - static let emptyTitle: LocalizedStringKey = "location_notes.empty_title" - static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle" - static let dismissError: LocalizedStringKey = "location_notes.action.dismiss" - static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder" - } - - var body: some View { -#if os(macOS) - VStack(spacing: 0) { - ScrollView { - VStack(spacing: 0) { - headerSection - notesContent - } - } - .themedSurface() - inputSection - } - .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) - .themedSheetBackground() - .onDisappear { manager.cancel() } - .onChange(of: geohash) { newValue in - manager.setGeohash(newValue) - } - .onAppear { onNotesCountChanged?(manager.notes.count) } - .onChange(of: manager.notes.count) { newValue in - onNotesCountChanged?(newValue) - } -#else - NavigationView { - VStack(spacing: 0) { - headerSection - ScrollView { - notesContent - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - inputSection - } - .themedSurface() - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - .navigationBarHidden(true) - #else - .navigationTitle("") - #endif - } - .themedSheetBackground() - .onDisappear { manager.cancel() } - .onChange(of: geohash) { newValue in - manager.setGeohash(newValue) - } - .onAppear { onNotesCountChanged?(manager.notes.count) } - .onChange(of: manager.notes.count) { newValue in - onNotesCountChanged?(newValue) - } -#endif - } - - private var closeButton: some View { - SheetCloseButton { dismiss() } - } - - private var headerSection: some View { - let count = manager.notes.count - return VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 12) { - Text(headerTitle(for: count)) - .bitchatFont(size: 18) - Spacer() - closeButton - } - if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty { - Text(building) - .bitchatFont(size: 12) - .foregroundColor(accentGreen) - } else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty { - Text(block) - .bitchatFont(size: 12) - .foregroundColor(accentGreen) - } - Text(Strings.description) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - .fixedSize(horizontal: false, vertical: true) - if manager.state == .noRelays { - Text(Strings.relaysPaused) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) - } - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 12) - .themedSurface() - } - - private func headerTitle(for count: Int) -> String { - String( - format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"), - locale: .current, - "\(geohash) ± 1", count - ) - } - - private var notesContent: some View { - LazyVStack(alignment: .leading, spacing: 12) { - if manager.state == .noRelays { - noRelaysRow - } else if manager.state == .loading && !manager.initialLoadComplete { - loadingRow - } else if manager.notes.isEmpty { - emptyRow - } else { - ForEach(manager.notes) { note in - noteRow(note) - } - } - - if let error = manager.errorMessage, manager.state != .noRelays { - errorRow(message: error) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - - private func noteRow(_ note: LocationNotesManager.Note) -> some View { - let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName - let ts = timestampText(for: note.createdAt) - return VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(verbatim: "@\(baseName)") - .bitchatFont(size: 12, weight: .semibold) - if !ts.isEmpty { - Text(ts) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) - } - Spacer() - } - Text(note.content) - .bitchatFont(size: 14) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.vertical, 4) - } - - private var noRelaysRow: some View { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.noRelaysNearby) - .bitchatFont(size: 13, weight: .semibold) - Text(Strings.relaysRetryHint) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - Button(Strings.retry) { manager.refresh() } - .bitchatFont(size: 12) - .buttonStyle(.plain) - } - .padding(.vertical, 6) - } - - private var loadingRow: some View { - HStack(spacing: 10) { - ProgressView() - Text(Strings.loadingNotes) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - Spacer() - } - .padding(.vertical, 8) - } - - private var emptyRow: some View { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.emptyTitle) - .bitchatFont(size: 13, weight: .semibold) - Text(Strings.emptySubtitle) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - } - .padding(.vertical, 6) - } - - private func errorRow(message: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .bitchatFont(size: 12) - Text(message) - .bitchatFont(size: 12) - Spacer() - } - Button(Strings.dismissError) { manager.clearError() } - .bitchatFont(size: 12) - .buttonStyle(.plain) - } - .padding(.vertical, 6) - } - - private var inputSection: some View { - HStack(alignment: .top, spacing: 10) { - TextField(Strings.addPlaceholder, text: $draft, axis: .vertical) - .textFieldStyle(.plain) - .bitchatFont(size: 14) - .lineLimit(maxDraftLines, reservesSpace: true) - .padding(.vertical, 6) - Button(action: send) { - Image(systemName: "arrow.up.circle.fill") - .font(.bitchatSystem(size: 20)) - .foregroundColor(sendButtonEnabled ? accentGreen : .secondary) - } - .padding(.top, 2) - .buttonStyle(.plain) - .disabled(!sendButtonEnabled) - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .themedSurface() - .overlay(Divider(), alignment: .top) - } - - private func send() { - guard let content = draft.trimmedOrNilIfEmpty else { return } - manager.send(content: content, nickname: senderNickname) - draft = "" - } - - private var sendButtonEnabled: Bool { - !draft.trimmed.isEmpty && manager.state != .noRelays - } - - // MARK: - Timestamp Formatting - private func timestampText(for date: Date) -> String { - let now = Date() - if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { - let rel = Self.relativeFormatter.string(from: date, to: now) ?? "" - return rel.isEmpty ? "" : "\(rel) ago" - } else { - let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year) - let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter - return fmt.string(from: date) - } - } - - private static let relativeFormatter: DateComponentsFormatter = { - let f = DateComponentsFormatter() - f.allowedUnits = [.day, .hour, .minute] - f.maximumUnitCount = 1 - f.unitsStyle = .abbreviated - f.collapsesLargestUnit = true - return f - }() - - private static let absDateFormatter: DateFormatter = { - let f = DateFormatter() - f.setLocalizedDateFormatFromTemplate("MMM d") - return f - }() - - private static let absDateYearFormatter: DateFormatter = { - let f = DateFormatter() - f.setLocalizedDateFormatFromTemplate("MMM d, y") - return f - }() -} diff --git a/bitchat/Views/NoticesView.swift b/bitchat/Views/NoticesView.swift new file mode 100644 index 00000000..692158f1 --- /dev/null +++ b/bitchat/Views/NoticesView.swift @@ -0,0 +1,560 @@ +// +// NoticesView.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// The unified notices sheet behind the header's pin icon: one place for +/// everything pinned around you, with a scope toggle. +/// +/// - geo: the current geohash's notices — mesh-synced board posts merged and +/// deduped with Nostr kind-1 location notes, so you also see notices from +/// people who aren't on your mesh. +/// - mesh: the mesh-local board only (empty geohash, fully offline). +struct NoticesView: View { + enum Tab: Hashable { + case geo + case mesh + } + + let senderNickname: String + @ObservedObject var board: BoardManager + + @ThemedPalette private var palette + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @State private var tab: Tab + @State private var draft: String = "" + @State private var urgent = false + @State private var expiryDays = 7 + + /// Injected notes manager for tests; live use derives one per geohash. + private let notesManager: LocationNotesManager? + + init( + senderNickname: String, + board: BoardManager, + initialTab: Tab, + notesManager: LocationNotesManager? = nil + ) { + self.senderNickname = senderNickname + self.board = board + self.notesManager = notesManager + _tab = State(initialValue: initialTab) + } + + private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } + + /// The geohash the geo tab is scoped to: the selected location channel, + /// or the device's building geohash when chatting on mesh. + private var geoGeohash: String? { + if case .location(let channel) = locationChannelsModel.selectedChannel { + return channel.geohash + } + return locationChannelsModel.currentBuildingGeohash + } + + /// The geo scope comes from device location only when no location channel + /// is selected; that's the case that needs the location machinery. + private var geoTabNeedsDeviceLocation: Bool { + if case .location = locationChannelsModel.selectedChannel { return false } + return true + } + + private var activeGeohash: String? { + switch tab { + case .geo: return geoGeohash + case .mesh: return "" + } + } + + enum Strings { + static let title = String(localized: "notices.title", defaultValue: "notices", comment: "Title prefix of the unified notices sheet") + static let geoTab = String(localized: "notices.tab.geo", defaultValue: "geo", comment: "Segmented control label for geohash-scoped notices") + static let meshTab = String(localized: "notices.tab.mesh", defaultValue: "mesh", comment: "Segmented control label for mesh-local notices") + static let scopePicker = String(localized: "notices.accessibility.scope", defaultValue: "Notices scope", comment: "Accessibility label for the geo/mesh scope toggle") + // The pre-merge location-notes explainer, reused so its existing + // translations carry over. + static let geoDescription = String(localized: "location_notes.description", comment: "Explainer for the geo tab of the notices sheet") + static let meshDescription = String(localized: "notices.description.mesh", defaultValue: "pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days.", comment: "Explainer for the mesh tab of the notices sheet") + static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts") + static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts") + static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts") + static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer") + static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field") + static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button") + static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts") + static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker") + static let closeHint = String(localized: "notices.accessibility.close", defaultValue: "Close notices", comment: "Accessibility label for the notices close button") + static let meshSource = String(localized: "notices.source.mesh", defaultValue: "mesh", comment: "Source badge for notices carried by the mesh") + static let nostrSource = String(localized: "notices.source.nostr", defaultValue: "net", comment: "Source badge for notices seen on internet relays") + static let locationUnavailable = String(localized: "content.notes.location_unavailable", comment: "Shown when the device location is unavailable for geo notices") + static let enableLocation = String(localized: "content.location.enable", comment: "Button enabling location for geo notices") + static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes" + static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby" + static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint" + static let retry: LocalizedStringKey = "location_notes.action.retry" + static let dismissError: LocalizedStringKey = "location_notes.action.dismiss" + + static func expiryDaysOption(_ days: Int) -> String { + String( + format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"), + locale: .current, + days + ) + } + + static func rowAccessibilityLabel(author: String, content: String, urgent: Bool) -> String { + let base = String( + format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"), + locale: .current, + author, content + ) + return urgent ? "\(urgentBadge), \(base)" : base + } + } + + var body: some View { + VStack(spacing: 0) { + headerSection + contentSection + if activeGeohash != nil { + composer + } + } + .themedSurface() + #if os(macOS) + .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) + #endif + .themedSheetBackground() + .onAppear { beginGeoLocationIfNeeded() } + .onChange(of: tab) { newTab in + if newTab == .geo { + beginGeoLocationIfNeeded() + } else { + locationChannelsModel.endLiveRefresh() + } + } + // Catches permission granted from the geo tab's enable button. + .onChange(of: locationChannelsModel.permissionState) { _ in + beginGeoLocationIfNeeded() + } + .onDisappear { locationChannelsModel.endLiveRefresh() } + } + + /// The geo tab tracks the device's building geohash while on mesh; keep + /// location fresh only in that case (a selected location channel already + /// fixes the scope). + private func beginGeoLocationIfNeeded() { + guard tab == .geo, geoTabNeedsDeviceLocation, + locationChannelsModel.permissionState == .authorized else { return } + locationChannelsModel.enableLocationChannels() + locationChannelsModel.beginLiveRefresh() + } + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Text(verbatim: scopeTitle) + .bitchatFont(size: 18) + Spacer() + SheetCloseButton { dismiss() } + .accessibilityLabel(Strings.closeHint) + } + Picker(Strings.scopePicker, selection: $tab) { + Text(Strings.geoTab).tag(Tab.geo) + Text(Strings.meshTab).tag(Tab.mesh) + } + .pickerStyle(.segmented) + .accessibilityLabel(Strings.scopePicker) + Text(tab == .geo ? Strings.geoDescription : Strings.meshDescription) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) + .themedSurface() + } + + private var scopeTitle: String { + switch tab { + case .mesh: + return "\(Strings.title) @ #mesh" + case .geo: + if let geohash = geoGeohash { + return "\(Strings.title) @ #\(geohash)" + } + return Strings.title + } + } + + @ViewBuilder + private var contentSection: some View { + switch tab { + case .mesh: + NoticesList( + items: UnifiedNotices.merge(posts: board.posts(forGeohash: ""), notes: []), + showsSource: false, + board: board, + notesManager: nil + ) + case .geo: + if let geohash = geoGeohash { + GeoNoticesList(geohash: geohash, board: board, manager: notesManager) + } else { + locationUnavailableSection + } + } + } + + private var locationUnavailableSection: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(Strings.locationUnavailable) + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + Button(Strings.enableLocation) { + locationChannelsModel.enableAndRefresh() + } + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .themedSurface() + } + + private var composer: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 10) { + TextField(Strings.placeholder, text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .bitchatFont(size: 14) + .lineLimit(maxDraftLines, reservesSpace: true) + .padding(.vertical, 6) + Button(action: send) { + Image(systemName: "arrow.up.circle.fill") + .font(.bitchatSystem(size: 20)) + .foregroundColor(sendEnabled ? palette.accent : .secondary) + } + .padding(.top, 2) + .buttonStyle(.plain) + .disabled(!sendEnabled) + .accessibilityLabel(Strings.send) + } + // Urgency and expiry only travel with the mesh copy — the bridged + // Nostr note carries neither, so relay-side readers would never + // see them. Offer the controls only where they fully apply. + if tab == .mesh { + HStack(spacing: 12) { + Toggle(isOn: $urgent) { + Text(Strings.urgentToggle) + .bitchatFont(size: 12) + .foregroundColor(urgent ? palette.alertRed : palette.secondary) + } + .toggleStyle(.switch) + .fixedSize() + .accessibilityLabel(Strings.urgentToggle) + Spacer() + Text(Strings.expiryLabel) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + Picker(Strings.expiryLabel, selection: $expiryDays) { + ForEach([1, 3, 7], id: \.self) { days in + Text(Strings.expiryDaysOption(days)).tag(days) + } + } + .pickerStyle(.segmented) + .fixedSize() + .accessibilityLabel(Strings.expiryLabel) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .themedSurface() + .overlay(Divider(), alignment: .top) + } + + private var sendEnabled: Bool { + let trimmed = draft.trimmed + return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes + } + + private func send() { + guard let geohash = activeGeohash, let content = draft.trimmedOrNilIfEmpty else { return } + // Geo posts go to the board and are bridged to Nostr by BoardManager, + // so mesh and internet see the same notice. They always use the + // defaults: non-urgent, 7-day expiry (NIP-40 on the bridged copy). + let sent = board.createPost( + content: content, + geohash: geohash, + urgent: tab == .mesh && urgent, + expiryDays: tab == .mesh ? expiryDays : 7, + nickname: senderNickname + ) + if sent { + draft = "" + urgent = false + } + } +} + +/// The geo tab's list: owns the Nostr notes subscription for the scope +/// geohash and merges it with the board posts for the same geohash. +private struct GeoNoticesList: View { + let geohash: String + @ObservedObject var board: BoardManager + @StateObject private var notesManager: LocationNotesManager + + init(geohash: String, board: BoardManager, manager: LocationNotesManager? = nil) { + let gh = geohash.lowercased() + self.geohash = gh + self.board = board + _notesManager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh)) + } + + var body: some View { + NoticesList( + items: UnifiedNotices.merge( + posts: board.posts(forGeohash: geohash), + notes: notesManager.notes + ), + showsSource: true, + board: board, + notesManager: notesManager + ) + .onChange(of: geohash) { newValue in + notesManager.setGeohash(newValue) + } + .onDisappear { notesManager.cancel() } + } +} + +/// Renders merged notices with per-source affordances: swipe-delete for own +/// items and a mesh/net badge when sources mix. +private struct NoticesList: View { + let items: [NoticeItem] + let showsSource: Bool + let board: BoardManager + let notesManager: LocationNotesManager? + + @ThemedPalette private var palette + + private typealias Strings = NoticesView.Strings + + var body: some View { + Group { + if items.isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + statusRows + if showEmptyState { + Text(Strings.emptyTitle) + .bitchatFont(size: 13, weight: .semibold) + Text(Strings.emptySubtitle) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + } else { + List { + statusRows + .listRowBackground(palette.background) + .listRowSeparatorTint(palette.divider) + ForEach(items) { item in + row(item) + .listRowBackground(palette.background) + .listRowSeparatorTint(palette.divider) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .themedSurface() + } + + /// Notes may still be loading or unreachable; only claim "no notices yet" + /// once the sources settled. + private var showEmptyState: Bool { + guard let notesManager else { return true } + return notesManager.initialLoadComplete && notesManager.state != .loading + } + + @ViewBuilder + private var statusRows: some View { + if let notesManager { + if notesManager.state == .loading && !notesManager.initialLoadComplete { + HStack(spacing: 10) { + ProgressView() + Text(Strings.loadingNotes) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + Spacer() + } + .padding(.vertical, 8) + } else if notesManager.state == .noRelays { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.noRelaysNearby) + .bitchatFont(size: 13, weight: .semibold) + Text(Strings.relaysRetryHint) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + Button(Strings.retry) { notesManager.refresh() } + .bitchatFont(size: 12) + .buttonStyle(.plain) + } + .padding(.bottom, 8) + } else if let error = notesManager.errorMessage { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .bitchatFont(size: 12) + Text(error) + .bitchatFont(size: 12) + Spacer() + } + Button(Strings.dismissError) { notesManager.clearError() } + .bitchatFont(size: 12) + .buttonStyle(.plain) + } + .padding(.bottom, 8) + } + } + } + + private func canDelete(_ item: NoticeItem) -> Bool { + switch item.source { + case .board(let post): + return board.isOwnPost(post) + case .nostr(let note): + return notesManager?.isOwnNote(note) ?? false + } + } + + private func delete(_ item: NoticeItem) { + switch item.source { + case .board(let post): + // Tombstones the board post and retracts the bridged Nostr copy. + board.deletePost(post) + case .nostr(let note): + notesManager?.delete(note: note) + } + } + + private func row(_ item: NoticeItem) -> some View { + let isOwn = canDelete(item) + return VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + if item.isUrgent { + Image(systemName: "exclamationmark.triangle.fill") + .font(.bitchatSystem(size: 11)) + .foregroundColor(palette.alertRed) + Text(Strings.urgentBadge) + .bitchatFont(size: 11, weight: .semibold) + .foregroundColor(palette.alertRed) + } + Text(verbatim: "@\(item.author)") + .bitchatFont(size: 12, weight: .semibold) + Text(Self.timestampText(for: item.createdAt)) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + Spacer() + if showsSource { + sourceBadge(item) + } + if isOwn { + Button { + delete(item) + } label: { + Image(systemName: "trash") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(Strings.deleteAction) + } + } + Text(item.content) + .bitchatFont(size: 14) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Strings.rowAccessibilityLabel(author: item.author, content: item.content, urgent: item.isUrgent)) + .accessibilityActions { + if isOwn { + Button(Strings.deleteAction) { delete(item) } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if isOwn { + Button(role: .destructive) { + delete(item) + } label: { + Label(Strings.deleteAction, systemImage: "trash") + } + } + } + } + + private func sourceBadge(_ item: NoticeItem) -> some View { + HStack(spacing: 3) { + Image(systemName: item.isBoardPost ? "antenna.radiowaves.left.and.right" : "globe") + .font(.bitchatSystem(size: 10)) + Text(item.isBoardPost ? Strings.meshSource : Strings.nostrSource) + .bitchatFont(size: 10) + } + .foregroundColor(palette.secondary.opacity(0.8)) + .accessibilityLabel(item.isBoardPost ? Strings.meshSource : Strings.nostrSource) + } + + // MARK: - Timestamp Formatting + + private static func timestampText(for date: Date) -> String { + let now = Date() + if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { + let rel = relativeFormatter.string(from: date, to: now) ?? "" + return rel.isEmpty ? "" : "\(rel) ago" + } + let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year) + return (sameYear ? absDateFormatter : absDateYearFormatter).string(from: date) + } + + private static let relativeFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.allowedUnits = [.day, .hour, .minute] + f.maximumUnitCount = 1 + f.unitsStyle = .abbreviated + f.collapsesLargestUnit = true + return f + }() + + private static let absDateFormatter: DateFormatter = { + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("MMM d") + return f + }() + + private static let absDateYearFormatter: DateFormatter = { + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("MMM d, y") + return f + }() +} diff --git a/bitchatTests/Services/BoardAlertsModelTests.swift b/bitchatTests/Services/BoardAlertsModelTests.swift new file mode 100644 index 00000000..df891cea --- /dev/null +++ b/bitchatTests/Services/BoardAlertsModelTests.swift @@ -0,0 +1,215 @@ +// +// BoardAlertsModelTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Combine +import Foundation +import Testing +@testable import bitchat + +@MainActor +struct BoardAlertsModelTests { + + private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) + private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) } + private let ownKey = Data(repeating: 7, count: 32) + + private final class Harness { + var lines: [(content: String, geohash: String)] = [] + var pendingFlushes: [@MainActor () -> Void] = [] + + @MainActor + func flushAll() { + let flushes = pendingFlushes + pendingFlushes = [] + for flush in flushes { flush() } + } + } + + private func makeModel(harness: Harness, now: Date? = nil) -> BoardAlertsModel { + let fixedNow = now ?? baseDate + return BoardAlertsModel( + arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(), + dependencies: BoardAlertsModel.Dependencies( + isOwnPost: { [ownKey] in $0.authorSigningKey == ownKey }, + emitSystemLine: { content, geohash in + harness.lines.append((content, geohash)) + }, + now: { fixedNow }, + scheduleFlush: { flush in + harness.pendingFlushes.append(flush) + } + ) + ) + } + + private func makePost( + content: String = "hello", + geohash: String = "9q8yy", + nickname: String = "alice", + createdAt: UInt64? = nil, + urgent: Bool = false, + authorKey: Data = Data(repeating: 1, count: 32), + postID: Data? = nil + ) -> BoardPostPacket { + BoardPostPacket( + postID: postID ?? Data((0..<16).map { _ in UInt8.random(in: 0...255) }), + geohash: geohash, + content: content, + authorSigningKey: authorKey, + authorNickname: nickname, + createdAt: createdAt ?? baseMs, + expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000, + flags: urgent ? BoardPostPacket.urgentFlag : 0, + signature: Data(repeating: 2, count: 64) + ) + } + + @Test + func ownPosts_neverBadgeOrAlert() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(urgent: true, authorKey: ownKey)) + harness.flushAll() + + #expect(model.unseenCount(forGeohash: "9q8yy") == 0) + #expect(harness.lines.isEmpty) + } + + @Test + func routinePost_badgesWithoutChatLine() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(geohash: "")) + harness.flushAll() + + #expect(model.unseenCount(forGeohash: "") == 1) + #expect(model.unseenCount(forGeohash: "9q8yy") == 0) + #expect(harness.lines.isEmpty) + } + + @Test + func urgentRecentPost_emitsLineInMatchingScope() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(content: "road closed", geohash: "9q8yy", urgent: true)) + #expect(harness.lines.isEmpty) + harness.flushAll() + + #expect(harness.lines.count == 1) + #expect(harness.lines[0].geohash == "9q8yy") + #expect(harness.lines[0].content.contains("road closed")) + #expect(harness.lines[0].content.contains("@alice")) + } + + @Test + func urgentBackfilledPost_badgesOnly() { + let harness = Harness() + let arrivalTime = baseDate.addingTimeInterval(BoardAlertsModel.inlineRecencyWindow + 120) + let model = makeModel(harness: harness, now: arrivalTime) + + model.handleArrival(makePost(createdAt: baseMs, urgent: true)) + harness.flushAll() + + #expect(model.unseenCount(forGeohash: "9q8yy") == 1) + #expect(harness.lines.isEmpty) + } + + @Test + func simultaneousUrgentPosts_collapseIntoOneLine() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(content: "one", urgent: true)) + model.handleArrival(makePost(content: "two", urgent: true)) + model.handleArrival(makePost(content: "three", urgent: true)) + harness.flushAll() + + #expect(harness.lines.count == 1) + #expect(harness.lines[0].content.contains("3")) + #expect(harness.pendingFlushes.isEmpty) + } + + @Test + func urgentPostsInDifferentScopes_alertEachScope() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(content: "geo pin", geohash: "9q8yy", urgent: true)) + model.handleArrival(makePost(content: "mesh pin", geohash: "", urgent: true)) + harness.flushAll() + + #expect(harness.lines.count == 2) + #expect(Set(harness.lines.map(\.geohash)) == ["9q8yy", ""]) + } + + @Test + func duplicateArrival_isHandledOnce() { + let harness = Harness() + let model = makeModel(harness: harness) + let id = Data(repeating: 3, count: 16) + + model.handleArrival(makePost(urgent: true, postID: id)) + model.handleArrival(makePost(urgent: true, postID: id)) + harness.flushAll() + + #expect(model.unseenCount(forGeohash: "9q8yy") == 1) + #expect(harness.lines.count == 1) + #expect(!harness.lines[0].content.contains("2")) + } + + @Test + func markSeen_clearsOnlyVisibleScopes() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(geohash: "")) + model.handleArrival(makePost(geohash: "9q8yy")) + model.handleArrival(makePost(geohash: "u4pruyd")) + + // Opening the sheet on mesh + 9q8yy must not eat the badge for the + // never-shown u4pruyd channel. + model.markSeen(forScopes: ["", "9q8yy"]) + + #expect(model.unseenCount(forGeohash: "") == 0) + #expect(model.unseenCount(forGeohash: "9q8yy") == 0) + #expect(model.unseenCount(forGeohash: "u4pruyd") == 1) + } + + @Test + func reset_dropsPendingUrgentLinesAndBadges() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(content: "pre-wipe secret", urgent: true)) + #expect(harness.pendingFlushes.count == 1) + + // Panic wipe lands before the collapse flush fires. + model.reset() + harness.flushAll() + + #expect(harness.lines.isEmpty) + #expect(model.unseenCount(forGeohash: "9q8yy") == 0) + } + + @Test + func longUrgentContent_isTruncatedInLine() { + let harness = Harness() + let model = makeModel(harness: harness) + let long = String(repeating: "a", count: 400) + + model.handleArrival(makePost(content: long, urgent: true)) + harness.flushAll() + + #expect(harness.lines.count == 1) + #expect(harness.lines[0].content.count < 200) + #expect(harness.lines[0].content.contains("…")) + } +} diff --git a/bitchatTests/Services/UnifiedNoticesTests.swift b/bitchatTests/Services/UnifiedNoticesTests.swift new file mode 100644 index 00000000..3dd369b4 --- /dev/null +++ b/bitchatTests/Services/UnifiedNoticesTests.swift @@ -0,0 +1,142 @@ +// +// UnifiedNoticesTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +struct UnifiedNoticesTests { + + private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) + private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) } + + private func makePost( + content: String, + nickname: String = "alice", + createdAt: UInt64? = nil, + urgent: Bool = false + ) -> BoardPostPacket { + BoardPostPacket( + postID: Data((0..<16).map { _ in UInt8.random(in: 0...255) }), + geohash: "9q8yy", + content: content, + authorSigningKey: Data(repeating: 1, count: 32), + authorNickname: nickname, + createdAt: createdAt ?? baseMs, + expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000, + flags: urgent ? BoardPostPacket.urgentFlag : 0, + signature: Data(repeating: 2, count: 64) + ) + } + + private func makeNote( + content: String, + nickname: String? = "alice", + createdAt: Date? = nil, + geohash: String = "9q8yy" + ) -> LocationNotesManager.Note { + LocationNotesManager.Note( + id: UUID().uuidString, + pubkey: "ab" + UUID().uuidString.replacingOccurrences(of: "-", with: ""), + content: content, + createdAt: createdAt ?? baseDate, + nickname: nickname, + geohash: geohash + ) + } + + @Test + func merge_dropsBridgedCopyOfBoardPost() { + let post = makePost(content: "free couch on 5th") + let bridged = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30)) + + let merged = UnifiedNotices.merge(posts: [post], notes: [bridged]) + + #expect(merged.count == 1) + #expect(merged[0].isBoardPost) + } + + @Test + func merge_keepsNoteWithSameContentOutsideWindow() { + let post = makePost(content: "water station here") + let oldNote = makeNote( + content: "water station here", + createdAt: baseDate.addingTimeInterval(-UnifiedNotices.bridgeDedupeWindow - 60) + ) + + let merged = UnifiedNotices.merge(posts: [post], notes: [oldNote]) + + #expect(merged.count == 2) + } + + @Test + func merge_keepsSameTextNoteFromNeighborCell() { + // The notes subscription covers the center cell plus 8 neighbors; a + // matching note posted to a *neighbor* is not the bridged copy. + let post = makePost(content: "free couch on 5th") + let neighborNote = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30), geohash: "9q8yz") + + let merged = UnifiedNotices.merge(posts: [post], notes: [neighborNote]) + + #expect(merged.count == 2) + } + + @Test + func merge_keepsNoteFromDifferentAuthor() { + let post = makePost(content: "meetup at 6", nickname: "alice") + let note = makeNote(content: "meetup at 6", nickname: "bob") + + let merged = UnifiedNotices.merge(posts: [post], notes: [note]) + + #expect(merged.count == 2) + } + + @Test + func merge_sortsUrgentFirstThenNewest() { + let urgent = makePost(content: "road closed", createdAt: baseMs - 60_000, urgent: true) + let newerPost = makePost(content: "later post", createdAt: baseMs) + let note = makeNote(content: "a note", nickname: "carol", createdAt: baseDate.addingTimeInterval(30)) + + let merged = UnifiedNotices.merge(posts: [newerPost, urgent], notes: [note]) + + #expect(merged.map(\.content) == ["road closed", "a note", "later post"]) + #expect(merged[0].isUrgent) + } + + @Test + func merge_anonNicknamesMatchForDedupe() { + // Bridged posts from an empty nickname arrive as anon notes with no + // "n" tag; they must still dedupe against the anon board copy. + let post = makePost(content: "hello", nickname: "") + let bridged = makeNote(content: "hello", nickname: nil) + + let merged = UnifiedNotices.merge(posts: [post], notes: [bridged]) + + #expect(merged.count == 1) + #expect(merged[0].isBoardPost) + #expect(merged[0].author == "anon") + } + + @Test + func noticeItem_normalizesNoteDisplayName() { + let note = LocationNotesManager.Note( + id: "e1", + pubkey: "deadbeef", + content: "hi", + createdAt: baseDate, + nickname: "dave", + geohash: "9q8yy" + ) + + let item = NoticeItem(note: note) + + #expect(item.author == "dave") + #expect(!item.isBoardPost) + #expect(!item.isUrgent) + } +} diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index 786b481e..debb41e5 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -1,3 +1,4 @@ +import Combine import Testing import Foundation import SwiftUI @@ -39,6 +40,7 @@ private struct SmokeFeatureModels { let verificationModel: VerificationModel let conversationUIModel: ConversationUIModel let peerListModel: PeerListModel + let boardAlertsModel: BoardAlertsModel } @MainActor @@ -80,6 +82,14 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur locationChannelsModel: locationChannelsModel ) + let boardAlertsModel = BoardAlertsModel( + arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(), + dependencies: BoardAlertsModel.Dependencies( + isOwnPost: { _ in false }, + emitSystemLine: { _, _ in } + ) + ) + return SmokeFeatureModels( publicChatModel: publicChatModel, appChromeModel: appChromeModel, @@ -88,7 +98,8 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur privateConversationModel: privateConversationModel, verificationModel: verificationModel, conversationUIModel: conversationUIModel, - peerListModel: peerListModel + peerListModel: peerListModel, + boardAlertsModel: boardAlertsModel ) } @@ -106,6 +117,7 @@ private func installSmokeEnvironment( .environmentObject(featureModels.verificationModel) .environmentObject(featureModels.conversationUIModel) .environmentObject(featureModels.peerListModel) + .environmentObject(featureModels.boardAlertsModel) } @MainActor @@ -395,9 +407,17 @@ struct ViewSmokeTests { } @Test - func locationNotesView_rendersNoRelayAndLoadedStates() throws { - let (viewModel, _, _) = makeSmokeViewModel() + func noticesView_rendersNoRelayAndLoadedStates() throws { + let (viewModel, transport, _) = makeSmokeViewModel() let featureModels = makeSmokeFeatureModels(for: viewModel) + featureModels.locationChannelsModel.select(.location(GeohashChannel(level: .building, geohash: "u4pruydq"))) + defer { featureModels.locationChannelsModel.select(.mesh) } + let board = BoardManager( + transport: transport, + store: BoardStore(persistsToDisk: false, fileURL: nil, now: { Date() }), + publishToNostr: { _, _, _, _ in nil }, + deleteFromNostr: { _, _ in } + ) let noRelayManager = LocationNotesManager( geohash: "u4pruydq", @@ -440,18 +460,28 @@ struct ViewSmokeTests { eose?() _ = mount( - LocationNotesView( - geohash: "u4pruydq", + NoticesView( senderNickname: viewModel.nickname, - manager: noRelayManager + board: board, + initialTab: .geo, + notesManager: noRelayManager ) .environmentObject(featureModels.locationChannelsModel) ) _ = mount( - LocationNotesView( - geohash: "u4pruydq", + NoticesView( senderNickname: viewModel.nickname, - manager: loadedManager + board: board, + initialTab: .geo, + notesManager: loadedManager + ) + .environmentObject(featureModels.locationChannelsModel) + ) + _ = mount( + NoticesView( + senderNickname: viewModel.nickname, + board: board, + initialTab: .mesh ) .environmentObject(featureModels.locationChannelsModel) )