mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 12:45:19 +00:00
439 lines
16 KiB
Swift
439 lines
16 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
|
|
#elseif os(macOS)
|
|
import AppKit
|
|
#endif
|
|
import BitFoundation
|
|
|
|
struct ContentRootModalPresentationState {
|
|
var isPeopleSheetPresented = false
|
|
var isAppInfoPresented = false
|
|
var isFingerprintPresented = false
|
|
var isLocationChannelsSheetPresented = false
|
|
var isNoticesSheetPresented = false
|
|
var isImagePreviewPresented = false
|
|
var isVerificationSheetPresented = false
|
|
var isVoiceAlertPresented = false
|
|
var isScreenshotPrivacyAlertPresented = false
|
|
var isMediaPickerPresented = false
|
|
|
|
var hasPresentation: Bool {
|
|
isPeopleSheetPresented
|
|
|| isAppInfoPresented
|
|
|| isFingerprintPresented
|
|
|| isLocationChannelsSheetPresented
|
|
|| isNoticesSheetPresented
|
|
|| isImagePreviewPresented
|
|
|| isVerificationSheetPresented
|
|
|| isVoiceAlertPresented
|
|
|| isScreenshotPrivacyAlertPresented
|
|
|| isMediaPickerPresented
|
|
}
|
|
}
|
|
|
|
extension ContentRootModalPresentationState {
|
|
@MainActor
|
|
init(
|
|
appChromeModel: AppChromeModel,
|
|
isPeopleSheetPresented: Bool = false,
|
|
isImagePreviewPresented: Bool = false,
|
|
isVerificationSheetPresented: Bool = false,
|
|
isVoiceAlertPresented: Bool = false,
|
|
isMediaPickerPresented: Bool = false
|
|
) {
|
|
self.init(
|
|
isPeopleSheetPresented: isPeopleSheetPresented,
|
|
isAppInfoPresented: appChromeModel.isAppInfoPresented,
|
|
isFingerprintPresented:
|
|
appChromeModel.showingFingerprintFor != nil,
|
|
isLocationChannelsSheetPresented:
|
|
appChromeModel.isLocationChannelsSheetPresented,
|
|
isNoticesSheetPresented:
|
|
appChromeModel.isNoticesSheetPresented,
|
|
isImagePreviewPresented: isImagePreviewPresented,
|
|
isVerificationSheetPresented: isVerificationSheetPresented,
|
|
isVoiceAlertPresented: isVoiceAlertPresented,
|
|
isScreenshotPrivacyAlertPresented:
|
|
appChromeModel.showScreenshotPrivacyWarning,
|
|
isMediaPickerPresented: isMediaPickerPresented
|
|
)
|
|
}
|
|
}
|
|
|
|
/// On macOS 14+, disables the default system focus ring on TextFields.
|
|
/// On earlier macOS versions and on iOS this is a no-op.
|
|
struct FocusEffectDisabledModifier: ViewModifier {
|
|
func body(content: Content) -> some View {
|
|
#if os(macOS)
|
|
if #available(macOS 14.0, *) {
|
|
content.focusEffectDisabled()
|
|
} else {
|
|
content
|
|
}
|
|
#else
|
|
content
|
|
#endif
|
|
}
|
|
}
|
|
|
|
struct ContentView: View {
|
|
@EnvironmentObject private var appChromeModel: AppChromeModel
|
|
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
|
@EnvironmentObject private var verificationModel: VerificationModel
|
|
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
|
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
|
|
|
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
|
|
@State private var messageText = ""
|
|
@FocusState private var isTextFieldFocused: Bool
|
|
@Environment(\.colorScheme) var colorScheme
|
|
@Environment(\.appTheme) private var appTheme
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@State private var showSidebar = false
|
|
@State private var selectedMessageSender: String?
|
|
@State private var selectedMessageSenderID: PeerID?
|
|
@FocusState private var isNicknameFieldFocused: Bool
|
|
@State private var isAtBottomPublic = true
|
|
@State private var isAtBottomPrivate = true
|
|
@State private var autocompleteDebounceTimer: Timer?
|
|
@State private var showVerifySheet = false
|
|
@State private var imagePreviewURL: URL?
|
|
#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
|
|
@State private var windowCountPublic: Int = 300
|
|
@State private var windowCountPrivate: [PeerID: Int] = [:]
|
|
|
|
@ThemedPalette private var palette
|
|
|
|
private var selectedPrivatePeerID: PeerID? {
|
|
privateConversationModel.selectedPeerID
|
|
}
|
|
|
|
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
|
|
|
|
private var isPeopleSheetPresented: Bool {
|
|
showSidebar || selectedPrivatePeerID != nil
|
|
}
|
|
|
|
private var hasRootModalPresentation: Bool {
|
|
#if os(iOS)
|
|
let isMediaPickerPresented = showImagePicker
|
|
#else
|
|
let isMediaPickerPresented = showMacImagePicker
|
|
#endif
|
|
|
|
return ContentRootModalPresentationState(
|
|
appChromeModel: appChromeModel,
|
|
isPeopleSheetPresented: isPeopleSheetPresented,
|
|
isImagePreviewPresented: imagePreviewURL != nil,
|
|
isVerificationSheetPresented: showVerifySheet,
|
|
isVoiceAlertPresented: voiceRecordingVM.showAlert,
|
|
isMediaPickerPresented: isMediaPickerPresented
|
|
).hasPresentation
|
|
}
|
|
|
|
private var rootBluetoothAlertBinding: Binding<Bool> {
|
|
Binding(
|
|
get: {
|
|
scenePhase == .active
|
|
&& appChromeModel.showBluetoothAlert
|
|
&& !hasRootModalPresentation
|
|
},
|
|
set: { isPresented in
|
|
guard !isPresented,
|
|
scenePhase == .active,
|
|
!hasRootModalPresentation else {
|
|
return
|
|
}
|
|
appChromeModel.showBluetoothAlert = false
|
|
}
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
mainContent
|
|
.onAppear {
|
|
conversationUIModel.setCurrentColorScheme(colorScheme)
|
|
conversationUIModel.setCurrentTheme(appTheme)
|
|
voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in
|
|
conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession()
|
|
}
|
|
appChromeModel.setPanicPreparation { [weak voiceRecordingVM] in
|
|
voiceRecordingVM?.panicWipe()
|
|
}
|
|
#if os(macOS)
|
|
DispatchQueue.main.async {
|
|
isNicknameFieldFocused = false
|
|
isTextFieldFocused = true
|
|
}
|
|
#endif
|
|
}
|
|
.onChange(of: colorScheme) { newValue in
|
|
conversationUIModel.setCurrentColorScheme(newValue)
|
|
}
|
|
.onChange(of: appTheme) { newValue in
|
|
conversationUIModel.setCurrentTheme(newValue)
|
|
}
|
|
.background(ThemedRootBackground())
|
|
.foregroundColor(palette.primary)
|
|
#if os(macOS)
|
|
.frame(minWidth: 600, minHeight: 400)
|
|
#endif
|
|
.onChange(of: selectedPrivatePeerID) { newValue in
|
|
if newValue != nil {
|
|
showSidebar = true
|
|
}
|
|
}
|
|
.sheet(
|
|
isPresented: Binding(
|
|
get: { isPeopleSheetPresented },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
showSidebar = false
|
|
// Scene/background and Bluetooth-alert presentation
|
|
// reconciliation are not user requests to leave the
|
|
// conversation. Keep the selected DM so the sheet
|
|
// remains live when the app returns from Settings.
|
|
if scenePhase == .active,
|
|
!appChromeModel.showBluetoothAlert {
|
|
privateConversationModel.endConversation()
|
|
}
|
|
}
|
|
}
|
|
)
|
|
) {
|
|
#if os(iOS)
|
|
ContentPeopleSheetView(
|
|
showSidebar: $showSidebar,
|
|
messageText: $messageText,
|
|
selectedMessageSender: $selectedMessageSender,
|
|
selectedMessageSenderID: $selectedMessageSenderID,
|
|
imagePreviewURL: $imagePreviewURL,
|
|
windowCountPublic: $windowCountPublic,
|
|
windowCountPrivate: $windowCountPrivate,
|
|
isAtBottomPrivate: $isAtBottomPrivate,
|
|
isTextFieldFocused: $isTextFieldFocused,
|
|
voiceRecordingVM: voiceRecordingVM,
|
|
autocompleteDebounceTimer: $autocompleteDebounceTimer,
|
|
headerHeight: headerHeight,
|
|
onSendMessage: sendMessage,
|
|
showImagePicker: $showImagePicker,
|
|
imagePickerSourceType: $imagePickerSourceType
|
|
)
|
|
#else
|
|
ContentPeopleSheetView(
|
|
showSidebar: $showSidebar,
|
|
messageText: $messageText,
|
|
selectedMessageSender: $selectedMessageSender,
|
|
selectedMessageSenderID: $selectedMessageSenderID,
|
|
imagePreviewURL: $imagePreviewURL,
|
|
windowCountPublic: $windowCountPublic,
|
|
windowCountPrivate: $windowCountPrivate,
|
|
isAtBottomPrivate: $isAtBottomPrivate,
|
|
isTextFieldFocused: $isTextFieldFocused,
|
|
voiceRecordingVM: voiceRecordingVM,
|
|
autocompleteDebounceTimer: $autocompleteDebounceTimer,
|
|
headerHeight: headerHeight,
|
|
onSendMessage: sendMessage,
|
|
showMacImagePicker: $showMacImagePicker
|
|
)
|
|
#endif
|
|
}
|
|
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {
|
|
AppInfoView(
|
|
topologyProvider: { appChromeModel.meshTopologyDisplayModel() },
|
|
onPanicWipe: { appChromeModel.panicClearAllData() }
|
|
)
|
|
.environmentObject(locationChannelsModel)
|
|
}
|
|
.sheet(isPresented: Binding(
|
|
get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil },
|
|
set: { _ in appChromeModel.clearFingerprint() }
|
|
)) {
|
|
if let peerID = appChromeModel.showingFingerprintFor {
|
|
FingerprintView(peerID: peerID)
|
|
.environmentObject(verificationModel)
|
|
}
|
|
}
|
|
#if os(iOS)
|
|
.fullScreenCover(isPresented: Binding(
|
|
get: { showImagePicker && !showSidebar && selectedPrivatePeerID == nil },
|
|
set: { newValue in
|
|
if !newValue {
|
|
showImagePicker = false
|
|
}
|
|
}
|
|
)) {
|
|
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
|
showImagePicker = false
|
|
conversationUIModel.processSelectedImage(image)
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
#endif
|
|
#if os(macOS)
|
|
.sheet(isPresented: Binding(
|
|
get: { showMacImagePicker && !showSidebar && selectedPrivatePeerID == nil },
|
|
set: { newValue in
|
|
if !newValue {
|
|
showMacImagePicker = false
|
|
}
|
|
}
|
|
)) {
|
|
MacImagePickerView { url in
|
|
showMacImagePicker = false
|
|
conversationUIModel.processSelectedImage(from: url)
|
|
}
|
|
}
|
|
#endif
|
|
.sheet(isPresented: Binding(
|
|
get: { imagePreviewURL != nil },
|
|
set: { presenting in
|
|
if !presenting {
|
|
imagePreviewURL = nil
|
|
}
|
|
}
|
|
)) {
|
|
if let url = imagePreviewURL {
|
|
ImagePreviewView(url: url)
|
|
}
|
|
}
|
|
.alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: {
|
|
Button("common.ok", role: .cancel) {}
|
|
if voiceRecordingVM.state == .permissionDenied {
|
|
Button("location_channels.action.open_settings") {
|
|
SystemSettings.microphone.open()
|
|
}
|
|
}
|
|
}, message: {
|
|
Text(voiceRecordingVM.state.alertMessage)
|
|
})
|
|
.alert("content.alert.bluetooth_required.title", isPresented: rootBluetoothAlertBinding) {
|
|
Button("content.alert.bluetooth_required.settings") {
|
|
SystemSettings.bluetooth.open()
|
|
}
|
|
Button("common.ok", role: .cancel) {}
|
|
} message: {
|
|
Text(appChromeModel.bluetoothAlertMessage)
|
|
}
|
|
.onDisappear {
|
|
autocompleteDebounceTimer?.invalidate()
|
|
appChromeModel.setPanicPreparation(nil)
|
|
}
|
|
}
|
|
|
|
/// Matrix: classic opaque bars with dividers. Glass: full-bleed message
|
|
/// list scrolling underneath floating chrome panels (safe-area insets),
|
|
/// so the translucency gains usable space instead of losing it.
|
|
@ViewBuilder
|
|
private var mainContent: some View {
|
|
if usesGlassLayout {
|
|
publicMessageList
|
|
.safeAreaInset(edge: .top, spacing: 0) {
|
|
headerView
|
|
}
|
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
|
if selectedPrivatePeerID == nil {
|
|
composerView
|
|
}
|
|
}
|
|
} else {
|
|
VStack(spacing: 0) {
|
|
headerView
|
|
|
|
Divider()
|
|
|
|
GeometryReader { geometry in
|
|
VStack(spacing: 0) {
|
|
publicMessageList
|
|
.background(palette.background)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
|
}
|
|
|
|
Divider()
|
|
|
|
if selectedPrivatePeerID == nil {
|
|
composerView
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var headerView: some View {
|
|
ContentHeaderView(
|
|
showSidebar: $showSidebar,
|
|
showVerifySheet: $showVerifySheet,
|
|
isNicknameFieldFocused: $isNicknameFieldFocused,
|
|
headerHeight: headerHeight,
|
|
headerPeerIconSize: headerPeerIconSize,
|
|
headerPeerCountFontSize: headerPeerCountFontSize
|
|
)
|
|
}
|
|
|
|
private var publicMessageList: some View {
|
|
MessageListView(
|
|
privatePeer: nil,
|
|
isAtBottom: $isAtBottomPublic,
|
|
messageText: $messageText,
|
|
selectedMessageSender: $selectedMessageSender,
|
|
selectedMessageSenderID: $selectedMessageSenderID,
|
|
imagePreviewURL: $imagePreviewURL,
|
|
windowCountPublic: $windowCountPublic,
|
|
windowCountPrivate: $windowCountPrivate,
|
|
showSidebar: $showSidebar,
|
|
isTextFieldFocused: $isTextFieldFocused
|
|
)
|
|
}
|
|
|
|
private var composerView: some View {
|
|
#if os(iOS)
|
|
ContentComposerView(
|
|
messageText: $messageText,
|
|
isTextFieldFocused: $isTextFieldFocused,
|
|
voiceRecordingVM: voiceRecordingVM,
|
|
autocompleteDebounceTimer: $autocompleteDebounceTimer,
|
|
onSendMessage: sendMessage,
|
|
showImagePicker: $showImagePicker,
|
|
imagePickerSourceType: $imagePickerSourceType
|
|
)
|
|
#else
|
|
ContentComposerView(
|
|
messageText: $messageText,
|
|
isTextFieldFocused: $isTextFieldFocused,
|
|
voiceRecordingVM: voiceRecordingVM,
|
|
autocompleteDebounceTimer: $autocompleteDebounceTimer,
|
|
onSendMessage: sendMessage,
|
|
showMacImagePicker: $showMacImagePicker
|
|
)
|
|
#endif
|
|
}
|
|
|
|
private func sendMessage() {
|
|
guard let trimmed = messageText.trimmedOrNilIfEmpty else { return }
|
|
|
|
messageText = ""
|
|
|
|
DispatchQueue.main.async {
|
|
self.conversationUIModel.sendMessage(trimmed)
|
|
}
|
|
}
|
|
}
|