From 4bb0ffba596e41a8309ba50d222248a0ba3eb42e Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 27 Oct 2025 21:48:20 -0700 Subject: Random project housekeeping/organizing source files. --- Hotline/macOS/AccountManagerView.swift | 398 --------------- Hotline/macOS/Accounts/AccountManagerView.swift | 398 +++++++++++++++ Hotline/macOS/Board/MessageBoardEditorView.swift | 117 +++++ Hotline/macOS/Board/MessageBoardView.swift | 102 ++++ Hotline/macOS/Chat/ChatView.swift | 321 ++++++++++++ Hotline/macOS/Chat/ServerAgreementView.swift | 78 +++ Hotline/macOS/Chat/ServerMessageView.swift | 33 ++ Hotline/macOS/ChatView.swift | 321 ------------ Hotline/macOS/FileDetailsView.swift | 147 ------ Hotline/macOS/FilePreviewImageView.swift | 123 ----- Hotline/macOS/FilePreviewTextView.swift | 127 ----- Hotline/macOS/Files/FileDetailsView.swift | 147 ++++++ Hotline/macOS/Files/FileItemView.swift | 67 +++ Hotline/macOS/Files/FilePreviewImageView.swift | 123 +++++ Hotline/macOS/Files/FilePreviewTextView.swift | 127 +++++ Hotline/macOS/Files/FilesView.swift | 418 +++++++++++++++ Hotline/macOS/Files/FolderItemView.swift | 140 +++++ Hotline/macOS/FilesView.swift | 619 ----------------------- Hotline/macOS/MessageBoardEditorView.swift | 117 ----- Hotline/macOS/MessageBoardView.swift | 102 ---- Hotline/macOS/News/NewsEditorView.swift | 153 ++++++ Hotline/macOS/News/NewsItemView.swift | 152 ++++++ Hotline/macOS/News/NewsView.swift | 297 +++++++++++ Hotline/macOS/NewsEditorView.swift | 153 ------ Hotline/macOS/NewsItemView.swift | 152 ------ Hotline/macOS/NewsView.swift | 297 ----------- Hotline/macOS/ServerAgreementView.swift | 78 --- Hotline/macOS/ServerMessageView.swift | 33 -- Hotline/macOS/Settings/GeneralSettingsView.swift | 67 +++ Hotline/macOS/Settings/IconSettingsView.swift | 58 +++ Hotline/macOS/Settings/SettingsView.swift | 31 ++ Hotline/macOS/Settings/SoundSettingsView.swift | 40 ++ Hotline/macOS/SettingsView.swift | 193 ------- Hotline/macOS/TrackerView.swift | 64 +-- 34 files changed, 2880 insertions(+), 2913 deletions(-) delete mode 100644 Hotline/macOS/AccountManagerView.swift create mode 100644 Hotline/macOS/Accounts/AccountManagerView.swift create mode 100644 Hotline/macOS/Board/MessageBoardEditorView.swift create mode 100644 Hotline/macOS/Board/MessageBoardView.swift create mode 100644 Hotline/macOS/Chat/ChatView.swift create mode 100644 Hotline/macOS/Chat/ServerAgreementView.swift create mode 100644 Hotline/macOS/Chat/ServerMessageView.swift delete mode 100644 Hotline/macOS/ChatView.swift delete mode 100644 Hotline/macOS/FileDetailsView.swift delete mode 100644 Hotline/macOS/FilePreviewImageView.swift delete mode 100644 Hotline/macOS/FilePreviewTextView.swift create mode 100644 Hotline/macOS/Files/FileDetailsView.swift create mode 100644 Hotline/macOS/Files/FileItemView.swift create mode 100644 Hotline/macOS/Files/FilePreviewImageView.swift create mode 100644 Hotline/macOS/Files/FilePreviewTextView.swift create mode 100644 Hotline/macOS/Files/FilesView.swift create mode 100644 Hotline/macOS/Files/FolderItemView.swift delete mode 100644 Hotline/macOS/FilesView.swift delete mode 100644 Hotline/macOS/MessageBoardEditorView.swift delete mode 100644 Hotline/macOS/MessageBoardView.swift create mode 100644 Hotline/macOS/News/NewsEditorView.swift create mode 100644 Hotline/macOS/News/NewsItemView.swift create mode 100644 Hotline/macOS/News/NewsView.swift delete mode 100644 Hotline/macOS/NewsEditorView.swift delete mode 100644 Hotline/macOS/NewsItemView.swift delete mode 100644 Hotline/macOS/NewsView.swift delete mode 100644 Hotline/macOS/ServerAgreementView.swift delete mode 100644 Hotline/macOS/ServerMessageView.swift create mode 100644 Hotline/macOS/Settings/GeneralSettingsView.swift create mode 100644 Hotline/macOS/Settings/IconSettingsView.swift create mode 100644 Hotline/macOS/Settings/SettingsView.swift create mode 100644 Hotline/macOS/Settings/SoundSettingsView.swift delete mode 100644 Hotline/macOS/SettingsView.swift (limited to 'Hotline/macOS') diff --git a/Hotline/macOS/AccountManagerView.swift b/Hotline/macOS/AccountManagerView.swift deleted file mode 100644 index 57682cc..0000000 --- a/Hotline/macOS/AccountManagerView.swift +++ /dev/null @@ -1,398 +0,0 @@ -import SwiftUI - -struct AccountManagerView: View { - @Environment(Hotline.self) private var model: Hotline - - @State private var accounts: [HotlineAccount] = [] - @State private var selection: HotlineAccount? - @State private var loading: Bool = true - - @State private var pendingName: String = "" - @State private var pendingLogin: String = "" - @State private var pendingPassword: String = "" - @State private var pendingAccess = HotlineUserAccessOptions.defaultAccess - - @State private var toDelete: HotlineAccount? - - let placeholderPassword = "xxxxxxxxxxxxxxxxxx" - - var body: some View { - HStack(spacing: 0) { - ZStack { - accountList - if loading { - ProgressView() - } - } - if selection != nil { - accountDetails - } - else { - ZStack(alignment: .center) { - Text("No Account Selected") - .font(.title) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding() - } - .frame(maxWidth: .infinity) - } - } - .environment(\.defaultMinListRowHeight, 34) - .listStyle(.inset) - .alternatingRowBackgrounds(.enabled) - .task { - if loading { - accounts = await model.getAccounts() - loading = false - } - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - let newAccount = HotlineAccount("unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess) - - pendingPassword = HotlineAccount.randomPassword() - accounts.append(newAccount) - selection = newAccount - } label: { - Label("New Account", systemImage: "plus") - } - .help("Create a new account") - .disabled(model.access?.contains(.canCreateUsers) != true) - } - - ToolbarItem(placement: .destructiveAction) { - Button { - toDelete = selection - } label: { - Label("Delete Account", systemImage: "trash") - } - .help("Delete account") - .disabled(selection == nil || model.access?.contains(.canDeleteUsers) != true) - } - } - } - - var accountDetails: some View { - VStack(alignment: .center, spacing: 0) { - ScrollView(.vertical) { - Form { - Section { - TextField(text: $pendingName) { - Text("Name") - } - TextField("Login", text: $pendingLogin) - .disabled(selection?.persisted == true) - if selection?.persisted == true { - SecureField("Password", text: $pendingPassword) - } else { - TextField("Password", text: $pendingPassword) - } - } - .textFieldStyle(.roundedBorder) - .controlSize(.large) - - Section("File System Maintenance") { - Toggle("Can Download Files", isOn: $pendingAccess.bind(.canDownloadFiles)) - .disabled(model.access?.contains(.canDownloadFiles) == false) - Toggle("Can Download Folders", isOn: $pendingAccess.bind(.canDownloadFolders)) - .disabled(model.access?.contains(.canDownloadFolders) == false) - Toggle("Can Upload Files", isOn: $pendingAccess.bind(.canUploadFiles)) - .disabled(model.access?.contains(.canUploadFiles) == false) - Toggle("Can Upload Folders", isOn: $pendingAccess.bind(.canUploadFolders)) - .disabled(model.access?.contains(.canUploadFolders) == false) - Toggle("Can Upload Anywhere", isOn: $pendingAccess.bind(.canUploadAnywhere)) - .disabled(model.access?.contains(.canUploadAnywhere) == false) - Toggle("Can Delete Files", isOn: $pendingAccess.bind(.canDeleteFiles)) - .disabled(model.access?.contains(.canDeleteFiles) == false) - Toggle("Can Rename Files", isOn: $pendingAccess.bind(.canRenameFiles)) - .disabled(model.access?.contains(.canRenameFiles) == false) - Toggle("Can Move Files", isOn: $pendingAccess.bind(.canMoveFiles)) - .disabled(model.access?.contains(.canMoveFiles) == false) - Toggle("Can Comment Files", isOn: $pendingAccess.bind(.canSetFileComment)) - .disabled(model.access?.contains(.canSetFileComment) == false) - Toggle("Can Create Folders", isOn: $pendingAccess.bind(.canCreateFolders)) - .disabled(model.access?.contains(.canCreateFolders) == false) - Toggle("Can Delete Folders", isOn: $pendingAccess.bind(.canDeleteFolders)) - .disabled(model.access?.contains(.canDeleteFolders) == false) - Toggle("Can Rename Folders", isOn: $pendingAccess.bind(.canRenameFolders)) - .disabled(model.access?.contains(.canRenameFolders) == false) - Toggle("Can Move Folders", isOn: $pendingAccess.bind(.canMoveFolders)) - .disabled(model.access?.contains(.canMoveFolders) == false) - Toggle("Can Comment Folders", isOn: $pendingAccess.bind(.canSetFolderComment)) - .disabled(model.access?.contains(.canSetFolderComment) == false) - Toggle("Can View Drop Boxes", isOn: $pendingAccess.bind(.canViewDropBoxes)) - .disabled(model.access?.contains(.canViewDropBoxes) == false) - Toggle("Can Make Aliases", isOn: $pendingAccess.bind(.canMakeAliases)) - .disabled(model.access?.contains(.canMakeAliases) == false) - } - - Section("User Maintenance") { - Toggle("Can Create Accounts", isOn: $pendingAccess.bind(.canCreateUsers)) - .disabled(model.access?.contains(.canCreateUsers) == false) - Toggle("Can Delete Accounts", isOn: $pendingAccess.bind(.canDeleteUsers)) - .disabled(model.access?.contains(.canDeleteUsers) == false) - Toggle("Can Read Accounts", isOn: $pendingAccess.bind(.canOpenUsers)) - .disabled(model.access?.contains(.canOpenUsers) == false) - Toggle("Can Modify Accounts", isOn: $pendingAccess.bind(.canModifyUsers)) - .disabled(model.access?.contains(.canModifyUsers) == false) - Toggle("Can Get User Info", isOn: $pendingAccess.bind(.canGetClientInfo)) - .disabled(model.access?.contains(.canGetClientInfo) == false) - - Toggle("Can Disconnect Users", isOn: $pendingAccess.bind(.canDisconnectUsers)) - .disabled(model.access?.contains(.canDisconnectUsers) == false) - Toggle("Cannot be Disconnected", isOn: $pendingAccess.bind(.cantBeDisconnected)) - .disabled(model.access?.contains(.cantBeDisconnected) == false) - } - - Section("Messaging") { - Toggle("Can Send Messages", isOn: $pendingAccess.bind(.canSendMessages)) - .disabled(model.access?.contains(.canSendMessages) == false) - Toggle("Can Broadcast", isOn: $pendingAccess.bind(.canBroadcast)) - .disabled(model.access?.contains(.canBroadcast) == false) - } - - Section("News") { - Toggle("Can Read Articles", isOn: $pendingAccess.bind(.canReadMessageBoard)) - .disabled(model.access?.contains(.canReadMessageBoard) == false) - Toggle("Can Post Articles", isOn: $pendingAccess.bind(.canPostMessageBoard)) - .disabled(model.access?.contains(.canPostMessageBoard) == false) - Toggle("Can Delete Articles", isOn: $pendingAccess.bind(.canDeleteNewsArticles)) - .disabled(model.access?.contains(.canDeleteNewsArticles) == false) - Toggle("Can Create Categories", isOn: $pendingAccess.bind(.canCreateNewsCategories)) - .disabled(model.access?.contains(.canCreateNewsCategories) == false) - Toggle("Can Delete Categories", isOn: $pendingAccess.bind(.canDeleteNewsCategories)) - .disabled(model.access?.contains(.canDeleteNewsCategories) == false) - Toggle("Can Create News Bundles", isOn: $pendingAccess.bind(.canCreateNewsFolders)) - .disabled(model.access?.contains(.canCreateNewsFolders) == false) - Toggle("Can Delete News Bundles", isOn: $pendingAccess.bind(.canDeleteNewsFolders)) - .disabled(model.access?.contains(.canDeleteNewsFolders) == false) - } - - Section("Chat") { - Toggle("Can Initiate Private Chat", isOn: $pendingAccess.bind(.canCreateChat)) - .disabled(model.access?.contains(.canCreateChat) == false) - Toggle("Can Read Chat", isOn: $pendingAccess.bind(.canReadChat)) - .disabled(model.access?.contains(.canReadChat) == false) - Toggle("Can Send Chat", isOn: $pendingAccess.bind(.canSendChat)) - .disabled(model.access?.contains(.canSendChat) == false) - } - - Section("Miscellaneous") { - Toggle("Can Use Any Name", isOn: $pendingAccess.bind(.canUseAnyName)) - .disabled(model.access?.contains(.canUseAnyName) == false) - Toggle("Don't Show Agreement", isOn: $pendingAccess.bind(.canSkipAgreement)) - .disabled(model.access?.contains(.canSkipAgreement) == false) - } - } - .disabled(model.access?.contains(.canModifyUsers) == false) - .formStyle(.grouped) - .onChange(of: selection) { - if let selection { - pendingName = selection.name - pendingLogin = selection.login - pendingAccess = selection.access - - if selection.persisted { - if selection.password == nil { - pendingPassword = "" - } else { - pendingPassword = placeholderPassword - } - } - } - } - .onAppear() { - if let selection { - pendingName = selection.name - pendingLogin = selection.login - pendingAccess = selection.access - - if selection.persisted { - if selection.password == nil { - pendingPassword = "" - } else { - pendingPassword = placeholderPassword - } - } else { - pendingPassword = HotlineAccount.randomPassword() - } - } - } - } - .frame(maxWidth: .infinity) - - Divider() - - HStack() { - Button("Revert") { - if let selection { - pendingAccess = selection.access - pendingName = selection.name - pendingLogin = selection.login - - if selection.password != nil { - pendingPassword = selection.password! - } - } - } - .controlSize(.large) - .frame(minWidth: 75) - .disabled(!self.isSaveable()) -// .padding() - - Spacer() - - Button("Save"){ - guard let selection else { - return - } - - // Update existing account - if selection.persisted == true { - - if pendingPassword == placeholderPassword { - Task { @MainActor in - model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) - } - } else { - Task { @MainActor in - model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) - } - } - - } else { - // Create new existing account - Task { @MainActor in - model.client.sendCreateUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) - } - self.selection?.password = pendingPassword - pendingPassword = placeholderPassword - } - - var account = HotlineAccount(pendingName, pendingLogin, pendingAccess) - account.persisted = true - account.password = placeholderPassword - - accounts = accounts.filter { $0.persisted == true && $0.login != selection.login } - - // Add new account to list - accounts.append(account) - - // Re-sort accounts - accounts.sort { $0.login < $1.login } - self.selection = account - } - .controlSize(.large) - .frame(minWidth: 75) - .keyboardShortcut(.defaultAction) - .disabled(!self.isSaveable()) - } - .padding() - } - - } - - var accountList: some View { - List(accounts, id: \.self, selection: $selection) { account in - HStack(spacing: 5) { - if account.access.contains(.canDisconnectUsers) { - Image("User Admin") - .frame(width: 16, height: 16) - .opacity(account.persisted ? 1.0 : 0.25) - // .padding(.leading, 4) - Text(account.login) - .foregroundStyle(Color.hotlineRed) - } - else if account.access.rawValue == 0 { - Image("User") - .frame(width: 16, height: 16) - // .padding(.leading, 4) - Text(account.login) - .foregroundStyle(.secondary) - } - // else if account.persisted == false { - // HStack { - // Image("User") - // .frame(width: 16, height: 16) - //// .padding(.leading, 4) - // Text(account.login) - // .italic() - // } - // } - else { - Image("User") - .frame(width: 16, height: 16) - .opacity(account.persisted ? 1.0 : 0.5) - // .padding(.leading, 4) - Text(account.login) - } - } - } - .frame(width: 250) - .sheet(item: $toDelete) { item in - Form { - HStack{ - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 30)) - Text("Delete account \"\(item.name)\" and all associated files?") - .lineSpacing(4) - } - } - .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100, maxHeight: .infinity) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - toDelete = nil - } - } - - ToolbarItem(placement: .primaryAction) { - Button("Delete") { - guard let userToDelete = toDelete else { - return - } - - self.toDelete = nil - self.selection = nil - - if userToDelete.persisted { - Task { @MainActor in - model.client.sendDeleteUser(login: userToDelete.login) - } - } - - accounts = accounts.filter { $0.login != userToDelete.login } - - } - } - } - } - } - - - private func isSaveable() -> Bool { - guard let selection else { - return false - } - - // Disable save if login field is cleared - if pendingLogin == "" { - return false - } - - // If the account initial has a password and it was updated - if selection.password != nil && pendingPassword != placeholderPassword { - return true - } - - // If the account initial has no password, but was updated to have one - if selection.password == nil && pendingPassword != "" { - return true - } - - // If the access bits or user name have been changed - return pendingAccess.rawValue != selection.access.rawValue || selection.name != pendingName - } -} diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift new file mode 100644 index 0000000..57682cc --- /dev/null +++ b/Hotline/macOS/Accounts/AccountManagerView.swift @@ -0,0 +1,398 @@ +import SwiftUI + +struct AccountManagerView: View { + @Environment(Hotline.self) private var model: Hotline + + @State private var accounts: [HotlineAccount] = [] + @State private var selection: HotlineAccount? + @State private var loading: Bool = true + + @State private var pendingName: String = "" + @State private var pendingLogin: String = "" + @State private var pendingPassword: String = "" + @State private var pendingAccess = HotlineUserAccessOptions.defaultAccess + + @State private var toDelete: HotlineAccount? + + let placeholderPassword = "xxxxxxxxxxxxxxxxxx" + + var body: some View { + HStack(spacing: 0) { + ZStack { + accountList + if loading { + ProgressView() + } + } + if selection != nil { + accountDetails + } + else { + ZStack(alignment: .center) { + Text("No Account Selected") + .font(.title) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding() + } + .frame(maxWidth: .infinity) + } + } + .environment(\.defaultMinListRowHeight, 34) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .task { + if loading { + accounts = await model.getAccounts() + loading = false + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + let newAccount = HotlineAccount("unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess) + + pendingPassword = HotlineAccount.randomPassword() + accounts.append(newAccount) + selection = newAccount + } label: { + Label("New Account", systemImage: "plus") + } + .help("Create a new account") + .disabled(model.access?.contains(.canCreateUsers) != true) + } + + ToolbarItem(placement: .destructiveAction) { + Button { + toDelete = selection + } label: { + Label("Delete Account", systemImage: "trash") + } + .help("Delete account") + .disabled(selection == nil || model.access?.contains(.canDeleteUsers) != true) + } + } + } + + var accountDetails: some View { + VStack(alignment: .center, spacing: 0) { + ScrollView(.vertical) { + Form { + Section { + TextField(text: $pendingName) { + Text("Name") + } + TextField("Login", text: $pendingLogin) + .disabled(selection?.persisted == true) + if selection?.persisted == true { + SecureField("Password", text: $pendingPassword) + } else { + TextField("Password", text: $pendingPassword) + } + } + .textFieldStyle(.roundedBorder) + .controlSize(.large) + + Section("File System Maintenance") { + Toggle("Can Download Files", isOn: $pendingAccess.bind(.canDownloadFiles)) + .disabled(model.access?.contains(.canDownloadFiles) == false) + Toggle("Can Download Folders", isOn: $pendingAccess.bind(.canDownloadFolders)) + .disabled(model.access?.contains(.canDownloadFolders) == false) + Toggle("Can Upload Files", isOn: $pendingAccess.bind(.canUploadFiles)) + .disabled(model.access?.contains(.canUploadFiles) == false) + Toggle("Can Upload Folders", isOn: $pendingAccess.bind(.canUploadFolders)) + .disabled(model.access?.contains(.canUploadFolders) == false) + Toggle("Can Upload Anywhere", isOn: $pendingAccess.bind(.canUploadAnywhere)) + .disabled(model.access?.contains(.canUploadAnywhere) == false) + Toggle("Can Delete Files", isOn: $pendingAccess.bind(.canDeleteFiles)) + .disabled(model.access?.contains(.canDeleteFiles) == false) + Toggle("Can Rename Files", isOn: $pendingAccess.bind(.canRenameFiles)) + .disabled(model.access?.contains(.canRenameFiles) == false) + Toggle("Can Move Files", isOn: $pendingAccess.bind(.canMoveFiles)) + .disabled(model.access?.contains(.canMoveFiles) == false) + Toggle("Can Comment Files", isOn: $pendingAccess.bind(.canSetFileComment)) + .disabled(model.access?.contains(.canSetFileComment) == false) + Toggle("Can Create Folders", isOn: $pendingAccess.bind(.canCreateFolders)) + .disabled(model.access?.contains(.canCreateFolders) == false) + Toggle("Can Delete Folders", isOn: $pendingAccess.bind(.canDeleteFolders)) + .disabled(model.access?.contains(.canDeleteFolders) == false) + Toggle("Can Rename Folders", isOn: $pendingAccess.bind(.canRenameFolders)) + .disabled(model.access?.contains(.canRenameFolders) == false) + Toggle("Can Move Folders", isOn: $pendingAccess.bind(.canMoveFolders)) + .disabled(model.access?.contains(.canMoveFolders) == false) + Toggle("Can Comment Folders", isOn: $pendingAccess.bind(.canSetFolderComment)) + .disabled(model.access?.contains(.canSetFolderComment) == false) + Toggle("Can View Drop Boxes", isOn: $pendingAccess.bind(.canViewDropBoxes)) + .disabled(model.access?.contains(.canViewDropBoxes) == false) + Toggle("Can Make Aliases", isOn: $pendingAccess.bind(.canMakeAliases)) + .disabled(model.access?.contains(.canMakeAliases) == false) + } + + Section("User Maintenance") { + Toggle("Can Create Accounts", isOn: $pendingAccess.bind(.canCreateUsers)) + .disabled(model.access?.contains(.canCreateUsers) == false) + Toggle("Can Delete Accounts", isOn: $pendingAccess.bind(.canDeleteUsers)) + .disabled(model.access?.contains(.canDeleteUsers) == false) + Toggle("Can Read Accounts", isOn: $pendingAccess.bind(.canOpenUsers)) + .disabled(model.access?.contains(.canOpenUsers) == false) + Toggle("Can Modify Accounts", isOn: $pendingAccess.bind(.canModifyUsers)) + .disabled(model.access?.contains(.canModifyUsers) == false) + Toggle("Can Get User Info", isOn: $pendingAccess.bind(.canGetClientInfo)) + .disabled(model.access?.contains(.canGetClientInfo) == false) + + Toggle("Can Disconnect Users", isOn: $pendingAccess.bind(.canDisconnectUsers)) + .disabled(model.access?.contains(.canDisconnectUsers) == false) + Toggle("Cannot be Disconnected", isOn: $pendingAccess.bind(.cantBeDisconnected)) + .disabled(model.access?.contains(.cantBeDisconnected) == false) + } + + Section("Messaging") { + Toggle("Can Send Messages", isOn: $pendingAccess.bind(.canSendMessages)) + .disabled(model.access?.contains(.canSendMessages) == false) + Toggle("Can Broadcast", isOn: $pendingAccess.bind(.canBroadcast)) + .disabled(model.access?.contains(.canBroadcast) == false) + } + + Section("News") { + Toggle("Can Read Articles", isOn: $pendingAccess.bind(.canReadMessageBoard)) + .disabled(model.access?.contains(.canReadMessageBoard) == false) + Toggle("Can Post Articles", isOn: $pendingAccess.bind(.canPostMessageBoard)) + .disabled(model.access?.contains(.canPostMessageBoard) == false) + Toggle("Can Delete Articles", isOn: $pendingAccess.bind(.canDeleteNewsArticles)) + .disabled(model.access?.contains(.canDeleteNewsArticles) == false) + Toggle("Can Create Categories", isOn: $pendingAccess.bind(.canCreateNewsCategories)) + .disabled(model.access?.contains(.canCreateNewsCategories) == false) + Toggle("Can Delete Categories", isOn: $pendingAccess.bind(.canDeleteNewsCategories)) + .disabled(model.access?.contains(.canDeleteNewsCategories) == false) + Toggle("Can Create News Bundles", isOn: $pendingAccess.bind(.canCreateNewsFolders)) + .disabled(model.access?.contains(.canCreateNewsFolders) == false) + Toggle("Can Delete News Bundles", isOn: $pendingAccess.bind(.canDeleteNewsFolders)) + .disabled(model.access?.contains(.canDeleteNewsFolders) == false) + } + + Section("Chat") { + Toggle("Can Initiate Private Chat", isOn: $pendingAccess.bind(.canCreateChat)) + .disabled(model.access?.contains(.canCreateChat) == false) + Toggle("Can Read Chat", isOn: $pendingAccess.bind(.canReadChat)) + .disabled(model.access?.contains(.canReadChat) == false) + Toggle("Can Send Chat", isOn: $pendingAccess.bind(.canSendChat)) + .disabled(model.access?.contains(.canSendChat) == false) + } + + Section("Miscellaneous") { + Toggle("Can Use Any Name", isOn: $pendingAccess.bind(.canUseAnyName)) + .disabled(model.access?.contains(.canUseAnyName) == false) + Toggle("Don't Show Agreement", isOn: $pendingAccess.bind(.canSkipAgreement)) + .disabled(model.access?.contains(.canSkipAgreement) == false) + } + } + .disabled(model.access?.contains(.canModifyUsers) == false) + .formStyle(.grouped) + .onChange(of: selection) { + if let selection { + pendingName = selection.name + pendingLogin = selection.login + pendingAccess = selection.access + + if selection.persisted { + if selection.password == nil { + pendingPassword = "" + } else { + pendingPassword = placeholderPassword + } + } + } + } + .onAppear() { + if let selection { + pendingName = selection.name + pendingLogin = selection.login + pendingAccess = selection.access + + if selection.persisted { + if selection.password == nil { + pendingPassword = "" + } else { + pendingPassword = placeholderPassword + } + } else { + pendingPassword = HotlineAccount.randomPassword() + } + } + } + } + .frame(maxWidth: .infinity) + + Divider() + + HStack() { + Button("Revert") { + if let selection { + pendingAccess = selection.access + pendingName = selection.name + pendingLogin = selection.login + + if selection.password != nil { + pendingPassword = selection.password! + } + } + } + .controlSize(.large) + .frame(minWidth: 75) + .disabled(!self.isSaveable()) +// .padding() + + Spacer() + + Button("Save"){ + guard let selection else { + return + } + + // Update existing account + if selection.persisted == true { + + if pendingPassword == placeholderPassword { + Task { @MainActor in + model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) + } + } else { + Task { @MainActor in + model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) + } + } + + } else { + // Create new existing account + Task { @MainActor in + model.client.sendCreateUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) + } + self.selection?.password = pendingPassword + pendingPassword = placeholderPassword + } + + var account = HotlineAccount(pendingName, pendingLogin, pendingAccess) + account.persisted = true + account.password = placeholderPassword + + accounts = accounts.filter { $0.persisted == true && $0.login != selection.login } + + // Add new account to list + accounts.append(account) + + // Re-sort accounts + accounts.sort { $0.login < $1.login } + self.selection = account + } + .controlSize(.large) + .frame(minWidth: 75) + .keyboardShortcut(.defaultAction) + .disabled(!self.isSaveable()) + } + .padding() + } + + } + + var accountList: some View { + List(accounts, id: \.self, selection: $selection) { account in + HStack(spacing: 5) { + if account.access.contains(.canDisconnectUsers) { + Image("User Admin") + .frame(width: 16, height: 16) + .opacity(account.persisted ? 1.0 : 0.25) + // .padding(.leading, 4) + Text(account.login) + .foregroundStyle(Color.hotlineRed) + } + else if account.access.rawValue == 0 { + Image("User") + .frame(width: 16, height: 16) + // .padding(.leading, 4) + Text(account.login) + .foregroundStyle(.secondary) + } + // else if account.persisted == false { + // HStack { + // Image("User") + // .frame(width: 16, height: 16) + //// .padding(.leading, 4) + // Text(account.login) + // .italic() + // } + // } + else { + Image("User") + .frame(width: 16, height: 16) + .opacity(account.persisted ? 1.0 : 0.5) + // .padding(.leading, 4) + Text(account.login) + } + } + } + .frame(width: 250) + .sheet(item: $toDelete) { item in + Form { + HStack{ + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 30)) + Text("Delete account \"\(item.name)\" and all associated files?") + .lineSpacing(4) + } + } + .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100, maxHeight: .infinity) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + toDelete = nil + } + } + + ToolbarItem(placement: .primaryAction) { + Button("Delete") { + guard let userToDelete = toDelete else { + return + } + + self.toDelete = nil + self.selection = nil + + if userToDelete.persisted { + Task { @MainActor in + model.client.sendDeleteUser(login: userToDelete.login) + } + } + + accounts = accounts.filter { $0.login != userToDelete.login } + + } + } + } + } + } + + + private func isSaveable() -> Bool { + guard let selection else { + return false + } + + // Disable save if login field is cleared + if pendingLogin == "" { + return false + } + + // If the account initial has a password and it was updated + if selection.password != nil && pendingPassword != placeholderPassword { + return true + } + + // If the account initial has no password, but was updated to have one + if selection.password == nil && pendingPassword != "" { + return true + } + + // If the access bits or user name have been changed + return pendingAccess.rawValue != selection.access.rawValue || selection.name != pendingName + } +} diff --git a/Hotline/macOS/Board/MessageBoardEditorView.swift b/Hotline/macOS/Board/MessageBoardEditorView.swift new file mode 100644 index 0000000..474384e --- /dev/null +++ b/Hotline/macOS/Board/MessageBoardEditorView.swift @@ -0,0 +1,117 @@ +import SwiftUI + +private enum FocusFields { + case body +} + +struct MessageBoardEditorView: View { + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + @Environment(Hotline.self) private var model: Hotline + + @State private var text: String = "" + @State private var sending: Bool = false + + @FocusState private var focusedField: FocusFields? + + func sendPost() async { + sending = true + + let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + + model.postToMessageBoard(text: cleanedText) + let _ = await model.getMessageBoard() + +// let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) +// if success { +// await model.getNewsList(at: path) +// } + + sending = false + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .opacity(0.5) + } + .buttonStyle(.plain) + .frame(width: 16, height: 16) + + Spacer() + +// Image("Message Board Post") +// .resizable() +// .scaledToFit() +// .frame(width: 16, height: 16) +// .padding(.trailing, 6) + + Text("New Post") + .fontWeight(.semibold) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + if sending { + ProgressView() + .controlSize(.small) + .frame(width: 22, height: 22) + } + else { + Button { + sending = true + model.postToMessageBoard(text: text) + Task { + let _ = await model.getMessageBoard() + Task { @MainActor in + sending = false + dismiss() + } + } + } label: { + Image(systemName: "arrow.up.circle.fill") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .secondary : .accentColor) + } + .buttonStyle(.plain) + .frame(width: 22, height: 22) + .help("Post to Message Board") + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .frame(maxWidth: .infinity) + .padding() + + Divider() + + BetterTextEditor(text: $text) + .betterEditorFont(NSFont.systemFont(ofSize: 14.0)) + .betterEditorAutomaticSpellingCorrection(true) + .betterEditorTextInset(.init(width: 16, height: 18)) + .lineSpacing(20) + .background(Color(nsColor: .textBackgroundColor)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .focused($focusedField, equals: .body) + } + .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + .presentationCompactAdaptation(.sheet) + .onAppear { + focusedField = .body + } + .onDisappear { + dismiss() + } + } +} diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift new file mode 100644 index 0000000..f870b0c --- /dev/null +++ b/Hotline/macOS/Board/MessageBoardView.swift @@ -0,0 +1,102 @@ +import SwiftUI + +struct MessageBoardView: View { + @Environment(Hotline.self) private var model: Hotline + + @State private var composerDisplayed: Bool = false + @State private var composerText: String = "" + + var body: some View { + NavigationStack { + if model.access?.contains(.canReadMessageBoard) != false { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(model.messageBoard, id: \.self) { msg in + Text(LocalizedStringKey(msg)) + .tint(Color("Link Color")) + .lineLimit(100) + .lineSpacing(4) + .padding() + .textSelection(.enabled) + Divider() + } + } + Spacer() + } + .task { + if !model.messageBoardLoaded { + let _ = await model.getMessageBoard() + } + } + .overlay { + if !model.messageBoardLoaded { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + .background(Color(nsColor: .textBackgroundColor)) + } + else { + ZStack(alignment: .center) { + Text("No Message Board") + .font(.title) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding() + } + .frame(maxWidth: .infinity) + } + } + .sheet(isPresented: $composerDisplayed) { + MessageBoardEditorView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(idealWidth: 450, idealHeight: 350) +// RichTextEditor(text: $composerText) +// .richEditorFont(NSFont.systemFont(ofSize: 16.0)) +// .richEditorAutomaticDashSubstitution(false) +// .richEditorAutomaticQuoteSubstitution(false) +// .richEditorAutomaticSpellingCorrection(false) +// .background(Color(nsColor: .textBackgroundColor)) +// .frame(maxWidth: .infinity, maxHeight: .infinity) +// .frame(idealWidth: 450, idealHeight: 350) +// .toolbar { +// ToolbarItem(placement: .cancellationAction) { +// Button("Cancel") { +// composerDisplayed.toggle() +// } +// } +// +// ToolbarItem(placement: .primaryAction) { +// Button("Post") { +// composerDisplayed.toggle() +// let text = composerText +// composerText = "" +// model.postToMessageBoard(text: text) +// Task { +// await model.getMessageBoard() +// } +// } +// } +// } + } + .toolbar { + ToolbarItem(placement:.primaryAction) { + Button { + composerDisplayed.toggle() + } label: { + Image(systemName: "square.and.pencil") + } + .disabled(model.access?.contains(.canPostMessageBoard) == false) + .help("Post to Message Board") + } + } + } +} + +#Preview { + MessageBoardView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/Chat/ChatView.swift b/Hotline/macOS/Chat/ChatView.swift new file mode 100644 index 0000000..65087c6 --- /dev/null +++ b/Hotline/macOS/Chat/ChatView.swift @@ -0,0 +1,321 @@ +import SwiftUI + +enum FocusedField: Int, Hashable { + case chatInput +} + +struct ChatJoinedMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .center, spacing: 4) { + Image(systemName: "arrow.right") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .foregroundStyle(.primary) + .frame(width: 12, height: 12) + + Text(message.text) + .lineLimit(1) + .truncationMode(.middle) + .fontWeight(.semibold) + .textSelection(.disabled) + + Spacer() + } + .opacity(0.3) + } +} + +struct ChatLeftMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .center, spacing: 4) { + Image(systemName: "arrow.left") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .foregroundStyle(.primary) + .frame(width: 12, height: 12) + + Text(message.text) + .lineLimit(1) + .truncationMode(.middle) + .fontWeight(.semibold) + .textSelection(.disabled) + + Spacer() + } + .opacity(0.3) + } +} + + +struct ChatDisconnectedMessageView: View { + let message: ChatMessage + + var body: some View { + HStack { + Spacer() + } + .frame(height: 5) + .background(Color.primary) + .clipShape(.capsule) + .opacity(0.1) + } +} + +struct ChatMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .firstTextBaseline) { + if let username = message.username { + Text("\(username): ").fontWeight(.semibold) + Text(message.text.markdownToAttributedString()) + } + else { + Text(message.text) + } + Spacer() + } + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .tint(Color("Link Color")) + } +} + +struct ChatView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) var dismiss + + @State private var scrollPos: Int? + @State private var contentHeight: CGFloat = 0 + + @State private var searchQuery: String = "" + @State private var searchResults: [ChatMessage] = [] + @State private var isSearching: Bool = false + + @FocusState private var focusedField: FocusedField? + + @Namespace var bottomID + + private var bindableModel: Bindable { + Bindable(model) + } + +// @State private var showingExporter: Bool = false +// +// @State private var chatDocument: TextFile = TextFile() + + var displayedMessages: [ChatMessage] { + searchQuery.isEmpty ? model.chat : searchResults + } + + var body: some View { + @Bindable var bindModel = model + + NavigationStack { + ScrollViewReader { reader in + VStack(alignment: .leading, spacing: 0) { + + // MARK: Scroll View + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 8) { + + ForEach(displayedMessages) { msg in + if msg.type == .agreement { + VStack(alignment: .center, spacing: 16) { + if let bannerImage = self.model.bannerImage { + HStack(spacing: 0) { + Spacer(minLength: 0) + ZStack { + Image(nsImage: bannerImage) + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .offset(y: 1.5) + .blur(radius: 4) + .opacity(0.2) + + Image(nsImage: bannerImage) + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + Spacer(minLength: 0) + } + } + + ServerAgreementView(text: msg.text) + } + .padding(.vertical, 24) + } + else if msg.type == .server { + ServerMessageView(message: msg.text) + } + else if msg.type == .joined { + ChatJoinedMessageView(message: msg) + } + else if msg.type == .left { + ChatLeftMessageView(message: msg) + } + else if msg.type == .signOut { + ChatDisconnectedMessageView(message: msg) + .padding(.vertical, 24) + } + else { + ChatMessageView(message: msg) + } + } + } + .padding() + + VStack(spacing: 0) {}.id(bottomID) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) +// .defaultScrollAnchor(.bottom, for: .initialOffset) + .defaultScrollAnchor(.bottom, for: .alignment) + .defaultScrollAnchor(.bottom, for: .sizeChanges) + .onChange(of: model.chat.count) { + // Re-run search when new messages arrive to keep filter active + if !searchQuery.isEmpty { + performSearch() + } + reader.scrollTo(bottomID, anchor: .bottom) + model.markPublicChatAsRead() + } + .onAppear { + reader.scrollTo(bottomID, anchor: .bottom) + self.focusedField = .chatInput + } + .onChange(of: self.model.bannerImage) { + reader.scrollTo(bottomID, anchor: .bottom) + } + .onChange(of: searchQuery) { + reader.scrollTo(bottomID, anchor: .bottom) + } + .onChange(of: isSearching) { + reader.scrollTo(bottomID, anchor: .bottom) + } + + // MARK: Input Divider + Divider() + + // MARK: Input Bar + HStack(alignment: .lastTextBaseline, spacing: 0) { + TextField("", text: $bindModel.chatInput, axis: .vertical) + .focused($focusedField, equals: .chatInput) + .textFieldStyle(.plain) + .lineLimit(1...5) + .multilineTextAlignment(.leading) + .onSubmit { + if !model.chatInput.isEmpty { + model.sendChat(model.chatInput, announce: NSEvent.modifierFlags.contains(.shift)) + } + model.chatInput = "" + } + .frame(maxWidth: .infinity) + .padding() + } + .frame(maxWidth: .infinity, minHeight: 28) + .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + .overlay(alignment: .leadingLastTextBaseline) { + Image(systemName: "chevron.right").fontWeight(.semibold).opacity(0.4).offset(x: 16) + } + .onContinuousHover { phase in + switch phase { + case .active(_): + NSCursor.iBeam.set() + case .ended: + NSCursor.arrow.set() + break + } + } + .onTapGesture(count: 1) { + focusedField = .chatInput + } + } + } + .searchable(text: $searchQuery, isPresented: $isSearching, placement: .toolbar, prompt: "Search") + .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) + } + .background(Color(nsColor: .textBackgroundColor)) +// .navigationTitle(model.serverTitle) + .onChange(of: searchQuery) { + performSearch() + } +// .toolbar { +// ToolbarItem(placement: .primaryAction) { +// Button { +// showingExporter = true +// } label: { +// Image(systemName: "square.and.arrow.up") +// }.help("Save Chat...") +// } +// } +// .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in +// switch result { +// case .success(let url): +// print("Saved to \(url)") +// +// case .failure(let error): +// print(error.localizedDescription) +// } +// self.chatDocument.text = "" +// } + } + + private func performSearch() { + guard !searchQuery.isEmpty else { + searchResults = [] + return + } + + searchResults = model.searchChat(query: searchQuery) + } + +// private func prepareChatDocument() -> Bool { +// var text: String = String() +// +// self.chatDocument.text = "" +// for msg in model.chat { +// if msg.type == .agreement { +// text.append(msg.text) +// text.append("\n\n") +// } +// else if msg.type == .message { +// if let username = msg.username { +// text.append("\(username): \(msg.text)") +// } +// else { +// text.append(msg.text) +// } +// text.append("\n") +// } +// else if msg.type == .status { +// text.append(msg.text) +// text.append("\n") +// } +// } +// +// if text.isEmpty { +// return false +// } +// +// self.chatDocument.text = text +// +// return true +// } +} + +#Preview { + ChatView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/Chat/ServerAgreementView.swift b/Hotline/macOS/Chat/ServerAgreementView.swift new file mode 100644 index 0000000..e72de7d --- /dev/null +++ b/Hotline/macOS/Chat/ServerAgreementView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 340 + +struct ServerAgreementView: View { + let text: String + + @State private var expandable: Bool = false + @State private var expanded: Bool = false + + var body: some View { + ScrollView(.vertical) { + HStack(alignment: .top) { + Spacer() + Text(text.convertToAttributedStringWithLinks()) + .font(.system(size: 12)) + .fontDesign(.monospaced) + .textSelection(.enabled) + .tint(Color("Link Color")) + .frame(maxWidth: 400) + .padding(16) + .background( + GeometryReader { geometry in + Color.clear.onAppear { + if geometry.size.height > MAX_AGREEMENT_HEIGHT { + expandable = true + } + else { + expandable = false + } + } + } + ) + Spacer() + } + } + .scrollIndicators(.never) + .frame(maxWidth: .infinity, maxHeight: (expandable && expanded) ? nil : MAX_AGREEMENT_HEIGHT) + .scrollBounceBehavior(.basedOnSize) +#if os(iOS) + .background(Color("Agreement Background")) +#elseif os(macOS) + .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) +#endif + .overlay(alignment: .bottomTrailing) { + ZStack(alignment: .bottomTrailing) { + Group { + if !expandable || expanded { + EmptyView() + } + else { + Button(action: { + withAnimation(.easeOut(duration: 0.15)) { + expanded = true + } + }, label: { + Image(systemName: "arrow.up.and.down.circle.fill") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .frame(width: 16, height: 16) + .foregroundColor(.primary.opacity(0.5)) + }) + .buttonStyle(.plain) + .buttonBorderShape(.circle) + .help("Expand Server Agreement") + .padding([.trailing, .bottom], 16) + } + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +#Preview { + ServerAgreementView(text: "Hello there and welcome to this server.") +} diff --git a/Hotline/macOS/Chat/ServerMessageView.swift b/Hotline/macOS/Chat/ServerMessageView.swift new file mode 100644 index 0000000..6c3c60e --- /dev/null +++ b/Hotline/macOS/Chat/ServerMessageView.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct ServerMessageView: View { + let message: String + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image("Server Message") + .symbolRenderingMode(.multicolor) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + Text(message) + .fontWeight(.semibold) + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + Spacer() + } + .padding() + .frame(maxWidth: .infinity) +#if os(iOS) + .background(Color("Agreement Background")) +#elseif os(macOS) + .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) +#endif + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +#Preview { + ServerMessageView(message: "This server has something important to say.") +} diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift deleted file mode 100644 index 65087c6..0000000 --- a/Hotline/macOS/ChatView.swift +++ /dev/null @@ -1,321 +0,0 @@ -import SwiftUI - -enum FocusedField: Int, Hashable { - case chatInput -} - -struct ChatJoinedMessageView: View { - let message: ChatMessage - - var body: some View { - HStack(alignment: .center, spacing: 4) { - Image(systemName: "arrow.right") - .resizable() - .scaledToFit() - .fontWeight(.semibold) - .foregroundStyle(.primary) - .frame(width: 12, height: 12) - - Text(message.text) - .lineLimit(1) - .truncationMode(.middle) - .fontWeight(.semibold) - .textSelection(.disabled) - - Spacer() - } - .opacity(0.3) - } -} - -struct ChatLeftMessageView: View { - let message: ChatMessage - - var body: some View { - HStack(alignment: .center, spacing: 4) { - Image(systemName: "arrow.left") - .resizable() - .scaledToFit() - .fontWeight(.semibold) - .foregroundStyle(.primary) - .frame(width: 12, height: 12) - - Text(message.text) - .lineLimit(1) - .truncationMode(.middle) - .fontWeight(.semibold) - .textSelection(.disabled) - - Spacer() - } - .opacity(0.3) - } -} - - -struct ChatDisconnectedMessageView: View { - let message: ChatMessage - - var body: some View { - HStack { - Spacer() - } - .frame(height: 5) - .background(Color.primary) - .clipShape(.capsule) - .opacity(0.1) - } -} - -struct ChatMessageView: View { - let message: ChatMessage - - var body: some View { - HStack(alignment: .firstTextBaseline) { - if let username = message.username { - Text("\(username): ").fontWeight(.semibold) + Text(message.text.markdownToAttributedString()) - } - else { - Text(message.text) - } - Spacer() - } - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) - } -} - -struct ChatView: View { - @Environment(Hotline.self) private var model: Hotline - @Environment(\.colorScheme) var colorScheme - @Environment(\.dismiss) var dismiss - - @State private var scrollPos: Int? - @State private var contentHeight: CGFloat = 0 - - @State private var searchQuery: String = "" - @State private var searchResults: [ChatMessage] = [] - @State private var isSearching: Bool = false - - @FocusState private var focusedField: FocusedField? - - @Namespace var bottomID - - private var bindableModel: Bindable { - Bindable(model) - } - -// @State private var showingExporter: Bool = false -// -// @State private var chatDocument: TextFile = TextFile() - - var displayedMessages: [ChatMessage] { - searchQuery.isEmpty ? model.chat : searchResults - } - - var body: some View { - @Bindable var bindModel = model - - NavigationStack { - ScrollViewReader { reader in - VStack(alignment: .leading, spacing: 0) { - - // MARK: Scroll View - ScrollView(.vertical) { - LazyVStack(alignment: .leading, spacing: 8) { - - ForEach(displayedMessages) { msg in - if msg.type == .agreement { - VStack(alignment: .center, spacing: 16) { - if let bannerImage = self.model.bannerImage { - HStack(spacing: 0) { - Spacer(minLength: 0) - ZStack { - Image(nsImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .offset(y: 1.5) - .blur(radius: 4) - .opacity(0.2) - - Image(nsImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - - Spacer(minLength: 0) - } - } - - ServerAgreementView(text: msg.text) - } - .padding(.vertical, 24) - } - else if msg.type == .server { - ServerMessageView(message: msg.text) - } - else if msg.type == .joined { - ChatJoinedMessageView(message: msg) - } - else if msg.type == .left { - ChatLeftMessageView(message: msg) - } - else if msg.type == .signOut { - ChatDisconnectedMessageView(message: msg) - .padding(.vertical, 24) - } - else { - ChatMessageView(message: msg) - } - } - } - .padding() - - VStack(spacing: 0) {}.id(bottomID) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) -// .defaultScrollAnchor(.bottom, for: .initialOffset) - .defaultScrollAnchor(.bottom, for: .alignment) - .defaultScrollAnchor(.bottom, for: .sizeChanges) - .onChange(of: model.chat.count) { - // Re-run search when new messages arrive to keep filter active - if !searchQuery.isEmpty { - performSearch() - } - reader.scrollTo(bottomID, anchor: .bottom) - model.markPublicChatAsRead() - } - .onAppear { - reader.scrollTo(bottomID, anchor: .bottom) - self.focusedField = .chatInput - } - .onChange(of: self.model.bannerImage) { - reader.scrollTo(bottomID, anchor: .bottom) - } - .onChange(of: searchQuery) { - reader.scrollTo(bottomID, anchor: .bottom) - } - .onChange(of: isSearching) { - reader.scrollTo(bottomID, anchor: .bottom) - } - - // MARK: Input Divider - Divider() - - // MARK: Input Bar - HStack(alignment: .lastTextBaseline, spacing: 0) { - TextField("", text: $bindModel.chatInput, axis: .vertical) - .focused($focusedField, equals: .chatInput) - .textFieldStyle(.plain) - .lineLimit(1...5) - .multilineTextAlignment(.leading) - .onSubmit { - if !model.chatInput.isEmpty { - model.sendChat(model.chatInput, announce: NSEvent.modifierFlags.contains(.shift)) - } - model.chatInput = "" - } - .frame(maxWidth: .infinity) - .padding() - } - .frame(maxWidth: .infinity, minHeight: 28) - .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) - .overlay(alignment: .leadingLastTextBaseline) { - Image(systemName: "chevron.right").fontWeight(.semibold).opacity(0.4).offset(x: 16) - } - .onContinuousHover { phase in - switch phase { - case .active(_): - NSCursor.iBeam.set() - case .ended: - NSCursor.arrow.set() - break - } - } - .onTapGesture(count: 1) { - focusedField = .chatInput - } - } - } - .searchable(text: $searchQuery, isPresented: $isSearching, placement: .toolbar, prompt: "Search") - .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) - } - .background(Color(nsColor: .textBackgroundColor)) -// .navigationTitle(model.serverTitle) - .onChange(of: searchQuery) { - performSearch() - } -// .toolbar { -// ToolbarItem(placement: .primaryAction) { -// Button { -// showingExporter = true -// } label: { -// Image(systemName: "square.and.arrow.up") -// }.help("Save Chat...") -// } -// } -// .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in -// switch result { -// case .success(let url): -// print("Saved to \(url)") -// -// case .failure(let error): -// print(error.localizedDescription) -// } -// self.chatDocument.text = "" -// } - } - - private func performSearch() { - guard !searchQuery.isEmpty else { - searchResults = [] - return - } - - searchResults = model.searchChat(query: searchQuery) - } - -// private func prepareChatDocument() -> Bool { -// var text: String = String() -// -// self.chatDocument.text = "" -// for msg in model.chat { -// if msg.type == .agreement { -// text.append(msg.text) -// text.append("\n\n") -// } -// else if msg.type == .message { -// if let username = msg.username { -// text.append("\(username): \(msg.text)") -// } -// else { -// text.append(msg.text) -// } -// text.append("\n") -// } -// else if msg.type == .status { -// text.append(msg.text) -// text.append("\n") -// } -// } -// -// if text.isEmpty { -// return false -// } -// -// self.chatDocument.text = text -// -// return true -// } -} - -#Preview { - ChatView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) -} diff --git a/Hotline/macOS/FileDetailsView.swift b/Hotline/macOS/FileDetailsView.swift deleted file mode 100644 index 34e083b..0000000 --- a/Hotline/macOS/FileDetailsView.swift +++ /dev/null @@ -1,147 +0,0 @@ -import Foundation -import SwiftUI - -struct FileDetailsView: View { - @Environment(Hotline.self) private var model: Hotline - @Environment(\.presentationMode) var presentationMode - - var fd: FileDetails - - @State private var comment: String = "" - @State private var filename: String = "" - - var body: some View { - VStack (alignment: .leading){ - Form { - HStack(alignment: .center){ - FileIconView(filename: fd.name, fileType: nil) - .frame(width: 32, height: 32) - TextField("", text: $filename) - .disabled(!self.canRename()) - } - HStack(alignment: .center){ - Text("Type:").bold().padding(.leading, 43) - Text(fd.type) - } - HStack(alignment: .center){ - Text("Creator:").bold().padding(.leading, 26) - Text(fd.creator) - } - HStack(alignment: .center){ - Text("Size:").bold().bold().padding(.leading, 48) - Text(self.formattedSize(byteCount: fd.size)) - } - HStack(alignment: .center){ - Text("Created:").bold().padding(.leading, 24) - Text("\(FileDetailsView.dateFormatter.string(from: fd.created))") - } - HStack(alignment: .center){ - Text("Modified:").bold().padding(.leading, 19) - Text("\(FileDetailsView.dateFormatter.string(from: fd.modified))") - } - HStack(alignment: .center){ - Text("Comments:").bold().padding(.top, 8) - .padding(.leading, 5) - } - - VStack(alignment: .trailing){ - TextEditor(text: $comment) - .padding(.leading, 2) - .padding(.top, 1) - .font(.system(size: 13)) - .background(Color(nsColor: .textBackgroundColor)) - .border(Color.secondary, width: 1) - .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) - .disabled(!self.canSetComment()) - } - } - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - presentationMode.wrappedValue.dismiss() - } - } - - ToolbarItem(placement: .primaryAction) { - Button{ - var editedFilename: String? - if filename != fd.name { - editedFilename = filename - } - - var editedComment: String? - if comment != fd.comment { - editedComment = comment - } - - model.client.sendSetFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) - presentationMode.wrappedValue.dismiss() - - // TODO: Update the file list if the filename was changed - } label: { - Text("Save") - }.disabled(!isEdited()) - } - } - - .onAppear { - self.filename = fd.name - self.comment = fd.comment - } - .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) - } - .frame(minWidth: 400, minHeight: 400) - } - - - static var dateFormatter: DateFormatter = { - var dateFormatter = DateFormatter() - dateFormatter.dateStyle = .long - dateFormatter.timeStyle = .short - - // Original format: Fri, Aug 20, 2021, 5:14:07 PM - return dateFormatter - }() - - static var byteCountSizeFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .decimal - return numberFormatter - }() - - static let byteFormatter = ByteCountFormatter() - - private func formattedFileSize(_ fileSize: UInt) -> String { - FileView.byteFormatter.allowedUnits = [.useAll] - FileView.byteFormatter.countStyle = .file - return FileView.byteFormatter.string(fromByteCount: Int64(fileSize)) - } - - // Format byte count Int into string like: 23.4M (24,601,664 bytes) - private func formattedSize(byteCount: Int) -> String { - let formattedByteCount = FileDetailsView.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" - return "\(FileView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" - } - - private func isEdited() -> Bool { - return self.filename != fd.name || self.comment != fd.comment - } - - private func canRename() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canRenameFolders) == true - } - return model.access?.contains(.canRenameFiles) == true - } - - private func canSetComment() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canSetFolderComment) == true - } - return model.access?.contains(.canSetFileComment) == true - } -} - -//#Preview { -// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) -//} diff --git a/Hotline/macOS/FilePreviewImageView.swift b/Hotline/macOS/FilePreviewImageView.swift deleted file mode 100644 index 9beeb80..0000000 --- a/Hotline/macOS/FilePreviewImageView.swift +++ /dev/null @@ -1,123 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -struct FilePreviewImageView: View { - enum FilePreviewFocus: Hashable { - case window - } - - @Environment(\.controlActiveState) private var controlActiveState - @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) var dismiss - - @Binding var info: PreviewFileInfo? - - @State var preview: FilePreview? = nil - @FocusState private var focusField: FilePreviewFocus? - - var body: some View { - Group { - if preview?.state != .loaded { - HStack(alignment: .center, spacing: 0) { - ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) - .focusable(false) - .progressViewStyle(.circular) - .controlSize(.extraLarge) - .tint(.white) - .frame(maxWidth: 300, alignment: .center) - } - .frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150) - .padding() - } - else { - if let image = preview?.image { - FileImageView(image: image) - .frame(minWidth: 200, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity) - } - else { - VStack(alignment: .center, spacing: 0) { - Spacer() - - Image(systemName: "eye.trianglebadge.exclamationmark") - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - .frame(height: 48) - .padding(.bottom) - Group { - Text("This file type is not previewable") - .bold() - Text("Try downloading and opening this file in another application.") - .foregroundStyle(Color.secondary) - } - .font(.system(size: 14.0)) - .frame(maxWidth: 300) - .multilineTextAlignment(.center) - - Spacer() - } - .frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150) - .padding() - } - } - } - .focusable() - .focusEffectDisabled() - .focused($focusField, equals: .window) - .preferredColorScheme(.dark) - .navigationTitle(info?.name ?? "Preview") - .background(.black) - .toolbar { - ToolbarItem(placement: .navigation) { - FileIconView(filename: info?.name ?? "", fileType: nil) - .frame(width: 16, height: 16) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - } - - if let img = preview?.image { - if let info = info { - ToolbarItem(placement: .primaryAction) { - Button { - let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) - } label: { - Label("Download Image...", systemImage: "arrow.down") - } - .help("Download Image") - } - - ToolbarItem(placement: .primaryAction) { - ShareLink(item: img, preview: SharePreview(info.name, image: img)) { - Label("Share Image...", systemImage: "square.and.arrow.up") - } - .help("Share Image") - } - } - } - } - .task { - if let info = info { - preview = FilePreview(info: info) - preview?.download() - } - } - .onAppear { - if info == nil { - Task { - dismiss() - } - return - } - - focusField = .window - } - .onDisappear { - preview?.cancel() - dismiss() - } - .onChange(of: preview?.state) { - if preview?.state == .failed { - dismiss() - } - } - } -} diff --git a/Hotline/macOS/FilePreviewTextView.swift b/Hotline/macOS/FilePreviewTextView.swift deleted file mode 100644 index c286381..0000000 --- a/Hotline/macOS/FilePreviewTextView.swift +++ /dev/null @@ -1,127 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -struct FilePreviewTextView: View { - enum FilePreviewFocus: Hashable { - case window - } - - @Environment(\.controlActiveState) private var controlActiveState - @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) var dismiss - - @Binding var info: PreviewFileInfo? - @State var preview: FilePreview? = nil - @FocusState private var focusField: FilePreviewFocus? - - var body: some View { - Group { - if preview?.state != .loaded { - VStack(alignment: .center, spacing: 0) { - Spacer() - ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) - .focusable(false) - .progressViewStyle(.circular) - .controlSize(.extraLarge) - .tint(.white) - .frame(maxWidth: 300, alignment: .center) - Spacer() - Spacer() - } - .background(Color(nsColor: .textBackgroundColor)) - .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) - .padding() - } - else { - if let text = preview?.text { - TextEditor(text: .constant(text)) - .textEditorStyle(.plain) - .font(.system(size: 14)) - .lineSpacing(3) - .padding(16) - .contentMargins(.top, -16.0, for: .scrollIndicators) - .contentMargins(.bottom, -16.0, for: .scrollIndicators) - .contentMargins(.trailing, -16.0, for: .scrollIndicators) - .scrollClipDisabled() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - else { - VStack(alignment: .center, spacing: 0) { - Spacer() - - Image(systemName: "eye.trianglebadge.exclamationmark") - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - .frame(height: 48) - .padding(.bottom) - Group { - Text("This file type is not previewable") - .bold() - Text("Try downloading and opening this file in another application.") - .foregroundStyle(Color.secondary) - } - .font(.system(size: 14.0)) - .frame(maxWidth: 300) - .multilineTextAlignment(.center) - - Spacer() - Spacer() - } - .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) - .padding() - } - } - } - .focusable() - .focusEffectDisabled() - .background(Color(nsColor: .textBackgroundColor)) - .focused($focusField, equals: .window) - .navigationTitle(info?.name ?? "File Preview") - .toolbar { - ToolbarItem(placement: .navigation) { - FileIconView(filename: info?.name ?? "", fileType: nil) - .frame(width: 16, height: 16) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - } - - if let _ = preview?.text { - if let info = info { - ToolbarItem(placement: .primaryAction) { - Button { - let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) - } label: { - Label("Save Text File...", systemImage: "square.and.arrow.down") - } - .help("Save Text File") - } - } - } - } - .task { - if let info = info { - preview = FilePreview(info: info) - preview?.download() - } - } - .onAppear { - if info == nil { - Task { - dismiss() - } - return - } - - focusField = .window - } - .onDisappear { - preview?.cancel() - dismiss() - } - .onChange(of: preview?.state) { - if preview?.state == .failed { - dismiss() - } - } - } -} diff --git a/Hotline/macOS/Files/FileDetailsView.swift b/Hotline/macOS/Files/FileDetailsView.swift new file mode 100644 index 0000000..812f4e5 --- /dev/null +++ b/Hotline/macOS/Files/FileDetailsView.swift @@ -0,0 +1,147 @@ +import Foundation +import SwiftUI + +struct FileDetailsView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.presentationMode) var presentationMode + + var fd: FileDetails + + @State private var comment: String = "" + @State private var filename: String = "" + + var body: some View { + VStack (alignment: .leading){ + Form { + HStack(alignment: .center){ + FileIconView(filename: fd.name, fileType: nil) + .frame(width: 32, height: 32) + TextField("", text: $filename) + .disabled(!self.canRename()) + } + HStack(alignment: .center){ + Text("Type:").bold().padding(.leading, 43) + Text(fd.type) + } + HStack(alignment: .center){ + Text("Creator:").bold().padding(.leading, 26) + Text(fd.creator) + } + HStack(alignment: .center){ + Text("Size:").bold().bold().padding(.leading, 48) + Text(self.formattedSize(byteCount: fd.size)) + } + HStack(alignment: .center){ + Text("Created:").bold().padding(.leading, 24) + Text("\(FileDetailsView.dateFormatter.string(from: fd.created))") + } + HStack(alignment: .center){ + Text("Modified:").bold().padding(.leading, 19) + Text("\(FileDetailsView.dateFormatter.string(from: fd.modified))") + } + HStack(alignment: .center){ + Text("Comments:").bold().padding(.top, 8) + .padding(.leading, 5) + } + + VStack(alignment: .trailing){ + TextEditor(text: $comment) + .padding(.leading, 2) + .padding(.top, 1) + .font(.system(size: 13)) + .background(Color(nsColor: .textBackgroundColor)) + .border(Color.secondary, width: 1) + .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) + .disabled(!self.canSetComment()) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + } + + ToolbarItem(placement: .primaryAction) { + Button{ + var editedFilename: String? + if filename != fd.name { + editedFilename = filename + } + + var editedComment: String? + if comment != fd.comment { + editedComment = comment + } + + model.client.sendSetFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) + presentationMode.wrappedValue.dismiss() + + // TODO: Update the file list if the filename was changed + } label: { + Text("Save") + }.disabled(!isEdited()) + } + } + + .onAppear { + self.filename = fd.name + self.comment = fd.comment + } + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + } + .frame(minWidth: 400, minHeight: 400) + } + + + static var dateFormatter: DateFormatter = { + var dateFormatter = DateFormatter() + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .short + + // Original format: Fri, Aug 20, 2021, 5:14:07 PM + return dateFormatter + }() + + static var byteCountSizeFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + return numberFormatter + }() + + static let byteFormatter = ByteCountFormatter() + + private func formattedFileSize(_ fileSize: UInt) -> String { + FileItemView.byteFormatter.allowedUnits = [.useAll] + FileItemView.byteFormatter.countStyle = .file + return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize)) + } + + // Format byte count Int into string like: 23.4M (24,601,664 bytes) + private func formattedSize(byteCount: Int) -> String { + let formattedByteCount = FileDetailsView.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" + return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" + } + + private func isEdited() -> Bool { + return self.filename != fd.name || self.comment != fd.comment + } + + private func canRename() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canRenameFolders) == true + } + return model.access?.contains(.canRenameFiles) == true + } + + private func canSetComment() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canSetFolderComment) == true + } + return model.access?.contains(.canSetFileComment) == true + } +} + +//#Preview { +// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) +//} diff --git a/Hotline/macOS/Files/FileItemView.swift b/Hotline/macOS/Files/FileItemView.swift new file mode 100644 index 0000000..5da744f --- /dev/null +++ b/Hotline/macOS/Files/FileItemView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct FileItemView: View { + @Environment(Hotline.self) private var model: Hotline + + var file: FileInfo + let depth: Int + + var body: some View { + HStack(alignment: .center, spacing: 0) { + Spacer() + .frame(width: CGFloat(depth * (12 + 2))) + + Spacer() + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 8) + + HStack(alignment: .center) { + if file.isUnavailable { + Image(systemName: "questionmark.app.fill") + .frame(width: 16, height: 16) + .opacity(0.5) + } + else { + FileIconView(filename: file.name, fileType: file.type) + .frame(width: 16, height: 16) + } + } + .frame(width: 16) + .padding(.trailing, 6) + + Text(file.name) + .lineLimit(1) + .truncationMode(.tail) + .opacity(file.isUnavailable ? 0.5 : 1.0) + + Spacer() + if !file.isUnavailable { + Text(formattedFileSize(file.fileSize)) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.trailing, 6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if file.expanded { + ForEach(file.children!, id: \.self) { childFile in + if childFile.isFolder { + FolderItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + else { + FileItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + } + } + } + + static let byteFormatter = ByteCountFormatter() + + private func formattedFileSize(_ fileSize: UInt) -> String { + FileItemView.byteFormatter.allowedUnits = [.useAll] + FileItemView.byteFormatter.countStyle = .file + return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize)) + } +} diff --git a/Hotline/macOS/Files/FilePreviewImageView.swift b/Hotline/macOS/Files/FilePreviewImageView.swift new file mode 100644 index 0000000..9beeb80 --- /dev/null +++ b/Hotline/macOS/Files/FilePreviewImageView.swift @@ -0,0 +1,123 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct FilePreviewImageView: View { + enum FilePreviewFocus: Hashable { + case window + } + + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) var dismiss + + @Binding var info: PreviewFileInfo? + + @State var preview: FilePreview? = nil + @FocusState private var focusField: FilePreviewFocus? + + var body: some View { + Group { + if preview?.state != .loaded { + HStack(alignment: .center, spacing: 0) { + ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + .focusable(false) + .progressViewStyle(.circular) + .controlSize(.extraLarge) + .tint(.white) + .frame(maxWidth: 300, alignment: .center) + } + .frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150) + .padding() + } + else { + if let image = preview?.image { + FileImageView(image: image) + .frame(minWidth: 200, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity) + } + else { + VStack(alignment: .center, spacing: 0) { + Spacer() + + Image(systemName: "eye.trianglebadge.exclamationmark") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .frame(height: 48) + .padding(.bottom) + Group { + Text("This file type is not previewable") + .bold() + Text("Try downloading and opening this file in another application.") + .foregroundStyle(Color.secondary) + } + .font(.system(size: 14.0)) + .frame(maxWidth: 300) + .multilineTextAlignment(.center) + + Spacer() + } + .frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150) + .padding() + } + } + } + .focusable() + .focusEffectDisabled() + .focused($focusField, equals: .window) + .preferredColorScheme(.dark) + .navigationTitle(info?.name ?? "Preview") + .background(.black) + .toolbar { + ToolbarItem(placement: .navigation) { + FileIconView(filename: info?.name ?? "", fileType: nil) + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + + if let img = preview?.image { + if let info = info { + ToolbarItem(placement: .primaryAction) { + Button { + let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + } label: { + Label("Download Image...", systemImage: "arrow.down") + } + .help("Download Image") + } + + ToolbarItem(placement: .primaryAction) { + ShareLink(item: img, preview: SharePreview(info.name, image: img)) { + Label("Share Image...", systemImage: "square.and.arrow.up") + } + .help("Share Image") + } + } + } + } + .task { + if let info = info { + preview = FilePreview(info: info) + preview?.download() + } + } + .onAppear { + if info == nil { + Task { + dismiss() + } + return + } + + focusField = .window + } + .onDisappear { + preview?.cancel() + dismiss() + } + .onChange(of: preview?.state) { + if preview?.state == .failed { + dismiss() + } + } + } +} diff --git a/Hotline/macOS/Files/FilePreviewTextView.swift b/Hotline/macOS/Files/FilePreviewTextView.swift new file mode 100644 index 0000000..c286381 --- /dev/null +++ b/Hotline/macOS/Files/FilePreviewTextView.swift @@ -0,0 +1,127 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct FilePreviewTextView: View { + enum FilePreviewFocus: Hashable { + case window + } + + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) var dismiss + + @Binding var info: PreviewFileInfo? + @State var preview: FilePreview? = nil + @FocusState private var focusField: FilePreviewFocus? + + var body: some View { + Group { + if preview?.state != .loaded { + VStack(alignment: .center, spacing: 0) { + Spacer() + ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + .focusable(false) + .progressViewStyle(.circular) + .controlSize(.extraLarge) + .tint(.white) + .frame(maxWidth: 300, alignment: .center) + Spacer() + Spacer() + } + .background(Color(nsColor: .textBackgroundColor)) + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + else { + if let text = preview?.text { + TextEditor(text: .constant(text)) + .textEditorStyle(.plain) + .font(.system(size: 14)) + .lineSpacing(3) + .padding(16) + .contentMargins(.top, -16.0, for: .scrollIndicators) + .contentMargins(.bottom, -16.0, for: .scrollIndicators) + .contentMargins(.trailing, -16.0, for: .scrollIndicators) + .scrollClipDisabled() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + else { + VStack(alignment: .center, spacing: 0) { + Spacer() + + Image(systemName: "eye.trianglebadge.exclamationmark") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .frame(height: 48) + .padding(.bottom) + Group { + Text("This file type is not previewable") + .bold() + Text("Try downloading and opening this file in another application.") + .foregroundStyle(Color.secondary) + } + .font(.system(size: 14.0)) + .frame(maxWidth: 300) + .multilineTextAlignment(.center) + + Spacer() + Spacer() + } + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + } + } + .focusable() + .focusEffectDisabled() + .background(Color(nsColor: .textBackgroundColor)) + .focused($focusField, equals: .window) + .navigationTitle(info?.name ?? "File Preview") + .toolbar { + ToolbarItem(placement: .navigation) { + FileIconView(filename: info?.name ?? "", fileType: nil) + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + + if let _ = preview?.text { + if let info = info { + ToolbarItem(placement: .primaryAction) { + Button { + let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + } label: { + Label("Save Text File...", systemImage: "square.and.arrow.down") + } + .help("Save Text File") + } + } + } + } + .task { + if let info = info { + preview = FilePreview(info: info) + preview?.download() + } + } + .onAppear { + if info == nil { + Task { + dismiss() + } + return + } + + focusField = .window + } + .onDisappear { + preview?.cancel() + dismiss() + } + .onChange(of: preview?.state) { + if preview?.state == .failed { + dismiss() + } + } + } +} diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift new file mode 100644 index 0000000..de699ff --- /dev/null +++ b/Hotline/macOS/Files/FilesView.swift @@ -0,0 +1,418 @@ +import SwiftUI +import UniformTypeIdentifiers +import AppKit + + + + + +struct FilesView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.openWindow) private var openWindow + + @State private var selection: FileInfo? + @State private var fileDetails: FileDetails? + @State private var uploadFileSelectorDisplayed: Bool = false + @State private var searchText: String = "" + @State private var isSearching: Bool = false + + private var isShowingSearchResults: Bool { + switch model.fileSearchStatus { + case .idle: + return !model.fileSearchResults.isEmpty + case .cancelled(_): + return !model.fileSearchResults.isEmpty + default: + return true + } + } + + private var displayedFiles: [FileInfo] { + isShowingSearchResults ? model.fileSearchResults : model.files + } + + private var searchStatusMessage: String? { + switch model.fileSearchStatus { + case .searching(let processed, _): + let scanned = processed == 1 ? "folder" : "folders" + return "Searched \(processed) \(scanned)..." + case .completed(let processed): + let count = model.fileSearchResults.count + let folderWord = processed == 1 ? "folder" : "folders" + if count == 0 { + return "No files found in \(processed) \(folderWord)" + } + return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" + case .cancelled(_): + if model.fileSearchResults.isEmpty { + return nil + } + return "Search cancelled" + case .failed(let message): + return "Search failed: \(message)" + case .idle: + return nil + } + } + + private var searchStatusPath: String? { + guard let path = model.fileSearchCurrentPath else { + return nil + } + if path.isEmpty { + return "/" + } + return path.joined(separator: "/") + } + + private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { + switch previewInfo.previewType { + case .image: + openWindow(id: "preview-image", value: previewInfo) + case .text: + openWindow(id: "preview-text", value: previewInfo) + default: + return + } + } + + @MainActor private func getFileInfo(_ file: FileInfo) { + Task { + if let fileInfo = await model.getFileDetails(file.name, path: file.path) { + Task { @MainActor in + self.fileDetails = fileInfo + } + } + } + } + + @MainActor private func downloadFile(_ file: FileInfo) { + if file.isFolder { + model.downloadFolder(file.name, path: file.path) + } + else { + model.downloadFile(file.name, path: file.path) + } + } + + @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { + model.uploadFile(url: fileURL, path: path) { info in + Task { + // Refresh file listing to display newly uploaded file. + let _ = await model.getFileList(path: path) + } + } + } + + @MainActor private func previewFile(_ file: FileInfo) { + guard file.isPreviewable else { + return + } + + model.previewFile(file.name, path: file.path) { info in + if let info = info { + openPreviewWindow(info) + } + } + } + + private func deleteFile(_ file: FileInfo) async { + var parentPath: [String] = [] + if file.path.count > 1 { + parentPath = Array(file.path[0.. 0 else { + return + } + + let fileURL = fileURLS.first! + + print(fileURL) + + var uploadPath: [String] = [] + + if let selection = selection { + if selection.isFolder { + uploadPath = selection.path + } + else { + uploadPath = Array(selection.path) + uploadPath.removeLast() + } + } + + print("UPLOAD PATH: \(uploadPath)") + uploadFile(file: fileURL, to: uploadPath) + + case .failure(let error): + print(error) + } + }) + .onSubmit(of: .search) { + #if os(macOS) + let shiftPressed = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false + if shiftPressed { + model.clearFileListCache() + } + #endif + + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + model.cancelFileSearch() + return + } + searchText = trimmed + model.startFileSearch(query: trimmed) + } + .onChange(of: searchText) { _, newValue in + if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if isShowingSearchResults { + model.cancelFileSearch() + } + } + } + .onChange(of: model.fileSearchQuery) { _, newValue in + if newValue != searchText { + searchText = newValue + } + } + .onAppear { + if searchText != model.fileSearchQuery { + searchText = model.fileSearchQuery + } + } + .safeAreaInset(edge: .top) { + if isShowingSearchResults, let message = searchStatusMessage { + HStack(alignment: .center, spacing: 6) { + if case .searching(_, _) = model.fileSearchStatus { + ProgressView() + .controlSize(.small) + .accentColor(.white) + .tint(.white) + } + else if case .completed = model.fileSearchStatus { + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.monochrome) + .foregroundStyle(.white) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + else if case .failed = model.fileSearchStatus { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .symbolRenderingMode(.monochrome) + .foregroundStyle(.white) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + + Text(message) + .lineLimit(1) + .font(.body) + .foregroundStyle(.white) + + Spacer() + + if let pathMessage = searchStatusPath { + Text(pathMessage) + .lineLimit(1) + .truncationMode(.tail) + .font(.footnote) +// .fontWeight(.semibold) + .foregroundStyle(.white) + .opacity(0.5) + .padding(.top, 2) + } + } + .padding(.trailing, 14) + .padding(.leading, 8) + .padding(.vertical, 8) + .background { + Group { + if case .completed = model.fileSearchStatus { + Color.fileComplete + } + else { + Color(nsColor: .controlAccentColor) + } + } + .clipShape(.capsule(style: .continuous)) + } + .padding(.horizontal, 8) + .padding(.top, 8) + } + } + } +} + +#Preview { + FilesView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift new file mode 100644 index 0000000..4a08974 --- /dev/null +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -0,0 +1,140 @@ +import SwiftUI + +struct FolderItemView: View { + @Environment(Hotline.self) private var model: Hotline + + @State var loading = false + @State var dragOver = false + + var file: FileInfo + let depth: Int + + @MainActor private func uploadFile(file fileURL: URL) { + var filePath: [String] = [String](self.file.path) + if !self.file.isFolder { + filePath.removeLast() + } + + print("UPLOADING TO PATH: ", filePath) + + model.uploadFile(url: fileURL, path: filePath) { info in + Task { + // Refresh file listing to display newly uploaded file. + let _ = await model.getFileList(path: filePath) + } + } + } + + var body: some View { + HStack(alignment: .center, spacing: 0) { + Spacer() + .frame(width: CGFloat(depth * (12 + 2))) + + Button { + if file.isFolder { + file.expanded.toggle() + } + } label: { + Text(Image(systemName: file.expanded ? "chevron.down" : "chevron.right")) + .bold() + .font(.system(size: 10)) + .foregroundStyle(dragOver ? Color.white : Color.primary) + .opacity(0.5) + } + .buttonStyle(.plain) + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 8) + + HStack(alignment: .center) { + if file.isUnavailable { + Image(systemName: "questionmark.app.fill") + .frame(width: 16, height: 16) + .opacity(0.5) + } + else if file.isAdminDropboxFolder { + Image("Admin Drop Box") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + else if file.isDropboxFolder { + Image("Drop Box") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + else { + Image("Folder") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + } + .frame(width: 16) + .padding(.trailing, 6) + + Text(file.name) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(dragOver ? Color.white : Color.primary) + .opacity(file.isUnavailable ? 0.5 : 1.0) + + if loading { + ProgressView().controlSize(.small).padding([.leading, .trailing], 5) + } + Spacer() + if !file.isUnavailable { + Text(file.fileSize == 0 ? "Empty" : "^[\(file.fileSize) \("file")](inflect: true)") + .foregroundStyle(dragOver ? Color.white.opacity(0.75) : Color.secondary) + .lineLimit(1) + .padding(.trailing, 6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + RoundedRectangle(cornerRadius: 4.0) + .fill(dragOver ? Color(nsColor: NSColor.selectedContentBackgroundColor) : Color.clear) + .padding(.horizontal, -6) + .padding(.vertical, -4) + ) + .onChange(of: file.expanded) { + loading = false + if file.expanded && file.fileSize > 0 { + Task { + loading = true + let _ = await model.getFileList(path: file.path) + loading = false + } + } + } + .onDrop(of: [.fileURL], isTargeted: $dragOver) { items in + guard let item = items.first, + let identifier = item.registeredTypeIdentifiers.first else { + return false + } + + item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in + DispatchQueue.main.async { + if let urlData = urlData as? Data, + let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true) { + uploadFile(file: fileURL) + } + } + } + + return true + } + + if file.expanded { + ForEach(file.children!, id: \.self) { childFile in + if childFile.isFolder { + FolderItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + else { + FileItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + } + } + } +} diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift deleted file mode 100644 index 42232af..0000000 --- a/Hotline/macOS/FilesView.swift +++ /dev/null @@ -1,619 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers -import AppKit - -struct FolderView: View { - @Environment(Hotline.self) private var model: Hotline - - @State var loading = false - @State var dragOver = false - - var file: FileInfo - let depth: Int - - @MainActor private func uploadFile(file fileURL: URL) { - var filePath: [String] = [String](self.file.path) - if !self.file.isFolder { - filePath.removeLast() - } - - print("UPLOADING TO PATH: ", filePath) - - model.uploadFile(url: fileURL, path: filePath) { info in - Task { - // Refresh file listing to display newly uploaded file. - let _ = await model.getFileList(path: filePath) - } - } - } - - var body: some View { - HStack(alignment: .center, spacing: 0) { - Spacer() - .frame(width: CGFloat(depth * (12 + 2))) - - Button { - if file.isFolder { - file.expanded.toggle() - } - } label: { - Text(Image(systemName: file.expanded ? "chevron.down" : "chevron.right")) - .bold() - .font(.system(size: 10)) - .foregroundStyle(dragOver ? Color.white : Color.primary) - .opacity(0.5) - } - .buttonStyle(.plain) - .frame(width: 10) - .padding(.leading, 4) - .padding(.trailing, 8) - - HStack(alignment: .center) { - if file.isUnavailable { - Image(systemName: "questionmark.app.fill") - .frame(width: 16, height: 16) - .opacity(0.5) - } - else if file.isAdminDropboxFolder { - Image("Admin Drop Box") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - } - else if file.isDropboxFolder { - Image("Drop Box") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - } - else { - Image("Folder") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - } - } - .frame(width: 16) - .padding(.trailing, 6) - - Text(file.name) - .lineLimit(1) - .truncationMode(.tail) - .foregroundStyle(dragOver ? Color.white : Color.primary) - .opacity(file.isUnavailable ? 0.5 : 1.0) - - if loading { - ProgressView().controlSize(.small).padding([.leading, .trailing], 5) - } - Spacer() - if !file.isUnavailable { - Text(file.fileSize == 0 ? "Empty" : "^[\(file.fileSize) \("file")](inflect: true)") - .foregroundStyle(dragOver ? Color.white.opacity(0.75) : Color.secondary) - .lineLimit(1) - .padding(.trailing, 6) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background( - RoundedRectangle(cornerRadius: 4.0) - .fill(dragOver ? Color(nsColor: NSColor.selectedContentBackgroundColor) : Color.clear) - .padding(.horizontal, -6) - .padding(.vertical, -4) - ) - .onChange(of: file.expanded) { - loading = false - if file.expanded && file.fileSize > 0 { - Task { - loading = true - let _ = await model.getFileList(path: file.path) - loading = false - } - } - } - .onDrop(of: [.fileURL], isTargeted: $dragOver) { items in - guard let item = items.first, - let identifier = item.registeredTypeIdentifiers.first else { - return false - } - - item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in - DispatchQueue.main.async { - if let urlData = urlData as? Data, - let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true) { - uploadFile(file: fileURL) - } - } - } - - return true - } - - if file.expanded { - ForEach(file.children!, id: \.self) { childFile in - if childFile.isFolder { - FolderView(file: childFile, depth: self.depth + 1).tag(file.id) - } - else { - FileView(file: childFile, depth: self.depth + 1).tag(file.id) - } - } - } - } -} - -struct FileView: View { - @Environment(Hotline.self) private var model: Hotline - - var file: FileInfo - let depth: Int - - var body: some View { - HStack(alignment: .center, spacing: 0) { - Spacer() - .frame(width: CGFloat(depth * (12 + 2))) - - Spacer() - .frame(width: 10) - .padding(.leading, 4) - .padding(.trailing, 8) - - HStack(alignment: .center) { - if file.isUnavailable { - Image(systemName: "questionmark.app.fill") - .frame(width: 16, height: 16) - .opacity(0.5) - } - else { - FileIconView(filename: file.name, fileType: file.type) - .frame(width: 16, height: 16) - } - } - .frame(width: 16) - .padding(.trailing, 6) - - Text(file.name) - .lineLimit(1) - .truncationMode(.tail) - .opacity(file.isUnavailable ? 0.5 : 1.0) - - Spacer() - if !file.isUnavailable { - Text(formattedFileSize(file.fileSize)) - .foregroundStyle(.secondary) - .lineLimit(1) - .padding(.trailing, 6) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - if file.expanded { - ForEach(file.children!, id: \.self) { childFile in - if childFile.isFolder { - FolderView(file: childFile, depth: self.depth + 1).tag(file.id) - } - else { - FileView(file: childFile, depth: self.depth + 1).tag(file.id) - } - } - } - } - - static let byteFormatter = ByteCountFormatter() - - private func formattedFileSize(_ fileSize: UInt) -> String { - FileView.byteFormatter.allowedUnits = [.useAll] - FileView.byteFormatter.countStyle = .file - return FileView.byteFormatter.string(fromByteCount: Int64(fileSize)) - } -} - -struct FilesView: View { - @Environment(Hotline.self) private var model: Hotline - @Environment(\.openWindow) private var openWindow - - @State private var selection: FileInfo? - @State private var fileDetails: FileDetails? - @State private var uploadFileSelectorDisplayed: Bool = false - @State private var searchText: String = "" - @State private var isSearching: Bool = false - - private var isShowingSearchResults: Bool { - switch model.fileSearchStatus { - case .idle: - return !model.fileSearchResults.isEmpty - case .cancelled(_): - return !model.fileSearchResults.isEmpty - default: - return true - } - } - - private var displayedFiles: [FileInfo] { - isShowingSearchResults ? model.fileSearchResults : model.files - } - - private var searchStatusMessage: String? { - switch model.fileSearchStatus { - case .searching(let processed, _): - let scanned = processed == 1 ? "folder" : "folders" - return "Searched \(processed) \(scanned)..." - case .completed(let processed): - let count = model.fileSearchResults.count - let folderWord = processed == 1 ? "folder" : "folders" - if count == 0 { - return "No files found in \(processed) \(folderWord)" - } - return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" - case .cancelled(_): - if model.fileSearchResults.isEmpty { - return nil - } - return "Search cancelled" - case .failed(let message): - return "Search failed: \(message)" - case .idle: - return nil - } - } - - private var searchStatusPath: String? { - guard let path = model.fileSearchCurrentPath else { - return nil - } - if path.isEmpty { - return "/" - } - return path.joined(separator: "/") - } - - private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { - switch previewInfo.previewType { - case .image: - openWindow(id: "preview-image", value: previewInfo) - case .text: - openWindow(id: "preview-text", value: previewInfo) - default: - return - } - } - - @MainActor private func getFileInfo(_ file: FileInfo) { - Task { - if let fileInfo = await model.getFileDetails(file.name, path: file.path) { - Task { @MainActor in - self.fileDetails = fileInfo - } - } - } - } - - @MainActor private func downloadFile(_ file: FileInfo) { - if file.isFolder { - model.downloadFolder(file.name, path: file.path) - } - else { - model.downloadFile(file.name, path: file.path) - } - } - - @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { - model.uploadFile(url: fileURL, path: path) { info in - Task { - // Refresh file listing to display newly uploaded file. - let _ = await model.getFileList(path: path) - } - } - } - - @MainActor private func previewFile(_ file: FileInfo) { - guard file.isPreviewable else { - return - } - - model.previewFile(file.name, path: file.path) { info in - if let info = info { - openPreviewWindow(info) - } - } - } - - private func deleteFile(_ file: FileInfo) async { - var parentPath: [String] = [] - if file.path.count > 1 { - parentPath = Array(file.path[0.. 0 else { - return - } - - let fileURL = fileURLS.first! - - print(fileURL) - - var uploadPath: [String] = [] - - if let selection = selection { - if selection.isFolder { - uploadPath = selection.path - } - else { - uploadPath = Array(selection.path) - uploadPath.removeLast() - } - } - - print("UPLOAD PATH: \(uploadPath)") - uploadFile(file: fileURL, to: uploadPath) - - case .failure(let error): - print(error) - } - }) - .onSubmit(of: .search) { - #if os(macOS) - let shiftPressed = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false - if shiftPressed { - model.clearFileListCache() - } - #endif - - let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - model.cancelFileSearch() - return - } - searchText = trimmed - model.startFileSearch(query: trimmed) - } - .onChange(of: searchText) { _, newValue in - if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - if isShowingSearchResults { - model.cancelFileSearch() - } - } - } - .onChange(of: model.fileSearchQuery) { _, newValue in - if newValue != searchText { - searchText = newValue - } - } - .onAppear { - if searchText != model.fileSearchQuery { - searchText = model.fileSearchQuery - } - } - .safeAreaInset(edge: .top) { - if isShowingSearchResults, let message = searchStatusMessage { - HStack(alignment: .center, spacing: 6) { - if case .searching(_, _) = model.fileSearchStatus { - ProgressView() - .controlSize(.small) - .accentColor(.white) - .tint(.white) - } - else if case .completed = model.fileSearchStatus { - Image(systemName: "checkmark.circle.fill") - .resizable() - .symbolRenderingMode(.monochrome) - .foregroundStyle(.white) - .aspectRatio(contentMode: .fit) - .frame(width: 16, height: 16) - } - else if case .failed = model.fileSearchStatus { - Image(systemName: "exclamationmark.triangle.fill") - .resizable() - .symbolRenderingMode(.monochrome) - .foregroundStyle(.white) - .aspectRatio(contentMode: .fit) - .frame(width: 16, height: 16) - } - - Text(message) - .lineLimit(1) - .font(.body) - .foregroundStyle(.white) - - Spacer() - - if let pathMessage = searchStatusPath { - Text(pathMessage) - .lineLimit(1) - .truncationMode(.tail) - .font(.footnote) -// .fontWeight(.semibold) - .foregroundStyle(.white) - .opacity(0.5) - .padding(.top, 2) - } - } - .padding(.trailing, 14) - .padding(.leading, 8) - .padding(.vertical, 8) - .background { - Group { - if case .completed = model.fileSearchStatus { - Color.fileComplete - } - else { - Color(nsColor: .controlAccentColor) - } - } - .clipShape(.capsule(style: .continuous)) - } - .padding(.horizontal, 8) - .padding(.top, 8) - } - } - } -} - -#Preview { - FilesView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) -} diff --git a/Hotline/macOS/MessageBoardEditorView.swift b/Hotline/macOS/MessageBoardEditorView.swift deleted file mode 100644 index 474384e..0000000 --- a/Hotline/macOS/MessageBoardEditorView.swift +++ /dev/null @@ -1,117 +0,0 @@ -import SwiftUI - -private enum FocusFields { - case body -} - -struct MessageBoardEditorView: View { - @Environment(\.controlActiveState) private var controlActiveState - @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) private var dismiss - @Environment(Hotline.self) private var model: Hotline - - @State private var text: String = "" - @State private var sending: Bool = false - - @FocusState private var focusedField: FocusFields? - - func sendPost() async { - sending = true - - let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - - model.postToMessageBoard(text: cleanedText) - let _ = await model.getMessageBoard() - -// let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) -// if success { -// await model.getNewsList(at: path) -// } - - sending = false - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center, spacing: 0) { - Button { - dismiss() - } label: { - Image(systemName: "xmark") - .resizable() - .scaledToFit() - .frame(width: 14, height: 14) - .opacity(0.5) - } - .buttonStyle(.plain) - .frame(width: 16, height: 16) - - Spacer() - -// Image("Message Board Post") -// .resizable() -// .scaledToFit() -// .frame(width: 16, height: 16) -// .padding(.trailing, 6) - - Text("New Post") - .fontWeight(.semibold) - .lineLimit(1) - .truncationMode(.middle) - - Spacer() - - if sending { - ProgressView() - .controlSize(.small) - .frame(width: 22, height: 22) - } - else { - Button { - sending = true - model.postToMessageBoard(text: text) - Task { - let _ = await model.getMessageBoard() - Task { @MainActor in - sending = false - dismiss() - } - } - } label: { - Image(systemName: "arrow.up.circle.fill") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .secondary : .accentColor) - } - .buttonStyle(.plain) - .frame(width: 22, height: 22) - .help("Post to Message Board") - .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - .frame(maxWidth: .infinity) - .padding() - - Divider() - - BetterTextEditor(text: $text) - .betterEditorFont(NSFont.systemFont(ofSize: 14.0)) - .betterEditorAutomaticSpellingCorrection(true) - .betterEditorTextInset(.init(width: 16, height: 18)) - .lineSpacing(20) - .background(Color(nsColor: .textBackgroundColor)) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .focused($focusedField, equals: .body) - } - .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) - .presentationCompactAdaptation(.sheet) - .onAppear { - focusedField = .body - } - .onDisappear { - dismiss() - } - } -} diff --git a/Hotline/macOS/MessageBoardView.swift b/Hotline/macOS/MessageBoardView.swift deleted file mode 100644 index f870b0c..0000000 --- a/Hotline/macOS/MessageBoardView.swift +++ /dev/null @@ -1,102 +0,0 @@ -import SwiftUI - -struct MessageBoardView: View { - @Environment(Hotline.self) private var model: Hotline - - @State private var composerDisplayed: Bool = false - @State private var composerText: String = "" - - var body: some View { - NavigationStack { - if model.access?.contains(.canReadMessageBoard) != false { - ScrollView { - LazyVStack(alignment: .leading) { - ForEach(model.messageBoard, id: \.self) { msg in - Text(LocalizedStringKey(msg)) - .tint(Color("Link Color")) - .lineLimit(100) - .lineSpacing(4) - .padding() - .textSelection(.enabled) - Divider() - } - } - Spacer() - } - .task { - if !model.messageBoardLoaded { - let _ = await model.getMessageBoard() - } - } - .overlay { - if !model.messageBoardLoaded { - VStack { - ProgressView() - .controlSize(.large) - } - .frame(maxWidth: .infinity) - } - } - .background(Color(nsColor: .textBackgroundColor)) - } - else { - ZStack(alignment: .center) { - Text("No Message Board") - .font(.title) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding() - } - .frame(maxWidth: .infinity) - } - } - .sheet(isPresented: $composerDisplayed) { - MessageBoardEditorView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .frame(idealWidth: 450, idealHeight: 350) -// RichTextEditor(text: $composerText) -// .richEditorFont(NSFont.systemFont(ofSize: 16.0)) -// .richEditorAutomaticDashSubstitution(false) -// .richEditorAutomaticQuoteSubstitution(false) -// .richEditorAutomaticSpellingCorrection(false) -// .background(Color(nsColor: .textBackgroundColor)) -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// .frame(idealWidth: 450, idealHeight: 350) -// .toolbar { -// ToolbarItem(placement: .cancellationAction) { -// Button("Cancel") { -// composerDisplayed.toggle() -// } -// } -// -// ToolbarItem(placement: .primaryAction) { -// Button("Post") { -// composerDisplayed.toggle() -// let text = composerText -// composerText = "" -// model.postToMessageBoard(text: text) -// Task { -// await model.getMessageBoard() -// } -// } -// } -// } - } - .toolbar { - ToolbarItem(placement:.primaryAction) { - Button { - composerDisplayed.toggle() - } label: { - Image(systemName: "square.and.pencil") - } - .disabled(model.access?.contains(.canPostMessageBoard) == false) - .help("Post to Message Board") - } - } - } -} - -#Preview { - MessageBoardView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) -} diff --git a/Hotline/macOS/News/NewsEditorView.swift b/Hotline/macOS/News/NewsEditorView.swift new file mode 100644 index 0000000..f69c846 --- /dev/null +++ b/Hotline/macOS/News/NewsEditorView.swift @@ -0,0 +1,153 @@ +import SwiftUI + +private enum FocusFields { + case title + case body +} + +struct NewsEditorView: View { + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + @Environment(Hotline.self) private var model: Hotline + + let editorTitle: String + let isReply: Bool + let path: [String] + let parentID: UInt32 + + @State var title: String = "" + @State private var text: String = "" + @State private var sending: Bool = false + + @FocusState private var focusedField: FocusFields? + + func sendArticle() async -> Bool { + sending = true + + let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) + if success { + await model.getNewsList(at: path) + } + + sending = false + + return success + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .opacity(0.5) + } + .buttonStyle(.plain) + .frame(width: 16, height: 16) + + Spacer() + + if !isReply { + Image("News Category") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .padding(.trailing, 6) + } + + Text(editorTitle) + .fontWeight(.semibold) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + if sending { + ProgressView() + .controlSize(.small) + .frame(width: 22, height: 22) + } + else { + Button { + Task { + if await sendArticle() { + dismiss() + } + } + } label: { + Image(systemName: "arrow.up.circle.fill") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor((title.isEmpty || text.isEmpty) ? .secondary : .accentColor) + } + .buttonStyle(.plain) + .frame(width: 22, height: 22) + .help("Post News") + .disabled(title.isEmpty || text.isEmpty) + } + } + .frame(maxWidth: .infinity) + .padding([.leading, .top, .trailing]) + + TextField("Title", text: $title, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(3) + .padding() + .focusEffectDisabled() + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .border(Color.pink, width: 0) + .background(.tertiary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding() + .focused($focusedField, equals: .title) + + Divider() + + BetterTextEditor(text: $text) + .betterEditorFont(NSFont.monospacedSystemFont(ofSize: 14.0, weight: .regular)) + .betterEditorAutomaticSpellingCorrection(true) + .betterEditorTextInset(.init(width: 16, height: 18)) + .background(Color(nsColor: .textBackgroundColor)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .focused($focusedField, equals: .body) + + HStack(alignment: .center) { + Spacer() + + Text(String("**bold** _italics_ [link name](url) ![image name](url)")) + .foregroundStyle(.secondary) + .font(.caption) + .fontDesign(.monospaced) + .lineLimit(1) + .truncationMode(.middle) + .padding() + + Spacer() + } + .frame(maxWidth: .infinity) + .background(.tertiary.opacity(0.15)) + } + .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + .presentationCompactAdaptation(.sheet) + .toolbarTitleDisplayMode(.inlineLarge) + .onAppear { + if !title.isEmpty { + focusedField = .body + } + else { + focusedField = .title + } + } + .onDisappear { + dismiss() + } + } +} diff --git a/Hotline/macOS/News/NewsItemView.swift b/Hotline/macOS/News/NewsItemView.swift new file mode 100644 index 0000000..fc20e61 --- /dev/null +++ b/Hotline/macOS/News/NewsItemView.swift @@ -0,0 +1,152 @@ +import SwiftUI + +struct NewsItemView: View { + @Environment(Hotline.self) private var model: Hotline + + var news: NewsInfo + let depth: Int + + static var dateFormatter: DateFormatter = { + var dateFormatter = DateFormatter() + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .short + dateFormatter.timeZone = .gmt + return dateFormatter + }() + + static var relativeDateFormatter: RelativeDateTimeFormatter = { + var formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + formatter.dateTimeStyle = .named + formatter.formattingContext = .listItem + return formatter + }() + + var body: some View { + HStack(alignment: .center, spacing: 0) { + if news.expandable { + Button { + news.expanded.toggle() + } label: { + Text(Image(systemName: news.expanded ? "chevron.down" : "chevron.right")) + .bold() + .font(.system(size: 10)) + .opacity(0.5) + .frame(alignment: .center) + } + .buttonStyle(.plain) + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 8) + } + else { + Spacer() + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 8) + } + + // Tree indent + Spacer() + .frame(width: (CGFloat(depth) * 22)) + + switch news.type { + case .category: + Image("News Category") + .resizable() + .frame(width: 16, height: 16, alignment: .center) + .padding(.trailing, 6) + case .bundle: + Image("News Bundle") + .resizable() + .frame(width: 16, height: 16, alignment: .center) + .padding(.trailing, 6) + case .article: + EmptyView() + } + + Text(news.name) + .fontWeight((news.type == .bundle || news.type == .category || !news.read) ? .semibold : .regular) + .lineLimit(1) + .truncationMode(.tail) + + if news.type == .article && news.articleUsername != nil { + Text(news.articleUsername!) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.leading, 8) + } + + Spacer() + + if news.type == .category && news.count > 0 { + Text("^[\(news.count) Post](inflect: true)") + .lineLimit(1) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } + else if news.type == .bundle && news.count > 0 { + Text("^[\(news.count) Category](inflect: true)") + .lineLimit(1) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } +// if news.type == .bundle { +// Text("\(news.count)") +// .lineLimit(1) +// .foregroundStyle(.tertiary) +// .padding(.trailing, 8) + +// ZStack { +// Text("^[\(news.count) \(news.type == .bundle ? "Category" : "Post")](inflect: true)") +// Text("\(news.count)") +// .foregroundStyle(.clear) +//// .font(.caption) +// .lineLimit(1) +// .padding([.leading, .trailing], 8) +// .padding([.top, .bottom], 2) +// .background(.secondary) +// .clipShape(Capsule()) +// +// Text("\(news.count)") +// .foregroundStyle(.white) +//// .font(.caption) +// .lineLimit(1) +// .padding([.leading, .trailing], 8) +// .padding([.top, .bottom], 2) +// .blendMode(.destinationOut) +// } +// .drawingGroup(opaque: false) +// } + else if news.type == .article && news.articleUsername != nil { + if let d = news.articleDate { + Text(NewsItemView.relativeDateFormatter.localizedString(for: d, relativeTo: Date.now)) + .lineLimit(1) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: news.expanded) { + guard news.expanded, news.type == .bundle || news.type == .category else { + return + } + + Task { + await model.getNewsList(at: news.path) + } + } + + if news.expanded { + ForEach(news.children.reversed(), id: \.self) { childNews in + NewsItemView(news: childNews, depth: self.depth + 1).tag(childNews.id) + } + } + } +} + +#Preview { + NewsItemView(news: NewsInfo(hotlineNewsArticle: HotlineNewsArticle(id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now, flavors: [("", 1)], path: ["Guest"])), depth: 0) + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift new file mode 100644 index 0000000..91bf2fe --- /dev/null +++ b/Hotline/macOS/News/NewsView.swift @@ -0,0 +1,297 @@ +import SwiftUI +import MarkdownUI +import SplitView + +struct NewsView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.openWindow) private var openWindow + @Environment(\.colorScheme) private var colorScheme + + @State private var selection: NewsInfo? + @State private var articleText: String? + @State private var splitHidden = SideHolder(.bottom) + @State private var splitFraction = FractionHolder.usingUserDefaults(0.25, key: "News Split Fraction") + @State private var editorOpen: Bool = false + @State private var replyOpen: Bool = false + @State private var loading: Bool = false + + var body: some View { + Group { + if model.serverVersion < 151 { + VStack { + Text("No News") + .bold() + .foregroundStyle(.secondary) + .font(.title3) + Text("This server has news turned off.") + .foregroundStyle(.tertiary) + .font(.system(size: 13)) + } + .padding() + } + else { + NavigationStack { + VSplit( + top: { + if !model.newsLoaded { + loadingIndicator + } + else if model.news.isEmpty { + ZStack(alignment: .center) { + Text("No News") + .font(.title) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding() + } + .frame(maxWidth: .infinity) + } + else { + newsBrowser + } + }, + bottom: { + articleViewer + } + ) + .fraction(splitFraction) + .constraints(minPFraction: 0.1, minSFraction: 0.3) + .hide(splitHidden) + .styling(color: colorScheme == .dark ? .black : Splitter.defaultColor, inset: 0, visibleThickness: 0.5, invisibleThickness: 5, hideSplitter: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + } + .task { + if !model.newsLoaded { + loading = true + await model.getNewsList() + loading = false + } + } + } + } + .sheet(isPresented: $editorOpen) { + } content: { + if let selection = selection { + switch selection.type { + case .article, .category: + NewsEditorView(editorTitle: selection.path.last ?? "New Post", isReply: false, path: selection.path, parentID: 0) + default: + EmptyView() + } + } + else { + EmptyView() + } + } + .sheet(isPresented: $replyOpen) { + } content: { + if let selection = selection, selection.type == .article { + NewsEditorView(editorTitle: "Reply to \(selection.articleUsername ?? "Post")", isReply: true, path: selection.path, parentID: UInt32(selection.articleID!), title: selection.name.replyToString()) + } + else { + EmptyView() + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + if selection?.type == .category || selection?.type == .article { + editorOpen = true + } + } label: { + Image(systemName: "square.and.pencil") + } + .help("New Post") + .disabled(selection?.type != .category && selection?.type != .article) + } + + ToolbarItem(placement: .primaryAction) { + Button { + if selection?.type == .article { + replyOpen = true + } + } label: { + Image(systemName: "arrowshape.turn.up.left") + } + .help("Reply to Post") + .disabled(selection?.type != .article) + } + + ToolbarItem(placement: .primaryAction) { + Button { + loading = true + if let selectionPath = selection?.path { + Task { + await model.getNewsList(at: selectionPath) + loading = false + } + } + else { + Task { + await model.getNewsList() + loading = false + } + } + } label: { + Image(systemName: "arrow.clockwise") + } + .help("Reload News") + .disabled(loading) + } + } + } + + var newsBrowser: some View { + List(model.news, id: \.self, selection: $selection) { newsItem in + NewsItemView(news: newsItem, depth: 0).tag(newsItem.id) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .environment(\.defaultMinListRowHeight, 28) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .contextMenu(forSelectionType: NewsInfo.self) { items in + let selectedItem = items.first + + Button { + if selectedItem?.type == .article { + replyOpen = true + } + } label: { + Label("Reply to \(selectedItem?.articleUsername ?? "Post")", systemImage: "arrowshape.turn.up.left") + } + .disabled(selectedItem == nil || selectedItem?.type != .article) + + } primaryAction: { items in + guard let clickedNews = items.first else { + return + } + + self.selection = clickedNews + if clickedNews.type == .bundle || clickedNews.type == .category || clickedNews.children.count > 0 { + clickedNews.expanded.toggle() + } + } + .onChange(of: selection) { + self.articleText = nil + if let article = selection, article.type == .article { + article.read = true + if let articleFlavor = article.articleFlavors?.first, + let articleID = article.articleID { + Task { + if let articleText = await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { + self.articleText = articleText + } + } + if self.splitHidden.side != nil { + withAnimation(.easeOut(duration: 0.15)) { + self.splitHidden.side = nil + } + } + + } + } + else { + if self.splitHidden.side != .bottom { + withAnimation(.easeOut(duration: 0.25)) { + self.splitHidden.side = .bottom + } + } + } + } + .onKeyPress(.rightArrow) { + if let s = selection, s.expandable { + s.expanded = true + return .handled + } + return .ignored + } + .onKeyPress(.leftArrow) { + if let s = selection, s.expandable { + s.expanded = false + return .handled + } + return .ignored + } + } + + var loadingIndicator: some View { + VStack { + HStack { + ProgressView { + Text("Loading Newsgroups") + } + .controlSize(.regular) + } + } + .frame(maxWidth: .infinity) + } + + var articleViewer: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if let selection = selection, selection.type == .article { + if let poster = selection.articleUsername, let postDate = selection.articleDate { + HStack(alignment: .firstTextBaseline) { + Text(poster) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .textSelection(.enabled) + .padding(.bottom, 16) + Spacer() + Text("\(NewsItemView.dateFormatter.string(from: postDate))") + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .textSelection(.enabled) + .padding(.bottom, 16) + } + } + + Divider() + + Text(selection.name).font(.title) + .textSelection(.enabled) + .padding(.bottom, 8) + .padding(.top, 16) + + if let newsText = self.articleText { + Markdown(newsText) + .markdownTheme(.basic) + .textSelection(.enabled) + .lineSpacing(6) + .padding(.top, 16) + } + } + else { + HStack(alignment: .center) { + Spacer() + HStack(alignment: .center, spacing: 8) { +// Image(systemName: "doc.append") +// .resizable() +// .scaledToFit() +// .foregroundStyle(.tertiary) +// .frame(width: 16, height: 16) + Text("Select a news post to read") + .foregroundStyle(.tertiary) + .font(.system(size: 13)) + } + Spacer() + } + .padding() + .padding(.top, 48) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.move(edge: .bottom)) + } +} + +#Preview { + NewsView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/NewsEditorView.swift b/Hotline/macOS/NewsEditorView.swift deleted file mode 100644 index f69c846..0000000 --- a/Hotline/macOS/NewsEditorView.swift +++ /dev/null @@ -1,153 +0,0 @@ -import SwiftUI - -private enum FocusFields { - case title - case body -} - -struct NewsEditorView: View { - @Environment(\.controlActiveState) private var controlActiveState - @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) private var dismiss - @Environment(Hotline.self) private var model: Hotline - - let editorTitle: String - let isReply: Bool - let path: [String] - let parentID: UInt32 - - @State var title: String = "" - @State private var text: String = "" - @State private var sending: Bool = false - - @FocusState private var focusedField: FocusFields? - - func sendArticle() async -> Bool { - sending = true - - let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) - if success { - await model.getNewsList(at: path) - } - - sending = false - - return success - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center, spacing: 0) { - Button { - dismiss() - } label: { - Image(systemName: "xmark") - .resizable() - .scaledToFit() - .frame(width: 14, height: 14) - .opacity(0.5) - } - .buttonStyle(.plain) - .frame(width: 16, height: 16) - - Spacer() - - if !isReply { - Image("News Category") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .padding(.trailing, 6) - } - - Text(editorTitle) - .fontWeight(.semibold) - .lineLimit(1) - .truncationMode(.middle) - - Spacer() - - if sending { - ProgressView() - .controlSize(.small) - .frame(width: 22, height: 22) - } - else { - Button { - Task { - if await sendArticle() { - dismiss() - } - } - } label: { - Image(systemName: "arrow.up.circle.fill") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor((title.isEmpty || text.isEmpty) ? .secondary : .accentColor) - } - .buttonStyle(.plain) - .frame(width: 22, height: 22) - .help("Post News") - .disabled(title.isEmpty || text.isEmpty) - } - } - .frame(maxWidth: .infinity) - .padding([.leading, .top, .trailing]) - - TextField("Title", text: $title, axis: .vertical) - .textFieldStyle(.plain) - .lineLimit(3) - .padding() - .focusEffectDisabled() - .fontWeight(.semibold) - .frame(maxWidth: .infinity) - .border(Color.pink, width: 0) - .background(.tertiary.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .padding() - .focused($focusedField, equals: .title) - - Divider() - - BetterTextEditor(text: $text) - .betterEditorFont(NSFont.monospacedSystemFont(ofSize: 14.0, weight: .regular)) - .betterEditorAutomaticSpellingCorrection(true) - .betterEditorTextInset(.init(width: 16, height: 18)) - .background(Color(nsColor: .textBackgroundColor)) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .focused($focusedField, equals: .body) - - HStack(alignment: .center) { - Spacer() - - Text(String("**bold** _italics_ [link name](url) ![image name](url)")) - .foregroundStyle(.secondary) - .font(.caption) - .fontDesign(.monospaced) - .lineLimit(1) - .truncationMode(.middle) - .padding() - - Spacer() - } - .frame(maxWidth: .infinity) - .background(.tertiary.opacity(0.15)) - } - .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) - .presentationCompactAdaptation(.sheet) - .toolbarTitleDisplayMode(.inlineLarge) - .onAppear { - if !title.isEmpty { - focusedField = .body - } - else { - focusedField = .title - } - } - .onDisappear { - dismiss() - } - } -} diff --git a/Hotline/macOS/NewsItemView.swift b/Hotline/macOS/NewsItemView.swift deleted file mode 100644 index fc20e61..0000000 --- a/Hotline/macOS/NewsItemView.swift +++ /dev/null @@ -1,152 +0,0 @@ -import SwiftUI - -struct NewsItemView: View { - @Environment(Hotline.self) private var model: Hotline - - var news: NewsInfo - let depth: Int - - static var dateFormatter: DateFormatter = { - var dateFormatter = DateFormatter() - dateFormatter.dateStyle = .long - dateFormatter.timeStyle = .short - dateFormatter.timeZone = .gmt - return dateFormatter - }() - - static var relativeDateFormatter: RelativeDateTimeFormatter = { - var formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full - formatter.dateTimeStyle = .named - formatter.formattingContext = .listItem - return formatter - }() - - var body: some View { - HStack(alignment: .center, spacing: 0) { - if news.expandable { - Button { - news.expanded.toggle() - } label: { - Text(Image(systemName: news.expanded ? "chevron.down" : "chevron.right")) - .bold() - .font(.system(size: 10)) - .opacity(0.5) - .frame(alignment: .center) - } - .buttonStyle(.plain) - .frame(width: 10) - .padding(.leading, 4) - .padding(.trailing, 8) - } - else { - Spacer() - .frame(width: 10) - .padding(.leading, 4) - .padding(.trailing, 8) - } - - // Tree indent - Spacer() - .frame(width: (CGFloat(depth) * 22)) - - switch news.type { - case .category: - Image("News Category") - .resizable() - .frame(width: 16, height: 16, alignment: .center) - .padding(.trailing, 6) - case .bundle: - Image("News Bundle") - .resizable() - .frame(width: 16, height: 16, alignment: .center) - .padding(.trailing, 6) - case .article: - EmptyView() - } - - Text(news.name) - .fontWeight((news.type == .bundle || news.type == .category || !news.read) ? .semibold : .regular) - .lineLimit(1) - .truncationMode(.tail) - - if news.type == .article && news.articleUsername != nil { - Text(news.articleUsername!) - .foregroundStyle(.secondary) - .lineLimit(1) - .padding(.leading, 8) - } - - Spacer() - - if news.type == .category && news.count > 0 { - Text("^[\(news.count) Post](inflect: true)") - .lineLimit(1) - .foregroundStyle(.secondary) - .padding(.trailing, 8) - } - else if news.type == .bundle && news.count > 0 { - Text("^[\(news.count) Category](inflect: true)") - .lineLimit(1) - .foregroundStyle(.secondary) - .padding(.trailing, 8) - } -// if news.type == .bundle { -// Text("\(news.count)") -// .lineLimit(1) -// .foregroundStyle(.tertiary) -// .padding(.trailing, 8) - -// ZStack { -// Text("^[\(news.count) \(news.type == .bundle ? "Category" : "Post")](inflect: true)") -// Text("\(news.count)") -// .foregroundStyle(.clear) -//// .font(.caption) -// .lineLimit(1) -// .padding([.leading, .trailing], 8) -// .padding([.top, .bottom], 2) -// .background(.secondary) -// .clipShape(Capsule()) -// -// Text("\(news.count)") -// .foregroundStyle(.white) -//// .font(.caption) -// .lineLimit(1) -// .padding([.leading, .trailing], 8) -// .padding([.top, .bottom], 2) -// .blendMode(.destinationOut) -// } -// .drawingGroup(opaque: false) -// } - else if news.type == .article && news.articleUsername != nil { - if let d = news.articleDate { - Text(NewsItemView.relativeDateFormatter.localizedString(for: d, relativeTo: Date.now)) - .lineLimit(1) - .foregroundStyle(.secondary) - .padding(.trailing, 8) - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onChange(of: news.expanded) { - guard news.expanded, news.type == .bundle || news.type == .category else { - return - } - - Task { - await model.getNewsList(at: news.path) - } - } - - if news.expanded { - ForEach(news.children.reversed(), id: \.self) { childNews in - NewsItemView(news: childNews, depth: self.depth + 1).tag(childNews.id) - } - } - } -} - -#Preview { - NewsItemView(news: NewsInfo(hotlineNewsArticle: HotlineNewsArticle(id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now, flavors: [("", 1)], path: ["Guest"])), depth: 0) - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) -} diff --git a/Hotline/macOS/NewsView.swift b/Hotline/macOS/NewsView.swift deleted file mode 100644 index 91bf2fe..0000000 --- a/Hotline/macOS/NewsView.swift +++ /dev/null @@ -1,297 +0,0 @@ -import SwiftUI -import MarkdownUI -import SplitView - -struct NewsView: View { - @Environment(Hotline.self) private var model: Hotline - @Environment(\.openWindow) private var openWindow - @Environment(\.colorScheme) private var colorScheme - - @State private var selection: NewsInfo? - @State private var articleText: String? - @State private var splitHidden = SideHolder(.bottom) - @State private var splitFraction = FractionHolder.usingUserDefaults(0.25, key: "News Split Fraction") - @State private var editorOpen: Bool = false - @State private var replyOpen: Bool = false - @State private var loading: Bool = false - - var body: some View { - Group { - if model.serverVersion < 151 { - VStack { - Text("No News") - .bold() - .foregroundStyle(.secondary) - .font(.title3) - Text("This server has news turned off.") - .foregroundStyle(.tertiary) - .font(.system(size: 13)) - } - .padding() - } - else { - NavigationStack { - VSplit( - top: { - if !model.newsLoaded { - loadingIndicator - } - else if model.news.isEmpty { - ZStack(alignment: .center) { - Text("No News") - .font(.title) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding() - } - .frame(maxWidth: .infinity) - } - else { - newsBrowser - } - }, - bottom: { - articleViewer - } - ) - .fraction(splitFraction) - .constraints(minPFraction: 0.1, minSFraction: 0.3) - .hide(splitHidden) - .styling(color: colorScheme == .dark ? .black : Splitter.defaultColor, inset: 0, visibleThickness: 0.5, invisibleThickness: 5, hideSplitter: true) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) - } - .task { - if !model.newsLoaded { - loading = true - await model.getNewsList() - loading = false - } - } - } - } - .sheet(isPresented: $editorOpen) { - } content: { - if let selection = selection { - switch selection.type { - case .article, .category: - NewsEditorView(editorTitle: selection.path.last ?? "New Post", isReply: false, path: selection.path, parentID: 0) - default: - EmptyView() - } - } - else { - EmptyView() - } - } - .sheet(isPresented: $replyOpen) { - } content: { - if let selection = selection, selection.type == .article { - NewsEditorView(editorTitle: "Reply to \(selection.articleUsername ?? "Post")", isReply: true, path: selection.path, parentID: UInt32(selection.articleID!), title: selection.name.replyToString()) - } - else { - EmptyView() - } - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - if selection?.type == .category || selection?.type == .article { - editorOpen = true - } - } label: { - Image(systemName: "square.and.pencil") - } - .help("New Post") - .disabled(selection?.type != .category && selection?.type != .article) - } - - ToolbarItem(placement: .primaryAction) { - Button { - if selection?.type == .article { - replyOpen = true - } - } label: { - Image(systemName: "arrowshape.turn.up.left") - } - .help("Reply to Post") - .disabled(selection?.type != .article) - } - - ToolbarItem(placement: .primaryAction) { - Button { - loading = true - if let selectionPath = selection?.path { - Task { - await model.getNewsList(at: selectionPath) - loading = false - } - } - else { - Task { - await model.getNewsList() - loading = false - } - } - } label: { - Image(systemName: "arrow.clockwise") - } - .help("Reload News") - .disabled(loading) - } - } - } - - var newsBrowser: some View { - List(model.news, id: \.self, selection: $selection) { newsItem in - NewsItemView(news: newsItem, depth: 0).tag(newsItem.id) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .environment(\.defaultMinListRowHeight, 28) - .listStyle(.inset) - .alternatingRowBackgrounds(.enabled) - .contextMenu(forSelectionType: NewsInfo.self) { items in - let selectedItem = items.first - - Button { - if selectedItem?.type == .article { - replyOpen = true - } - } label: { - Label("Reply to \(selectedItem?.articleUsername ?? "Post")", systemImage: "arrowshape.turn.up.left") - } - .disabled(selectedItem == nil || selectedItem?.type != .article) - - } primaryAction: { items in - guard let clickedNews = items.first else { - return - } - - self.selection = clickedNews - if clickedNews.type == .bundle || clickedNews.type == .category || clickedNews.children.count > 0 { - clickedNews.expanded.toggle() - } - } - .onChange(of: selection) { - self.articleText = nil - if let article = selection, article.type == .article { - article.read = true - if let articleFlavor = article.articleFlavors?.first, - let articleID = article.articleID { - Task { - if let articleText = await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { - self.articleText = articleText - } - } - if self.splitHidden.side != nil { - withAnimation(.easeOut(duration: 0.15)) { - self.splitHidden.side = nil - } - } - - } - } - else { - if self.splitHidden.side != .bottom { - withAnimation(.easeOut(duration: 0.25)) { - self.splitHidden.side = .bottom - } - } - } - } - .onKeyPress(.rightArrow) { - if let s = selection, s.expandable { - s.expanded = true - return .handled - } - return .ignored - } - .onKeyPress(.leftArrow) { - if let s = selection, s.expandable { - s.expanded = false - return .handled - } - return .ignored - } - } - - var loadingIndicator: some View { - VStack { - HStack { - ProgressView { - Text("Loading Newsgroups") - } - .controlSize(.regular) - } - } - .frame(maxWidth: .infinity) - } - - var articleViewer: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - if let selection = selection, selection.type == .article { - if let poster = selection.articleUsername, let postDate = selection.articleDate { - HStack(alignment: .firstTextBaseline) { - Text(poster) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .textSelection(.enabled) - .padding(.bottom, 16) - Spacer() - Text("\(NewsItemView.dateFormatter.string(from: postDate))") - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .textSelection(.enabled) - .padding(.bottom, 16) - } - } - - Divider() - - Text(selection.name).font(.title) - .textSelection(.enabled) - .padding(.bottom, 8) - .padding(.top, 16) - - if let newsText = self.articleText { - Markdown(newsText) - .markdownTheme(.basic) - .textSelection(.enabled) - .lineSpacing(6) - .padding(.top, 16) - } - } - else { - HStack(alignment: .center) { - Spacer() - HStack(alignment: .center, spacing: 8) { -// Image(systemName: "doc.append") -// .resizable() -// .scaledToFit() -// .foregroundStyle(.tertiary) -// .frame(width: 16, height: 16) - Text("Select a news post to read") - .foregroundStyle(.tertiary) - .font(.system(size: 13)) - } - Spacer() - } - .padding() - .padding(.top, 48) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .padding() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .transition(.move(edge: .bottom)) - } -} - -#Preview { - NewsView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) -} diff --git a/Hotline/macOS/ServerAgreementView.swift b/Hotline/macOS/ServerAgreementView.swift deleted file mode 100644 index e72de7d..0000000 --- a/Hotline/macOS/ServerAgreementView.swift +++ /dev/null @@ -1,78 +0,0 @@ -import SwiftUI - -fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 340 - -struct ServerAgreementView: View { - let text: String - - @State private var expandable: Bool = false - @State private var expanded: Bool = false - - var body: some View { - ScrollView(.vertical) { - HStack(alignment: .top) { - Spacer() - Text(text.convertToAttributedStringWithLinks()) - .font(.system(size: 12)) - .fontDesign(.monospaced) - .textSelection(.enabled) - .tint(Color("Link Color")) - .frame(maxWidth: 400) - .padding(16) - .background( - GeometryReader { geometry in - Color.clear.onAppear { - if geometry.size.height > MAX_AGREEMENT_HEIGHT { - expandable = true - } - else { - expandable = false - } - } - } - ) - Spacer() - } - } - .scrollIndicators(.never) - .frame(maxWidth: .infinity, maxHeight: (expandable && expanded) ? nil : MAX_AGREEMENT_HEIGHT) - .scrollBounceBehavior(.basedOnSize) -#if os(iOS) - .background(Color("Agreement Background")) -#elseif os(macOS) - .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) -#endif - .overlay(alignment: .bottomTrailing) { - ZStack(alignment: .bottomTrailing) { - Group { - if !expandable || expanded { - EmptyView() - } - else { - Button(action: { - withAnimation(.easeOut(duration: 0.15)) { - expanded = true - } - }, label: { - Image(systemName: "arrow.up.and.down.circle.fill") - .resizable() - .scaledToFit() - .fontWeight(.semibold) - .frame(width: 16, height: 16) - .foregroundColor(.primary.opacity(0.5)) - }) - .buttonStyle(.plain) - .buttonBorderShape(.circle) - .help("Expand Server Agreement") - .padding([.trailing, .bottom], 16) - } - } - } - } - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - } -} - -#Preview { - ServerAgreementView(text: "Hello there and welcome to this server.") -} diff --git a/Hotline/macOS/ServerMessageView.swift b/Hotline/macOS/ServerMessageView.swift deleted file mode 100644 index 6c3c60e..0000000 --- a/Hotline/macOS/ServerMessageView.swift +++ /dev/null @@ -1,33 +0,0 @@ -import SwiftUI - -struct ServerMessageView: View { - let message: String - - var body: some View { - HStack(alignment: .center, spacing: 8) { - Image("Server Message") - .symbolRenderingMode(.multicolor) - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - Text(message) - .fontWeight(.semibold) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - Spacer() - } - .padding() - .frame(maxWidth: .infinity) -#if os(iOS) - .background(Color("Agreement Background")) -#elseif os(macOS) - .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) -#endif - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - } -} - -#Preview { - ServerMessageView(message: "This server has something important to say.") -} diff --git a/Hotline/macOS/Settings/GeneralSettingsView.swift b/Hotline/macOS/Settings/GeneralSettingsView.swift new file mode 100644 index 0000000..6be73aa --- /dev/null +++ b/Hotline/macOS/Settings/GeneralSettingsView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct GeneralSettingsView: View { + @State private var username: String = "" + @State private var usernameChanged: Bool = false + @State private var showClearHistoryConfirmation: Bool = false + + let saveTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + var body: some View { + @Bindable var preferences = Prefs.shared + + Form { + TextField("Your Name", text: $username, prompt: Text("guest")) + Toggle("Show Join/Leave in Chat", isOn: $preferences.showJoinLeaveMessages) + Toggle("Refuse private messages", isOn: $preferences.refusePrivateMessages) + Toggle("Refuse private chat", isOn: $preferences.refusePrivateChat) + Toggle("Automatic Response", isOn: $preferences.enableAutomaticMessage) + if preferences.enableAutomaticMessage { + TextField("", text: $preferences.automaticMessage, prompt: Text("Write a response message")) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity) + .onSubmit(of: .text) { + preferences.username = self.username + } + } + + Divider() + + Button(role: .destructive) { + showClearHistoryConfirmation = true + } label: { + Text("Clear Chat History…") + } + } + .padding() + .frame(width: 392) + .confirmationDialog("Clear chat history?", isPresented: $showClearHistoryConfirmation, titleVisibility: .visible) { + Button("Clear Chat History", role: .destructive) { + Task { + await ChatStore.shared.clearAll() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes all saved chat logs across servers. Active chats will repopulate only with new messages.") + } + .onAppear { + self.username = preferences.username + self.usernameChanged = false + } + .onDisappear { + preferences.username = self.username + self.usernameChanged = false + } + .onChange(of: username) { oldValue, newValue in + self.usernameChanged = true + } + .onReceive(saveTimer) { _ in + if self.usernameChanged { + self.usernameChanged = false + preferences.username = self.username + } + } + } +} diff --git a/Hotline/macOS/Settings/IconSettingsView.swift b/Hotline/macOS/Settings/IconSettingsView.swift new file mode 100644 index 0000000..98cbb09 --- /dev/null +++ b/Hotline/macOS/Settings/IconSettingsView.swift @@ -0,0 +1,58 @@ +import SwiftUI + +struct IconSettingsView: View { + @State private var hoveredUserIconID: Int = -1 + + var body: some View { + @Bindable var preferences = Prefs.shared + + Form { + ScrollViewReader { scrollProxy in + ScrollView { + LazyVGrid(columns: [ + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)) + ], spacing: 0) { + ForEach(Hotline.classicIconSet, id: \.self) { iconID in + HStack { + Image("Classic/\(iconID)") + .resizable() + .interpolation(.none) + .scaledToFit() + .frame(width: 32, height: 16) + .help("Icon \(String(iconID))") + } + .tag(iconID) + .frame(width: 32, height: 32) + .padding(4) + .background(iconID == preferences.userIconID ? Color.accentColor : (iconID == hoveredUserIconID ? Color.accentColor.opacity(0.1) : Color(nsColor: .textBackgroundColor))) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .onTapGesture { + preferences.userIconID = iconID + } + .onHover { hovered in + if hovered { + self.hoveredUserIconID = iconID + } + } + } + } + .padding() + } + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor), lineWidth: 1)) + .onAppear { + scrollProxy.scrollTo(preferences.userIconID, anchor: .center) + } + .frame(height: 355) + } + } + .padding() + } +} diff --git a/Hotline/macOS/Settings/SettingsView.swift b/Hotline/macOS/Settings/SettingsView.swift new file mode 100644 index 0000000..b2a2948 --- /dev/null +++ b/Hotline/macOS/Settings/SettingsView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct SettingsView: View { + private enum Tabs: Hashable { + case general, icon + } + + var body: some View { + TabView { + GeneralSettingsView() + .tabItem { + Label("General", systemImage: "person.text.rectangle") + } + .tag(Tabs.general) + IconSettingsView() + .tabItem { + Label("Icon", systemImage: "person") + } + .tag(Tabs.icon) + SoundSettingsView() + .tabItem { + Label("Sound", systemImage: "speaker.wave.3") + } + .tag(Tabs.icon) + } + } +} + +#Preview { + SettingsView() +} diff --git a/Hotline/macOS/Settings/SoundSettingsView.swift b/Hotline/macOS/Settings/SoundSettingsView.swift new file mode 100644 index 0000000..b99db77 --- /dev/null +++ b/Hotline/macOS/Settings/SoundSettingsView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct SoundSettingsView: View { + var body: some View { + @Bindable var preferences = Prefs.shared + + Form { + Toggle("Enable Sounds", isOn: $preferences.playSounds) + .controlSize(.large) + + Section("Sounds") { + Toggle("Chat", isOn: $preferences.playChatSound) + .disabled(!preferences.playSounds) + + Toggle("File Transfers", isOn: $preferences.playFileTransferCompleteSound) + .disabled(!preferences.playSounds) + + Toggle("Private Message", isOn: $preferences.playPrivateMessageSound) + .disabled(!preferences.playSounds) + + Toggle("Join", isOn: $preferences.playJoinSound) + .disabled(!preferences.playSounds) + + Toggle("Leave", isOn: $preferences.playLeaveSound) + .disabled(!preferences.playSounds) + + Toggle("Logged in", isOn: $preferences.playLoggedInSound) + .disabled(!preferences.playSounds) + + Toggle("Error", isOn: $preferences.playErrorSound) + .disabled(!preferences.playSounds) + + Toggle("Chat Invitation", isOn: $preferences.playChatInvitationSound) + .disabled(!preferences.playSounds) + } + } + .formStyle(.grouped) + .frame(width: 392, height: 433) + } +} diff --git a/Hotline/macOS/SettingsView.swift b/Hotline/macOS/SettingsView.swift deleted file mode 100644 index d301abd..0000000 --- a/Hotline/macOS/SettingsView.swift +++ /dev/null @@ -1,193 +0,0 @@ -import SwiftUI - -struct GeneralSettingsView: View { - @State private var username: String = "" - @State private var usernameChanged: Bool = false - @State private var showClearHistoryConfirmation: Bool = false - - let saveTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() - - var body: some View { - @Bindable var preferences = Prefs.shared - - Form { - TextField("Your Name", text: $username, prompt: Text("guest")) - Toggle("Show Join/Leave in Chat", isOn: $preferences.showJoinLeaveMessages) - Toggle("Refuse private messages", isOn: $preferences.refusePrivateMessages) - Toggle("Refuse private chat", isOn: $preferences.refusePrivateChat) - Toggle("Automatic Response", isOn: $preferences.enableAutomaticMessage) - if preferences.enableAutomaticMessage { - TextField("", text: $preferences.automaticMessage, prompt: Text("Write a response message")) - .lineLimit(2) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity) - .onSubmit(of: .text) { - preferences.username = self.username - } - } - - Divider() - - Button(role: .destructive) { - showClearHistoryConfirmation = true - } label: { - Text("Clear Chat History…") - } - } - .padding() - .frame(width: 392) - .confirmationDialog("Clear chat history?", isPresented: $showClearHistoryConfirmation, titleVisibility: .visible) { - Button("Clear Chat History", role: .destructive) { - Task { - await ChatStore.shared.clearAll() - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This removes all saved chat logs across servers. Active chats will repopulate only with new messages.") - } - .onAppear { - self.username = preferences.username - self.usernameChanged = false - } - .onDisappear { - preferences.username = self.username - self.usernameChanged = false - } - .onChange(of: username) { oldValue, newValue in - self.usernameChanged = true - } - .onReceive(saveTimer) { _ in - if self.usernameChanged { - self.usernameChanged = false - preferences.username = self.username - } - } - } -} - -struct IconSettingsView: View { - @State private var hoveredUserIconID: Int = -1 - - var body: some View { - @Bindable var preferences = Prefs.shared - - Form { - ScrollViewReader { scrollProxy in - ScrollView { - LazyVGrid(columns: [ - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)) - ], spacing: 0) { - ForEach(Hotline.classicIconSet, id: \.self) { iconID in - HStack { - Image("Classic/\(iconID)") - .resizable() - .interpolation(.none) - .scaledToFit() - .frame(width: 32, height: 16) - .help("Icon \(String(iconID))") - } - .tag(iconID) - .frame(width: 32, height: 32) - .padding(4) - .background(iconID == preferences.userIconID ? Color.accentColor : (iconID == hoveredUserIconID ? Color.accentColor.opacity(0.1) : Color(nsColor: .textBackgroundColor))) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .onTapGesture { - preferences.userIconID = iconID - } - .onHover { hovered in - if hovered { - self.hoveredUserIconID = iconID - } - } - } - } - .padding() - } - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor), lineWidth: 1)) - .onAppear { - scrollProxy.scrollTo(preferences.userIconID, anchor: .center) - } - .frame(height: 355) - } - } - .padding() - } -} - -struct SoundSettingsView: View { - var body: some View { - @Bindable var preferences = Prefs.shared - - Form { - Toggle("Enable Sounds", isOn: $preferences.playSounds) - .controlSize(.large) - - Section("Sounds") { - Toggle("Chat", isOn: $preferences.playChatSound) - .disabled(!preferences.playSounds) - - Toggle("File Transfers", isOn: $preferences.playFileTransferCompleteSound) - .disabled(!preferences.playSounds) - - Toggle("Private Message", isOn: $preferences.playPrivateMessageSound) - .disabled(!preferences.playSounds) - - Toggle("Join", isOn: $preferences.playJoinSound) - .disabled(!preferences.playSounds) - - Toggle("Leave", isOn: $preferences.playLeaveSound) - .disabled(!preferences.playSounds) - - Toggle("Logged in", isOn: $preferences.playLoggedInSound) - .disabled(!preferences.playSounds) - - Toggle("Error", isOn: $preferences.playErrorSound) - .disabled(!preferences.playSounds) - - Toggle("Chat Invitation", isOn: $preferences.playChatInvitationSound) - .disabled(!preferences.playSounds) - } - } - .formStyle(.grouped) - .frame(width: 392, height: 433) - } -} - -struct SettingsView: View { - private enum Tabs: Hashable { - case general, icon - } - - var body: some View { - TabView { - GeneralSettingsView() - .tabItem { - Label("General", systemImage: "person.text.rectangle") - } - .tag(Tabs.general) - IconSettingsView() - .tabItem { - Label("Icon", systemImage: "person") - } - .tag(Tabs.icon) - SoundSettingsView() - .tabItem { - Label("Sound", systemImage: "speaker.wave.3") - } - .tag(Tabs.icon) - } - } -} - -#Preview { - SettingsView() -} diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 251248f..34f7a57 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -105,6 +105,7 @@ struct TrackerView: View { .moveDisabled(true) .deleteDisabled(true) .tag(TrackerSelection.bookmarkServer(trackedServer)) + .padding(.leading, 16 + 8 + 10) } } } @@ -180,18 +181,6 @@ struct TrackerView: View { case .bookmarkServer(let bookmarkServer): openWindow(id: "server", value: bookmarkServer.server) } - -// if clickedItem.type == .tracker { -// if NSEvent.modifierFlags.contains(.option) { -// trackerSheetBookmark = clickedItem -// } -// else { -// clickedItem.expanded.toggle() -// } -// } -// else if let server = clickedItem.server { -// openWindow(id: "server", value: server) -// } } .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in switch result { @@ -208,38 +197,26 @@ struct TrackerView: View { switch self.selection { case .bookmark(let bookmark): if bookmark.type == .tracker { - self.expandedTrackers.insert(bookmark) + self.setExpanded(true, for: bookmark) return .handled } default: break } -// if -// let bookmark = selection, -// bookmark.type == .tracker { -// bookmark.expanded = true -// return .handled -// } return .ignored } .onKeyPress(.leftArrow) { switch self.selection { case .bookmark(let bookmark): if bookmark.type == .tracker { - self.expandedTrackers.remove(bookmark) + self.setExpanded(false, for: bookmark) return .handled } default: break } -// if -// let bookmark = selection, -// bookmark.type == .tracker { -// bookmark.expanded = false -// return .handled -// } return .ignored } .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in @@ -354,8 +331,7 @@ struct TrackerView: View { Button { NSPasteboard.general.clearContents() - let displayAddress = server.port == HotlinePorts.DefaultServerPort ? - server.address : "\(server.address):\(server.port)" + let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)" NSPasteboard.general.setString(displayAddress, forType: .string) } label: { Label("Copy Address", systemImage: "doc.on.doc") @@ -451,22 +427,7 @@ struct TrackerView: View { func toggleExpanded(for bookmark: Bookmark) { guard bookmark.type == .tracker else { return } - - if self.expandedTrackers.contains(bookmark) { - // Collapse: cancel ongoing fetch and clear data - self.fetchTasks[bookmark]?.cancel() - self.fetchTasks[bookmark] = nil - self.expandedTrackers.remove(bookmark) - self.trackerServers[bookmark] = nil - self.loadingTrackers.remove(bookmark) - } else { - // Expand: start fetch task - self.expandedTrackers.insert(bookmark) - let task = Task { - await self.fetchServers(for: bookmark) - } - self.fetchTasks[bookmark] = task - } + self.setExpanded(!self.expandedTrackers.contains(bookmark), for: bookmark) } func setExpanded(_ expanded: Bool, for bookmark: Bookmark) { @@ -678,8 +639,6 @@ struct TrackerBookmarkServerView: View { var body: some View { HStack(alignment: .center, spacing: 6) { - Spacer() - .frame(width: 14 + 8 + 16) Image("Server") .resizable() .scaledToFit() @@ -720,6 +679,7 @@ struct TrackerItemView: View { let isLoading: Bool let count: Int let onToggleExpanded: () -> Void + @Environment(\.appearsActive) private var appearsActive var body: some View { HStack(alignment: .center, spacing: 6) { @@ -759,22 +719,20 @@ struct TrackerItemView: View { SpinningGlobeView() .fontWeight(.semibold) .frame(width: 12, height: 12) -// .opacity(0.5) } - - .padding(.horizontal, 8) + .padding(.horizontal, 6) .padding(.vertical, 2) .foregroundStyle(.secondary) - .background(.quinary) +// .background(.quinary) .clipShape(.capsule) } case .server: Image(systemName: "bookmark.fill") .resizable() - .renderingMode(.template) - .aspectRatio(contentMode: .fit) + .scaledToFit() + .foregroundStyle(Color.secondary) .frame(width: 11, height: 11, alignment: .center) - .opacity(0.5) + .opacity(0.75) .padding(.leading, 3) .padding(.trailing, 2) Image("Server") -- cgit