Files
bitchat/bitchat/Views/ContentView.swift
T

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