mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:25:19 +00:00
Require review before importing shared content
This commit is contained in:
+1
-1
@@ -22,7 +22,7 @@ bitchat is designed for private, account-free communication. This policy describ
|
||||
|
||||
2. **Nickname, preferences, and relationships**
|
||||
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
||||
- The share extension briefly places content you choose to share in the app-group preferences so the main app can import it.
|
||||
- The share extension can retain one item you choose to share in the app-group preferences for up to 24 hours. The app shows the destination and a preview for review; it does not send the item automatically. The item is cleared when you add it to the composer, cancel, panic-wipe, or it expires.
|
||||
|
||||
3. **Private group state**
|
||||
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support.
|
||||
|
||||
Generated
+1
@@ -70,6 +70,7 @@
|
||||
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Services/SharedContentHandoff.swift,
|
||||
Services/TransportConfig.swift,
|
||||
);
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
|
||||
@@ -2,11 +2,6 @@ import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum SharedContentKind: String, Sendable, Equatable {
|
||||
case text
|
||||
case url
|
||||
}
|
||||
|
||||
enum RuntimeScenePhase: String, Sendable, Equatable {
|
||||
case active
|
||||
case inactive
|
||||
@@ -25,7 +20,7 @@ enum AppEvent: Sendable, Equatable {
|
||||
case startupCompleted
|
||||
case scenePhaseChanged(RuntimeScenePhase)
|
||||
case openedURL(String)
|
||||
case sharedContentAccepted(SharedContentKind)
|
||||
case sharedContentReadyForReview(SharedContentKind)
|
||||
case notificationOpened(peerID: PeerID?)
|
||||
case deepLinkOpened(String)
|
||||
case torLifecycleChanged(TorLifecycleEvent)
|
||||
|
||||
@@ -20,13 +20,19 @@ final class AppChromeModel: ObservableObject {
|
||||
@Published var showScreenshotPrivacyWarning = false
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let onPanicWipe: () -> Void
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||
|
||||
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
privateInboxModel: PrivateInboxModel,
|
||||
onPanicWipe: @escaping () -> Void = {}
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.onPanicWipe = onPanicWipe
|
||||
self.nickname = chatViewModel.nickname
|
||||
|
||||
bind(privateInboxModel: privateInboxModel)
|
||||
@@ -98,6 +104,7 @@ final class AppChromeModel: ObservableObject {
|
||||
}
|
||||
|
||||
func panicClearAllData() {
|
||||
onPanicWipe()
|
||||
chatViewModel.panicClearAllData()
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ final class AppRuntime: ObservableObject {
|
||||
let peerListModel: PeerListModel
|
||||
let appChromeModel: AppChromeModel
|
||||
let boardAlertsModel: BoardAlertsModel
|
||||
let sharedContentImportModel: SharedContentImportModel
|
||||
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
@@ -41,7 +42,8 @@ final class AppRuntime: ObservableObject {
|
||||
|
||||
init(
|
||||
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
|
||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||
idBridge: NostrIdentityBridge = NostrIdentityBridge(),
|
||||
sharedContentStore: SharedContentStore? = nil
|
||||
) {
|
||||
self.idBridge = idBridge
|
||||
let conversations = ConversationStore()
|
||||
@@ -84,9 +86,20 @@ final class AppRuntime: ObservableObject {
|
||||
peerIdentityStore: peerIdentityStore,
|
||||
locationPresenceStore: locationPresenceStore
|
||||
)
|
||||
let resolvedSharedContentStore: SharedContentStore?
|
||||
if let sharedContentStore {
|
||||
resolvedSharedContentStore = sharedContentStore
|
||||
} else if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) {
|
||||
resolvedSharedContentStore = SharedContentStore(defaults: sharedDefaults)
|
||||
} else {
|
||||
resolvedSharedContentStore = nil
|
||||
}
|
||||
let sharedContentImportModel = SharedContentImportModel(store: resolvedSharedContentStore)
|
||||
self.sharedContentImportModel = sharedContentImportModel
|
||||
self.appChromeModel = AppChromeModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
privateInboxModel: self.privateInboxModel
|
||||
privateInboxModel: self.privateInboxModel,
|
||||
onPanicWipe: { sharedContentImportModel.discardAll() }
|
||||
)
|
||||
let chatViewModel = self.chatViewModel
|
||||
self.boardAlertsModel = BoardAlertsModel(
|
||||
@@ -106,7 +119,6 @@ final class AppRuntime: ObservableObject {
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
bindRuntimeObservers()
|
||||
NotificationDelegate.shared.runtime = self
|
||||
@@ -313,44 +325,22 @@ private extension AppRuntime {
|
||||
}
|
||||
|
||||
func checkForSharedContent() {
|
||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
||||
let clearSharedContent = {
|
||||
userDefaults.removeObject(forKey: "sharedContent")
|
||||
userDefaults.removeObject(forKey: "sharedContentType")
|
||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||
let previousID = sharedContentImportModel.offer?.id
|
||||
guard let payload = sharedContentImportModel.refresh(
|
||||
destination: currentSharedContentDestination
|
||||
) else { return }
|
||||
|
||||
if previousID != payload.id {
|
||||
record(.sharedContentReadyForReview(payload.kind))
|
||||
}
|
||||
}
|
||||
|
||||
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
||||
// A partial or malformed handoff must not linger in the shared
|
||||
// app-group container indefinitely.
|
||||
clearSharedContent()
|
||||
return
|
||||
}
|
||||
|
||||
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
||||
clearSharedContent()
|
||||
return
|
||||
}
|
||||
|
||||
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
||||
|
||||
clearSharedContent()
|
||||
|
||||
switch contentKind {
|
||||
case .url:
|
||||
if let data = sharedContent.data(using: .utf8),
|
||||
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||
let url = urlData["url"] {
|
||||
chatViewModel.sendMessage(url)
|
||||
} else {
|
||||
chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
case .text:
|
||||
chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
|
||||
record(.sharedContentAccepted(contentKind))
|
||||
var currentSharedContentDestination: SharedContentDestination {
|
||||
SharedContentDestination.resolve(
|
||||
selectedPrivatePeerID: privateConversationModel.selectedPeerID,
|
||||
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
|
||||
activeChannel: locationChannelsModel.selectedChannel
|
||||
)
|
||||
}
|
||||
|
||||
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum SharedContentDestination: Sendable, Equatable {
|
||||
case mesh
|
||||
case geohash(String)
|
||||
case privateConversation(peerID: PeerID, displayName: String)
|
||||
|
||||
static func resolve(
|
||||
selectedPrivatePeerID: PeerID?,
|
||||
privateDisplayName: String?,
|
||||
activeChannel: ChannelID
|
||||
) -> SharedContentDestination {
|
||||
if let selectedPrivatePeerID {
|
||||
let fallback = String(selectedPrivatePeerID.id.prefix(12))
|
||||
return .privateConversation(
|
||||
peerID: selectedPrivatePeerID,
|
||||
displayName: privateDisplayName?.trimmedOrNilIfEmpty ?? fallback
|
||||
)
|
||||
}
|
||||
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
return .mesh
|
||||
case .location(let channel):
|
||||
return .geohash(channel.geohash.lowercased())
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .mesh:
|
||||
return "#mesh"
|
||||
case .geohash(let geohash):
|
||||
return "#\(geohash)"
|
||||
case .privateConversation(_, let displayName):
|
||||
return displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedContentOffer: Identifiable, Sendable, Equatable {
|
||||
let payload: SharedContentPayload
|
||||
let destination: SharedContentDestination
|
||||
|
||||
var id: UUID { payload.id }
|
||||
}
|
||||
|
||||
/// Holds a pending extension handoff until the user chooses a destination and
|
||||
/// explicitly adds it to the composer. This type has no send dependency by
|
||||
/// design: confirming an import can never transmit a message.
|
||||
@MainActor
|
||||
final class SharedContentImportModel: ObservableObject {
|
||||
@Published private(set) var offer: SharedContentOffer?
|
||||
|
||||
private let store: SharedContentStore?
|
||||
|
||||
init(store: SharedContentStore?) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func refresh(
|
||||
destination: SharedContentDestination,
|
||||
now: Date = Date()
|
||||
) -> SharedContentPayload? {
|
||||
guard let payload = store?.pending(now: now) else {
|
||||
offer = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let nextOffer = SharedContentOffer(payload: payload, destination: destination)
|
||||
if offer != nextOffer {
|
||||
offer = nextOffer
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func updateDestination(_ destination: SharedContentDestination) {
|
||||
guard let offer, offer.destination != destination else { return }
|
||||
self.offer = SharedContentOffer(payload: offer.payload, destination: destination)
|
||||
}
|
||||
|
||||
/// Returns composer text only when the currently displayed destination is
|
||||
/// still current and the reviewed envelope is still the stored envelope.
|
||||
/// A destination change updates the prompt and requires another tap.
|
||||
func confirm(
|
||||
destination: SharedContentDestination,
|
||||
now: Date = Date()
|
||||
) -> String? {
|
||||
guard let offer else { return nil }
|
||||
guard offer.destination == destination else {
|
||||
updateDestination(destination)
|
||||
return nil
|
||||
}
|
||||
guard let payload = store?.consume(id: offer.id, now: now) else {
|
||||
_ = refresh(destination: destination, now: now)
|
||||
return nil
|
||||
}
|
||||
|
||||
self.offer = nil
|
||||
return payload.composerText
|
||||
}
|
||||
|
||||
func cancel(destination: SharedContentDestination, now: Date = Date()) {
|
||||
guard let offer else { return }
|
||||
store?.discard(id: offer.id)
|
||||
self.offer = nil
|
||||
// If a newer share replaced the reviewed envelope, surface it rather
|
||||
// than losing it with the older cancellation.
|
||||
_ = refresh(destination: destination, now: now)
|
||||
}
|
||||
|
||||
func discardAll() {
|
||||
store?.discardAll()
|
||||
offer = nil
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ struct BitchatApp: App {
|
||||
.environmentObject(runtime.peerListModel)
|
||||
.environmentObject(runtime.appChromeModel)
|
||||
.environmentObject(runtime.boardAlertsModel)
|
||||
.environmentObject(runtime.sharedContentImportModel)
|
||||
.onAppear {
|
||||
appDelegate.runtime = runtime
|
||||
runtime.start()
|
||||
|
||||
@@ -69564,6 +69564,111 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share_import.review.message" : {
|
||||
"comment" : "Explains that shared content replaces the named destination's composer and is not sent automatically",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "هل تريد استبدال مسودة %@ بهذا المحتوى؟ لن يتم إرسال أي شيء تلقائيًا." } },
|
||||
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "%@-এর খসড়াটি এই কনটেন্ট দিয়ে বদলাবেন? কিছুই স্বয়ংক্রিয়ভাবে পাঠানো হবে না।" } },
|
||||
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Entwurf für %@ durch diesen Inhalt ersetzen? Es wird nichts automatisch gesendet." } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Replace the draft for %@ with this content? Nothing will be sent automatically." } },
|
||||
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "¿Reemplazar el borrador de %@ con este contenido? No se enviará nada automáticamente." } },
|
||||
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Palitan ng content na ito ang draft para sa %@? Walang awtomatikong ipapadala." } },
|
||||
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Remplacer le brouillon pour %@ par ce contenu ? Rien ne sera envoyé automatiquement." } },
|
||||
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "להחליף את הטיוטה עבור %@ בתוכן הזה? שום דבר לא יישלח אוטומטית." } },
|
||||
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ का ड्राफ़्ट इस सामग्री से बदलें? कुछ भी अपने आप नहीं भेजा जाएगा।" } },
|
||||
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Ganti draf untuk %@ dengan konten ini? Tidak ada yang akan dikirim otomatis." } },
|
||||
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Sostituire la bozza per %@ con questo contenuto? Nulla verrà inviato automaticamente." } },
|
||||
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ の下書きをこの内容で置き換えますか?自動的には送信されません。" } },
|
||||
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "%@의 초안을 이 콘텐츠로 바꾸시겠습니까? 자동으로 전송되지 않습니다." } },
|
||||
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Gantikan draf untuk %@ dengan kandungan ini? Tiada apa-apa akan dihantar secara automatik." } },
|
||||
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ को मस्यौदा यो सामग्रीले बदल्ने? केही पनि स्वचालित रूपमा पठाइने छैन।" } },
|
||||
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Concept voor %@ vervangen door deze inhoud? Er wordt niets automatisch verzonden." } },
|
||||
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Zastąpić szkic dla %@ tą treścią? Nic nie zostanie wysłane automatycznie." } },
|
||||
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Substituir o rascunho para %@ por este conteúdo? Nada será enviado automaticamente." } },
|
||||
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Substituir o rascunho de %@ por este conteúdo? Nada será enviado automaticamente." } },
|
||||
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Заменить черновик для %@ этим содержимым? Ничего не будет отправлено автоматически." } },
|
||||
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Ersätta utkastet för %@ med detta innehåll? Inget skickas automatiskt." } },
|
||||
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ க்கான வரைவைக் இந்த உள்ளடக்கத்தால் மாற்றவா? எதுவும் தானாக அனுப்பப்படாது." } },
|
||||
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "แทนที่ฉบับร่างสำหรับ %@ ด้วยเนื้อหานี้หรือไม่? จะไม่มีการส่งอัตโนมัติ" } },
|
||||
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ taslağı bu içerikle değiştirilsin mi? Hiçbir şey otomatik olarak gönderilmez." } },
|
||||
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Замінити чернетку для %@ цим вмістом? Нічого не буде надіслано автоматично." } },
|
||||
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ کا مسودہ اس مواد سے بدلیں؟ کچھ بھی خودکار طور پر نہیں بھیجا جائے گا۔" } },
|
||||
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Thay bản nháp cho %@ bằng nội dung này? Không có gì được tự động gửi." } },
|
||||
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "要用此内容替换 %@ 的草稿吗?内容不会自动发送。" } },
|
||||
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "要用此內容取代 %@ 的草稿嗎?內容不會自動傳送。" } }
|
||||
}
|
||||
},
|
||||
"share_import.review.title" : {
|
||||
"comment" : "Title for reviewing content received from the share extension",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "مراجعة المحتوى المشترك" } },
|
||||
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "শেয়ার করা কনটেন্ট পর্যালোচনা করুন" } },
|
||||
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Geteilte Inhalte prüfen" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Review shared content" } },
|
||||
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "Revisar contenido compartido" } },
|
||||
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Suriin ang ibinahaging content" } },
|
||||
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Vérifier le contenu partagé" } },
|
||||
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "בדיקת תוכן משותף" } },
|
||||
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "शेयर की गई सामग्री की समीक्षा करें" } },
|
||||
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tinjau konten yang dibagikan" } },
|
||||
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Controlla il contenuto condiviso" } },
|
||||
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "共有コンテンツを確認" } },
|
||||
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "공유 콘텐츠 검토" } },
|
||||
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Semak kandungan yang dikongsi" } },
|
||||
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "साझा सामग्री समीक्षा गर्नुहोस्" } },
|
||||
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Gedeelde inhoud bekijken" } },
|
||||
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Sprawdź udostępnioną treść" } },
|
||||
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Rever conteúdo partilhado" } },
|
||||
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Revisar conteúdo compartilhado" } },
|
||||
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Проверить общий контент" } },
|
||||
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Granska delat innehåll" } },
|
||||
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "பகிரப்பட்ட உள்ளடக்கத்தை மதிப்பாய்வு செய்" } },
|
||||
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "ตรวจสอบเนื้อหาที่แชร์" } },
|
||||
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "Paylaşılan içeriği gözden geçir" } },
|
||||
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Переглянути спільний вміст" } },
|
||||
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "شیئر کردہ مواد کا جائزہ لیں" } },
|
||||
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Xem lại nội dung được chia sẻ" } },
|
||||
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "查看共享内容" } },
|
||||
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "查看分享內容" } }
|
||||
}
|
||||
},
|
||||
"share_import.review.use_in_composer" : {
|
||||
"comment" : "Action that places reviewed shared content in the composer without sending it",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "استخدام في مسودة الرسالة" } },
|
||||
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "বার্তার খসড়ায় ব্যবহার করুন" } },
|
||||
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Im Nachrichtenentwurf verwenden" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Use in composer" } },
|
||||
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar en el borrador" } },
|
||||
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Gamitin sa draft" } },
|
||||
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Utiliser dans le brouillon" } },
|
||||
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "שימוש בטיוטה" } },
|
||||
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "संदेश के ड्राफ़्ट में उपयोग करें" } },
|
||||
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Gunakan di draf" } },
|
||||
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Usa nella bozza" } },
|
||||
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "下書きで使用" } },
|
||||
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "초안에 사용" } },
|
||||
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Gunakan dalam draf" } },
|
||||
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "सन्देशको मस्यौदामा प्रयोग गर्नुहोस्" } },
|
||||
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "In concept gebruiken" } },
|
||||
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Użyj w szkicu" } },
|
||||
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar no rascunho" } },
|
||||
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar no rascunho" } },
|
||||
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Использовать в черновике" } },
|
||||
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Använd i utkast" } },
|
||||
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "செய்தி வரைவில் பயன்படுத்து" } },
|
||||
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "ใช้ในฉบับร่าง" } },
|
||||
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "Taslakta kullan" } },
|
||||
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Використати в чернетці" } },
|
||||
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "پیغام کے مسودے میں استعمال کریں" } },
|
||||
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Dùng trong bản nháp" } },
|
||||
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "用于草稿" } },
|
||||
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "用於草稿" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import Foundation
|
||||
|
||||
enum SharedContentKind: String, Codable, Sendable, Equatable {
|
||||
case text
|
||||
case url
|
||||
}
|
||||
|
||||
/// The single, bounded payload handed from the share extension to the app.
|
||||
///
|
||||
/// The app-group store intentionally contains at most one envelope. A newer
|
||||
/// share replaces an older one, which prevents unbounded shared-container
|
||||
/// growth while still surviving suspension and a later app launch.
|
||||
struct SharedContentPayload: Codable, Sendable, Equatable, Identifiable {
|
||||
static let currentVersion = 1
|
||||
static let maxContentBytes = 16_000
|
||||
static let maxTitleBytes = 512
|
||||
static let maxEnvelopeBytes = 24_000
|
||||
static let retentionSeconds: TimeInterval = 24 * 60 * 60
|
||||
static let allowedFutureSkewSeconds: TimeInterval = 5 * 60
|
||||
|
||||
let version: Int
|
||||
let id: UUID
|
||||
let kind: SharedContentKind
|
||||
let content: String
|
||||
let title: String?
|
||||
let createdAt: Date
|
||||
|
||||
init(
|
||||
version: Int = Self.currentVersion,
|
||||
id: UUID = UUID(),
|
||||
kind: SharedContentKind,
|
||||
content: String,
|
||||
title: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.version = version
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.content = content
|
||||
self.title = title
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
static func text(_ content: String, createdAt: Date = Date()) -> SharedContentPayload {
|
||||
SharedContentPayload(kind: .text, content: content, createdAt: createdAt)
|
||||
}
|
||||
|
||||
static func url(_ url: URL, title: String?, createdAt: Date = Date()) -> SharedContentPayload {
|
||||
SharedContentPayload(kind: .url, content: url.absoluteString, title: title, createdAt: createdAt)
|
||||
}
|
||||
|
||||
var composerText: String { content }
|
||||
|
||||
var preview: String {
|
||||
let normalized = content
|
||||
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
.replacingOccurrences(of: "\r", with: "\n")
|
||||
guard normalized.count > 240 else { return normalized }
|
||||
return String(normalized.prefix(240)) + "…"
|
||||
}
|
||||
|
||||
func validate(now: Date = Date()) throws {
|
||||
guard version == Self.currentVersion else {
|
||||
throw SharedContentHandoffError.unsupportedVersion
|
||||
}
|
||||
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
throw SharedContentHandoffError.emptyContent
|
||||
}
|
||||
guard content.utf8.count <= Self.maxContentBytes else {
|
||||
throw SharedContentHandoffError.contentTooLarge
|
||||
}
|
||||
if let title {
|
||||
guard title.utf8.count <= Self.maxTitleBytes else {
|
||||
throw SharedContentHandoffError.titleTooLarge
|
||||
}
|
||||
guard !Self.containsDisallowedControl(in: title, allowsTextLayout: false) else {
|
||||
throw SharedContentHandoffError.invalidCharacters
|
||||
}
|
||||
}
|
||||
|
||||
let age = now.timeIntervalSince(createdAt)
|
||||
guard age >= -Self.allowedFutureSkewSeconds,
|
||||
age <= Self.retentionSeconds else {
|
||||
throw SharedContentHandoffError.expired
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case .text:
|
||||
guard !Self.containsDisallowedControl(in: content, allowsTextLayout: true) else {
|
||||
throw SharedContentHandoffError.invalidCharacters
|
||||
}
|
||||
case .url:
|
||||
guard !Self.containsDisallowedControl(in: content, allowsTextLayout: false),
|
||||
let components = URLComponents(string: content),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https",
|
||||
components.host?.isEmpty == false else {
|
||||
throw SharedContentHandoffError.unsupportedURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func containsDisallowedControl(
|
||||
in value: String,
|
||||
allowsTextLayout: Bool
|
||||
) -> Bool {
|
||||
value.unicodeScalars.contains { scalar in
|
||||
guard CharacterSet.controlCharacters.contains(scalar) else { return false }
|
||||
if allowsTextLayout, scalar == "\n" || scalar == "\r" || scalar == "\t" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SharedContentHandoffError: Error, Equatable {
|
||||
case unsupportedVersion
|
||||
case emptyContent
|
||||
case contentTooLarge
|
||||
case titleTooLarge
|
||||
case invalidCharacters
|
||||
case expired
|
||||
case unsupportedURL
|
||||
case envelopeTooLarge
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
/// Durable, single-item app-group storage used by both the extension and app.
|
||||
final class SharedContentStore {
|
||||
static let storageKey = "sharedContentEnvelopeV1"
|
||||
|
||||
private static let legacyKeys = [
|
||||
"sharedContent",
|
||||
"sharedContentType",
|
||||
"sharedContentDate"
|
||||
]
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
init(defaults: UserDefaults) {
|
||||
self.defaults = defaults
|
||||
self.encoder = JSONEncoder()
|
||||
self.decoder = JSONDecoder()
|
||||
}
|
||||
|
||||
convenience init?(suiteName: String) {
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else { return nil }
|
||||
self.init(defaults: defaults)
|
||||
}
|
||||
|
||||
/// Replaces any older pending share with a validated, bounded envelope.
|
||||
func stage(_ payload: SharedContentPayload, now: Date = Date()) throws {
|
||||
try payload.validate(now: now)
|
||||
guard let encoded = try? encoder.encode(payload) else {
|
||||
throw SharedContentHandoffError.encodingFailed
|
||||
}
|
||||
guard encoded.count <= SharedContentPayload.maxEnvelopeBytes else {
|
||||
throw SharedContentHandoffError.envelopeTooLarge
|
||||
}
|
||||
|
||||
defaults.set(encoded, forKey: Self.storageKey)
|
||||
clearLegacyKeys()
|
||||
}
|
||||
|
||||
/// Reads the pending share without consuming it. Invalid and expired data
|
||||
/// is removed immediately so malformed app-group state cannot linger.
|
||||
func pending(now: Date = Date()) -> SharedContentPayload? {
|
||||
clearLegacyKeys()
|
||||
|
||||
guard let encoded = defaults.data(forKey: Self.storageKey) else { return nil }
|
||||
guard encoded.count <= SharedContentPayload.maxEnvelopeBytes,
|
||||
let payload = try? decoder.decode(SharedContentPayload.self, from: encoded) else {
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
try payload.validate(now: now)
|
||||
return payload
|
||||
} catch {
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes only the envelope the user actually reviewed. If a newer share
|
||||
/// already replaced it, the newer content remains pending.
|
||||
func consume(id: UUID, now: Date = Date()) -> SharedContentPayload? {
|
||||
guard let payload = pending(now: now), payload.id == id else { return nil }
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
return payload
|
||||
}
|
||||
|
||||
/// Explicit cancellation has the same identity guard as consumption so it
|
||||
/// can never discard a newer share that arrived while a prompt was open.
|
||||
func discard(id: UUID) {
|
||||
guard let encoded = defaults.data(forKey: Self.storageKey),
|
||||
encoded.count <= SharedContentPayload.maxEnvelopeBytes,
|
||||
let payload = try? decoder.decode(SharedContentPayload.self, from: encoded),
|
||||
payload.id == id else {
|
||||
return
|
||||
}
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
}
|
||||
|
||||
func discardAll() {
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
clearLegacyKeys()
|
||||
}
|
||||
|
||||
private func clearLegacyKeys() {
|
||||
for key in Self.legacyKeys {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,7 +314,6 @@ enum TransportConfig {
|
||||
|
||||
// Share extension
|
||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||
|
||||
// Gossip Sync Configuration
|
||||
|
||||
@@ -36,6 +36,7 @@ struct ContentView: View {
|
||||
@EnvironmentObject private var verificationModel: VerificationModel
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@EnvironmentObject private var sharedContentImportModel: SharedContentImportModel
|
||||
|
||||
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
|
||||
@State private var messageText = ""
|
||||
@@ -69,6 +70,14 @@ struct ContentView: View {
|
||||
privateConversationModel.selectedPeerID
|
||||
}
|
||||
|
||||
private var sharedContentDestination: SharedContentDestination {
|
||||
SharedContentDestination.resolve(
|
||||
selectedPrivatePeerID: selectedPrivatePeerID,
|
||||
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
|
||||
activeChannel: locationChannelsModel.selectedChannel
|
||||
)
|
||||
}
|
||||
|
||||
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
|
||||
|
||||
var body: some View {
|
||||
@@ -85,6 +94,7 @@ struct ContentView: View {
|
||||
isTextFieldFocused = true
|
||||
}
|
||||
#endif
|
||||
sharedContentImportModel.updateDestination(sharedContentDestination)
|
||||
}
|
||||
.onChange(of: colorScheme) { newValue in
|
||||
conversationUIModel.setCurrentColorScheme(newValue)
|
||||
@@ -101,6 +111,10 @@ struct ContentView: View {
|
||||
if newValue != nil {
|
||||
showSidebar = true
|
||||
}
|
||||
sharedContentImportModel.updateDestination(sharedContentDestination)
|
||||
}
|
||||
.onChange(of: locationChannelsModel.selectedChannel) { _ in
|
||||
sharedContentImportModel.updateDestination(sharedContentDestination)
|
||||
}
|
||||
.sheet(
|
||||
isPresented: Binding(
|
||||
@@ -227,6 +241,34 @@ struct ContentView: View {
|
||||
} message: {
|
||||
Text(appChromeModel.bluetoothAlertMessage)
|
||||
}
|
||||
.alert(
|
||||
String(localized: "share_import.review.title", comment: "Title for reviewing content received from the share extension"),
|
||||
isPresented: Binding(
|
||||
get: { sharedContentImportModel.offer != nil },
|
||||
set: { _ in }
|
||||
),
|
||||
presenting: sharedContentImportModel.offer
|
||||
) { _ in
|
||||
Button("common.cancel", role: .cancel) {
|
||||
sharedContentImportModel.cancel(destination: sharedContentDestination)
|
||||
}
|
||||
Button("share_import.review.use_in_composer") {
|
||||
guard let importedText = sharedContentImportModel.confirm(
|
||||
destination: sharedContentDestination
|
||||
) else { return }
|
||||
// Replacing is deliberate and called out in the prompt. It
|
||||
// avoids combining a stale draft from another conversation
|
||||
// with newly shared content.
|
||||
messageText = importedText
|
||||
isTextFieldFocused = true
|
||||
}
|
||||
} message: { offer in
|
||||
let format = String(
|
||||
localized: "share_import.review.message",
|
||||
comment: "Explains that shared content will replace the named destination's composer and will not be sent automatically"
|
||||
)
|
||||
Text(String(format: format, offer.destination.displayName) + "\n\n" + offer.payload.preview)
|
||||
}
|
||||
.onDisappear {
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
}
|
||||
},
|
||||
"share.status.failed_to_encode" : {
|
||||
"extractionState" : "manual",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
@@ -782,7 +782,7 @@
|
||||
}
|
||||
},
|
||||
"share.status.shared_link" : {
|
||||
"extractionState" : "manual",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
@@ -977,7 +977,7 @@
|
||||
}
|
||||
},
|
||||
"share.status.shared_text" : {
|
||||
"extractionState" : "manual",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
@@ -1170,6 +1170,76 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share.status.failed_to_save" : {
|
||||
"comment" : "Shown when content cannot be staged for the main app",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "تعذر الحفظ في bitchat" } },
|
||||
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-এ সংরক্ষণ করা যায়নি" } },
|
||||
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Konnte nicht in bitchat gespeichert werden" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Could not save to bitchat" } },
|
||||
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "No se pudo guardar en bitchat" } },
|
||||
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Hindi ma-save sa bitchat" } },
|
||||
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossible d’enregistrer dans bitchat" } },
|
||||
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "לא ניתן לשמור ב-bitchat" } },
|
||||
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat में सेव नहीं किया जा सका" } },
|
||||
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } },
|
||||
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossibile salvare in bitchat" } },
|
||||
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat に保存できませんでした" } },
|
||||
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat에 저장할 수 없습니다" } },
|
||||
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } },
|
||||
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat मा सुरक्षित गर्न सकिएन" } },
|
||||
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Kon niet opslaan in bitchat" } },
|
||||
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Nie udało się zapisać w bitchat" } },
|
||||
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível guardar no bitchat" } },
|
||||
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível salvar no bitchat" } },
|
||||
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Не удалось сохранить в bitchat" } },
|
||||
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Kunde inte spara i bitchat" } },
|
||||
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-இல் சேமிக்க முடியவில்லை" } },
|
||||
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "บันทึกไปยัง bitchat ไม่ได้" } },
|
||||
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat’e kaydedilemedi" } },
|
||||
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Не вдалося зберегти в bitchat" } },
|
||||
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat میں محفوظ نہیں ہو سکا" } },
|
||||
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Không thể lưu vào bitchat" } },
|
||||
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "无法保存到 bitchat" } },
|
||||
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "無法儲存到 bitchat" } }
|
||||
}
|
||||
},
|
||||
"share.status.saved_for_review" : {
|
||||
"comment" : "Shown after content is staged for review in the main app",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ حُفظ في bitchat — افتح التطبيق للمراجعة" } },
|
||||
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-এ সংরক্ষিত — পর্যালোচনার জন্য অ্যাপটি খুলুন" } },
|
||||
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ In bitchat gespeichert — App zum Prüfen öffnen" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "✓ Saved in bitchat — open the app to review" } },
|
||||
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado en bitchat — abre la app para revisarlo" } },
|
||||
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Na-save sa bitchat — buksan ang app para suriin" } },
|
||||
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Enregistré dans bitchat — ouvrez l’app pour vérifier" } },
|
||||
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ נשמר ב-bitchat — יש לפתוח את האפליקציה לבדיקה" } },
|
||||
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat में सेव किया गया — समीक्षा के लिए ऐप खोलें" } },
|
||||
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan di bitchat — buka aplikasi untuk meninjau" } },
|
||||
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvato in bitchat — apri l’app per controllare" } },
|
||||
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat に保存しました — アプリを開いて確認してください" } },
|
||||
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat에 저장됨 — 앱을 열어 검토하세요" } },
|
||||
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan dalam bitchat — buka aplikasi untuk menyemak" } },
|
||||
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat मा सुरक्षित गरियो — समीक्षा गर्न एप खोल्नुहोस्" } },
|
||||
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Opgeslagen in bitchat — open de app om te bekijken" } },
|
||||
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Zapisano w bitchat — otwórz aplikację, aby sprawdzić" } },
|
||||
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado no bitchat — abra a app para rever" } },
|
||||
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvo no bitchat — abra o app para revisar" } },
|
||||
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Сохранено в bitchat — откройте приложение для проверки" } },
|
||||
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Sparat i bitchat — öppna appen för att granska" } },
|
||||
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-இல் சேமிக்கப்பட்டது — மதிப்பாய்வு செய்ய செயலியைத் திறக்கவும்" } },
|
||||
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ บันทึกใน bitchat แล้ว — เปิดแอปเพื่อตรวจสอบ" } },
|
||||
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat’e kaydedildi — incelemek için uygulamayı açın" } },
|
||||
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Збережено в bitchat — відкрийте застосунок для перегляду" } },
|
||||
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat میں محفوظ ہو گیا — جائزے کے لیے ایپ کھولیں" } },
|
||||
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Đã lưu trong bitchat — mở ứng dụng để xem lại" } },
|
||||
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已保存在 bitchat 中 — 打开应用查看" } },
|
||||
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已儲存在 bitchat 中 — 開啟 App 查看" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
|
||||
@@ -19,9 +19,8 @@ final class ShareViewController: UIViewController {
|
||||
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
|
||||
static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared")
|
||||
static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link")
|
||||
static let sharedLinkConfirmation = String(localized: "share.status.shared_link", comment: "Confirmation after successfully sharing a link")
|
||||
static let sharedTextConfirmation = String(localized: "share.status.shared_text", comment: "Confirmation after successfully sharing text")
|
||||
static let failedToEncode = String(localized: "share.status.failed_to_encode", comment: "Shown when the share payload cannot be encoded")
|
||||
static let savedForReview = String(localized: "share.status.saved_for_review", comment: "Shown after content is staged for review in the main app")
|
||||
static let failedToSave = String(localized: "share.status.failed_to_save", comment: "Shown when content cannot be staged for the main app")
|
||||
}
|
||||
|
||||
private let statusLabel: UILabel = {
|
||||
@@ -44,9 +43,7 @@ final class ShareViewController: UIViewController {
|
||||
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
||||
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
||||
])
|
||||
DispatchQueue.global().async {
|
||||
self.processShare()
|
||||
}
|
||||
processShare()
|
||||
}
|
||||
|
||||
// MARK: - Processing
|
||||
@@ -151,30 +148,31 @@ final class ShareViewController: UIViewController {
|
||||
|
||||
// MARK: - Save + Finish
|
||||
private func saveAndFinish(url: URL, title: String?) {
|
||||
let payload: [String: String] = [
|
||||
"url": url.absoluteString,
|
||||
"title": title ?? url.host ?? Strings.sharedLinkTitleFallback
|
||||
]
|
||||
if let json = try? JSONSerialization.data(withJSONObject: payload),
|
||||
let s = String(data: json, encoding: .utf8) {
|
||||
saveToSharedDefaults(content: s, type: "url")
|
||||
finishWithMessage(Strings.sharedLinkConfirmation)
|
||||
} else {
|
||||
finishWithMessage(Strings.failedToEncode)
|
||||
}
|
||||
let payload = SharedContentPayload.url(
|
||||
url,
|
||||
title: title ?? url.host ?? Strings.sharedLinkTitleFallback
|
||||
)
|
||||
stageAndFinish(payload)
|
||||
}
|
||||
|
||||
private func saveAndFinish(text: String) {
|
||||
saveToSharedDefaults(content: text, type: "text")
|
||||
finishWithMessage(Strings.sharedTextConfirmation)
|
||||
stageAndFinish(.text(text))
|
||||
}
|
||||
|
||||
private func saveToSharedDefaults(content: String, type: String) {
|
||||
guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return }
|
||||
userDefaults.set(content, forKey: "sharedContent")
|
||||
userDefaults.set(type, forKey: "sharedContentType")
|
||||
userDefaults.set(Date(), forKey: "sharedContentDate")
|
||||
// No need to force synchronize; the system persists changes
|
||||
private func stageAndFinish(_ payload: SharedContentPayload) {
|
||||
guard let store = SharedContentStore(suiteName: Self.groupID) else {
|
||||
finishWithMessage(Strings.failedToSave)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try store.stage(payload)
|
||||
// Staging is not sending. The main app will require a second,
|
||||
// destination-labelled confirmation before filling its composer.
|
||||
finishWithMessage(Strings.savedForReview)
|
||||
} catch {
|
||||
finishWithMessage(Strings.failedToSave)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishWithMessage(_ msg: String) {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("Share extension handoff", .serialized)
|
||||
struct SharedContentHandoffTests {
|
||||
private func makeStore() -> (suite: String, defaults: UserDefaults, store: SharedContentStore) {
|
||||
let suite = "SharedContentHandoffTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
return (suite, defaults, SharedContentStore(defaults: defaults))
|
||||
}
|
||||
|
||||
@Test("A staged share survives an inactive app and a late open")
|
||||
func stagedShareSurvivesLateOpen() throws {
|
||||
let context = makeStore()
|
||||
defer { context.defaults.removePersistentDomain(forName: context.suite) }
|
||||
let stagedAt = Date(timeIntervalSince1970: 1_000_000)
|
||||
let payload = SharedContentPayload.text("review me later", createdAt: stagedAt)
|
||||
|
||||
try context.store.stage(payload, now: stagedAt)
|
||||
|
||||
#expect(context.store.pending(now: stagedAt.addingTimeInterval(60 * 60)) == payload)
|
||||
#expect(context.defaults.data(forKey: SharedContentStore.storageKey) != nil)
|
||||
}
|
||||
|
||||
@Test("Malformed, oversized, unsupported, and expired payloads are rejected and cleared")
|
||||
func invalidPayloadsAreRejectedAndCleared() throws {
|
||||
let context = makeStore()
|
||||
defer { context.defaults.removePersistentDomain(forName: context.suite) }
|
||||
let now = Date(timeIntervalSince1970: 2_000_000)
|
||||
|
||||
context.defaults.set(Data("not-json".utf8), forKey: SharedContentStore.storageKey)
|
||||
#expect(context.store.pending(now: now) == nil)
|
||||
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
|
||||
|
||||
context.defaults.set(
|
||||
Data(repeating: 0x41, count: SharedContentPayload.maxEnvelopeBytes + 1),
|
||||
forKey: SharedContentStore.storageKey
|
||||
)
|
||||
#expect(context.store.pending(now: now) == nil)
|
||||
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
|
||||
|
||||
let oversized = SharedContentPayload.text(
|
||||
String(repeating: "x", count: SharedContentPayload.maxContentBytes + 1),
|
||||
createdAt: now
|
||||
)
|
||||
#expect(throws: SharedContentHandoffError.contentTooLarge) {
|
||||
try context.store.stage(oversized, now: now)
|
||||
}
|
||||
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
|
||||
|
||||
let unsupportedURL = SharedContentPayload(
|
||||
kind: .url,
|
||||
content: "file:///private/tmp/secret.txt",
|
||||
createdAt: now
|
||||
)
|
||||
#expect(throws: SharedContentHandoffError.unsupportedURL) {
|
||||
try context.store.stage(unsupportedURL, now: now)
|
||||
}
|
||||
|
||||
let misleadingControl = SharedContentPayload.text("safe\u{202E}txt", createdAt: now)
|
||||
#expect(throws: SharedContentHandoffError.invalidCharacters) {
|
||||
try context.store.stage(misleadingControl, now: now)
|
||||
}
|
||||
|
||||
let expired = SharedContentPayload.text(
|
||||
"too old",
|
||||
createdAt: now.addingTimeInterval(-SharedContentPayload.retentionSeconds - 1)
|
||||
)
|
||||
context.defaults.set(try JSONEncoder().encode(expired), forKey: SharedContentStore.storageKey)
|
||||
#expect(context.store.pending(now: now) == nil)
|
||||
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
|
||||
}
|
||||
|
||||
@Test("Mesh, geohash, and stale private selections resolve to explicit destinations")
|
||||
func destinationsAreExplicit() {
|
||||
let geohashChannel = ChannelID.location(
|
||||
GeohashChannel(level: .city, geohash: "9Q8YY")
|
||||
)
|
||||
let stalePeer = PeerID(str: "0011223344556677")
|
||||
|
||||
#expect(SharedContentDestination.resolve(
|
||||
selectedPrivatePeerID: nil,
|
||||
privateDisplayName: nil,
|
||||
activeChannel: .mesh
|
||||
) == .mesh)
|
||||
#expect(SharedContentDestination.resolve(
|
||||
selectedPrivatePeerID: nil,
|
||||
privateDisplayName: nil,
|
||||
activeChannel: geohashChannel
|
||||
) == .geohash("9q8yy"))
|
||||
#expect(SharedContentDestination.resolve(
|
||||
selectedPrivatePeerID: stalePeer,
|
||||
privateDisplayName: "alice",
|
||||
activeChannel: geohashChannel
|
||||
) == .privateConversation(peerID: stalePeer, displayName: "alice"))
|
||||
}
|
||||
|
||||
@Test("A destination change requires a new confirmation and never consumes on the stale tap")
|
||||
@MainActor
|
||||
func staleDestinationCannotBeConfirmed() throws {
|
||||
let context = makeStore()
|
||||
defer { context.defaults.removePersistentDomain(forName: context.suite) }
|
||||
let now = Date(timeIntervalSince1970: 3_000_000)
|
||||
let payload = SharedContentPayload.text("do not auto-send", createdAt: now)
|
||||
let peer = PeerID(str: "8899aabbccddeeff")
|
||||
let privateDestination = SharedContentDestination.privateConversation(
|
||||
peerID: peer,
|
||||
displayName: "alice"
|
||||
)
|
||||
let model = SharedContentImportModel(store: context.store)
|
||||
try context.store.stage(payload, now: now)
|
||||
model.refresh(destination: privateDestination, now: now)
|
||||
|
||||
#expect(model.confirm(destination: .mesh, now: now) == nil)
|
||||
#expect(model.offer?.destination == .mesh)
|
||||
#expect(context.store.pending(now: now) == payload)
|
||||
|
||||
#expect(model.confirm(destination: .mesh, now: now) == payload.content)
|
||||
#expect(model.offer == nil)
|
||||
#expect(context.store.pending(now: now) == nil)
|
||||
}
|
||||
|
||||
@Test("Confirmation consumes once and cancellation explicitly clears without producing composer text")
|
||||
@MainActor
|
||||
func oneTimeConfirmationAndCancellation() throws {
|
||||
let context = makeStore()
|
||||
defer { context.defaults.removePersistentDomain(forName: context.suite) }
|
||||
let now = Date(timeIntervalSince1970: 4_000_000)
|
||||
let model = SharedContentImportModel(store: context.store)
|
||||
|
||||
let first = SharedContentPayload.text("confirmed", createdAt: now)
|
||||
try context.store.stage(first, now: now)
|
||||
model.refresh(destination: .geohash("u4pruy"), now: now)
|
||||
#expect(model.confirm(destination: .geohash("u4pruy"), now: now) == "confirmed")
|
||||
#expect(model.confirm(destination: .geohash("u4pruy"), now: now) == nil)
|
||||
|
||||
let second = SharedContentPayload.text("cancelled", createdAt: now)
|
||||
try context.store.stage(second, now: now)
|
||||
model.refresh(destination: .mesh, now: now)
|
||||
model.cancel(destination: .mesh, now: now)
|
||||
#expect(model.offer == nil)
|
||||
#expect(context.store.pending(now: now) == nil)
|
||||
|
||||
let third = SharedContentPayload.text("panic-wiped", createdAt: now)
|
||||
try context.store.stage(third, now: now)
|
||||
model.refresh(destination: .mesh, now: now)
|
||||
model.discardAll()
|
||||
#expect(model.offer == nil)
|
||||
#expect(context.store.pending(now: now) == nil)
|
||||
}
|
||||
|
||||
@Test("Cancelling an old review never deletes a newer staged share")
|
||||
@MainActor
|
||||
func cancellationPreservesNewerShare() throws {
|
||||
let context = makeStore()
|
||||
defer { context.defaults.removePersistentDomain(forName: context.suite) }
|
||||
let now = Date(timeIntervalSince1970: 5_000_000)
|
||||
let model = SharedContentImportModel(store: context.store)
|
||||
let old = SharedContentPayload.text("old", createdAt: now)
|
||||
let newer = SharedContentPayload.text("new", createdAt: now)
|
||||
|
||||
try context.store.stage(old, now: now)
|
||||
model.refresh(destination: .mesh, now: now)
|
||||
try context.store.stage(newer, now: now)
|
||||
model.cancel(destination: .mesh, now: now)
|
||||
|
||||
#expect(model.offer?.payload == newer)
|
||||
#expect(context.store.pending(now: now) == newer)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ private struct SmokeFeatureModels {
|
||||
let conversationUIModel: ConversationUIModel
|
||||
let peerListModel: PeerListModel
|
||||
let boardAlertsModel: BoardAlertsModel
|
||||
let sharedContentImportModel: SharedContentImportModel
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -99,7 +100,8 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
|
||||
verificationModel: verificationModel,
|
||||
conversationUIModel: conversationUIModel,
|
||||
peerListModel: peerListModel,
|
||||
boardAlertsModel: boardAlertsModel
|
||||
boardAlertsModel: boardAlertsModel,
|
||||
sharedContentImportModel: SharedContentImportModel(store: nil)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,6 +120,7 @@ private func installSmokeEnvironment<V: View>(
|
||||
.environmentObject(featureModels.conversationUIModel)
|
||||
.environmentObject(featureModels.peerListModel)
|
||||
.environmentObject(featureModels.boardAlertsModel)
|
||||
.environmentObject(featureModels.sharedContentImportModel)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
Reference in New Issue
Block a user