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:
Taksh Kothari 2026-08-01 14:53:41 +05:30 committed by GitHub
parent c66015d029
commit 681c180060
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1358 additions and 2 deletions

File diff suppressed because it is too large Load Diff

View 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
}

View File

@ -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 {

View File

@ -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)
}
}
}

View 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

View 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"))
}
}