mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:25:19 +00:00
Consolidates 9 competing sheet modifiers into a single unified system with a SheetType enum and priority-based selection. Implements parent-child presentation pattern for image picker as suggested by @qalandarov in PR #834: - Main view presents image picker when NOT in a sheet - DM/people sheet presents image picker when IN a sheet Benefits: - Simpler code without complex conditional bindings - Better architecture matching UI hierarchy - Easier to maintain with clear separation of concerns - Type-safe enum-based sheet management Addresses feedback on PR #834.
2438 lines
103 KiB
Swift
2438 lines
103 KiB
Swift
//
|
|
// ContentView.swift
|
|
// bitchat
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import SwiftUI
|
|
#if os(iOS)
|
|
import UIKit
|
|
#endif
|
|
#if os(macOS)
|
|
import AppKit
|
|
#endif
|
|
import UniformTypeIdentifiers
|
|
import BitLogger
|
|
|
|
// MARK: - Supporting Types
|
|
|
|
//
|
|
|
|
//
|
|
|
|
private struct MessageDisplayItem: Identifiable {
|
|
let id: String
|
|
let message: BitchatMessage
|
|
}
|
|
|
|
// MARK: - Sheet Management
|
|
|
|
private enum SheetType: Identifiable {
|
|
case peopleOrChat
|
|
case appInfo
|
|
case fingerprint(PeerID)
|
|
case imagePreview(URL)
|
|
case verification
|
|
case locationChannels
|
|
case locationNotes
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .peopleOrChat:
|
|
return "peopleOrChat"
|
|
case .appInfo:
|
|
return "appInfo"
|
|
case .fingerprint(let peerID):
|
|
return "fingerprint-\(peerID.id)"
|
|
case .imagePreview(let url):
|
|
return "imagePreview-\(url.path)"
|
|
case .verification:
|
|
return "verification"
|
|
case .locationChannels:
|
|
return "locationChannels"
|
|
case .locationNotes:
|
|
return "locationNotes"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Main Content View
|
|
|
|
struct ContentView: View {
|
|
// MARK: - Properties
|
|
|
|
@EnvironmentObject var viewModel: ChatViewModel
|
|
@ObservedObject private var locationManager = LocationChannelManager.shared
|
|
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
|
@State private var messageText = ""
|
|
@FocusState private var isTextFieldFocused: Bool
|
|
@Environment(\.colorScheme) var colorScheme
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
|
@State private var showPeerList = false
|
|
@State private var showSidebar = false
|
|
@State private var showAppInfo = false
|
|
@State private var showCommandSuggestions = false
|
|
@State private var commandSuggestions: [String] = []
|
|
@State private var showMessageActions = false
|
|
@State private var selectedMessageSender: String?
|
|
@State private var selectedMessageSenderID: String?
|
|
@FocusState private var isNicknameFieldFocused: Bool
|
|
@State private var isAtBottomPublic: Bool = true
|
|
@State private var isAtBottomPrivate: Bool = true
|
|
@State private var lastScrollTime: Date = .distantPast
|
|
@State private var scrollThrottleTimer: Timer?
|
|
@State private var autocompleteDebounceTimer: Timer?
|
|
@State private var showLocationChannelsSheet = false
|
|
@State private var showVerifySheet = false
|
|
@State private var expandedMessageIDs: Set<String> = []
|
|
@State private var showLocationNotes = false
|
|
@State private var notesGeohash: String? = nil
|
|
@State private var sheetNotesCount: Int = 0
|
|
@State private var imagePreviewURL: URL? = nil
|
|
@State private var recordingAlertMessage: String = ""
|
|
@State private var showRecordingAlert = false
|
|
@State private var isRecordingVoiceNote = false
|
|
@State private var isPreparingVoiceNote = false
|
|
@State private var recordingDuration: TimeInterval = 0
|
|
@State private var recordingTimer: Timer?
|
|
@State private var recordingStartDate: Date?
|
|
#if os(iOS)
|
|
@State private var showImagePicker = false
|
|
@State private var imagePickerSourceType: UIImagePickerController.SourceType = .camera
|
|
#else
|
|
@State private var showMacImagePicker = false
|
|
#endif
|
|
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
|
|
@ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11
|
|
@ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12
|
|
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
|
// Window sizes for rendering (infinite scroll up)
|
|
@State private var windowCountPublic: Int = 300
|
|
@State private var windowCountPrivate: [PeerID: Int] = [:]
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
private var backgroundColor: Color {
|
|
colorScheme == .dark ? Color.black : Color.white
|
|
}
|
|
|
|
private var textColor: Color {
|
|
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
|
}
|
|
|
|
private var secondaryTextColor: Color {
|
|
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
|
|
}
|
|
|
|
private var headerLineLimit: Int? {
|
|
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
|
}
|
|
|
|
private var peopleSheetTitle: String {
|
|
String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased()
|
|
}
|
|
|
|
private var peopleSheetSubtitle: String? {
|
|
switch locationManager.selectedChannel {
|
|
case .mesh:
|
|
return "#mesh"
|
|
case .location(let channel):
|
|
return "#\(channel.geohash.lowercased())"
|
|
}
|
|
}
|
|
|
|
private var peopleSheetActiveCount: Int {
|
|
switch locationManager.selectedChannel {
|
|
case .mesh:
|
|
return viewModel.allPeers.filter { $0.peerID != viewModel.meshService.myPeerID }.count
|
|
case .location:
|
|
return viewModel.visibleGeohashPeople().count
|
|
}
|
|
}
|
|
|
|
// MARK: - Sheet Management
|
|
|
|
/// Determines which sheet should currently be active based on all state variables.
|
|
/// Priority is determined by the order of checks (first match wins).
|
|
private var currentSheet: SheetType? {
|
|
// People or private chat sheet (highest priority - primary navigation)
|
|
if showSidebar || viewModel.selectedPrivateChatPeer != nil {
|
|
return .peopleOrChat
|
|
}
|
|
|
|
// Verification sheet
|
|
if showVerifySheet {
|
|
return .verification
|
|
}
|
|
|
|
// Location channels sheet
|
|
if showLocationChannelsSheet {
|
|
return .locationChannels
|
|
}
|
|
|
|
// Location notes sheet
|
|
if showLocationNotes {
|
|
return .locationNotes
|
|
}
|
|
|
|
// App info sheet
|
|
if showAppInfo {
|
|
return .appInfo
|
|
}
|
|
|
|
// Fingerprint sheet
|
|
if let peerID = viewModel.showingFingerprintFor {
|
|
return .fingerprint(peerID)
|
|
}
|
|
|
|
// Image preview sheet
|
|
if let url = imagePreviewURL {
|
|
return .imagePreview(url)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Binding that maps between the SheetType enum and the individual state variables.
|
|
/// When a sheet is presented, it reads from currentSheet.
|
|
/// When a sheet is dismissed, it clears all related state variables.
|
|
private var sheetBinding: Binding<SheetType?> {
|
|
Binding(
|
|
get: {
|
|
return currentSheet
|
|
},
|
|
set: { newSheet in
|
|
// First, clear all sheet-related state to ensure clean transitions
|
|
showSidebar = false
|
|
if viewModel.selectedPrivateChatPeer != nil {
|
|
viewModel.endPrivateChat()
|
|
}
|
|
showAppInfo = false
|
|
viewModel.showingFingerprintFor = nil
|
|
imagePreviewURL = nil
|
|
showVerifySheet = false
|
|
showLocationChannelsSheet = false
|
|
showLocationNotes = false
|
|
notesGeohash = nil
|
|
|
|
// Now set the new sheet state if one was requested
|
|
if let sheet = newSheet {
|
|
switch sheet {
|
|
case .peopleOrChat:
|
|
showSidebar = true
|
|
case .appInfo:
|
|
showAppInfo = true
|
|
case .fingerprint(let peerID):
|
|
viewModel.showingFingerprintFor = peerID
|
|
case .imagePreview(let url):
|
|
imagePreviewURL = url
|
|
case .verification:
|
|
showVerifySheet = true
|
|
case .locationChannels:
|
|
showLocationChannelsSheet = true
|
|
case .locationNotes:
|
|
showLocationNotes = true
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
|
|
private struct PrivateHeaderContext {
|
|
let headerPeerID: PeerID
|
|
let peer: BitchatPeer?
|
|
let displayName: String
|
|
let isNostrAvailable: Bool
|
|
}
|
|
|
|
// MARK: - Body
|
|
|
|
private var mainContent: some View {
|
|
VStack(spacing: 0) {
|
|
mainHeaderView
|
|
.onAppear {
|
|
viewModel.currentColorScheme = colorScheme
|
|
#if os(macOS)
|
|
// Focus message input on macOS launch, not nickname field
|
|
DispatchQueue.main.async {
|
|
isNicknameFieldFocused = false
|
|
isTextFieldFocused = true
|
|
}
|
|
#endif
|
|
}
|
|
.onChange(of: colorScheme) { newValue in
|
|
viewModel.currentColorScheme = newValue
|
|
}
|
|
|
|
Divider()
|
|
|
|
GeometryReader { geometry in
|
|
VStack(spacing: 0) {
|
|
messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic)
|
|
.background(backgroundColor)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
|
}
|
|
|
|
Divider()
|
|
|
|
if viewModel.selectedPrivateChatPeer == nil {
|
|
inputView
|
|
}
|
|
}
|
|
.background(backgroundColor)
|
|
.foregroundColor(textColor)
|
|
#if os(macOS)
|
|
.frame(minWidth: 600, minHeight: 400)
|
|
#endif
|
|
.onChange(of: viewModel.selectedPrivateChatPeer) { newValue in
|
|
if newValue != nil {
|
|
showSidebar = true
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
mainContent
|
|
// MARK: - Consolidated Sheet Presentation
|
|
.sheet(item: sheetBinding) { sheet in
|
|
switch sheet {
|
|
case .peopleOrChat:
|
|
peopleSheetView
|
|
|
|
case .appInfo:
|
|
AppInfoView()
|
|
.onAppear { viewModel.isAppInfoPresented = true }
|
|
.onDisappear { viewModel.isAppInfoPresented = false }
|
|
|
|
case .fingerprint(let peerID):
|
|
FingerprintView(viewModel: viewModel, peerID: peerID)
|
|
|
|
case .imagePreview(let url):
|
|
ImagePreviewView(url: url)
|
|
|
|
case .verification:
|
|
VerificationSheetView(isPresented: $showVerifySheet)
|
|
.environmentObject(viewModel)
|
|
|
|
case .locationChannels:
|
|
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
|
|
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
|
|
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
|
|
|
|
case .locationNotes:
|
|
Group {
|
|
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
|
LocationNotesView(geohash: gh)
|
|
.environmentObject(viewModel)
|
|
} else {
|
|
VStack(spacing: 12) {
|
|
HStack {
|
|
Text("content.notes.title")
|
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
|
Spacer()
|
|
Button(action: { showLocationNotes = false }) {
|
|
Image(systemName: "xmark")
|
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
|
.foregroundColor(textColor)
|
|
.frame(width: 32, height: 32)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
|
|
}
|
|
.frame(height: headerHeight)
|
|
.padding(.horizontal, 12)
|
|
.background(backgroundColor.opacity(0.95))
|
|
Text("content.notes.location_unavailable")
|
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
|
.foregroundColor(secondaryTextColor)
|
|
Button("content.location.enable") {
|
|
LocationChannelManager.shared.enableLocationChannels()
|
|
LocationChannelManager.shared.refreshChannels()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
Spacer()
|
|
}
|
|
.background(backgroundColor)
|
|
.foregroundColor(textColor)
|
|
}
|
|
}
|
|
.onAppear {
|
|
LocationChannelManager.shared.enableLocationChannels()
|
|
LocationChannelManager.shared.beginLiveRefresh()
|
|
}
|
|
.onDisappear {
|
|
LocationChannelManager.shared.endLiveRefresh()
|
|
}
|
|
.onChange(of: locationManager.availableChannels) { channels in
|
|
if let current = channels.first(where: { $0.level == .building })?.geohash,
|
|
notesGeohash != current {
|
|
notesGeohash = current
|
|
#if os(iOS)
|
|
let generator = UIImpactFeedbackGenerator(style: .light)
|
|
generator.prepare()
|
|
generator.impactOccurred()
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// MARK: - Image Picker for Main View
|
|
// Only present when NOT in a DM or people sheet (parent-child pattern)
|
|
#if os(iOS)
|
|
.sheet(isPresented: Binding(
|
|
get: { showImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
|
|
set: { newValue in
|
|
if !newValue {
|
|
showImagePicker = false
|
|
}
|
|
}
|
|
)) {
|
|
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
|
showImagePicker = false
|
|
if let image = image {
|
|
Task {
|
|
do {
|
|
let processedURL = try ImageUtils.processImage(image)
|
|
await MainActor.run {
|
|
viewModel.sendImage(from: processedURL)
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
#endif
|
|
#if os(macOS)
|
|
.sheet(isPresented: Binding(
|
|
get: { showMacImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
|
|
set: { newValue in
|
|
if !newValue {
|
|
showMacImagePicker = false
|
|
}
|
|
}
|
|
)) {
|
|
MacImagePickerView { url in
|
|
showMacImagePicker = false
|
|
if let url = url {
|
|
Task {
|
|
do {
|
|
let processedURL = try ImageUtils.processImage(at: url)
|
|
await MainActor.run {
|
|
viewModel.sendImage(from: processedURL)
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
|
|
Button("OK", role: .cancel) {}
|
|
}, message: {
|
|
Text(recordingAlertMessage)
|
|
})
|
|
.confirmationDialog(
|
|
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
|
|
isPresented: $showMessageActions,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("content.actions.mention") {
|
|
if let sender = selectedMessageSender {
|
|
// Pre-fill the input with an @mention and focus the field
|
|
messageText = "@\(sender) "
|
|
isTextFieldFocused = true
|
|
}
|
|
}
|
|
|
|
Button("content.actions.direct_message") {
|
|
if let peerID = selectedMessageSenderID {
|
|
if peerID.hasPrefix("nostr:") {
|
|
if let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)) {
|
|
viewModel.startGeohashDM(withPubkeyHex: full)
|
|
}
|
|
} else {
|
|
viewModel.startPrivateChat(with: PeerID(str: peerID))
|
|
}
|
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
showSidebar = true
|
|
}
|
|
}
|
|
}
|
|
|
|
Button("content.actions.hug") {
|
|
if let sender = selectedMessageSender {
|
|
viewModel.sendMessage("/hug @\(sender)")
|
|
}
|
|
}
|
|
|
|
Button("content.actions.slap") {
|
|
if let sender = selectedMessageSender {
|
|
viewModel.sendMessage("/slap @\(sender)")
|
|
}
|
|
}
|
|
|
|
Button("content.actions.block", role: .destructive) {
|
|
// Prefer direct geohash block when we have a Nostr sender ID
|
|
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
|
|
let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)),
|
|
let sender = selectedMessageSender {
|
|
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
|
} else if let sender = selectedMessageSender {
|
|
viewModel.sendMessage("/block \(sender)")
|
|
}
|
|
}
|
|
|
|
Button("common.cancel", role: .cancel) {}
|
|
}
|
|
.alert("content.alert.bluetooth_required.title", isPresented: $viewModel.showBluetoothAlert) {
|
|
Button("content.alert.bluetooth_required.settings") {
|
|
#if os(iOS)
|
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
#endif
|
|
}
|
|
Button("common.ok", role: .cancel) {}
|
|
} message: {
|
|
Text(viewModel.bluetoothAlertMessage)
|
|
}
|
|
.onDisappear {
|
|
// Clean up timers
|
|
scrollThrottleTimer?.invalidate()
|
|
autocompleteDebounceTimer?.invalidate()
|
|
}
|
|
}
|
|
|
|
// MARK: - Message List View
|
|
|
|
private func messagesView(privatePeer: PeerID?, isAtBottom: Binding<Bool>) -> some View {
|
|
let messages: [BitchatMessage] = {
|
|
if let peerID = privatePeer {
|
|
return viewModel.getPrivateChatMessages(for: peerID)
|
|
}
|
|
return viewModel.messages
|
|
}()
|
|
|
|
let currentWindowCount: Int = {
|
|
if let peer = privatePeer {
|
|
return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
|
|
}
|
|
return windowCountPublic
|
|
}()
|
|
|
|
let windowedMessages: [BitchatMessage] = Array(messages.suffix(currentWindowCount))
|
|
|
|
let contextKey: String = {
|
|
if let peer = privatePeer { return "dm:\(peer)" }
|
|
switch locationManager.selectedChannel {
|
|
case .mesh: return "mesh"
|
|
case .location(let ch): return "geo:\(ch.geohash)"
|
|
}
|
|
}()
|
|
|
|
let messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in
|
|
let trimmed = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message)
|
|
}
|
|
|
|
return ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
ForEach(messageItems) { item in
|
|
let message = item.message
|
|
messageRow(for: message)
|
|
.onAppear {
|
|
if message.id == windowedMessages.last?.id {
|
|
isAtBottom.wrappedValue = true
|
|
}
|
|
if message.id == windowedMessages.first?.id,
|
|
messages.count > windowedMessages.count {
|
|
expandWindow(
|
|
ifNeededFor: message,
|
|
allMessages: messages,
|
|
privatePeer: privatePeer,
|
|
proxy: proxy
|
|
)
|
|
}
|
|
}
|
|
.onDisappear {
|
|
if message.id == windowedMessages.last?.id {
|
|
isAtBottom.wrappedValue = false
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
if message.sender != "system" {
|
|
messageText = "@\(message.sender) "
|
|
isTextFieldFocused = true
|
|
}
|
|
}
|
|
.contextMenu {
|
|
Button("content.message.copy") {
|
|
#if os(iOS)
|
|
UIPasteboard.general.string = message.content
|
|
#else
|
|
let pb = NSPasteboard.general
|
|
pb.clearContents()
|
|
pb.setString(message.content, forType: .string)
|
|
#endif
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 1)
|
|
}
|
|
}
|
|
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
|
|
.padding(.vertical, 2)
|
|
}
|
|
.background(backgroundColor)
|
|
.onOpenURL { handleOpenURL($0) }
|
|
.onTapGesture(count: 3) {
|
|
viewModel.sendMessage("/clear")
|
|
}
|
|
.onAppear {
|
|
scrollToBottom(on: proxy, privatePeer: privatePeer, isAtBottom: isAtBottom)
|
|
}
|
|
.onChange(of: privatePeer) { _ in
|
|
scrollToBottom(on: proxy, privatePeer: privatePeer, isAtBottom: isAtBottom)
|
|
}
|
|
.onChange(of: viewModel.messages.count) { _ in
|
|
if privatePeer == nil && !viewModel.messages.isEmpty {
|
|
// If the newest message is from me, always scroll to bottom
|
|
let lastMsg = viewModel.messages.last!
|
|
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
|
if !isFromSelf {
|
|
// Only autoscroll when user is at/near bottom
|
|
guard isAtBottom.wrappedValue else { return }
|
|
} else {
|
|
// Ensure we consider ourselves at bottom for subsequent messages
|
|
isAtBottom.wrappedValue = true
|
|
}
|
|
// Throttle scroll animations to prevent excessive UI updates
|
|
let now = Date()
|
|
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
|
// Immediate scroll if enough time has passed
|
|
lastScrollTime = now
|
|
let contextKey: String = {
|
|
switch locationManager.selectedChannel {
|
|
case .mesh: return "mesh"
|
|
case .location(let ch): return "geo:\(ch.geohash)"
|
|
}
|
|
}()
|
|
let count = windowCountPublic
|
|
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
|
DispatchQueue.main.async {
|
|
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
}
|
|
} else {
|
|
// Schedule a delayed scroll
|
|
scrollThrottleTimer?.invalidate()
|
|
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
|
lastScrollTime = Date()
|
|
let contextKey: String = {
|
|
switch locationManager.selectedChannel {
|
|
case .mesh: return "mesh"
|
|
case .location(let ch): return "geo:\(ch.geohash)"
|
|
}
|
|
}()
|
|
let count = windowCountPublic
|
|
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
|
DispatchQueue.main.async {
|
|
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: viewModel.privateChats) { _ in
|
|
if let peerID = privatePeer,
|
|
let messages = viewModel.privateChats[peerID],
|
|
!messages.isEmpty {
|
|
// If the newest private message is from me, always scroll
|
|
let lastMsg = messages.last!
|
|
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
|
if !isFromSelf {
|
|
// Only autoscroll when user is at/near bottom
|
|
guard isAtBottom.wrappedValue else { return }
|
|
} else {
|
|
isAtBottom.wrappedValue = true
|
|
}
|
|
// Same throttling for private chats
|
|
let now = Date()
|
|
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
|
lastScrollTime = now
|
|
let contextKey = "dm:\(peerID)"
|
|
let count = windowCountPrivate[peerID] ?? 300
|
|
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
|
DispatchQueue.main.async {
|
|
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
}
|
|
} else {
|
|
scrollThrottleTimer?.invalidate()
|
|
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
|
lastScrollTime = Date()
|
|
let contextKey = "dm:\(peerID)"
|
|
let count = windowCountPrivate[peerID] ?? 300
|
|
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
|
DispatchQueue.main.async {
|
|
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: locationManager.selectedChannel) { newChannel in
|
|
// When switching to a new geohash channel, scroll to the bottom
|
|
guard privatePeer == nil else { return }
|
|
switch newChannel {
|
|
case .mesh:
|
|
break
|
|
case .location(let ch):
|
|
// Reset window size
|
|
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
|
let contextKey = "geo:\(ch.geohash)"
|
|
let last = viewModel.messages.suffix(windowCountPublic).last?.id
|
|
let target = last.map { "\(contextKey)|\($0)" }
|
|
isAtBottom.wrappedValue = true
|
|
DispatchQueue.main.async {
|
|
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
// Also check when view appears
|
|
if let peerID = privatePeer {
|
|
// Try multiple times to ensure read receipts are sent
|
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
|
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
|
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.environment(\.openURL, OpenURLAction { url in
|
|
// Intercept custom cashu: links created in attributed text
|
|
if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" {
|
|
#if os(iOS)
|
|
UIApplication.shared.open(url)
|
|
return .handled
|
|
#else
|
|
// On non-iOS platforms, let the system handle or ignore
|
|
return .systemAction
|
|
#endif
|
|
}
|
|
return .systemAction
|
|
})
|
|
}
|
|
|
|
// MARK: - Input View
|
|
|
|
@ViewBuilder
|
|
private var inputView: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
// @mentions autocomplete
|
|
if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(Array(viewModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in
|
|
Button(action: {
|
|
_ = viewModel.completeNickname(suggestion, in: &messageText)
|
|
}) {
|
|
HStack {
|
|
Text(suggestion)
|
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
|
.foregroundColor(textColor)
|
|
.fontWeight(.medium)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 3)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.background(Color.gray.opacity(0.1))
|
|
}
|
|
}
|
|
.background(backgroundColor)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
|
)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
|
|
// Command suggestions
|
|
if showCommandSuggestions && !commandSuggestions.isEmpty {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Define commands with aliases and syntax
|
|
let baseInfo: [(commands: [String], syntax: String?, description: String)] = [
|
|
(["/block"], "[nickname]", "block or list blocked peers"),
|
|
(["/clear"], nil, "clear chat messages"),
|
|
(["/hug"], "<nickname>", "send someone a warm hug"),
|
|
(["/m", "/msg"], "<nickname> [message]", "send private message"),
|
|
(["/slap"], "<nickname>", "slap someone with a trout"),
|
|
(["/unblock"], "<nickname>", "unblock a peer"),
|
|
(["/w"], nil, "see who's online")
|
|
]
|
|
let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }()
|
|
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
|
|
let favInfo: [(commands: [String], syntax: String?, description: String)] = [
|
|
(["/fav"], "<nickname>", "add to favorites"),
|
|
(["/unfav"], "<nickname>", "remove from favorites")
|
|
]
|
|
let commandInfo = baseInfo + ((isGeoPublic || isGeoDM) ? [] : favInfo)
|
|
|
|
// Build the display
|
|
let allCommands = commandInfo
|
|
|
|
// Show matching commands
|
|
ForEach(commandSuggestions, id: \.self) { command in
|
|
// Find the command info for this suggestion
|
|
if let info = allCommands.first(where: { $0.commands.contains(command) }) {
|
|
Button(action: {
|
|
// Replace current text with selected command
|
|
messageText = command + " "
|
|
showCommandSuggestions = false
|
|
commandSuggestions = []
|
|
}) {
|
|
HStack {
|
|
// Show all aliases together
|
|
Text(info.commands.joined(separator: ", "))
|
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
|
.foregroundColor(textColor)
|
|
.fontWeight(.medium)
|
|
|
|
// Show syntax if any
|
|
if let syntax = info.syntax {
|
|
Text(syntax)
|
|
.font(.bitchatSystem(size: 10, design: .monospaced))
|
|
.foregroundColor(secondaryTextColor.opacity(0.8))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Show description
|
|
Text(info.description)
|
|
.font(.bitchatSystem(size: 10, design: .monospaced))
|
|
.foregroundColor(secondaryTextColor)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 3)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.background(Color.gray.opacity(0.1))
|
|
}
|
|
}
|
|
}
|
|
.background(backgroundColor)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
|
)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
|
|
// Recording indicator
|
|
if isPreparingVoiceNote || isRecordingVoiceNote {
|
|
recordingIndicator
|
|
}
|
|
|
|
HStack(alignment: .center, spacing: 4) {
|
|
TextField(
|
|
"",
|
|
text: $messageText,
|
|
prompt: Text(
|
|
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer")
|
|
)
|
|
.foregroundColor(secondaryTextColor.opacity(0.6))
|
|
)
|
|
.textFieldStyle(.plain)
|
|
.font(.bitchatSystem(size: 15, design: .monospaced))
|
|
.foregroundColor(textColor)
|
|
.focused($isTextFieldFocused)
|
|
.autocorrectionDisabled(true)
|
|
#if os(iOS)
|
|
.textInputAutocapitalization(.sentences)
|
|
#endif
|
|
.submitLabel(.send)
|
|
.onSubmit { sendMessage() }
|
|
.padding(.vertical, 4)
|
|
.padding(.horizontal, 6)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7))
|
|
)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.onChange(of: messageText) { newValue in
|
|
// Cancel previous debounce timer
|
|
autocompleteDebounceTimer?.invalidate()
|
|
|
|
// Debounce autocomplete updates to reduce calls during rapid typing
|
|
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
|
// Get cursor position (approximate - end of text for now)
|
|
let cursorPosition = newValue.count
|
|
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
|
}
|
|
|
|
// Check for command autocomplete (instant, no debounce needed)
|
|
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
|
// Build context-aware command list
|
|
let isGeoPublic: Bool = {
|
|
if case .location = locationManager.selectedChannel { return true }
|
|
return false
|
|
}()
|
|
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
|
|
var commandDescriptions = [
|
|
("/block", String(localized: "content.commands.block", comment: "Description for /block command")),
|
|
("/clear", String(localized: "content.commands.clear", comment: "Description for /clear command")),
|
|
("/hug", String(localized: "content.commands.hug", comment: "Description for /hug command")),
|
|
("/m", String(localized: "content.commands.message", comment: "Description for /m command")),
|
|
("/slap", String(localized: "content.commands.slap", comment: "Description for /slap command")),
|
|
("/unblock", String(localized: "content.commands.unblock", comment: "Description for /unblock command")),
|
|
("/w", String(localized: "content.commands.who", comment: "Description for /w command"))
|
|
]
|
|
// Only show favorites commands when not in geohash context
|
|
if !(isGeoPublic || isGeoDM) {
|
|
commandDescriptions.append(("/fav", String(localized: "content.commands.favorite", comment: "Description for /fav command")))
|
|
commandDescriptions.append(("/unfav", String(localized: "content.commands.unfavorite", comment: "Description for /unfav command")))
|
|
}
|
|
|
|
let input = newValue.lowercased()
|
|
|
|
// Map of aliases to primary commands
|
|
let aliases: [String: String] = [
|
|
"/join": "/j",
|
|
"/msg": "/m"
|
|
]
|
|
|
|
// Filter commands, but convert aliases to primary
|
|
commandSuggestions = commandDescriptions
|
|
.filter { $0.0.starts(with: input) }
|
|
.map { $0.0 }
|
|
|
|
// Also check if input matches an alias
|
|
for (alias, primary) in aliases {
|
|
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
|
|
if commandDescriptions.contains(where: { $0.0 == primary }) {
|
|
commandSuggestions.append(primary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove duplicates and sort
|
|
commandSuggestions = Array(Set(commandSuggestions)).sorted()
|
|
showCommandSuggestions = !commandSuggestions.isEmpty
|
|
} else {
|
|
showCommandSuggestions = false
|
|
commandSuggestions = []
|
|
}
|
|
}
|
|
|
|
HStack(alignment: .center, spacing: 4) {
|
|
if shouldShowMediaControls {
|
|
attachmentButton
|
|
}
|
|
|
|
sendOrMicButton
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 6)
|
|
.padding(.top, 6)
|
|
.padding(.bottom, 8)
|
|
.background(backgroundColor.opacity(0.95))
|
|
}
|
|
|
|
private func handleOpenURL(_ url: URL) {
|
|
guard url.scheme == "bitchat" else { return }
|
|
switch url.host {
|
|
case "user":
|
|
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
|
selectedMessageSenderID = peerID.id
|
|
|
|
if peerID.isGeoDM || peerID.isGeoChat {
|
|
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
|
} else if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
|
selectedMessageSender = name
|
|
} else {
|
|
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
|
}
|
|
|
|
if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
|
|
selectedMessageSender = nil
|
|
selectedMessageSenderID = nil
|
|
} else {
|
|
showMessageActions = true
|
|
}
|
|
|
|
case "geohash":
|
|
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
|
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
|
|
|
|
func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
|
switch len {
|
|
case 0...2: return .region
|
|
case 3...4: return .province
|
|
case 5: return .city
|
|
case 6: return .neighborhood
|
|
case 7: return .block
|
|
default: return .block
|
|
}
|
|
}
|
|
|
|
let level = levelForLength(gh.count)
|
|
let channel = GeohashChannel(level: level, geohash: gh)
|
|
|
|
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
|
|
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
|
|
LocationChannelManager.shared.markTeleported(for: gh, true)
|
|
}
|
|
LocationChannelManager.shared.select(ChannelID.location(channel))
|
|
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
|
|
private func scrollToBottom(on proxy: ScrollViewProxy,
|
|
privatePeer: PeerID?,
|
|
isAtBottom: Binding<Bool>) {
|
|
let targetID: String? = {
|
|
if let peer = privatePeer,
|
|
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
|
return "dm:\(peer)|\(last)"
|
|
}
|
|
let contextKey: String = {
|
|
switch locationManager.selectedChannel {
|
|
case .mesh: return "mesh"
|
|
case .location(let ch): return "geo:\(ch.geohash)"
|
|
}
|
|
}()
|
|
if let last = viewModel.messages.suffix(300).last?.id {
|
|
return "\(contextKey)|\(last)"
|
|
}
|
|
return nil
|
|
}()
|
|
|
|
isAtBottom.wrappedValue = true
|
|
|
|
DispatchQueue.main.async {
|
|
if let targetID {
|
|
proxy.scrollTo(targetID, anchor: .bottom)
|
|
}
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
let secondTarget: String? = {
|
|
if let peer = privatePeer,
|
|
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
|
return "dm:\(peer)|\(last)"
|
|
}
|
|
let contextKey: String = {
|
|
switch locationManager.selectedChannel {
|
|
case .mesh: return "mesh"
|
|
case .location(let ch): return "geo:\(ch.geohash)"
|
|
}
|
|
}()
|
|
if let last = viewModel.messages.suffix(300).last?.id {
|
|
return "\(contextKey)|\(last)"
|
|
}
|
|
return nil
|
|
}()
|
|
|
|
if let secondTarget {
|
|
proxy.scrollTo(secondTarget, anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
// MARK: - Actions
|
|
|
|
private func sendMessage() {
|
|
let trimmed = trimmedMessageText
|
|
guard !trimmed.isEmpty else { return }
|
|
|
|
// Clear input immediately for instant feedback
|
|
messageText = ""
|
|
|
|
// Defer actual send to next runloop to avoid blocking
|
|
DispatchQueue.main.async {
|
|
self.viewModel.sendMessage(trimmed)
|
|
}
|
|
}
|
|
|
|
// MARK: - Sheet Content
|
|
|
|
private var peopleSheetView: some View {
|
|
Group {
|
|
if viewModel.selectedPrivateChatPeer != nil {
|
|
privateChatSheetView
|
|
} else {
|
|
peopleListSheetView
|
|
}
|
|
}
|
|
.background(backgroundColor)
|
|
.foregroundColor(textColor)
|
|
#if os(macOS)
|
|
.frame(minWidth: 420, minHeight: 520)
|
|
#endif
|
|
// MARK: - Image Picker for DM Context (Parent-Child Pattern)
|
|
// Present image picker when IN a sheet (DM or people sheet)
|
|
#if os(iOS)
|
|
.sheet(isPresented: Binding(
|
|
get: { showImagePicker && (showSidebar || viewModel.selectedPrivateChatPeer != nil) },
|
|
set: { newValue in
|
|
if !newValue {
|
|
showImagePicker = false
|
|
}
|
|
}
|
|
)) {
|
|
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
|
showImagePicker = false
|
|
if let image = image {
|
|
Task {
|
|
do {
|
|
let processedURL = try ImageUtils.processImage(image)
|
|
await MainActor.run {
|
|
viewModel.sendImage(from: processedURL)
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
#endif
|
|
#if os(macOS)
|
|
.sheet(isPresented: Binding(
|
|
get: { showMacImagePicker && (showSidebar || viewModel.selectedPrivateChatPeer != nil) },
|
|
set: { newValue in
|
|
if !newValue {
|
|
showMacImagePicker = false
|
|
}
|
|
}
|
|
)) {
|
|
MacImagePickerView { url in
|
|
showMacImagePicker = false
|
|
if let url = url {
|
|
Task {
|
|
do {
|
|
let processedURL = try ImageUtils.processImage(at: url)
|
|
await MainActor.run {
|
|
viewModel.sendImage(from: processedURL)
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - People Sheet Views
|
|
|
|
private var peopleListSheetView: some View {
|
|
VStack(spacing: 0) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 12) {
|
|
Text(peopleSheetTitle)
|
|
.font(.bitchatSystem(size: 18, design: .monospaced))
|
|
.foregroundColor(textColor)
|
|
Spacer()
|
|
if case .mesh = locationManager.selectedChannel {
|
|
Button(action: { showVerifySheet = true }) {
|
|
Image(systemName: "qrcode")
|
|
.font(.bitchatSystem(size: 14))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(
|
|
String(localized: "content.help.verification", comment: "Help text for verification button")
|
|
)
|
|
}
|
|
Button(action: {
|
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
dismiss()
|
|
showSidebar = false
|
|
showVerifySheet = false
|
|
viewModel.endPrivateChat()
|
|
}
|
|
}) {
|
|
Image(systemName: "xmark")
|
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
|
.frame(width: 32, height: 32)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Close")
|
|
}
|
|
let activeText = String.localizedStringWithFormat(
|
|
String(localized: "%@ active", comment: "Count of active users in the people sheet"),
|
|
"\(peopleSheetActiveCount)"
|
|
)
|
|
|
|
if let subtitle = peopleSheetSubtitle {
|
|
let subtitleColor: Color = {
|
|
switch locationManager.selectedChannel {
|
|
case .mesh:
|
|
return Color.blue
|
|
case .location:
|
|
return Color.green
|
|
}
|
|
}()
|
|
HStack(spacing: 6) {
|
|
Text(subtitle)
|
|
.foregroundColor(subtitleColor)
|
|
Text(activeText)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
|
} else {
|
|
Text(activeText)
|
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 12)
|
|
.background(backgroundColor)
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if case .location = locationManager.selectedChannel {
|
|
GeohashPeopleList(
|
|
viewModel: viewModel,
|
|
textColor: textColor,
|
|
secondaryTextColor: secondaryTextColor,
|
|
onTapPerson: {
|
|
showSidebar = true
|
|
}
|
|
)
|
|
} else {
|
|
MeshPeerList(
|
|
viewModel: viewModel,
|
|
textColor: textColor,
|
|
secondaryTextColor: secondaryTextColor,
|
|
onTapPeer: { peerID in
|
|
viewModel.startPrivateChat(with: peerID)
|
|
showSidebar = true
|
|
},
|
|
onToggleFavorite: { peerID in
|
|
viewModel.toggleFavorite(peerID: peerID)
|
|
},
|
|
onShowFingerprint: { peerID in
|
|
viewModel.showFingerprint(for: peerID)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
.id(viewModel.allPeers.map { "\($0.peerID)-\($0.isConnected)" }.joined())
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - View Components
|
|
|
|
private var privateChatSheetView: some View {
|
|
VStack(spacing: 0) {
|
|
if let privatePeerID = viewModel.selectedPrivateChatPeer {
|
|
let headerContext = makePrivateHeaderContext(for: privatePeerID)
|
|
|
|
HStack(spacing: 12) {
|
|
Button(action: {
|
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
viewModel.endPrivateChat()
|
|
}
|
|
}) {
|
|
Image(systemName: "chevron.left")
|
|
.font(.bitchatSystem(size: 12))
|
|
.foregroundColor(textColor)
|
|
.frame(width: 44, height: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(
|
|
String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat")
|
|
)
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
HStack(spacing: 8) {
|
|
privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID)
|
|
let isFavorite = viewModel.isFavorite(peerID: headerContext.headerPeerID)
|
|
|
|
if !privatePeerID.isGeoDM {
|
|
Button(action: {
|
|
viewModel.toggleFavorite(peerID: headerContext.headerPeerID)
|
|
}) {
|
|
Image(systemName: isFavorite ? "star.fill" : "star")
|
|
.font(.bitchatSystem(size: 14))
|
|
.foregroundColor(isFavorite ? Color.yellow : textColor)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(
|
|
isFavorite
|
|
? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
|
|
: String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
|
|
)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
Button(action: {
|
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
viewModel.endPrivateChat()
|
|
showSidebar = true
|
|
}
|
|
}) {
|
|
Image(systemName: "xmark")
|
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
|
.frame(width: 32, height: 32)
|
|
}
|
|
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Close")
|
|
}
|
|
.frame(height: headerHeight)
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 10)
|
|
.padding(.bottom, 12)
|
|
.background(backgroundColor)
|
|
}
|
|
|
|
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
|
|
.background(backgroundColor)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
Divider()
|
|
inputView
|
|
}
|
|
.background(backgroundColor)
|
|
.foregroundColor(textColor)
|
|
.highPriorityGesture(
|
|
DragGesture(minimumDistance: 25, coordinateSpace: .local)
|
|
.onEnded { value in
|
|
let horizontal = value.translation.width
|
|
let vertical = abs(value.translation.height)
|
|
guard horizontal > 80, vertical < 60 else { return }
|
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
showSidebar = true
|
|
viewModel.endPrivateChat()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: PeerID) -> some View {
|
|
Button(action: {
|
|
viewModel.showFingerprint(for: context.headerPeerID)
|
|
}) {
|
|
HStack(spacing: 6) {
|
|
if let connectionState = context.peer?.connectionState {
|
|
switch connectionState {
|
|
case .bluetoothConnected:
|
|
Image(systemName: "dot.radiowaves.left.and.right")
|
|
.font(.bitchatSystem(size: 14))
|
|
.foregroundColor(textColor)
|
|
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
|
|
case .meshReachable:
|
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
|
.font(.bitchatSystem(size: 14))
|
|
.foregroundColor(textColor)
|
|
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
|
|
case .nostrAvailable:
|
|
Image(systemName: "globe")
|
|
.font(.bitchatSystem(size: 14))
|
|
.foregroundColor(.purple)
|
|
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
|
case .offline:
|
|
EmptyView()
|
|
}
|
|
} else if viewModel.meshService.isPeerReachable(context.headerPeerID) {
|
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
|
.font(.bitchatSystem(size: 14))
|
|
.foregroundColor(textColor)
|
|
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
|
|
} else if context.isNostrAvailable {
|
|
Image(systemName: "globe")
|
|
.font(.bitchatSystem(size: 14))
|
|
.foregroundColor(.purple)
|
|
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
|
} else if viewModel.meshService.isPeerConnected(context.headerPeerID) || viewModel.connectedPeers.contains(context.headerPeerID) {
|
|
Image(systemName: "dot.radiowaves.left.and.right")
|
|
.font(.bitchatSystem(size: 14))
|
|
.foregroundColor(textColor)
|
|
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
|
|
}
|
|
|
|
Text(context.displayName)
|
|
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
|
|
.foregroundColor(textColor)
|
|
|
|
if !privatePeerID.isGeoDM {
|
|
let statusPeerID: PeerID = {
|
|
if privatePeerID.id.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID.id) {
|
|
return short
|
|
}
|
|
return context.headerPeerID
|
|
}()
|
|
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
|
if let icon = encryptionStatus.icon {
|
|
Image(systemName: icon)
|
|
.font(.bitchatSystem(size: 14))
|
|
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
|
|
encryptionStatus == .noiseSecured ? textColor :
|
|
Color.red)
|
|
.accessibilityLabel(
|
|
String(
|
|
format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"),
|
|
locale: .current,
|
|
encryptionStatus.accessibilityDescription
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(
|
|
String(
|
|
format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"),
|
|
locale: .current,
|
|
context.displayName
|
|
)
|
|
)
|
|
.accessibilityHint(
|
|
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
|
|
)
|
|
.frame(height: headerHeight)
|
|
}
|
|
|
|
private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext {
|
|
let headerPeerID: PeerID = {
|
|
if privatePeerID.id.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID.id) {
|
|
return short
|
|
}
|
|
return privatePeerID
|
|
}()
|
|
|
|
let peer = viewModel.getPeer(byID: headerPeerID)
|
|
|
|
let displayName: String = {
|
|
if privatePeerID.isGeoDM, case .location(let ch) = locationManager.selectedChannel {
|
|
let disp = viewModel.geohashDisplayName(for: privatePeerID)
|
|
return "#\(ch.geohash)/@\(disp)"
|
|
}
|
|
if let name = peer?.displayName { return name }
|
|
if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name }
|
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID.id) ?? Data()),
|
|
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
|
if headerPeerID.id.count == 16 {
|
|
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
|
if let id = candidates.first,
|
|
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
|
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
|
}
|
|
} else if let keyData = headerPeerID.noiseKey {
|
|
let fp = keyData.sha256Fingerprint()
|
|
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
|
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
|
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
|
}
|
|
}
|
|
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
|
}()
|
|
|
|
let isNostrAvailable: Bool = {
|
|
guard let connectionState = peer?.connectionState else {
|
|
if let noiseKey = Data(hexString: headerPeerID.id),
|
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
|
favoriteStatus.isMutual {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
return connectionState == .nostrAvailable
|
|
}()
|
|
|
|
return PrivateHeaderContext(
|
|
headerPeerID: headerPeerID,
|
|
peer: peer,
|
|
displayName: displayName,
|
|
isNostrAvailable: isNostrAvailable
|
|
)
|
|
}
|
|
|
|
// Split a name into base and a '#abcd' suffix if present
|
|
private func splitNameSuffix(_ name: String) -> (base: String, suffix: String) {
|
|
guard name.count >= 5 else { return (name, "") }
|
|
let suffix = String(name.suffix(5))
|
|
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
|
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
|
}) {
|
|
let base = String(name.dropLast(5))
|
|
return (base, suffix)
|
|
}
|
|
return (name, "")
|
|
}
|
|
|
|
// Compute channel-aware people count and color for toolbar (cross-platform)
|
|
private func channelPeopleCountAndColor() -> (Int, Color) {
|
|
switch locationManager.selectedChannel {
|
|
case .location:
|
|
let n = viewModel.geohashPeople.count
|
|
let standardGreen = (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
|
return (n, n > 0 ? standardGreen : Color.secondary)
|
|
case .mesh:
|
|
let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
|
|
guard peer.peerID != viewModel.meshService.myPeerID else { return }
|
|
if peer.isConnected { counts.mesh += 1; counts.others += 1 }
|
|
else if peer.isReachable { counts.others += 1 }
|
|
}
|
|
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
|
let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary
|
|
return (counts.others, color)
|
|
}
|
|
}
|
|
|
|
|
|
private var mainHeaderView: some View {
|
|
HStack(spacing: 0) {
|
|
Text(verbatim: "bitchat/")
|
|
.font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced))
|
|
.foregroundColor(textColor)
|
|
.onTapGesture(count: 3) {
|
|
// PANIC: Triple-tap to clear all data
|
|
viewModel.panicClearAllData()
|
|
}
|
|
.onTapGesture(count: 1) {
|
|
// Single tap for app info
|
|
showAppInfo = true
|
|
}
|
|
|
|
HStack(spacing: 0) {
|
|
Text(verbatim: "@")
|
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
|
.foregroundColor(secondaryTextColor)
|
|
|
|
TextField("content.input.nickname_placeholder", text: $viewModel.nickname)
|
|
.textFieldStyle(.plain)
|
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
|
.frame(maxWidth: 80)
|
|
.foregroundColor(textColor)
|
|
.focused($isNicknameFieldFocused)
|
|
.autocorrectionDisabled(true)
|
|
#if os(iOS)
|
|
.textInputAutocapitalization(.never)
|
|
#endif
|
|
.onChange(of: isNicknameFieldFocused) { isFocused in
|
|
if !isFocused {
|
|
// Only validate when losing focus
|
|
viewModel.validateAndSaveNickname()
|
|
}
|
|
}
|
|
.onSubmit {
|
|
viewModel.validateAndSaveNickname()
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Channel badge + dynamic spacing + people counter
|
|
// Precompute header count and color outside the ViewBuilder expressions
|
|
let cc = channelPeopleCountAndColor()
|
|
let headerCountColor: Color = cc.1
|
|
let headerOtherPeersCount: Int = {
|
|
if case .location = locationManager.selectedChannel {
|
|
return viewModel.visibleGeohashPeople().count
|
|
}
|
|
return cc.0
|
|
}()
|
|
|
|
HStack(spacing: 10) {
|
|
// Unread icon immediately to the left of the channel badge (independent from channel button)
|
|
|
|
// Unread indicator (now shown on iOS and macOS)
|
|
if viewModel.hasAnyUnreadMessages {
|
|
Button(action: { viewModel.openMostRelevantPrivateChat() }) {
|
|
Image(systemName: "envelope.fill")
|
|
.font(.bitchatSystem(size: 12))
|
|
.foregroundColor(Color.orange)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(
|
|
String(localized: "content.accessibility.open_unread_private_chat", comment: "Accessibility label for the unread private chat button")
|
|
)
|
|
}
|
|
// Notes icon (mesh only and when location is authorized), to the left of #mesh
|
|
if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized {
|
|
Button(action: {
|
|
// Kick a one-shot refresh and show the sheet immediately.
|
|
LocationChannelManager.shared.enableLocationChannels()
|
|
LocationChannelManager.shared.refreshChannels()
|
|
// If we already have a block geohash, pass it; otherwise wait in the sheet.
|
|
notesGeohash = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
|
|
showLocationNotes = true
|
|
}) {
|
|
HStack(alignment: .center, spacing: 4) {
|
|
Image(systemName: "note.text")
|
|
.font(.bitchatSystem(size: 12))
|
|
.foregroundColor(Color.orange.opacity(0.8))
|
|
.padding(.top, 1)
|
|
}
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(
|
|
String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button")
|
|
)
|
|
}
|
|
|
|
// Bookmark toggle (geochats): to the left of #geohash
|
|
if case .location(let ch) = locationManager.selectedChannel {
|
|
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
|
|
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
|
.font(.bitchatSystem(size: 12))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(
|
|
String(
|
|
format: String(localized: "content.accessibility.toggle_bookmark", comment: "Accessibility label for toggling a geohash bookmark"),
|
|
locale: .current,
|
|
ch.geohash
|
|
)
|
|
)
|
|
}
|
|
|
|
// Location channels button '#'
|
|
Button(action: { showLocationChannelsSheet = true }) {
|
|
let badgeText: String = {
|
|
switch locationManager.selectedChannel {
|
|
case .mesh: return "#mesh"
|
|
case .location(let ch): return "#\(ch.geohash)"
|
|
}
|
|
}()
|
|
let badgeColor: Color = {
|
|
switch locationManager.selectedChannel {
|
|
case .mesh:
|
|
return Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
|
case .location:
|
|
return (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
|
}
|
|
}()
|
|
Text(badgeText)
|
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
|
.foregroundColor(badgeColor)
|
|
.lineLimit(headerLineLimit)
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
.layoutPriority(2)
|
|
.accessibilityLabel(
|
|
String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button")
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.leading, 4)
|
|
.padding(.trailing, 2)
|
|
|
|
HStack(spacing: 4) {
|
|
// People icon with count
|
|
Image(systemName: "person.2.fill")
|
|
.font(.system(size: headerPeerIconSize, weight: .regular))
|
|
.accessibilityLabel(
|
|
String(
|
|
format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"),
|
|
locale: .current,
|
|
headerOtherPeersCount
|
|
)
|
|
)
|
|
Text("\(headerOtherPeersCount)")
|
|
.font(.system(size: headerPeerCountFontSize, weight: .regular, design: .monospaced))
|
|
.accessibilityHidden(true)
|
|
}
|
|
.foregroundColor(headerCountColor)
|
|
.padding(.leading, 2)
|
|
.lineLimit(headerLineLimit)
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
|
|
// QR moved to the PEOPLE header in the sidebar when on mesh channel
|
|
}
|
|
.layoutPriority(3)
|
|
.onTapGesture {
|
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
showSidebar.toggle()
|
|
}
|
|
}
|
|
}
|
|
.frame(height: headerHeight)
|
|
.padding(.horizontal, 12)
|
|
.onAppear {
|
|
if case .mesh = locationManager.selectedChannel,
|
|
locationManager.permissionState == .authorized,
|
|
LocationChannelManager.shared.availableChannels.isEmpty {
|
|
LocationChannelManager.shared.refreshChannels()
|
|
}
|
|
}
|
|
.onChange(of: locationManager.selectedChannel) { _ in
|
|
if case .mesh = locationManager.selectedChannel,
|
|
locationManager.permissionState == .authorized,
|
|
LocationChannelManager.shared.availableChannels.isEmpty {
|
|
LocationChannelManager.shared.refreshChannels()
|
|
}
|
|
}
|
|
.onChange(of: locationManager.permissionState) { _ in
|
|
if case .mesh = locationManager.selectedChannel,
|
|
locationManager.permissionState == .authorized,
|
|
LocationChannelManager.shared.availableChannels.isEmpty {
|
|
LocationChannelManager.shared.refreshChannels()
|
|
}
|
|
}
|
|
.alert("content.alert.screenshot.title", isPresented: $viewModel.showScreenshotPrivacyWarning) {
|
|
Button("common.ok", role: .cancel) {}
|
|
} message: {
|
|
Text("content.alert.screenshot.message")
|
|
}
|
|
.background(backgroundColor.opacity(0.95))
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Helper Views
|
|
|
|
// Rounded payment chip button
|
|
//
|
|
|
|
private enum MessageMedia {
|
|
case voice(URL)
|
|
case image(URL)
|
|
|
|
var url: URL {
|
|
switch self {
|
|
case .voice(let url), .image(let url):
|
|
return url
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension ContentView {
|
|
func mediaAttachment(for message: BitchatMessage) -> MessageMedia? {
|
|
guard let baseDirectory = applicationFilesDirectory() else { return nil }
|
|
|
|
// Extract filename from message content
|
|
func url(from prefix: String, subdirectory: String) -> URL? {
|
|
guard message.content.hasPrefix(prefix) else { return nil }
|
|
let filename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !filename.isEmpty else { return nil }
|
|
|
|
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
|
|
// Files are checked during playback/display, so missing files fail gracefully
|
|
let directory = baseDirectory.appendingPathComponent(subdirectory, isDirectory: true)
|
|
return directory.appendingPathComponent(filename)
|
|
}
|
|
|
|
// Try outgoing first (most common for sent media), fall back to incoming
|
|
if message.content.hasPrefix("[voice] ") {
|
|
let filename = String(message.content.dropFirst("[voice] ".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !filename.isEmpty else { return nil }
|
|
// Check outgoing first for sent messages, incoming for received
|
|
let subdir = message.sender == viewModel.nickname ? "voicenotes/outgoing" : "voicenotes/incoming"
|
|
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
|
return .voice(url)
|
|
}
|
|
if message.content.hasPrefix("[image] ") {
|
|
let filename = String(message.content.dropFirst("[image] ".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !filename.isEmpty else { return nil }
|
|
let subdir = message.sender == viewModel.nickname ? "images/outgoing" : "images/incoming"
|
|
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
|
return .image(url)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mediaSendState(for message: BitchatMessage, mediaURL: URL) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
|
|
var isSending = false
|
|
var progress: Double?
|
|
if let status = message.deliveryStatus {
|
|
switch status {
|
|
case .sending:
|
|
isSending = true
|
|
progress = 0
|
|
case .partiallyDelivered(let reached, let total):
|
|
if total > 0 {
|
|
isSending = true
|
|
progress = Double(reached) / Double(total)
|
|
}
|
|
case .sent, .read, .delivered, .failed:
|
|
break
|
|
}
|
|
}
|
|
let isOutgoing = mediaURL.path.contains("/outgoing/")
|
|
let canCancel = isSending && isOutgoing
|
|
let clamped = progress.map { max(0, min(1, $0)) }
|
|
return (isSending, isSending ? clamped : nil, canCancel)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func messageRow(for message: BitchatMessage) -> some View {
|
|
if message.sender == "system" {
|
|
systemMessageRow(message)
|
|
} else if let media = mediaAttachment(for: message) {
|
|
mediaMessageRow(message: message, media: media)
|
|
} else {
|
|
textMessageRow(message)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func systemMessageRow(_ message: BitchatMessage) -> some View {
|
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func mediaMessageRow(message: BitchatMessage, media: MessageMedia) -> some View {
|
|
let mediaURL = media.url
|
|
let state = mediaSendState(for: message, mediaURL: mediaURL)
|
|
let isOutgoing = mediaURL.path.contains("/outgoing/")
|
|
let isAuthoredByUs = isOutgoing || (message.senderPeerID == viewModel.meshService.myPeerID)
|
|
let shouldBlurImage = !isAuthoredByUs
|
|
let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(alignment: .center, spacing: 4) {
|
|
Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
if message.isPrivate && message.sender == viewModel.nickname,
|
|
let status = message.deliveryStatus {
|
|
DeliveryStatusView(status: status)
|
|
.padding(.leading, 4)
|
|
}
|
|
}
|
|
|
|
Group {
|
|
switch media {
|
|
case .voice(let url):
|
|
VoiceNoteView(
|
|
url: url,
|
|
isSending: state.isSending,
|
|
sendProgress: state.progress,
|
|
onCancel: cancelAction
|
|
)
|
|
case .image(let url):
|
|
BlockRevealImageView(
|
|
url: url,
|
|
revealProgress: state.progress,
|
|
isSending: state.isSending,
|
|
onCancel: cancelAction,
|
|
initiallyBlurred: shouldBlurImage,
|
|
onOpen: {
|
|
if !state.isSending {
|
|
imagePreviewURL = url
|
|
}
|
|
},
|
|
onDelete: shouldBlurImage ? {
|
|
viewModel.deleteMediaMessage(messageID: message.id)
|
|
} : nil
|
|
)
|
|
.frame(maxWidth: 280)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func textMessageRow(_ message: BitchatMessage) -> some View {
|
|
let cashuTokens = message.content.extractCashuLinks()
|
|
let lightningLinks = message.content.extractLightningLinks()
|
|
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
|
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(alignment: .top, spacing: 0) {
|
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
if message.isPrivate && message.sender == viewModel.nickname,
|
|
let status = message.deliveryStatus {
|
|
DeliveryStatusView(status: status)
|
|
.padding(.leading, 4)
|
|
}
|
|
}
|
|
|
|
if isLong && cashuTokens.isEmpty {
|
|
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
|
Button(labelKey) {
|
|
if isExpanded { expandedMessageIDs.remove(message.id) }
|
|
else { expandedMessageIDs.insert(message.id) }
|
|
}
|
|
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
|
|
.foregroundColor(Color.blue)
|
|
.padding(.top, 4)
|
|
}
|
|
|
|
if !lightningLinks.isEmpty || !cashuTokens.isEmpty {
|
|
HStack(spacing: 8) {
|
|
ForEach(Array(lightningLinks.prefix(3)), id: \.self) { link in
|
|
PaymentChipView(paymentType: .lightning(link))
|
|
}
|
|
|
|
ForEach(Array(cashuTokens.prefix(3)), id: \.self) { token in
|
|
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
|
|
let urlStr = "cashu:\(enc)"
|
|
PaymentChipView(paymentType: .cashu(urlStr))
|
|
}
|
|
}
|
|
.padding(.top, 6)
|
|
.padding(.leading, 2)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func expandWindow(ifNeededFor message: BitchatMessage,
|
|
allMessages: [BitchatMessage],
|
|
privatePeer: PeerID?,
|
|
proxy: ScrollViewProxy) {
|
|
let step = TransportConfig.uiWindowStepCount
|
|
let contextKey: String = {
|
|
if let peer = privatePeer { return "dm:\(peer)" }
|
|
switch locationManager.selectedChannel {
|
|
case .mesh: return "mesh"
|
|
case .location(let ch): return "geo:\(ch.geohash)"
|
|
}
|
|
}()
|
|
let preserveID = "\(contextKey)|\(message.id)"
|
|
|
|
if let peer = privatePeer {
|
|
let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
|
|
let newCount = min(allMessages.count, current + step)
|
|
guard newCount != current else { return }
|
|
windowCountPrivate[peer] = newCount
|
|
DispatchQueue.main.async {
|
|
proxy.scrollTo(preserveID, anchor: .top)
|
|
}
|
|
} else {
|
|
let current = windowCountPublic
|
|
let newCount = min(allMessages.count, current + step)
|
|
guard newCount != current else { return }
|
|
windowCountPublic = newCount
|
|
DispatchQueue.main.async {
|
|
proxy.scrollTo(preserveID, anchor: .top)
|
|
}
|
|
}
|
|
}
|
|
|
|
var recordingIndicator: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "waveform.circle.fill")
|
|
.foregroundColor(.red)
|
|
.font(.bitchatSystem(size: 20))
|
|
Text("recording \(formattedRecordingDuration())", comment: "Voice note recording duration indicator")
|
|
.font(.bitchatSystem(size: 13, design: .monospaced))
|
|
.foregroundColor(.red)
|
|
Spacer()
|
|
Button(action: cancelVoiceRecording) {
|
|
Label("Cancel", systemImage: "xmark.circle")
|
|
.labelStyle(.iconOnly)
|
|
.font(.bitchatSystem(size: 18))
|
|
.foregroundColor(.red)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color.red.opacity(0.15))
|
|
)
|
|
}
|
|
|
|
private var trimmedMessageText: String {
|
|
messageText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private var shouldShowMediaControls: Bool {
|
|
if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) {
|
|
return true
|
|
}
|
|
switch locationManager.selectedChannel {
|
|
case .mesh:
|
|
return true
|
|
case .location:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private var shouldShowVoiceControl: Bool {
|
|
if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) {
|
|
return true
|
|
}
|
|
switch locationManager.selectedChannel {
|
|
case .mesh:
|
|
return true
|
|
case .location:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private var composerAccentColor: Color {
|
|
viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor
|
|
}
|
|
|
|
var attachmentButton: some View {
|
|
#if os(iOS)
|
|
Image(systemName: "camera.circle.fill")
|
|
.font(.bitchatSystem(size: 24))
|
|
.foregroundColor(composerAccentColor)
|
|
.frame(width: 36, height: 36)
|
|
.contentShape(Circle())
|
|
.onTapGesture {
|
|
// Tap = Photo Library
|
|
imagePickerSourceType = .photoLibrary
|
|
showImagePicker = true
|
|
}
|
|
.onLongPressGesture(minimumDuration: 0.3) {
|
|
// Long press = Camera
|
|
imagePickerSourceType = .camera
|
|
showImagePicker = true
|
|
}
|
|
.accessibilityLabel("Tap for library, long press for camera")
|
|
#else
|
|
Button(action: { showMacImagePicker = true }) {
|
|
Image(systemName: "photo.circle.fill")
|
|
.font(.bitchatSystem(size: 24))
|
|
.foregroundColor(composerAccentColor)
|
|
.frame(width: 36, height: 36)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Choose photo")
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
var sendOrMicButton: some View {
|
|
let hasText = !trimmedMessageText.isEmpty
|
|
if shouldShowVoiceControl {
|
|
ZStack {
|
|
micButtonView
|
|
.opacity(hasText ? 0 : 1)
|
|
.allowsHitTesting(!hasText)
|
|
sendButtonView(enabled: hasText)
|
|
.opacity(hasText ? 1 : 0)
|
|
.allowsHitTesting(hasText)
|
|
}
|
|
.frame(width: 36, height: 36)
|
|
} else {
|
|
sendButtonView(enabled: hasText)
|
|
.frame(width: 36, height: 36)
|
|
}
|
|
}
|
|
|
|
private var micButtonView: some View {
|
|
let tint = (isRecordingVoiceNote || isPreparingVoiceNote) ? Color.red : composerAccentColor
|
|
|
|
return Image(systemName: "mic.circle.fill")
|
|
.font(.bitchatSystem(size: 24))
|
|
.foregroundColor(tint)
|
|
.frame(width: 36, height: 36)
|
|
.contentShape(Circle())
|
|
.overlay(
|
|
Color.clear
|
|
.contentShape(Circle())
|
|
.gesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { _ in startVoiceRecording() }
|
|
.onEnded { _ in finishVoiceRecording(send: true) }
|
|
)
|
|
)
|
|
.accessibilityLabel("Hold to record a voice note")
|
|
}
|
|
|
|
private func sendButtonView(enabled: Bool) -> some View {
|
|
let activeColor = composerAccentColor
|
|
return Button(action: sendMessage) {
|
|
Image(systemName: "arrow.up.circle.fill")
|
|
.font(.bitchatSystem(size: 24))
|
|
.foregroundColor(enabled ? activeColor : Color.gray)
|
|
.frame(width: 36, height: 36)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(!enabled)
|
|
.accessibilityLabel(
|
|
String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button")
|
|
)
|
|
.accessibilityHint(
|
|
enabled
|
|
? String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message")
|
|
: String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message")
|
|
)
|
|
}
|
|
|
|
func formattedRecordingDuration() -> String {
|
|
let clamped = max(0, recordingDuration)
|
|
let totalMilliseconds = Int((clamped * 1000).rounded())
|
|
let minutes = totalMilliseconds / 60_000
|
|
let seconds = (totalMilliseconds % 60_000) / 1_000
|
|
let centiseconds = (totalMilliseconds % 1_000) / 10
|
|
return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds)
|
|
}
|
|
|
|
func startVoiceRecording() {
|
|
guard shouldShowVoiceControl else { return }
|
|
guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return }
|
|
isPreparingVoiceNote = true
|
|
Task { @MainActor in
|
|
let granted = await VoiceRecorder.shared.requestPermission()
|
|
guard granted else {
|
|
isPreparingVoiceNote = false
|
|
recordingAlertMessage = "Microphone access is required to record voice notes."
|
|
showRecordingAlert = true
|
|
return
|
|
}
|
|
do {
|
|
_ = try VoiceRecorder.shared.startRecording()
|
|
recordingDuration = 0
|
|
recordingStartDate = Date()
|
|
recordingTimer?.invalidate()
|
|
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
|
|
if let start = recordingStartDate {
|
|
recordingDuration = Date().timeIntervalSince(start)
|
|
}
|
|
}
|
|
if let timer = recordingTimer {
|
|
RunLoop.main.add(timer, forMode: .common)
|
|
}
|
|
isPreparingVoiceNote = false
|
|
isRecordingVoiceNote = true
|
|
} catch {
|
|
SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
|
|
recordingAlertMessage = "Could not start recording."
|
|
showRecordingAlert = true
|
|
VoiceRecorder.shared.cancelRecording()
|
|
isPreparingVoiceNote = false
|
|
isRecordingVoiceNote = false
|
|
recordingStartDate = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func finishVoiceRecording(send: Bool) {
|
|
if isPreparingVoiceNote {
|
|
isPreparingVoiceNote = false
|
|
VoiceRecorder.shared.cancelRecording()
|
|
return
|
|
}
|
|
guard isRecordingVoiceNote else { return }
|
|
isRecordingVoiceNote = false
|
|
recordingTimer?.invalidate()
|
|
recordingTimer = nil
|
|
if let start = recordingStartDate {
|
|
recordingDuration = Date().timeIntervalSince(start)
|
|
}
|
|
recordingStartDate = nil
|
|
if send {
|
|
let minimumDuration: TimeInterval = 1.0
|
|
VoiceRecorder.shared.stopRecording { url in
|
|
DispatchQueue.main.async {
|
|
guard
|
|
let url = url,
|
|
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
|
let fileSize = attributes[.size] as? NSNumber,
|
|
fileSize.intValue > 0,
|
|
recordingDuration >= minimumDuration
|
|
else {
|
|
if let url = url {
|
|
try? FileManager.default.removeItem(at: url)
|
|
}
|
|
recordingAlertMessage = recordingDuration < minimumDuration
|
|
? "Recording is too short."
|
|
: "Recording failed to save."
|
|
showRecordingAlert = true
|
|
return
|
|
}
|
|
viewModel.sendVoiceNote(at: url)
|
|
}
|
|
}
|
|
} else {
|
|
VoiceRecorder.shared.cancelRecording()
|
|
}
|
|
}
|
|
|
|
func cancelVoiceRecording() {
|
|
if isPreparingVoiceNote || isRecordingVoiceNote {
|
|
finishVoiceRecording(send: false)
|
|
}
|
|
}
|
|
|
|
func handleImportResult(_ result: Result<[URL], Error>, handler: @escaping (URL) async -> Void) {
|
|
switch result {
|
|
case .success(let urls):
|
|
guard let url = urls.first else { return }
|
|
let needsStop = url.startAccessingSecurityScopedResource()
|
|
Task {
|
|
defer {
|
|
if needsStop {
|
|
url.stopAccessingSecurityScopedResource()
|
|
}
|
|
}
|
|
await handler(url)
|
|
}
|
|
case .failure(let error):
|
|
SecureLogger.error("Media import failed: \(error)", category: .session)
|
|
}
|
|
}
|
|
|
|
|
|
func applicationFilesDirectory() -> URL? {
|
|
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
|
struct Cache {
|
|
static var cachedURL: URL?
|
|
static var didAttempt = false
|
|
}
|
|
|
|
if Cache.didAttempt {
|
|
return Cache.cachedURL
|
|
}
|
|
|
|
do {
|
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
|
Cache.cachedURL = filesDir
|
|
Cache.didAttempt = true
|
|
return filesDir
|
|
} catch {
|
|
SecureLogger.error("Failed to resolve application files directory: \(error)", category: .session)
|
|
Cache.didAttempt = true
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
//
|
|
|
|
struct ImagePreviewView: View {
|
|
let url: URL
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
#if os(iOS)
|
|
@State private var showExporter = false
|
|
@State private var platformImage: UIImage?
|
|
#else
|
|
@State private var platformImage: NSImage?
|
|
#endif
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.black.ignoresSafeArea()
|
|
VStack {
|
|
Spacer()
|
|
if let image = platformImage {
|
|
#if os(iOS)
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.padding()
|
|
#else
|
|
Image(nsImage: image)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.padding()
|
|
#endif
|
|
} else {
|
|
ProgressView()
|
|
.progressViewStyle(.circular)
|
|
.tint(.white)
|
|
}
|
|
Spacer()
|
|
HStack {
|
|
Button(action: { dismiss() }) {
|
|
Text("close", comment: "Button to dismiss fullscreen media viewer")
|
|
.font(.bitchatSystem(size: 15, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.5), lineWidth: 1))
|
|
}
|
|
Spacer()
|
|
Button(action: saveCopy) {
|
|
Text("save", comment: "Button to save media to device")
|
|
.font(.bitchatSystem(size: 15, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.blue.opacity(0.6)))
|
|
}
|
|
}
|
|
.padding([.horizontal, .bottom], 24)
|
|
}
|
|
}
|
|
.onAppear(perform: loadImage)
|
|
#if os(iOS)
|
|
.sheet(isPresented: $showExporter) {
|
|
FileExportWrapper(url: url)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func loadImage() {
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
#if os(iOS)
|
|
guard let image = UIImage(contentsOfFile: url.path) else { return }
|
|
#else
|
|
guard let image = NSImage(contentsOf: url) else { return }
|
|
#endif
|
|
DispatchQueue.main.async {
|
|
self.platformImage = image
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveCopy() {
|
|
#if os(iOS)
|
|
showExporter = true
|
|
#else
|
|
Task { @MainActor in
|
|
let panel = NSSavePanel()
|
|
panel.canCreateDirectories = true
|
|
panel.nameFieldStringValue = url.lastPathComponent
|
|
panel.prompt = "save"
|
|
if panel.runModal() == .OK, let destination = panel.url {
|
|
do {
|
|
if FileManager.default.fileExists(atPath: destination.path) {
|
|
try FileManager.default.removeItem(at: destination)
|
|
}
|
|
try FileManager.default.copyItem(at: url, to: destination)
|
|
} catch {
|
|
SecureLogger.error("Failed to save image preview copy: \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if os(iOS)
|
|
private struct FileExportWrapper: UIViewControllerRepresentable {
|
|
let url: URL
|
|
|
|
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
|
let controller = UIDocumentPickerViewController(forExporting: [url])
|
|
controller.shouldShowFileExtensions = true
|
|
return controller
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if os(iOS)
|
|
// MARK: - Image Picker (Camera or Photo Library)
|
|
struct ImagePickerView: UIViewControllerRepresentable {
|
|
let sourceType: UIImagePickerController.SourceType
|
|
let completion: (UIImage?) -> Void
|
|
|
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
|
let picker = UIImagePickerController()
|
|
picker.sourceType = sourceType
|
|
picker.delegate = context.coordinator
|
|
picker.allowsEditing = false
|
|
|
|
// Use standard full screen - iOS handles safe areas automatically
|
|
picker.modalPresentationStyle = .fullScreen
|
|
|
|
// Force dark mode to make safe area bars black instead of white
|
|
picker.overrideUserInterfaceStyle = .dark
|
|
|
|
return picker
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(completion: completion)
|
|
}
|
|
|
|
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
|
let completion: (UIImage?) -> Void
|
|
|
|
init(completion: @escaping (UIImage?) -> Void) {
|
|
self.completion = completion
|
|
}
|
|
|
|
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
|
let image = info[.originalImage] as? UIImage
|
|
completion(image)
|
|
}
|
|
|
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
|
completion(nil)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
#if os(macOS)
|
|
// MARK: - macOS Image Picker
|
|
struct MacImagePickerView: View {
|
|
let completion: (URL?) -> Void
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
var body: some View {
|
|
VStack(spacing: 16) {
|
|
Text("Choose an image")
|
|
.font(.headline)
|
|
|
|
Button("Select Image") {
|
|
let panel = NSOpenPanel()
|
|
panel.allowsMultipleSelection = false
|
|
panel.canChooseDirectories = false
|
|
panel.canChooseFiles = true
|
|
panel.allowedContentTypes = [.image, .png, .jpeg, .heic]
|
|
panel.message = "Choose an image to send"
|
|
|
|
if panel.runModal() == .OK {
|
|
completion(panel.url)
|
|
} else {
|
|
dismiss()
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
|
|
Button("Cancel") {
|
|
completion(nil)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.padding(40)
|
|
.frame(minWidth: 300, minHeight: 150)
|
|
}
|
|
}
|
|
#endif
|