Add a persistent connectivity banner and stop the radar lying

Two "the app looks fine while it isn't" problems from the UX audit:

- Bluetooth-off had exactly one signal: a dismissible alert. Once
  dismissed, the app looked completely normal — empty timeline, radar
  sweeping "searching for people" — while the radio was off. The radar
  animation now requires a radio that can actually scan, and a
  persistent red banner under the header carries the state instead:
  off (tap → settings), denied (tap → settings), or unsupported
  (mesh unavailable; location channels still work).
- A Tor bootstrap stall was only ever announced inside geohash
  timelines (addGeohashOnlySystemMessage), so someone sitting in #mesh
  or a DM whose messages route over Nostr got silence and hanging
  "sent" states. The stall now also drives a chrome-level torBlocked
  flag and the same banner: "tor can't connect — internet features are
  paused. mesh still works."

The banner renders nothing when everything is healthy, and .unknown/
.resetting radio states deliberately stay quiet — a false "bluetooth is
off" flash at launch would be the same kind of lie. Priority and
quiet-start behavior are pinned by ConnectivityIssueTests. 4 new
strings, all 30 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-08-01 18:32:20 +03:00
parent 681c180060
commit 09b3f294ac
9 changed files with 945 additions and 11 deletions

View File

@ -17,6 +17,9 @@ final class AppChromeModel: ObservableObject {
@Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
/// Tor bootstrap has stalled (network likely blocks it); drives the
/// connectivity banner. Mirrored from `ChatViewModel.torBlocked`.
@Published private(set) var torBlocked = false
@Published var showScreenshotPrivacyWarning = false
private let chatViewModel: ChatViewModel
@ -145,6 +148,10 @@ final class AppChromeModel: ObservableObject {
.receive(on: DispatchQueue.main)
.assign(to: &$bluetoothState)
chatViewModel.$torBlocked
.receive(on: DispatchQueue.main)
.assign(to: &$torBlocked)
hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty
}
}

View File

