Files
bitchat/bitchat/Views/ContentView.swift
T
jack ca420567b3 Refactor sheet presentations to use parent-child pattern
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.
2025-10-19 14:58:31 +02:00

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