mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
feat: share location channel invites via the system share sheet (#1513)
* feat: share location channel invites via the system share sheet Adds text-first geohash invites (deep link + App Store URL) from channel rows and the active-channel header, with an OpSec warning for neighborhood-or-finer cells. Closes #1497 Co-authored-by: Cursor <cursoragent@cursor.com> * fix: add Localizable.xcstrings entries for channel share copy Cover all six new share/done keys across the 30-locale catalog so LocalizationCoverageTests and non-English builds stop falling back to English defaults. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: retrigger CI after unrelated VoiceRecorder flake Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c66015d029
commit
681c180060
File diff suppressed because it is too large
Load Diff
49
bitchat/Services/ChannelShare.swift
Normal file
49
bitchat/Services/ChannelShare.swift
Normal file
@ -0,0 +1,49 @@
|
||||
//
|
||||
// ChannelShare.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Builds plain-text location-channel invites for the system share sheet (#1497).
|
||||
///
|
||||
/// Text-first on purpose: a `bitchat://` deep link is dead weight for people
|
||||
/// who have not installed yet, and SMS does not reliably linkify custom
|
||||
/// schemes. The payload always includes the App Store URL and the geohash a
|
||||
/// person can type under location channels after installing.
|
||||
enum ChannelShare {
|
||||
/// App Store listing used in out-of-app invites.
|
||||
static let appStoreURL = "https://apps.apple.com/us/app/bitchat-mesh/id6748219622"
|
||||
|
||||
/// Neighborhood (6) and finer imply a small cell — sharing that over SMS
|
||||
/// discloses location interest to the carrier and both handsets.
|
||||
static let precisionWarningMinimumLength = 6
|
||||
|
||||
static func shouldWarn(forGeohash geohash: String) -> Bool {
|
||||
geohash.count >= precisionWarningMinimumLength
|
||||
}
|
||||
|
||||
/// Channel-not-presence framing: "join #x", never "I'm in #x".
|
||||
static func payload(forGeohash geohash: String) -> String {
|
||||
let gh = geohash.lowercased()
|
||||
return String(
|
||||
format: String(
|
||||
localized: "channel.share.payload",
|
||||
defaultValue: "join the #%1$@ channel on bitchat: bitchat://geohash/%1$@ — new to bitchat? get it at %2$@ then type #%1$@ under location channels.",
|
||||
comment: "Plain-text share payload for a location channel; %1$@ is the geohash, %2$@ is the App Store URL"
|
||||
),
|
||||
locale: .current,
|
||||
gh,
|
||||
appStoreURL
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifiable wrapper so `.sheet(item:)` can present the system share UI.
|
||||
struct ChannelSharePayload: Identifiable {
|
||||
let id = UUID()
|
||||
let text: String
|
||||
}
|
||||
@ -30,6 +30,10 @@ struct ContentHeaderView: View {
|
||||
/// timeline is showing) — they should light the pin too.
|
||||
@ObservedObject private var nearbyNotes = NearbyNotesCounter.shared
|
||||
|
||||
@State private var pendingShareGeohash: String?
|
||||
@State private var showSharePrecisionWarning = false
|
||||
@State private var activeSharePayload: ChannelSharePayload?
|
||||
|
||||
/// The bridged-people count belongs to the mesh channel only.
|
||||
private var showBridgedPeerCount: Bool {
|
||||
if case .location = locationChannelsModel.selectedChannel { return false }
|
||||
@ -213,6 +217,16 @@ struct ContentHeaderView: View {
|
||||
channel.geohash
|
||||
)
|
||||
)
|
||||
|
||||
Button(action: { requestHeaderShare(forGeohash: channel.geohash) }) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.headerTapTarget()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "channel.share.action", defaultValue: "share channel", comment: "Accessibility label for sharing the active location channel")
|
||||
)
|
||||
}
|
||||
|
||||
Button(action: { appChromeModel.isLocationChannelsSheetPresented = true }) {
|
||||
@ -336,8 +350,37 @@ struct ContentHeaderView: View {
|
||||
} message: {
|
||||
Text("content.alert.screenshot.message")
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: "channel.share.precision_warning.title", defaultValue: "share a precise location channel?", comment: "Title of the confirmation before sharing a neighborhood-or-finer geohash invite"),
|
||||
isPresented: $showSharePrecisionWarning,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "channel.share.precision_warning.confirm", defaultValue: "share anyway", comment: "Confirms sharing a fine-precision location channel after the OpSec warning")) {
|
||||
if let gh = pendingShareGeohash {
|
||||
activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: gh))
|
||||
}
|
||||
pendingShareGeohash = nil
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {
|
||||
pendingShareGeohash = nil
|
||||
}
|
||||
} message: {
|
||||
Text(String(localized: "channel.share.precision_warning.message", defaultValue: "this channel covers a small area. an invite sent over sms or imessage is visible to the carrier and both handsets — it discloses interest in that place, not only that someone uses bitchat.", comment: "Body of the confirmation before sharing a fine-precision geohash invite"))
|
||||
}
|
||||
.sheet(item: $activeSharePayload) { payload in
|
||||
ShareActivityView(text: payload.text)
|
||||
}
|
||||
.themedChromePanel(edge: .top)
|
||||
}
|
||||
|
||||
private func requestHeaderShare(forGeohash geohash: String) {
|
||||
if ChannelShare.shouldWarn(forGeohash: geohash) {
|
||||
pendingShareGeohash = geohash
|
||||
showSharePrecisionWarning = true
|
||||
} else {
|
||||
activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: geohash))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
|
||||
@ -12,6 +12,10 @@ struct LocationChannelsSheet: View {
|
||||
@ThemedPalette private var palette
|
||||
@State private var customGeohash: String = ""
|
||||
@State private var customError: String? = nil
|
||||
/// Geohash waiting on the fine-precision OpSec confirmation before share.
|
||||
@State private var pendingShareGeohash: String?
|
||||
@State private var showSharePrecisionWarning = false
|
||||
@State private var activeSharePayload: ChannelSharePayload?
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "location_channels.title"
|
||||
@ -44,6 +48,10 @@ struct LocationChannelsSheet: View {
|
||||
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it")
|
||||
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel")
|
||||
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark")
|
||||
static let shareChannel = String(localized: "channel.share.action", defaultValue: "share channel", comment: "Context-menu / accessibility action that shares a location-channel invite")
|
||||
static let sharePrecisionTitle = String(localized: "channel.share.precision_warning.title", defaultValue: "share a precise location channel?", comment: "Title of the confirmation before sharing a neighborhood-or-finer geohash invite")
|
||||
static let sharePrecisionMessage = String(localized: "channel.share.precision_warning.message", defaultValue: "this channel covers a small area. an invite sent over sms or imessage is visible to the carrier and both handsets — it discloses interest in that place, not only that someone uses bitchat.", comment: "Body of the confirmation before sharing a fine-precision geohash invite")
|
||||
static let shareAnyway = String(localized: "channel.share.precision_warning.confirm", defaultValue: "share anyway", comment: "Confirms sharing a fine-precision location channel after the OpSec warning")
|
||||
|
||||
static func meshTitle(_ count: Int) -> String {
|
||||
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
|
||||
@ -179,6 +187,39 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: locationChannelsModel.availableChannels) { _ in }
|
||||
.confirmationDialog(
|
||||
Strings.sharePrecisionTitle,
|
||||
isPresented: $showSharePrecisionWarning,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(Strings.shareAnyway) {
|
||||
if let gh = pendingShareGeohash {
|
||||
presentShare(forGeohash: gh)
|
||||
}
|
||||
pendingShareGeohash = nil
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {
|
||||
pendingShareGeohash = nil
|
||||
}
|
||||
} message: {
|
||||
Text(Strings.sharePrecisionMessage)
|
||||
}
|
||||
.sheet(item: $activeSharePayload) { payload in
|
||||
ShareActivityView(text: payload.text)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestShare(forGeohash geohash: String) {
|
||||
if ChannelShare.shouldWarn(forGeohash: geohash) {
|
||||
pendingShareGeohash = geohash
|
||||
showSharePrecisionWarning = true
|
||||
} else {
|
||||
presentShare(forGeohash: geohash)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentShare(forGeohash geohash: String) {
|
||||
activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: geohash))
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
@ -220,12 +261,21 @@ struct LocationChannelsSheet: View {
|
||||
.accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark)
|
||||
},
|
||||
accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark,
|
||||
accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) }
|
||||
accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) },
|
||||
shareGeohash: channel.geohash,
|
||||
onShare: { requestShare(forGeohash: channel.geohash) }
|
||||
) {
|
||||
locationChannelsModel.markTeleported(for: channel.geohash, false)
|
||||
locationChannelsModel.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
requestShare(forGeohash: channel.geohash)
|
||||
} label: {
|
||||
Label(Strings.shareChannel, systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
} else if locationChannelsModel.permissionState == .authorized {
|
||||
@ -409,7 +459,9 @@ struct LocationChannelsSheet: View {
|
||||
.accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark)
|
||||
},
|
||||
accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark,
|
||||
accessoryAction: { locationChannelsModel.toggleBookmark(gh) }
|
||||
accessoryAction: { locationChannelsModel.toggleBookmark(gh) },
|
||||
shareGeohash: gh,
|
||||
onShare: { requestShare(forGeohash: gh) }
|
||||
) {
|
||||
let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
|
||||
@ -420,6 +472,13 @@ struct LocationChannelsSheet: View {
|
||||
locationChannelsModel.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
requestShare(forGeohash: gh)
|
||||
} label: {
|
||||
Label(Strings.shareChannel, systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.onAppear { locationChannelsModel.resolveBookmarkNameIfNeeded(for: gh) }
|
||||
|
||||
@ -453,6 +512,8 @@ struct LocationChannelsSheet: View {
|
||||
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
|
||||
accessoryActionTitle: String? = nil,
|
||||
accessoryAction: (() -> Void)? = nil,
|
||||
shareGeohash: String? = nil,
|
||||
onShare: (() -> Void)? = nil,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
@ -500,6 +561,9 @@ struct LocationChannelsSheet: View {
|
||||
if let accessoryActionTitle, let accessoryAction {
|
||||
Button(accessoryActionTitle, action: accessoryAction)
|
||||
}
|
||||
if shareGeohash != nil, let onShare {
|
||||
Button(Strings.shareChannel, action: onShare)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
58
bitchat/Views/ShareActivityView.swift
Normal file
58
bitchat/Views/ShareActivityView.swift
Normal file
@ -0,0 +1,58 @@
|
||||
//
|
||||
// ShareActivityView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Hosts the system share UI after an optional OpSec confirmation (#1497).
|
||||
struct ShareActivityView: View {
|
||||
let text: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
ShareActivityController(items: [text])
|
||||
.ignoresSafeArea()
|
||||
#elseif os(macOS)
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(text)
|
||||
.font(.body)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
HStack {
|
||||
Spacer()
|
||||
ShareLink(item: text) {
|
||||
Label(
|
||||
String(localized: "channel.share.action", defaultValue: "share channel", comment: "Button that opens the system share sheet for a location channel invite"),
|
||||
systemImage: "square.and.arrow.up"
|
||||
)
|
||||
}
|
||||
Button(String(localized: "common.done", defaultValue: "done", comment: "Dismisses a sheet")) {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 360)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
private struct ShareActivityController: UIViewControllerRepresentable {
|
||||
let items: [Any]
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
#endif
|
||||
26
bitchatTests/ChannelShareTests.swift
Normal file
26
bitchatTests/ChannelShareTests.swift
Normal file
@ -0,0 +1,26 @@
|
||||
//
|
||||
// ChannelShareTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct ChannelShareTests {
|
||||
@Test func payloadIncludesGeohashDeepLinkAndStoreURL() {
|
||||
let text = ChannelShare.payload(forGeohash: "u4pru")
|
||||
#expect(text.contains("#u4pru"))
|
||||
#expect(text.contains("bitchat://geohash/u4pru"))
|
||||
#expect(text.contains(ChannelShare.appStoreURL))
|
||||
#expect(!text.lowercased().contains("i'm in"))
|
||||
}
|
||||
|
||||
@Test func precisionWarningStartsAtNeighborhood() {
|
||||
#expect(!ChannelShare.shouldWarn(forGeohash: "u4pru")) // city = 5
|
||||
#expect(ChannelShare.shouldWarn(forGeohash: "u4pruy")) // neighborhood = 6
|
||||
#expect(ChannelShare.shouldWarn(forGeohash: "u4pruyzd"))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user