mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-22 07:16:03 +00:00
Add a persistent connectivity banner and stop the radar lying (#1597)
* 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> * 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 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
66fdea52ae
commit
5b592c8bae
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
116
bitchat/Views/Components/ConnectivityStatusBanner.swift
Normal file
116
bitchat/Views/Components/ConnectivityStatusBanner.swift
Normal file
@ -0,0 +1,116 @@
|
||||
//
|
||||
// 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 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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
31
bitchatTests/ConnectivityStatusTests.swift
Normal file
31
bitchatTests/ConnectivityStatusTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user