@ -2047,6 +2047,750 @@
}
}
},
"content.banner.bluetooth_denied" : {
"comment" : "Persistent banner while Bluetooth permission is denied; tapping opens system settings",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "تم رفض الوصول إلى البلوتوث — الشبكة غير متصلة. انقر لفتح الإعدادات."
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "ব্লুটুথ অ্যাক্সেস অস্বীকৃত — মেশ অফলাইন। সেটিংস খুলতে ট্যাপ করুন।"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth-zugriff verweigert — mesh ist offline. tippen, um die einstellungen zu öffnen."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth access denied — mesh is offline. tap to open settings."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "acceso a bluetooth denegado — la malla está desconectada. toca para abrir ajustes."
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "دسترسی بلوتوث رد شد — مش آفلاین است. برای باز کردن تنظیمات بزنید."
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "tinanggihan ang access sa bluetooth — offline ang mesh. i-tap para buksan ang settings."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "accès bluetooth refusé — le mesh est hors ligne. touchez pour ouvrir les réglages."
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "הגישה לבלוטות' נדחתה — הרשת לא מקוונת. הקישו לפתיחת ההגדרות."
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "ब्लूटूथ की अनुमति नहीं है — मेश ऑफ़लाइन है। सेटिंग्स खोलने के लिए टैप करें।"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "akses bluetooth ditolak — mesh offline. ketuk untuk membuka pengaturan."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "accesso bluetooth negato — la mesh è offline. tocca per aprire le impostazioni."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth へのアクセスが拒否されています — メッシュはオフラインです。タップして設定を開きます。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "블루투스 접근이 거부되었습니다 — 메시가 오프라인입니다. 탭하여 설정을 여세요."
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "akses bluetooth ditolak — mesh di luar talian. ketik untuk membuka tetapan."
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "ब्लुटुथ पहुँच अस्वीकृत — मेश अफलाइन छ। सेटिङ खोल्न ट्याप गर्नुहोस्।"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth-toegang geweigerd — mesh is offline. tik om instellingen te openen."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "odmowa dostępu do bluetooth — sieć mesh jest offline. stuknij, aby otworzyć ustawienia."
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "acesso ao bluetooth negado — a mesh está offline. toca para abrir as definições."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "acesso ao bluetooth negado — a mesh está offline. toque para abrir os ajustes."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "доступ к bluetooth запрещён — mesh-сеть недоступна. нажмите, чтобы открыть настройки."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth-åtkomst nekad — mesh är offline. tryck för att öppna inställningar."
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "புளூடூத் அணுகல் மறுக்கப்பட்டது — மெஷ் ஆஃப்லைனில் உள்ளது. அமைப்புகளைத் திறக்கத் தட்டவும்."
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "การเข้าถึงบลูทูธถูกปฏิเสธ — เมชออฟไลน์ แตะเพื่อเปิดการตั้งค่า"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth erişimi reddedildi — mesh çevrimdışı. ayarları açmak için dokunun."
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "доступ до bluetooth заборонено — mesh-мережа офлайн. торкніться, щоб відкрити налаштування."
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "بلوٹوتھ رسائی مسترد — میش آف لائن ہے۔ سیٹنگز کھولنے کے لیے ٹیپ کریں۔"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "quyền truy cập bluetooth bị từ chối — mesh đang ngoại tuyến. chạm để mở cài đặt."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "蓝牙权限被拒绝 — 网状网络已离线。点按打开设置。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "藍牙權限遭拒 — 網狀網路已離線。點按開啟設定。"
}
}
}
},
"content.banner.bluetooth_off" : {
"comment" : "Persistent banner while Bluetooth is switched off; tapping opens system settings",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "البلوتوث مطفأ — لا يمكن لمن حولك الوصول إليك. انقر للإصلاح."
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "ব্লুটুথ বন্ধ — কাছের কেউ আপনার সঙ্গে যোগাযোগ করতে পারবে না। ঠিক করতে ট্যাপ করুন।"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth ist aus — niemand in der nähe kann dich erreichen. zum beheben tippen."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth is off — nobody nearby can reach you. tap to fix."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth está apagado — nadie cerca puede contactarte. toca para arreglarlo."
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "بلوتوث خاموش است — هیچ‌کس در نزدیکی نمی‌تواند به شما برسد. برای رفع مشکل بزنید."
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "naka-off ang bluetooth — walang makakaabot sa iyo na kalapit. i-tap para ayusin."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "le bluetooth est désactivé — personne à proximité ne peut vous joindre. touchez pour corriger."
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "הבלוטות' כבוי — אף אחד בסביבה לא יכול להגיע אליך. הקישו לתיקון."
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "ब्लूटूथ बंद है — आस-पास का कोई आप तक नहीं पहुँच सकता। ठीक करने के लिए टैप करें।"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth mati — tidak ada orang di sekitar yang dapat menghubungi anda. ketuk untuk memperbaiki."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "il bluetooth è spento — nessuno nelle vicinanze può raggiungerti. tocca per risolvere."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth がオフです — 近くの人と通信できません。タップして設定へ。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "블루투스가 꺼져 있습니다 — 주변 누구와도 연결할 수 없습니다. 탭하여 해결하세요."
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth dimatikan — tiada sesiapa berdekatan boleh menghubungi anda. ketik untuk membaiki."
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "ब्लुटुथ बन्द छ — नजिकको कोही तपाईंसम्म पुग्न सक्दैन। मिलाउन ट्याप गर्नुहोस्।"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth staat uit — niemand in de buurt kan je bereiken. tik om dit op te lossen."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth jest wyłączony — nikt w pobliżu nie może się z tobą połączyć. stuknij, aby naprawić."
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "o bluetooth está desligado — ninguém por perto te consegue alcançar. toca para corrigir."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "o bluetooth está desligado — ninguém por perto consegue te alcançar. toque para corrigir."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth выключен — никто поблизости не может с вами связаться. нажмите, чтобы исправить."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth är av — ingen i närheten kan nå dig. tryck för att åtgärda."
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "புளூடூத் அணைக்கப்பட்டுள்ளது — அருகிலுள்ள யாரும் உங்களை அடைய முடியாது. சரிசெய்யத் தட்டவும்."
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "บลูทูธปิดอยู่ — คนใกล้เคียงติดต่อคุณไม่ได้ แตะเพื่อแก้ไข"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth kapalı — yakınınızdaki hiç kimse size ulaşamaz. düzeltmek için dokunun."
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth вимкнено — ніхто поблизу не може з вами зв'язатися. торкніться, щоб виправити."
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "بلوٹوتھ بند ہے — قریب کا کوئی آپ تک نہیں پہنچ سکتا۔ ٹھیک کرنے کے لیے ٹیپ کریں۔"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "bluetooth đang tắt — không ai gần bạn có thể liên lạc với bạn. chạm để khắc phục."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "蓝牙已关闭 — 附近的人无法联系到你。点按以修复。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "藍牙已關閉 — 附近的人無法聯繫到你。點按以修復。"
}
}
}
},
"content.banner.bluetooth_unsupported" : {
"comment" : "Persistent banner on devices without Bluetooth support",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "لا يوجد بلوتوث على هذا الجهاز — الشبكة غير متاحة. قنوات الموقع لا تزال تعمل عبر الإنترنت."
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "এই ডিভাইসে ব্লুটুথ নেই — মেশ অনুপলব্ধ। লোকেশন চ্যানেল এখনও ইন্টারনেটে কাজ করে।"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "kein bluetooth auf diesem gerät — mesh ist nicht verfügbar. standortkanäle funktionieren weiter übers internet."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "no bluetooth on this device — mesh is unavailable. location channels still work over the internet."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "este dispositivo no tiene bluetooth — la malla no está disponible. los canales de ubicación siguen funcionando por internet."
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "این دستگاه بلوتوث ندارد — مش در دسترس نیست. کانال‌های مکان همچنان از طریق اینترنت کار می‌کنند."
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "walang bluetooth ang device na ito — hindi available ang mesh. gumagana pa rin ang mga location channel sa internet."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "pas de bluetooth sur cet appareil — le mesh est indisponible. les canaux de localisation fonctionnent toujours via internet."
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "אין בלוטות' במכשיר הזה — הרשת לא זמינה. ערוצי מיקום עדיין עובדים דרך האינטרנט."
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "इस डिवाइस में ब्लूटूथ नहीं है — मेश अनुपलब्ध है। लोकेशन चैनल अब भी इंटरनेट पर काम करते हैं।"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "perangkat ini tidak memiliki bluetooth — mesh tidak tersedia. kanal lokasi tetap berfungsi lewat internet."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "questo dispositivo non ha il bluetooth — la mesh non è disponibile. i canali di posizione funzionano comunque via internet."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "この端末に bluetooth がありません — メッシュは利用できません。ロケーションチャンネルはインターネット経由で利用できます。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "이 기기에는 블루투스가 없습니다 — 메시를 사용할 수 없습니다. 위치 채널은 인터넷으로 계속 사용할 수 있습니다."
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "tiada bluetooth pada peranti ini — mesh tidak tersedia. saluran lokasi masih berfungsi melalui internet."
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "यो यन्त्रमा ब्लुटुथ छैन — मेश उपलब्ध छैन। स्थान च्यानलहरू अझै इन्टरनेटमा चल्छन्।"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "geen bluetooth op dit apparaat — mesh is niet beschikbaar. locatiekanalen werken nog via internet."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "brak bluetooth w tym urządzeniu — sieć mesh jest niedostępna. kanały lokalizacji nadal działają przez internet."
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "este dispositivo não tem bluetooth — a mesh está indisponível. os canais de localização continuam a funcionar pela internet."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "este dispositivo não tem bluetooth — a mesh está indisponível. os canais de localização continuam funcionando pela internet."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "на этом устройстве нет bluetooth — mesh-сеть недоступна. каналы мест по-прежнему работают через интернет."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "ingen bluetooth på den här enheten — mesh är inte tillgängligt. platskanaler fungerar fortfarande via internet."
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "இந்தச் சாதனத்தில் புளூடூத் இல்லை — மெஷ் கிடைக்காது. இருப்பிடச் சேனல்கள் இணையம் வழியாக இயங்கும்."
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "อุปกรณ์นี้ไม่มีบลูทูธ — ใช้เมชไม่ได้ ช่องตำแหน่งยังใช้งานผ่านอินเทอร์เน็ตได้"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "bu cihazda bluetooth yok — mesh kullanılamıyor. konum kanalları internet üzerinden çalışmaya devam ediyor."
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "на цьому пристрої немає bluetooth — mesh-мережа недоступна. канали місць далі працюють через інтернет."
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "اس ڈیوائس میں بلوٹوتھ نہیں — میش دستیاب نہیں۔ لوکیشن چینلز انٹرنیٹ پر بدستور کام کرتے ہیں۔"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "thiết bị này không có bluetooth — mesh không khả dụng. các kênh vị trí vẫn hoạt động qua internet."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "此设备没有蓝牙 — 网状网络不可用。位置频道仍可通过互联网使用。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "此裝置沒有藍牙 — 網狀網路無法使用。位置頻道仍可透過網際網路使用。"
}
}
}
},
"content.banner.tor_blocked" : {
"comment" : "Persistent banner while Tor bootstrap has stalled, likely because the network blocks it",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "يتعذر على tor الاتصال — ميزات الإنترنت متوقفة مؤقتًا. الشبكة لا تزال تعمل."
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor সংযোগ করতে পারছে না — ইন্টারনেট ফিচার স্থগিত। মেশ এখনও কাজ করছে।"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor kann sich nicht verbinden — internetfunktionen pausieren. mesh funktioniert weiter."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor can't connect — internet features are paused. mesh still works."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor no puede conectarse — las funciones de internet están en pausa. la malla sigue funcionando."
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor نمی‌تواند متصل شود — قابلیت‌های اینترنتی متوقف‌اند. مش همچنان کار می‌کند."
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "hindi makakonekta ang tor — naka-pause ang mga internet feature. gumagana pa rin ang mesh."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor ne peut pas se connecter — les fonctions internet sont en pause. le mesh fonctionne toujours."
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor לא מצליח להתחבר — תכונות האינטרנט מושהות. הרשת עדיין עובדת."
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor कनेक्ट नहीं हो पा रहा — इंटरनेट सुविधाएँ रुकी हैं। मेश अब भी काम करता है।"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor tidak dapat terhubung — fitur internet dijeda. mesh tetap berfungsi."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor non riesce a connettersi — le funzioni internet sono in pausa. la mesh continua a funzionare."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor が接続できません — インターネット機能は一時停止中です。メッシュは引き続き使えます。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor가 연결되지 않습니다 — 인터넷 기능이 일시 중지되었습니다. 메시는 계속 작동합니다."
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor tidak dapat bersambung — ciri internet dijeda. mesh masih berfungsi."
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor जडान हुन सकेन — इन्टरनेट सुविधाहरू रोकिएका छन्। मेश अझै चल्छ।"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor kan geen verbinding maken — internetfuncties zijn gepauzeerd. mesh werkt nog."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor nie może się połączyć — funkcje internetowe są wstrzymane. sieć mesh nadal działa."
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "o tor não consegue ligar-se — as funções de internet estão em pausa. a mesh continua a funcionar."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "o tor não consegue se conectar — os recursos de internet estão pausados. a mesh continua funcionando."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor не может подключиться — интернет-функции приостановлены. mesh-сеть продолжает работать."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor kan inte ansluta — internetfunktioner är pausade. mesh fungerar fortfarande."
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor இணைக்க முடியவில்லை — இணைய அம்சங்கள் இடைநிறுத்தப்பட்டுள்ளன. மெஷ் தொடர்ந்து இயங்குகிறது."
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor เชื่อมต่อไม่ได้ — ฟีเจอร์อินเทอร์เน็ตหยุดชั่วคราว เมชยังใช้งานได้"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor bağlanamıyor — internet özellikleri duraklatıldı. mesh çalışmaya devam ediyor."
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor не може під'єднатися — інтернет-функції призупинено. mesh-мережа далі працює."
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor منسلک نہیں ہو پا رہا — انٹرنیٹ فیچرز رکے ہوئے ہیں۔ میش بدستور کام کرتا ہے۔"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor không thể kết nối — các tính năng internet tạm dừng. mesh vẫn hoạt động."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor 无法连接 — 互联网功能已暂停。网状网络仍可使用。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "tor 無法連線 — 網際網路功能已暫停。網狀網路仍可使用。"
}
}
}
},
"content.system.media_delete_refused" : {
"comment" : "System message shown in the affected chat when an explicit media delete or /clear was refused and bubbles/files were kept",
"extractionState" : "manual",

View File

@ -367,6 +367,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
var torRestartPending: Bool = false
// Announce a stalled bootstrap once per attempt, not once per poll.
var torStallAnnounced: Bool = false
// Live "tor is blocked" state for the connectivity banner. The system
// message above only reaches geohash timelines; this is the chrome-level
// signal that stays up until tor actually gets through.
@Published var torBlocked: Bool = false
// Ensure we set up DM subscription only once per app session
var nostrHandlersSetup: Bool = false
var geoChannelCoordinator: GeoChannelCoordinator?

View File

@ -40,6 +40,7 @@ extension ChatViewModel {
@objc func handleTorDidBecomeReady() {
Task { @MainActor in
self.torStallAnnounced = false
self.torBlocked = false
// Only announce "restarted" if we actually restarted this session
if self.torRestartPending {
// Post only in geohash channels (queue if not active)
@ -68,6 +69,7 @@ extension ChatViewModel {
// runtime preference is what says whether anyone is waiting on
// Tor. Turning Tor off mid-bootstrap must not read as blocking.
guard NetworkActivationService.persistedTorPreference() else { return }
self.torBlocked = true
guard !self.torStallAnnounced else { return }
self.torStallAnnounced = true
self.addGeohashOnlySystemMessage(
@ -86,6 +88,9 @@ extension ChatViewModel {
self.torInitialReadyAnnounced = false
self.torRestartPending = false
self.torStallAnnounced = false
// Turning tor off means nobody is waiting on it; turning it on
// starts a fresh attempt. Either way the stall banner resets.
self.torBlocked = false
}
}
}

View File

@ -0,0 +1,113 @@
//
// ConnectivityStatusBanner.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CoreBluetooth
import SwiftUI
/// The degraded state a banner should surface, in priority order. Bluetooth
/// outranks tor: without the radio the app's core promise (mesh) is dead,
/// while a tor stall only pauses internet features.
enum ConnectivityIssue: Equatable {
case bluetoothOff
case bluetoothDenied
case bluetoothUnsupported
case torBlocked
/// Pure resolution so the banner's priority logic is testable without a
/// view. `.unknown`/`.resetting` deliberately resolve to nil the radio
/// is still starting and a flash of "bluetooth is off" at launch would
/// be the same false signal this banner exists to prevent.
static func resolve(bluetoothState: CBManagerState, torBlocked: Bool) -> ConnectivityIssue? {
switch bluetoothState {
case .poweredOff: return .bluetoothOff
case .unauthorized: return .bluetoothDenied
case .unsupported: return .bluetoothUnsupported
case .poweredOn, .unknown, .resetting: break
@unknown default: break
}
return torBlocked ? .torBlocked : nil
}
}
/// Persistent one-line banner under the header while connectivity is
/// degraded. The Bluetooth alert is a one-shot modal once dismissed, the
/// app used to look completely normal (empty timeline, radar sweeping)
/// while the radio was off. Tor stalls were only ever announced inside
/// geohash timelines. This banner is the always-visible truth; it renders
/// nothing when everything is fine.
struct ConnectivityStatusBanner: View {
let issue: ConnectivityIssue
@ThemedPalette private var palette
private var message: String {
switch issue {
case .bluetoothOff:
return String(
localized: "content.banner.bluetooth_off",
defaultValue: "bluetooth is off — nobody nearby can reach you. tap to fix.",
comment: "Persistent banner while Bluetooth is switched off; tapping opens system settings"
)
case .bluetoothDenied:
return String(
localized: "content.banner.bluetooth_denied",
defaultValue: "bluetooth access denied — mesh is offline. tap to open settings.",
comment: "Persistent banner while Bluetooth permission is denied; tapping opens system settings"
)
case .bluetoothUnsupported:
return String(
localized: "content.banner.bluetooth_unsupported",
defaultValue: "no bluetooth on this device — mesh is unavailable. location channels still work over the internet.",
comment: "Persistent banner on devices without Bluetooth support"
)
case .torBlocked:
return String(
localized: "content.banner.tor_blocked",
defaultValue: "tor can't connect — internet features are paused. mesh still works.",
comment: "Persistent banner while Tor bootstrap has stalled, likely because the network blocks it"
)
}
}
/// Bluetooth problems deep-link to settings; the tor stall has no
/// in-app remedy (the network is blocking it), so that banner is inert.
private var opensSettings: Bool {
switch issue {
case .bluetoothOff, .bluetoothDenied: return true
case .bluetoothUnsupported, .torBlocked: return false
}
}
var body: some View {
Group {
if opensSettings {
Button(action: { SystemSettings.bluetooth.open() }) {
label
}
.buttonStyle(.plain)
} else {
label
}
}
}
private var label: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: issue == .torBlocked ? "network.slash" : "antenna.radiowaves.left.and.right.slash")
.bitchatFont(size: 11, weight: .semibold)
Text(message)
.bitchatFont(size: 11)
.fixedSize(horizontal: false, vertical: true)
}
.foregroundColor(palette.alertRed)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.red.opacity(0.12))
.accessibilityElement(children: .combine)
}
}

View File

@ -19,6 +19,10 @@ struct MeshEmptyStateView: View {
/// intro/help narration (the timeline isn't empty) and shrinks the
/// radar, keeping the sightings tally and the live hints visible.
var compact: Bool = false
/// Whether the radio can actually scan. With Bluetooth off or denied the
/// sweep must not run an animated "searching" over a dead radio is an
/// actively false status display. Defaults to true for previews.
var bluetoothAvailable: Bool = true
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@ -69,8 +73,12 @@ struct MeshEmptyStateView: View {
/// The radar means "searching for people": once anyone is connected or
/// reachable on the mesh, the search is over and the sweep goes away.
/// And it only means that while the radio is actually on the
/// connectivity banner carries the message when it isn't.
private var isSearchingForPeers: Bool {
peerListModel.connectedMeshPeerCount == 0 && peerListModel.reachableMeshPeerCount == 0
bluetoothAvailable
&& peerListModel.connectedMeshPeerCount == 0
&& peerListModel.reachableMeshPeerCount == 0
}
var body: some View {

View File

@ -489,14 +489,23 @@ struct ContentView: View {
}
private var headerView: some View {
ContentHeaderView(
showSidebar: $showSidebar,
showVerifySheet: $showVerifySheet,
isNicknameFieldFocused: $isNicknameFieldFocused,
headerHeight: headerHeight,
headerPeerIconSize: headerPeerIconSize,
headerPeerCountFontSize: headerPeerCountFontSize
)
VStack(spacing: 0) {
ContentHeaderView(
showSidebar: $showSidebar,
showVerifySheet: $showVerifySheet,
isNicknameFieldFocused: $isNicknameFieldFocused,
headerHeight: headerHeight,
headerPeerIconSize: headerPeerIconSize,
headerPeerCountFontSize: headerPeerCountFontSize
)
if let issue = ConnectivityIssue.resolve(
bluetoothState: appChromeModel.bluetoothState,
torBlocked: appChromeModel.torBlocked
) {
ConnectivityStatusBanner(issue: issue)
}
}
}
private var publicMessageList: some View {

View File

@ -6,6 +6,7 @@
//
import BitFoundation
import CoreBluetooth
import SwiftUI
private struct MessageDisplayItem: Identifiable {
@ -176,7 +177,7 @@ struct MessageListView: View {
// sightings, live hints) stays visible below it instead of
// vanishing the moment echoes exist.
if privatePeer == nil, showsAmbientFooter(messageItems: messageItems) {
MeshEmptyStateView(compact: true)
MeshEmptyStateView(compact: true, bluetoothAvailable: bluetoothCanScan)
.padding(.horizontal, 12)
.padding(.top, 20)
.padding(.bottom, 8)
@ -296,6 +297,15 @@ struct MessageListView: View {
}
private extension MessageListView {
/// Whether the radio could be scanning at all the empty-state radar
/// must not sweep over a switched-off or denied radio.
var bluetoothCanScan: Bool {
switch appChromeModel.bluetoothState {
case .poweredOff, .unauthorized, .unsupported: return false
default: return true
}
}
var currentContextKey: String {
if let peer = privatePeer {
return "dm:\(peer)"
@ -373,7 +383,10 @@ private extension MessageListView {
VStack(alignment: .leading, spacing: 6) {
switch locationChannelsModel.selectedChannel {
case .mesh:
MeshEmptyStateView(fillHeight: max(0, fillHeight - 24))
MeshEmptyStateView(
fillHeight: max(0, fillHeight - 24),
bluetoothAvailable: bluetoothCanScan
)
case .location(let channel):
emptyStateLine(
String(

View File

@ -0,0 +1,31 @@
//
// ConnectivityStatusTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CoreBluetooth
import Testing
@testable import bitchat
struct ConnectivityIssueTests {
@Test("Bluetooth problems outrank a tor stall; healthy state shows nothing")
func resolvePrioritizesBluetoothOverTor() {
#expect(ConnectivityIssue.resolve(bluetoothState: .poweredOff, torBlocked: true) == .bluetoothOff)
#expect(ConnectivityIssue.resolve(bluetoothState: .unauthorized, torBlocked: false) == .bluetoothDenied)
#expect(ConnectivityIssue.resolve(bluetoothState: .unsupported, torBlocked: false) == .bluetoothUnsupported)
#expect(ConnectivityIssue.resolve(bluetoothState: .poweredOn, torBlocked: true) == .torBlocked)
#expect(ConnectivityIssue.resolve(bluetoothState: .poweredOn, torBlocked: false) == nil)
}
@Test("A starting radio must not flash a false 'bluetooth is off' banner")
func resolveStaysQuietWhileRadioIsStarting() {
#expect(ConnectivityIssue.resolve(bluetoothState: .unknown, torBlocked: false) == nil)
#expect(ConnectivityIssue.resolve(bluetoothState: .resetting, torBlocked: false) == nil)
// A tor stall still surfaces once known, even while the radio starts.
#expect(ConnectivityIssue.resolve(bluetoothState: .unknown, torBlocked: true) == .torBlocked)
}
}