From 5b592c8bae5d692abe56fc309362470604abf25e Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:47:53 +0200 Subject: [PATCH] Add a persistent connectivity banner and stop the radar lying (#1597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * Review fixes: banner in the sheet, real fix targets, tor-clear tests - The people/DM sheet covers the root header, so the connectivity banner vanished exactly where people sit longest. It now mirrors into the sheet via a top safe-area inset over both the people list and the DM view. - "tap to fix" for powered-off Bluetooth opened the macOS privacy permission pane, where an already-authorized person can't fix anything. New SystemSettings.bluetoothPower routes to the Bluetooth pane; denied still goes to the privacy anchor. The one-shot alert's settings button routes by state the same way. - Tests pin that torBlocked clears on tor-ready and on preference change (no stale "tor can't connect" after toggling tor off). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/AppChromeModel.swift | 7 + bitchat/Localizable.xcstrings | 744 ++++++++++++++++++ bitchat/Utils/SystemSettings.swift | 16 +- bitchat/ViewModels/ChatViewModel.swift | 4 + .../Extensions/ChatViewModel+Tor.swift | 5 + .../Components/ConnectivityStatusBanner.swift | 116 +++ .../Views/Components/MeshEmptyStateView.swift | 10 +- bitchat/Views/ContentSheetViews.swift | 17 +- bitchat/Views/ContentView.swift | 14 +- bitchat/Views/MessageListView.swift | 17 +- bitchatTests/ChatViewModelTorTests.swift | 21 +- bitchatTests/ConnectivityStatusTests.swift | 31 + 12 files changed, 990 insertions(+), 12 deletions(-) create mode 100644 bitchat/Views/Components/ConnectivityStatusBanner.swift create mode 100644 bitchatTests/ConnectivityStatusTests.swift diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 8d369c5b..51cadfb0 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -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 /// Triple-tapping the logo asks first; the dialog lives on the header. @Published var showPanicConfirmation = false @@ -168,6 +171,10 @@ final class AppChromeModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$bluetoothState) + chatViewModel.$torBlocked + .receive(on: DispatchQueue.main) + .assign(to: &$torBlocked) + chatViewModel.$panicRecoveryBlocked .receive(on: DispatchQueue.main) .assign(to: &$panicWipeBlocked) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 7f393629..48d05c3a 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -2419,6 +2419,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.banner.panic_blocked" : { "comment" : "Banner shown when a panic wipe did not fully commit; relaunching the app retries the wipe", "extractionState" : "manual", diff --git a/bitchat/Utils/SystemSettings.swift b/bitchat/Utils/SystemSettings.swift index ab552e07..2a739ea9 100644 --- a/bitchat/Utils/SystemSettings.swift +++ b/bitchat/Utils/SystemSettings.swift @@ -14,17 +14,22 @@ import AppKit enum SystemSettings { case bluetooth + /// The radio power toggle, distinct from the `.bluetooth` privacy + /// permission anchor: an already-authorized person whose radio is + /// switched off can't fix anything from the privacy pane. + case bluetoothPower case location case microphone #if os(macOS) private static let baseURL = "x-apple.systempreferences:com.apple.preference.security" - private var macPrivacyAnchor: String { + private var macURLString: String { switch self { - case .bluetooth: "Privacy_Bluetooth" - case .location: "Privacy_LocationServices" - case .microphone: "Privacy_Microphone" + case .bluetooth: "\(Self.baseURL)?Privacy_Bluetooth" + case .bluetoothPower: "x-apple.systempreferences:com.apple.BluetoothSettings" + case .location: "\(Self.baseURL)?Privacy_LocationServices" + case .microphone: "\(Self.baseURL)?Privacy_Microphone" } } #endif @@ -35,8 +40,7 @@ enum SystemSettings { UIApplication.shared.open(url) } #elseif os(macOS) - let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)" - if let url = URL(string: urlString) { + if let url = URL(string: macURLString) { NSWorkspace.shared.open(url) } #endif diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 7dd3dd8f..63fba0f8 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -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? diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift index 3bf7a5a1..3c1f9005 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift @@ -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 } } } diff --git a/bitchat/Views/Components/ConnectivityStatusBanner.swift b/bitchat/Views/Components/ConnectivityStatusBanner.swift new file mode 100644 index 00000000..4caedccf --- /dev/null +++ b/bitchat/Views/Components/ConnectivityStatusBanner.swift @@ -0,0 +1,116 @@ +// +// ConnectivityStatusBanner.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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 where the fix actually lives — + /// powered-off goes to the radio controls, denied to the privacy + /// permission pane. The tor stall has no in-app remedy (the network is + /// blocking it), so that banner is inert. + private var settingsDestination: SystemSettings? { + switch issue { + case .bluetoothOff: return .bluetoothPower + case .bluetoothDenied: return .bluetooth + case .bluetoothUnsupported, .torBlocked: return nil + } + } + + var body: some View { + Group { + if let destination = settingsDestination { + Button(action: { destination.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) + } +} diff --git a/bitchat/Views/Components/MeshEmptyStateView.swift b/bitchat/Views/Components/MeshEmptyStateView.swift index a244d5f1..b225927d 100644 --- a/bitchat/Views/Components/MeshEmptyStateView.swift +++ b/bitchat/Views/Components/MeshEmptyStateView.swift @@ -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 { diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 18c0f148..cf62debb 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -183,6 +183,18 @@ struct ContentPeopleSheetView: View { .environmentObject(verificationModel) } } + // This sheet covers the root header where the connectivity + // banner lives; a person sitting in the people list or a DM + // would otherwise get no persistent signal that the radio is + // off or tor is stalled. Mirror it here. + .safeAreaInset(edge: .top, spacing: 0) { + if let issue = ConnectivityIssue.resolve( + bluetoothState: appChromeModel.bluetoothState, + torBlocked: appChromeModel.torBlocked + ) { + ConnectivityStatusBanner(issue: issue) + } + } } .themedSheetBackground() .foregroundColor(palette.primary) @@ -285,7 +297,10 @@ struct ContentPeopleSheetView: View { isPresented: bluetoothAlertBinding ) { Button("content.alert.bluetooth_required.settings") { - SystemSettings.bluetooth.open() + // Powered-off needs the radio controls, not the privacy pane. + (appChromeModel.bluetoothState == .poweredOff + ? SystemSettings.bluetoothPower + : SystemSettings.bluetooth).open() } Button("common.ok", role: .cancel) {} } message: { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 6ef956ed..48d353c3 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -409,7 +409,10 @@ struct ContentView: View { }) .alert("content.alert.bluetooth_required.title", isPresented: rootBluetoothAlertBinding) { Button("content.alert.bluetooth_required.settings") { - SystemSettings.bluetooth.open() + // Powered-off needs the radio controls, not the privacy pane. + (appChromeModel.bluetoothState == .poweredOff + ? SystemSettings.bluetoothPower + : SystemSettings.bluetooth).open() } Button("common.ok", role: .cancel) {} } message: { @@ -499,9 +502,18 @@ struct ContentView: View { headerPeerCountFontSize: headerPeerCountFontSize ) + // Failed-wipe outranks connectivity: "your data may still be + // here" matters more than "the radio is off". if appChromeModel.panicWipeBlocked { PanicWipeBlockedBanner() } + + if let issue = ConnectivityIssue.resolve( + bluetoothState: appChromeModel.bluetoothState, + torBlocked: appChromeModel.torBlocked + ) { + ConnectivityStatusBanner(issue: issue) + } } // Hosted here rather than on the logo Text so the pending dialog // survives whatever happens to the header chrome. diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index 6e1c6b95..1894a347 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -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( diff --git a/bitchatTests/ChatViewModelTorTests.swift b/bitchatTests/ChatViewModelTorTests.swift index d4697dd8..d5eba112 100644 --- a/bitchatTests/ChatViewModelTorTests.swift +++ b/bitchatTests/ChatViewModelTorTests.swift @@ -165,14 +165,33 @@ struct ChatViewModelTorTests { viewModel.torStatusAnnounced = true viewModel.torInitialReadyAnnounced = true viewModel.torRestartPending = true + viewModel.torBlocked = true // Action viewModel.handleTorPreferenceChanged(Notification(name: .init("test"))) try? await Task.sleep(nanoseconds: 100_000_000) - // Assert: all flags reset + // Assert: all flags reset — torBlocked drives the connectivity + // banner; toggling tor off must not leave a stale "tor can't + // connect" line for a network nobody is waiting on. #expect(!viewModel.torStatusAnnounced) #expect(!viewModel.torInitialReadyAnnounced) #expect(!viewModel.torRestartPending) + #expect(!viewModel.torBlocked) + } + + @Test @MainActor + func handleTorDidBecomeReady_clearsBlockedBanner() async { + let (viewModel, _) = makeTestableViewModel() + + // Setup: a stalled bootstrap raised the banner, then tor got through. + viewModel.torBlocked = true + + // Action + viewModel.handleTorDidBecomeReady() + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert + #expect(!viewModel.torBlocked) } } diff --git a/bitchatTests/ConnectivityStatusTests.swift b/bitchatTests/ConnectivityStatusTests.swift new file mode 100644 index 00000000..93ecc2fb --- /dev/null +++ b/bitchatTests/ConnectivityStatusTests.swift @@ -0,0 +1,31 @@ +// +// ConnectivityStatusTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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) + } +}