mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 17:05:21 +00:00
Replaces the share-extension handoff that auto-sent shared text/URLs into the current channel with a validated, single-slot 24h envelope that shows the destination and a bounded preview and requires an explicit tap to copy into the composer — it never sends. Rejects malformed/oversized/control-character/non-HTTP(S)/expired payloads (including bidi-override U+202E), and clears on consume/cancel/expiry/panic. Rebased onto main with Persian strings added for all new keys (30-locale coverage verified), plus a panic-recovery clear so an envelope staged during an interrupted wipe cannot survive relaunch.
120 lines
3.6 KiB
Swift
120 lines
3.6 KiB
Swift
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
|
|
}
|
|
}
|