From 54b85a88efcd455d5c12b7ca65d425d095a675f1 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 21 Oct 2025 23:09:53 -0700 Subject: Add initial support for folder downloading. --- Hotline/macOS/FilesView.swift | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) (limited to 'Hotline/macOS/FilesView.swift') diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 2befa18..f399372 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -236,11 +236,12 @@ struct FilesView: View { } @MainActor private func downloadFile(_ file: FileInfo) { - guard !file.isFolder else { - return + if file.isFolder { + model.downloadFolder(file.name, path: file.path) + } + else { + model.downloadFile(file.name, path: file.path) } - - model.downloadFile(file.name, path: file.path) } @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { @@ -297,13 +298,13 @@ struct FilesView: View { let selectedFile = items.first Button { - if let s = selectedFile, !s.isFolder { + if let s = selectedFile { downloadFile(s) } } label: { Label("Download", systemImage: "arrow.down") } - .disabled(selectedFile == nil || (selectedFile != nil && selectedFile!.isFolder)) + .disabled(selectedFile == nil) Divider() @@ -419,14 +420,14 @@ struct FilesView: View { ToolbarItem(placement: .primaryAction) { Button { - if let selectedFile = selection, !selectedFile.isFolder { + if let selectedFile = selection { downloadFile(selectedFile) } } label: { Label("Download", systemImage: "arrow.down") } .help("Download") - .disabled(selection == nil || selection?.isFolder == true || model.access?.contains(.canDownloadFiles) != true) + .disabled(selection == nil || model.access?.contains(.canDownloadFiles) != true) } } } -- cgit From 46213b6e09b8b23bfa039634ff5af4919c758473 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 22 Oct 2025 17:32:59 -0700 Subject: First pass at file search in files view. --- Hotline/Hotline/HotlineClient.swift | 38 +++-- Hotline/Models/Hotline.swift | 295 +++++++++++++++++++++++++++++++++++- Hotline/macOS/FilesView.swift | 139 +++++++++++++++-- Hotline/macOS/ServerView.swift | 11 ++ 4 files changed, 454 insertions(+), 29 deletions(-) (limited to 'Hotline/macOS/FilesView.swift') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 99aa167..3c6a643 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -89,7 +89,13 @@ class HotlineClient: NetSocketDelegate { private var serverAddress: String? = nil private var serverPort: UInt16? = nil - private var transactionLog: [UInt32:(HotlineTransactionType, ((HotlineTransaction, HotlineTransactionError?) -> Void)?)] = [:] + private struct TransactionContext { + let type: HotlineTransactionType + let callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? + let suppressErrors: Bool + } + + private var transactionLog: [UInt32: TransactionContext] = [:] private var socket: NetSocket? private var stage: HotlineClientStage = .handshake @@ -208,15 +214,15 @@ class HotlineClient: NetSocketDelegate { // MARK: - Packets - @MainActor private func sendPacket(_ t: HotlineTransaction, callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) { + @MainActor private func sendPacket(_ t: HotlineTransaction, suppressErrors: Bool = false, callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) { guard let socket = self.socket else { return } print("HotlineClient => \(t.id) \(t.type)") - if callback != nil { - self.transactionLog[t.id] = (t.type, callback) + if callback != nil || suppressErrors { + self.transactionLog[t.id] = TransactionContext(type: t.type, callback: callback, suppressErrors: suppressErrors) } socket.write(t.encoded()) @@ -375,22 +381,22 @@ class HotlineClient: NetSocketDelegate { return } + let context = self.transactionLog[packet.id] + self.transactionLog[packet.id] = nil + if packet.errorCode != 0 { let errorField: HotlineTransactionField? = packet.getField(type: .errorText) print("HotlineClient 😵 \(packet.errorCode): \(errorField?.getString() ?? "")") - self.delegate?.hotlineReceivedErrorMessage(code: packet.errorCode, message: errorField?.getString()) + if context?.suppressErrors != true { + self.delegate?.hotlineReceivedErrorMessage(code: packet.errorCode, message: errorField?.getString()) + } } - - guard let replyCallbackInfo = self.transactionLog[packet.id] else { - print("Hmm, no reply waiting though") - return + + if let context { + print("HotlineClient reply in response to \(context.type)") } - self.transactionLog[packet.id] = nil - - print("HotlineClient reply in response to \(replyCallbackInfo.0)") - - let replyCallback = replyCallbackInfo.1 + let replyCallback = context?.callback guard packet.errorCode == 0 else { let errorField: HotlineTransactionField? = packet.getField(type: .errorText) @@ -630,13 +636,13 @@ class HotlineClient: NetSocketDelegate { } } - @MainActor func sendGetFileList(path: [String] = [], callback: (([HotlineFile]) -> Void)? = nil) { + @MainActor func sendGetFileList(path: [String] = [], suppressErrors: Bool = false, callback: (([HotlineFile]) -> Void)? = nil) { var t = HotlineTransaction(type: .getFileNameList) if !path.isEmpty { t.setFieldPath(type: .filePath, val: path) } - self.sendPacket(t) { reply, err in + self.sendPacket(t, suppressErrors: suppressErrors) { reply, err in guard err == nil else { callback?([]) return diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 48cae54..b93fb55 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1,5 +1,29 @@ import SwiftUI +struct FileSearchConfig: Equatable { + var initialBurstCount: Int = 8 + var initialDelay: TimeInterval = 0.05 + var backoffMultiplier: Double = 1.6 + var maxDelay: TimeInterval = 1.5 + var maxDepth: Int = 40 + var loopRepetitionLimit: Int = 4 +} + +enum FileSearchStatus: Equatable { + case idle + case searching(processed: Int, pending: Int) + case completed(processed: Int) + case cancelled(processed: Int) + case failed(String) + + var isActive: Bool { + if case .searching = self { + return true + } + return false + } +} + @Observable class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelegate, HotlineFilePreviewClientDelegate, HotlineFileUploadClientDelegate, HotlineFolderDownloadClientDelegate { let id: UUID = UUID() @@ -131,6 +155,13 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var messageBoardLoaded: Bool = false var files: [FileInfo] = [] var filesLoaded: Bool = false + var fileSearchResults: [FileInfo] = [] + var fileSearchStatus: FileSearchStatus = .idle + var fileSearchQuery: String = "" + var fileSearchConfig = FileSearchConfig() + var fileSearchScannedFolders: Int = 0 + @ObservationIgnored private var fileSearchSession: FileSearchSession? = nil + @ObservationIgnored private var fileSearchResultKeys: Set = [] var news: [NewsInfo] = [] private var newsLookup: [String:NewsInfo] = [:] var newsLoaded: Bool = false @@ -209,6 +240,9 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @MainActor func disconnect() { self.client.disconnect() self.bannerClient?.cancel() + self.fileSearchSession?.cancel() + self.fileSearchSession = nil + self.resetFileSearchState() } @MainActor func sendAgree() { @@ -264,9 +298,9 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.client.sendPostMessageBoard(text: text) } - @MainActor func getFileList(path: [String] = []) async -> [FileInfo] { + @MainActor func getFileList(path: [String] = [], suppressErrors: Bool = false) async -> [FileInfo] { return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetFileList(path: path) { [weak self] files in + self?.client.sendGetFileList(path: path, suppressErrors: suppressErrors) { [weak self] files in let parentFile = self?.findFile(in: self?.files ?? [], at: path) var newFiles: [FileInfo] = [] @@ -289,6 +323,92 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } } + @MainActor func startFileSearch(query: String) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + cancelFileSearch() + return + } + + fileSearchSession?.cancel() + resetFileSearchState() + fileSearchQuery = trimmed + fileSearchStatus = .searching(processed: 0, pending: 0) + fileSearchScannedFolders = 0 + + let session = FileSearchSession(hotline: self, query: trimmed, config: fileSearchConfig) + fileSearchSession = session + + Task { await session.start() } + } + + @MainActor func cancelFileSearch(clearResults: Bool = true) { + guard let session = fileSearchSession else { + if clearResults { + resetFileSearchState() + } else if !fileSearchResults.isEmpty { + fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) + } + return + } + + session.cancel() + fileSearchSession = nil + if clearResults { + resetFileSearchState() + } + else { + fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) + } + } + + @MainActor fileprivate func searchSession(_ session: FileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) { + guard fileSearchSession === session else { + return + } + + var appended: [FileInfo] = [] + for match in matches { + let key = searchPathKey(for: match.path) + if fileSearchResultKeys.insert(key).inserted { + appended.append(match) + } + } + + if !appended.isEmpty { + fileSearchResults.append(contentsOf: appended) + } + + fileSearchScannedFolders = processed + fileSearchStatus = .searching(processed: processed, pending: pending) + } + + @MainActor fileprivate func searchSessionDidFinish(_ session: FileSearchSession, processed: Int, pending: Int, completed: Bool) { + guard fileSearchSession === session else { + return + } + + fileSearchScannedFolders = processed + fileSearchSession = nil + if completed { + fileSearchStatus = .completed(processed: processed) + } else { + fileSearchStatus = .cancelled(processed: processed) + } + } + + private func resetFileSearchState() { + fileSearchResults = [] + fileSearchResultKeys.removeAll(keepingCapacity: true) + fileSearchStatus = .idle + fileSearchQuery = "" + fileSearchScannedFolders = 0 + } + + private func searchPathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + @MainActor func getNewsArticle(id articleID: UInt, at path: [String], flavor: String) async -> String? { return await withCheckedContinuation { [weak self] continuation in self?.client.sendGetNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) { articleText in @@ -809,6 +929,10 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.serverName = nil self.access = nil + self.fileSearchSession?.cancel() + self.fileSearchSession = nil + self.resetFileSearchState() + self.users = [] self.chat = [] self.messageBoard = [] @@ -1156,3 +1280,170 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } } } + +@MainActor +final class FileSearchSession { + private struct FolderTask { + let path: [String] + let depth: Int + } + + private weak var hotline: Hotline? + private let queryTokens: [String] + private let config: FileSearchConfig + + private var queue: [FolderTask] = [] + private var visited: Set = [] + private var loopHistogram: [String: Int] = [:] + + private var processedCount: Int = 0 + private var currentDelay: TimeInterval + private var isCancelled = false + + init(hotline: Hotline, query: String, config: FileSearchConfig) { + self.hotline = hotline + self.queryTokens = query.lowercased().split(separator: " ").map(String.init) + self.config = config + self.currentDelay = config.initialDelay + } + + func start() async { + guard let hotline else { + return + } + + await Task.yield() + + if !hotline.filesLoaded { + let rootFiles = await hotline.getFileList(path: [], suppressErrors: true) + processedCount = max(processedCount, 1) + processListing(rootFiles, depth: 0) + } + else { + processedCount = max(processedCount, 1) + processListing(hotline.files, depth: 0) + } + + while !queue.isEmpty && !isCancelled { + await Task.yield() + + let task = queue.removeFirst() + if shouldSkip(path: task.path, depth: task.depth) { + hotline.searchSession(self, didEmit: [], processed: processedCount, pending: queue.count) + continue + } + + visited.insert(pathKey(for: task.path)) + + let children = await hotline.getFileList(path: task.path, suppressErrors: true) + processedCount += 1 + + if isCancelled { + break + } + + processListing(children, depth: task.depth) + + await applyBackoff() + } + + hotline.searchSessionDidFinish(self, processed: processedCount, pending: queue.count, completed: !isCancelled) + } + + func cancel() { + isCancelled = true + } + + private func processListing(_ items: [FileInfo], depth: Int) { + guard let hotline else { + return + } + + var matches: [FileInfo] = [] + + for file in items { + if nameMatchesQuery(file.name) { + matches.append(file) + } + + if file.isFolder { + enqueueFolder(file, depth: depth + 1) + } + } + + hotline.searchSession(self, didEmit: matches, processed: processedCount, pending: queue.count) + } + + private func enqueueFolder(_ folder: FileInfo, depth: Int) { + guard !isCancelled else { return } + guard depth <= config.maxDepth else { return } + + let path = folder.path + let key = pathKey(for: path) + guard !visited.contains(key) else { return } + + if exceedsLoopThreshold(for: path) { + return + } + + queue.append(FolderTask(path: path, depth: depth)) + } + + private func shouldSkip(path: [String], depth: Int) -> Bool { + if isCancelled { + return true + } + + if depth > config.maxDepth { + return true + } + + let key = pathKey(for: path) + if visited.contains(key) { + return true + } + + return false + } + + private func nameMatchesQuery(_ name: String) -> Bool { + guard !queryTokens.isEmpty else { return false } + let lowercased = name.lowercased() + return queryTokens.allSatisfy { lowercased.contains($0) } + } + + private func exceedsLoopThreshold(for path: [String]) -> Bool { + guard config.loopRepetitionLimit > 0 else { return false } + guard let last = path.last else { return false } + let parent = path.dropLast() + + guard let previousIndex = parent.lastIndex(of: last) else { + return false + } + + let suffix = Array(path[previousIndex...]) + let key = suffix.joined(separator: "\u{001F}") + let count = (loopHistogram[key] ?? 0) + 1 + loopHistogram[key] = count + return count > config.loopRepetitionLimit + } + + private func pathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + + private func applyBackoff() async { + guard !isCancelled else { return } + + if processedCount > config.initialBurstCount { + currentDelay = min(config.maxDelay, max(config.initialDelay, currentDelay * config.backoffMultiplier)) + } + + guard currentDelay > 0 else { + return + } + + let nanoseconds = UInt64(currentDelay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } +} diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index f399372..206e0a4 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -213,6 +213,48 @@ struct FilesView: View { @State private var selection: FileInfo? @State private var fileDetails: FileDetails? @State private var uploadFileSelectorDisplayed: Bool = false + @State private var searchText: String = "" + + 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 pending): + 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(let processed): + if model.fileSearchResults.isEmpty { + return nil + } + let count = model.fileSearchResults.count + let folderWord = processed == 1 ? "folder" : "folders" + return "Search cancelled" + case .failed(let message): + return "Search failed: \(message)" + case .idle: + return nil + } + } private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { switch previewInfo.previewType { @@ -278,7 +320,7 @@ struct FilesView: View { var body: some View { NavigationStack { - List(model.files, id: \.self, selection: $selection) { file in + List(displayedFiles, id: \.self, selection: $selection) { file in if file.isFolder { FolderView(file: file, depth: 0).tag(file.id) } @@ -383,8 +425,9 @@ struct FilesView: View { .frame(maxWidth: .infinity) } } + .searchable(text: $searchText, placement: .automatic, prompt: "Search") .toolbar { - ToolbarItem(placement: .primaryAction) { + ToolbarItemGroup(placement: .automatic) { Button { if let selectedFile = selection, selectedFile.isPreviewable { previewFile(selectedFile) @@ -394,9 +437,7 @@ struct FilesView: View { } .help("Preview") .disabled(selection == nil || selection?.isPreviewable == false) - } - - ToolbarItem(placement: .primaryAction) { + Button { if let selectedFile = selection { getFileInfo(selectedFile) @@ -406,9 +447,7 @@ struct FilesView: View { } .help("Get Info") .disabled(selection == nil) - } - - ToolbarItem(placement: .primaryAction) { + Button { uploadFileSelectorDisplayed = true } label: { @@ -416,9 +455,7 @@ struct FilesView: View { } .help("Upload") .disabled(model.access?.contains(.canUploadFiles) != true) - } - - ToolbarItem(placement: .primaryAction) { + Button { if let selectedFile = selection { downloadFile(selectedFile) @@ -464,6 +501,86 @@ struct FilesView: View { print(error) } }) + .onSubmit(of: .search) { + 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 { + Spacer() + + if case .searching(_, _) = model.fileSearchStatus { + ProgressView() + .controlSize(.small) + .tint(.red) + } + else if case .completed = model.fileSearchStatus { + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .fileComplete) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + else if case .failed = model.fileSearchStatus { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .symbolRenderingMode(.multicolor) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + + Text(message) + .font(.body) + .foregroundStyle(.white) + + Spacer() + } +// .overlay(alignment: .leading) { +// Button { +// model.cancelFileSearch(clearResults: true) +// } label: { +// Image(systemName: "xmark.circle.fill") +// .resizable() +// .scaledToFit() +// .foregroundStyle(.white) +// .opacity(0.8) +// } +// .buttonStyle(.plain) +// } + .padding(.horizontal, 8) + .padding(.leading, 4) + .padding(.vertical, 8) + .background { + Color(nsColor: .controlAccentColor) + .clipShape(.capsule(style: .continuous)) + } + .padding(.horizontal, 8) + } + } } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 81c993a..49413fe 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -392,6 +392,16 @@ struct ServerView: View { ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) } } + else if menuItem.type == .files { + ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) + .overlay(alignment: .trailing) { + if case .searching(_, _) = model.fileSearchStatus { + ProgressView() + .controlSize(.mini) + .padding(.trailing, 4) + } + } + } else { ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) } @@ -700,3 +710,4 @@ struct TransferItemView: View { .help(formattedProgressHelp()) } } + -- cgit From 485e4d981b6cd19a1e00ebf244ed1dd538fba284 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 22 Oct 2025 21:15:04 -0700 Subject: Improvements to file search UI and config. --- Hotline/Models/Hotline.swift | 25 +++++++++++++--- Hotline/macOS/FilesView.swift | 64 ++++++++++++++++++++++++++--------------- Hotline/macOS/TrackerView.swift | 3 +- 3 files changed, 64 insertions(+), 28 deletions(-) (limited to 'Hotline/macOS/FilesView.swift') diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index b93fb55..786fb0c 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1,10 +1,10 @@ import SwiftUI struct FileSearchConfig: Equatable { - var initialBurstCount: Int = 8 - var initialDelay: TimeInterval = 0.05 - var backoffMultiplier: Double = 1.6 - var maxDelay: TimeInterval = 1.5 + var initialBurstCount: Int = 15 + var initialDelay: TimeInterval = 0.02 + var backoffMultiplier: Double = 1.1 + var maxDelay: TimeInterval = 1.3 var maxDepth: Int = 40 var loopRepetitionLimit: Int = 4 } @@ -160,6 +160,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var fileSearchQuery: String = "" var fileSearchConfig = FileSearchConfig() var fileSearchScannedFolders: Int = 0 + var fileSearchCurrentPath: [String]? = nil @ObservationIgnored private var fileSearchSession: FileSearchSession? = nil @ObservationIgnored private var fileSearchResultKeys: Set = [] var news: [NewsInfo] = [] @@ -335,6 +336,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileSearchQuery = trimmed fileSearchStatus = .searching(processed: 0, pending: 0) fileSearchScannedFolders = 0 + fileSearchCurrentPath = [] let session = FileSearchSession(hotline: self, query: trimmed, config: fileSearchConfig) fileSearchSession = session @@ -348,12 +350,14 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega resetFileSearchState() } else if !fileSearchResults.isEmpty { fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) + fileSearchCurrentPath = nil } return } session.cancel() fileSearchSession = nil + fileSearchCurrentPath = nil if clearResults { resetFileSearchState() } @@ -383,6 +387,14 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileSearchStatus = .searching(processed: processed, pending: pending) } + @MainActor fileprivate func searchSession(_ session: FileSearchSession, didFocusOn path: [String]) { + guard fileSearchSession === session else { + return + } + + fileSearchCurrentPath = path + } + @MainActor fileprivate func searchSessionDidFinish(_ session: FileSearchSession, processed: Int, pending: Int, completed: Bool) { guard fileSearchSession === session else { return @@ -390,6 +402,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileSearchScannedFolders = processed fileSearchSession = nil + fileSearchCurrentPath = nil if completed { fileSearchStatus = .completed(processed: processed) } else { @@ -403,6 +416,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileSearchStatus = .idle fileSearchQuery = "" fileSearchScannedFolders = 0 + fileSearchCurrentPath = nil } private func searchPathKey(for path: [String]) -> String { @@ -1315,11 +1329,13 @@ final class FileSearchSession { await Task.yield() if !hotline.filesLoaded { + hotline.searchSession(self, didFocusOn: []) let rootFiles = await hotline.getFileList(path: [], suppressErrors: true) processedCount = max(processedCount, 1) processListing(rootFiles, depth: 0) } else { + hotline.searchSession(self, didFocusOn: []) processedCount = max(processedCount, 1) processListing(hotline.files, depth: 0) } @@ -1333,6 +1349,7 @@ final class FileSearchSession { continue } + hotline.searchSession(self, didFocusOn: task.path) visited.insert(pathKey(for: task.path)) let children = await hotline.getFileList(path: task.path, suppressErrors: true) diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 206e0a4..4498dc9 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -255,6 +255,16 @@ struct FilesView: View { 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 { @@ -529,56 +539,64 @@ struct FilesView: View { } .safeAreaInset(edge: .top) { if isShowingSearchResults, let message = searchStatusMessage { - HStack { - Spacer() - + HStack(alignment: .center, spacing: 6) { if case .searching(_, _) = model.fileSearchStatus { ProgressView() .controlSize(.small) - .tint(.red) + .accentColor(.white) + .tint(.white) } else if case .completed = model.fileSearchStatus { Image(systemName: "checkmark.circle.fill") .resizable() - .symbolRenderingMode(.palette) - .foregroundStyle(.white, .fileComplete) + .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(.multicolor) + .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) + } } -// .overlay(alignment: .leading) { -// Button { -// model.cancelFileSearch(clearResults: true) -// } label: { -// Image(systemName: "xmark.circle.fill") -// .resizable() -// .scaledToFit() -// .foregroundStyle(.white) -// .opacity(0.8) -// } -// .buttonStyle(.plain) -// } - .padding(.horizontal, 8) - .padding(.leading, 4) + .padding(.trailing, 14) + .padding(.leading, 8) .padding(.vertical, 8) .background { - Color(nsColor: .controlAccentColor) - .clipShape(.capsule(style: .continuous)) + Group { + if case .completed = model.fileSearchStatus { + Color.fileComplete + } + else { + Color(nsColor: .controlAccentColor) + } + } + .clipShape(.capsule(style: .continuous)) } .padding(.horizontal, 8) + .padding(.top, 8) } } } diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 83a56b8..c14f982 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -416,8 +416,9 @@ struct TrackerView: View { // Not expanded, expand it (which also fetches) self.setExpanded(true, for: bookmark) } + return } - return + break default: break } -- cgit From 5b4d615792a951979ff79506657e8407d2a48016 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 23 Oct 2025 11:24:06 -0700 Subject: Improve file search to move down the tree once current depth has been searched with exception now for hot spots where we burst into folders that contain matches. --- Hotline.xcodeproj/project.pbxproj | 4 +- Hotline/Models/Hotline.swift | 101 ++++++++++++++++++++++++++++++++++---- Hotline/macOS/FilesView.swift | 7 ++- 3 files changed, 98 insertions(+), 14 deletions(-) (limited to 'Hotline/macOS/FilesView.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 54adc37..d35440f 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -656,7 +656,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; @@ -696,7 +696,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 786fb0c..1e2c6c5 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1,12 +1,20 @@ import SwiftUI struct FileSearchConfig: Equatable { + /// Number of folders we process before we start applying delay backoff. var initialBurstCount: Int = 15 + /// Base delay applied between folder requests during the backoff phase. var initialDelay: TimeInterval = 0.02 + /// Multiplier used to increase the delay after each processed folder in backoff. var backoffMultiplier: Double = 1.1 - var maxDelay: TimeInterval = 1.3 + /// Maximum delay cap so searches don't stall out during long walks. + var maxDelay: TimeInterval = 1.0 + /// Maximum recursion depth allowed during file search. var maxDepth: Int = 40 + /// Limit for repeated folder loops (guards against circular server listings). var loopRepetitionLimit: Int = 4 + /// Number of child folders that get prioritized after a matching parent is found. + var hotBurstLimit: Int = 2 } enum FileSearchStatus: Equatable { @@ -1300,6 +1308,7 @@ final class FileSearchSession { private struct FolderTask { let path: [String] let depth: Int + let isHot: Bool } private weak var hotline: Hotline? @@ -1332,18 +1341,20 @@ final class FileSearchSession { hotline.searchSession(self, didFocusOn: []) let rootFiles = await hotline.getFileList(path: [], suppressErrors: true) processedCount = max(processedCount, 1) - processListing(rootFiles, depth: 0) + processListing(rootFiles, depth: 0, parentPath: [], parentIsHot: false) } else { hotline.searchSession(self, didFocusOn: []) processedCount = max(processedCount, 1) - processListing(hotline.files, depth: 0) + processListing(hotline.files, depth: 0, parentPath: [], parentIsHot: false) } while !queue.isEmpty && !isCancelled { await Task.yield() - let task = queue.removeFirst() + guard let task = dequeueNextTask() else { + continue + } if shouldSkip(path: task.path, depth: task.depth) { hotline.searchSession(self, didEmit: [], processed: processedCount, pending: queue.count) continue @@ -1359,7 +1370,7 @@ final class FileSearchSession { break } - processListing(children, depth: task.depth) + processListing(children, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) await applyBackoff() } @@ -1371,27 +1382,61 @@ final class FileSearchSession { isCancelled = true } - private func processListing(_ items: [FileInfo], depth: Int) { + private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) { guard let hotline else { return } var matches: [FileInfo] = [] + var folderEntries: [(file: FileInfo, isHot: Bool)] = [] + var hasFileMatch = false for file in items { - if nameMatchesQuery(file.name) { + let matchesName = nameMatchesQuery(file.name) + + if matchesName { matches.append(file) + if !file.isFolder { + hasFileMatch = true + } } if file.isFolder { - enqueueFolder(file, depth: depth + 1) + folderEntries.append((file, matchesName)) + } + } + + var remainingBurst = 0 + if config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) { + remainingBurst = config.hotBurstLimit + } + + if remainingBurst > 0 { + var candidateIndices: [Int] = [] + for index in folderEntries.indices where !folderEntries[index].isHot { + candidateIndices.append(index) + } + + if !candidateIndices.isEmpty { + candidateIndices.shuffle() + for index in candidateIndices { + folderEntries[index].isHot = true + remainingBurst -= 1 + if remainingBurst == 0 { + break + } + } } } + for entry in folderEntries { + enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot) + } + hotline.searchSession(self, didEmit: matches, processed: processedCount, pending: queue.count) } - private func enqueueFolder(_ folder: FileInfo, depth: Int) { + private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) { guard !isCancelled else { return } guard depth <= config.maxDepth else { return } @@ -1403,7 +1448,43 @@ final class FileSearchSession { return } - queue.append(FolderTask(path: path, depth: depth)) + queue.append(FolderTask(path: path, depth: depth, isHot: markHot)) + } + + private func dequeueNextTask() -> FolderTask? { + guard !queue.isEmpty else { + return nil + } + + if queue.count == 1 { + return queue.removeFirst() + } + + let currentDepth = queue[0].depth + var lastSameDepthIndex = 0 + var hotIndices: [Int] = [] + + for index in 0.. Bool { diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 4498dc9..5d06b02 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -263,7 +263,7 @@ struct FilesView: View { if path.isEmpty { return "/" } - return "/" + path.joined(separator: "/") + "/" + return path.joined(separator: "/") } private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { @@ -464,7 +464,10 @@ struct FilesView: View { Label("Upload", systemImage: "arrow.up") } .help("Upload") - .disabled(model.access?.contains(.canUploadFiles) != true) + .disabled( + model.access?.contains(.canUploadFiles) != true || + (model.fileSearchStatus.isActive && !(selection?.isFolder ?? false)) + ) Button { if let selectedFile = selection { -- cgit From 2df678753e2e6fe309de6b546925540f89e3d680 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 23 Oct 2025 12:11:22 -0700 Subject: Add a basic file cache that gets searched over first. TTL on entries. Force clear cache but holding shift+enter to search. Don't cache upload/dropbox folders. Don't search in .app folders. --- Hotline/Models/FileInfo.swift | 26 +++++-- Hotline/Models/Hotline.swift | 162 +++++++++++++++++++++++++++++++++++++++--- Hotline/macOS/FilesView.swift | 8 +++ 3 files changed, 182 insertions(+), 14 deletions(-) (limited to 'Hotline/macOS/FilesView.swift') diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift index 2da7378..62ec831 100644 --- a/Hotline/Models/FileInfo.swift +++ b/Hotline/Models/FileInfo.swift @@ -15,18 +15,36 @@ import UniformTypeIdentifiers let isUnavailable: Bool var isDropboxFolder: Bool { - guard self.isFolder, - (self.name.range(of: "upload", options: [.caseInsensitive]) != nil) || (self.name.range(of: "drop box", options: [.caseInsensitive]) != nil) - else { + guard self.isFolder else { return false } - return true + + if self.name.range(of: "upload", options: [.caseInsensitive]) != nil { + return true + } + + if self.name.range(of: "dropbox", options: [.caseInsensitive]) != nil { + return true + } + + if self.name.range(of: "drop box", options: [.caseInsensitive]) != nil { + return true + } + + return false } var isAdminDropboxFolder: Bool { self.isDropboxFolder && (self.name.range(of: "admin", options: [.caseInsensitive]) != nil) } + var isAppBundle: Bool { + guard self.isFolder else { + return false + } + return self.name.lowercased().hasSuffix(".app") + } + var expanded: Bool = false var children: [FileInfo]? = nil diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 1e2c6c5..054a1bc 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -15,6 +15,10 @@ struct FileSearchConfig: Equatable { var loopRepetitionLimit: Int = 4 /// Number of child folders that get prioritized after a matching parent is found. var hotBurstLimit: Int = 2 + /// Maximum age, in seconds, that a cached folder listing is treated as fresh. + var cacheTTL: TimeInterval = 60 * 15 + /// Upper bound on the number of folder listings retained in the cache. + var maxCachedFolders: Int = 1024 * 3 } enum FileSearchStatus: Equatable { @@ -171,6 +175,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var fileSearchCurrentPath: [String]? = nil @ObservationIgnored private var fileSearchSession: FileSearchSession? = nil @ObservationIgnored private var fileSearchResultKeys: Set = [] + private struct FileListCacheEntry { + let files: [FileInfo] + let timestamp: Date + } + @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:] var news: [NewsInfo] = [] private var newsLookup: [String:NewsInfo] = [:] var newsLoaded: Bool = false @@ -307,7 +316,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.client.sendPostMessageBoard(text: text) } - @MainActor func getFileList(path: [String] = [], suppressErrors: Bool = false) async -> [FileInfo] { + @MainActor func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async -> [FileInfo] { + if preferCache, let cached = cachedFileList(for: path, ttl: fileSearchConfig.cacheTTL, allowStale: false) { + return cached.items + } + return await withCheckedContinuation { [weak self] continuation in self?.client.sendGetFileList(path: path, suppressErrors: suppressErrors) { [weak self] files in let parentFile = self?.findFile(in: self?.files ?? [], at: path) @@ -326,6 +339,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self?.files = newFiles } + self?.storeFileListInCache(newFiles, for: path) continuation.resume(returning: newFiles) } } @@ -430,6 +444,119 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega private func searchPathKey(for path: [String]) -> String { path.joined(separator: "\u{001F}") } + + private func shouldBypassFileCache(for path: [String]) -> Bool { + guard let folderName = path.last else { + return false + } + + let trimmed = folderName.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.range(of: "upload", options: [.caseInsensitive]) != nil { + return true + } + + if trimmed.range(of: "dropbox", options: [.caseInsensitive]) != nil { + return true + } + + if trimmed.range(of: "drop box", options: [.caseInsensitive]) != nil { + return true + } + + return false + } + + private func cachedFileList(for path: [String], ttl: TimeInterval, allowStale: Bool) -> (items: [FileInfo], isFresh: Bool)? { + guard ttl > 0 else { + return nil + } + + if shouldBypassFileCache(for: path) { + return nil + } + + let key = searchPathKey(for: path) + guard let entry = fileListCache[key] else { + return nil + } + + let age = Date().timeIntervalSince(entry.timestamp) + let isFresh = age <= ttl + if !allowStale && !isFresh { + return nil + } + + return (entry.files, isFresh) + } + + private func storeFileListInCache(_ files: [FileInfo], for path: [String]) { + guard fileSearchConfig.cacheTTL > 0 else { + return + } + + if shouldBypassFileCache(for: path) { + return + } + + let key = searchPathKey(for: path) + fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date()) + pruneFileListCacheIfNeeded() + } + + private func pruneFileListCacheIfNeeded() { + let limit = fileSearchConfig.maxCachedFolders + guard limit > 0, fileListCache.count > limit else { + return + } + + let excess = fileListCache.count - limit + guard excess > 0 else { return } + + let sortedKeys = fileListCache.sorted { lhs, rhs in + lhs.value.timestamp < rhs.value.timestamp + } + + for index in 0.. (items: [FileInfo], isFresh: Bool)? { + cachedFileList(for: path, ttl: ttl, allowStale: true) + } + + @MainActor func clearFileListCache() { + guard !fileListCache.isEmpty else { + return + } + + fileListCache.removeAll(keepingCapacity: false) + } @MainActor func getNewsArticle(id articleID: UInt, at path: [String], flavor: String) async -> String? { return await withCheckedContinuation { [weak self] continuation in @@ -748,7 +875,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @MainActor func uploadFile(url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) { let fileName = fileURL.lastPathComponent - + guard fileURL.isFileURL, !fileName.isEmpty else { print("NOT A FILE URL?") return @@ -789,9 +916,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega print("FILE IS EMPTY??") return } - + print("FILE SIZE: \(fileSize) NAME: \(fileName) PATH: \(path)") - + + invalidateFileListCache(for: path, includingAncestors: true) + self.client.sendUploadFile(name: fileName, path: path) { [weak self] success, uploadReferenceNumber in print("UPLOAD REFERENCE: \(String(describing: uploadReferenceNumber))") @@ -809,11 +938,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega let transfer = TransferInfo(id: referenceNumber, title: fileName, size: fileSize) transfer.uploadCallback = callback self.transfers.append(transfer) - + fileClient.start() } } - + } @MainActor func getFileDetails(_ fileName: String, path: [String]) async -> FileDetails? { @@ -834,9 +963,12 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega if path.count > 1 { fullPath = Array(path[0.. Date: Fri, 24 Oct 2025 14:21:34 -0700 Subject: Add cmd-f to activate search in Files. --- Hotline/macOS/FilesView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'Hotline/macOS/FilesView.swift') diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index f9fb36f..3447b28 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -215,6 +215,7 @@ struct FilesView: View { @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 { @@ -436,7 +437,8 @@ struct FilesView: View { .frame(maxWidth: .infinity) } } - .searchable(text: $searchText, placement: .automatic, prompt: "Search") + .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search") + .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) .toolbar { ToolbarItemGroup(placement: .automatic) { Button { -- cgit From cf113ea053334175b93770b025c1f7d22eda6eab Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 24 Oct 2025 22:47:44 -0700 Subject: A first pass at chat persistence. Also some chat UI cleanup. --- Hotline.xcodeproj/project.pbxproj | 12 ++ Hotline/Hotline/HotlineClient.swift | 4 + Hotline/Managers/ChatStore.swift | 246 ++++++++++++++++++++++++ Hotline/Models/ChatMessage.swift | 45 +++++ Hotline/Models/Hotline.swift | 132 +++++++++++-- Hotline/iOS/ChatView.swift | 16 ++ Hotline/macOS/ChatView.swift | 288 ++++++++++++++++++++--------- Hotline/macOS/FilesView.swift | 3 +- Hotline/macOS/MessageBoardEditorView.swift | 2 +- Hotline/macOS/ServerAgreementView.swift | 27 ++- Hotline/macOS/ServerMessageView.swift | 2 +- Hotline/macOS/ServerView.swift | 4 +- Hotline/macOS/SettingsView.swift | 19 ++ 13 files changed, 671 insertions(+), 129 deletions(-) create mode 100644 Hotline/Managers/ChatStore.swift (limited to 'Hotline/macOS/FilesView.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 642a329..a31f398 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -74,6 +74,7 @@ DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */; }; DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; + DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */; }; DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC87F062C5010E80060FADF /* HotlineExtensions.swift */; }; DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */; platformFilters = (macos, ); }; DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */; platformFilters = (macos, ); }; @@ -161,6 +162,7 @@ DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationExtensions.swift; sourceTree = ""; }; DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.swift; sourceTree = ""; }; DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = ""; }; DAC87F062C5010E80060FADF /* HotlineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineExtensions.swift; sourceTree = ""; }; DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdate.swift; sourceTree = ""; }; DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateView.swift; sourceTree = ""; }; @@ -247,6 +249,7 @@ DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */, DA4F2BF92B1A516F00D8ADDC /* iOS */, DAE734F72B2E4126000C56F6 /* macOS */, + DAC6B2DF2EAC6236004E2CBA /* Managers */, DA4B8F3B2EA6FB5F00CBFD53 /* State */, DADDB2892B22B2C60024040D /* Models */, DABFCC262B1530AE009F40D2 /* Hotline */, @@ -329,6 +332,14 @@ path = Utility; sourceTree = ""; }; + DAC6B2DF2EAC6236004E2CBA /* Managers */ = { + isa = PBXGroup; + children = ( + DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */, + ); + path = Managers; + sourceTree = ""; + }; DADDB2892B22B2C60024040D /* Models */ = { isa = PBXGroup; children = ( @@ -534,6 +545,7 @@ DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, + DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 3c6a643..0e54872 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -209,7 +209,11 @@ class HotlineClient: NetSocketDelegate { } @MainActor func disconnect() { + let wasConnected = self.connectionStatus != .disconnected self.reset() + if wasConnected { + self.updateConnectionStatus(.disconnected) + } } // MARK: - Packets diff --git a/Hotline/Managers/ChatStore.swift b/Hotline/Managers/ChatStore.swift new file mode 100644 index 0000000..daf8ca0 --- /dev/null +++ b/Hotline/Managers/ChatStore.swift @@ -0,0 +1,246 @@ +import Foundation +import CryptoKit +import Security + +actor ChatStore { + static let shared = ChatStore() + static let historyClearedNotification = Notification.Name("ChatStoreHistoryCleared") + + struct SessionKey: Hashable { + let address: String + let port: Int + + var identifier: String { "\(address):\(port)" } + } + + struct Metadata: Codable { + let address: String + let port: Int + var serverName: String? + var createdAt: Date + var updatedAt: Date + + mutating func update(serverName: String?, timestamp: Date) { + if let serverName, !serverName.isEmpty { + self.serverName = serverName + } + self.updatedAt = timestamp + } + } + + struct Entry: Codable { + let id: UUID + let body: String + let username: String? + let type: String + let date: Date + } + + struct LoadResult { + let entries: [Entry] + let metadata: Metadata? + } + + private struct LogFile: Codable { + var metadata: Metadata + var entries: [Entry] + } + + private enum StoreError: Error { + case encryptionFailed + case invalidCombinedCiphertext + case keyGenerationFailed + } + + private let keychainKey = "chatlog-encryption-key" + private let applicationFolderName = "Hotline" + private let logsFolderName = "ChatLogs" + private let fileExtension = "hlchat" + private let maxEntries = 2000 + + private var cache: [SessionKey: LogFile] = [:] + private var cachedDirectory: URL? + private var cachedKey: SymmetricKey? + + func append(entry: Entry, for key: SessionKey, serverName: String?) async { + do { + var logFile = try loadLogFile(for: key) ?? newLogFile(for: key, serverName: serverName) + + logFile.entries.append(entry) + if logFile.entries.count > maxEntries { + logFile.entries = Array(logFile.entries.suffix(maxEntries)) + } + + logFile.metadata.update(serverName: serverName, timestamp: entry.date) + cache[key] = logFile + + try persist(logFile, for: key) + } + catch { + print("ChatStore: failed to append entry —", error) + } + } + + func loadHistory(for key: SessionKey, limit: Int? = nil) async -> LoadResult { + do { + let logFile = try loadLogFile(for: key) + guard let logFile else { + return LoadResult(entries: [], metadata: nil) + } + + let entries: [Entry] + if let limit, limit < logFile.entries.count { + entries = Array(logFile.entries.suffix(limit)) + } + else { + entries = logFile.entries + } + + return LoadResult(entries: entries, metadata: logFile.metadata) + } + catch { + print("ChatStore: failed to load history —", error) + return LoadResult(entries: [], metadata: nil) + } + } + + func clearAll() async { + let fm = FileManager.default + if let dir = try? directoryURL(), fm.fileExists(atPath: dir.path) { + do { + try fm.removeItem(at: dir) + } + catch { + print("ChatStore: failed to clear chat logs —", error) + } + } + + cache.removeAll() + cachedDirectory = nil + + await MainActor.run { + NotificationCenter.default.post(name: Self.historyClearedNotification, object: nil) + } + } + + static func digest(for string: String) -> String { + let hash = SHA256.hash(data: Data(string.utf8)) + return hash.compactMap { String(format: "%02x", $0) }.joined() + } + + private func newLogFile(for key: SessionKey, serverName: String?) -> LogFile { + let now = Date() + var metadata = Metadata(address: key.address, port: key.port, serverName: nil, createdAt: now, updatedAt: now) + metadata.update(serverName: serverName, timestamp: now) + let logFile = LogFile(metadata: metadata, entries: []) + cache[key] = logFile + return logFile + } + + private func loadLogFile(for key: SessionKey) throws -> LogFile? { + if let cached = cache[key] { + return cached + } + + let url = try fileURL(for: key) + let fm = FileManager.default + guard fm.fileExists(atPath: url.path) else { + return nil + } + + let encryptedData = try Data(contentsOf: url) + let decryptedData = try decrypt(encryptedData) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let logFile = try decoder.decode(LogFile.self, from: decryptedData) + cache[key] = logFile + return logFile + } + + private func persist(_ logFile: LogFile, for key: SessionKey) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(logFile) + let encrypted = try encrypt(data) + let url = try fileURL(for: key) + let fm = FileManager.default + let directory = url.deletingLastPathComponent() + if !fm.fileExists(atPath: directory.path) { + try fm.createDirectory(at: directory, withIntermediateDirectories: true) + } + try encrypted.write(to: url, options: .atomic) + } + + private func directoryURL() throws -> URL { + if let cachedDirectory { + return cachedDirectory + } + + let fm = FileManager.default + guard let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + throw StoreError.keyGenerationFailed + } + + let appDirectory = base.appendingPathComponent(applicationFolderName, isDirectory: true) + let logsDirectory = appDirectory.appendingPathComponent(logsFolderName, isDirectory: true) + + if !fm.fileExists(atPath: appDirectory.path) { + try fm.createDirectory(at: appDirectory, withIntermediateDirectories: true) + } + if !fm.fileExists(atPath: logsDirectory.path) { + try fm.createDirectory(at: logsDirectory, withIntermediateDirectories: true) + } + + cachedDirectory = logsDirectory + return logsDirectory + } + + private func fileURL(for key: SessionKey) throws -> URL { + let directory = try directoryURL() + let digest = Self.digest(for: key.identifier) + return directory.appendingPathComponent(digest).appendingPathExtension(fileExtension) + } + + private func encrypt(_ data: Data) throws -> Data { + let key = try symmetricKey() + let sealedBox = try AES.GCM.seal(data, using: key) + guard let combined = sealedBox.combined else { + throw StoreError.encryptionFailed + } + return combined + } + + private func decrypt(_ data: Data) throws -> Data { + let key = try symmetricKey() + let sealedBox = try AES.GCM.SealedBox(combined: data) + return try AES.GCM.open(sealedBox, using: key) + } + + private func symmetricKey() throws -> SymmetricKey { + if let cachedKey { + return cachedKey + } + + if let stored = DAKeychain.shared[keychainKey], + let storedData = Data(base64Encoded: stored), + storedData.count == 32 { + let key = SymmetricKey(data: storedData) + cachedKey = key + return key + } + + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw StoreError.keyGenerationFailed + } + + let data = Data(bytes) + let key = SymmetricKey(data: data) + DAKeychain.shared[keychainKey] = data.base64EncodedString() + cachedKey = key + return key + } +} diff --git a/Hotline/Models/ChatMessage.swift b/Hotline/Models/ChatMessage.swift index e585cdf..365f91f 100644 --- a/Hotline/Models/ChatMessage.swift +++ b/Hotline/Models/ChatMessage.swift @@ -3,8 +3,53 @@ import SwiftUI enum ChatMessageType { case agreement case status + case joined + case left case message case server + case signOut +} + +extension ChatMessageType { + var storageKey: String { + switch self { + case .agreement: + return "agreement" + case .status: + return "status" + case .joined: + return "joined" + case .left: + return "left" + case .message: + return "message" + case .server: + return "server" + case .signOut: + return "signOut" + } + } + + init?(storageKey: String) { + switch storageKey { + case "agreement": + self = .agreement + case "status": + self = .status + case "joined": + self = .joined + case "left": + self = .left + case "message": + self = .message + case "server": + self = .server + case "signOut": + self = .signOut + default: + return nil + } + } } struct ChatMessage: Identifiable { diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 054a1bc..8cdeccd 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -191,8 +191,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var unreadPublicChat: Bool = false var errorDisplayed: Bool = false var errorMessage: String? = nil - + @ObservationIgnored var bannerClient: HotlineFilePreviewClient? + @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? #if os(macOS) var bannerImage: NSImage? = nil #elseif os(iOS) @@ -206,6 +209,10 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.trackerClient = trackerClient self.client = client self.client.delegate = self + + self.chatHistoryObserver = NotificationCenter.default.addObserver(forName: ChatStore.historyClearedNotification, object: nil, queue: .main) { [weak self] _ in + self?.handleChatHistoryCleared() + } } // MARK: - @@ -233,7 +240,13 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.serverName = server.name self.username = username self.iconID = iconID - + + let key = sessionKey(for: server) + self.chatSessionKey = key + self.restoredChatSessionKey = nil + self.chat = [] + self.restoreChatHistory(for: key) + self.client.login(address: server.address, port: server.port, login: server.login, password: server.password, username: username, iconID: UInt16(iconID)) { [weak self] err, serverName, serverVersion in self?.serverVersion = serverVersion ?? 123 if serverName != nil { @@ -258,10 +271,16 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @MainActor func disconnect() { self.client.disconnect() self.bannerClient?.cancel() - self.fileSearchSession?.cancel() + self.fileSearchSession?.cancel() self.fileSearchSession = nil self.resetFileSearchState() } + + deinit { + if let observer = chatHistoryObserver { + NotificationCenter.default.removeObserver(observer) + } + } @MainActor func sendAgree() { self.client.sendAgree(username: self.username, iconID: UInt16(self.iconID), options: .none) @@ -1077,12 +1096,19 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @MainActor func hotlineStatusChanged(status: HotlineClientStatus) { print("Hotline: Connection status changed to: \(status)") - + let previousStatus = self.status + let previousTitle = self.serverTitle + if status == .disconnected { + if previousStatus == .loggedIn { + let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date()) + self.recordChatMessage(message, persist: true, display: false) + } + self.serverVersion = 123 self.serverName = nil self.access = nil - + self.fileSearchSession?.cancel() self.fileSearchSession = nil self.resetFileSearchState() @@ -1095,30 +1121,34 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.filesLoaded = false self.news = [] self.newsLoaded = false - + self.bannerImage = nil if let b = self.bannerClient { b.cancel() self.bannerClient = nil } - + self.deleteAllTransfers() + + self.chatSessionKey = nil + self.restoredChatSessionKey = nil } else if status == .loggedIn { if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { SoundEffectPlayer.shared.playSoundEffect(.loggedIn) } } - + self.status = status } func hotlineGetUserInfo() -> (String, UInt16) { return (self.username, UInt16(self.iconID)) } - + func hotlineReceivedAgreement(text: String) { - self.chat.append(ChatMessage(text: text, type: .agreement, date: Date())) + let message = ChatMessage(text: text, type: .agreement, date: Date()) + self.recordChatMessage(message, persist: false) } func hotlineReceivedNewsPost(message: String) { @@ -1139,9 +1169,10 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega if Prefs.shared.playChatSound && Prefs.shared.playChatSound { SoundEffectPlayer.shared.playSoundEffect(.serverMessage) } - + print("Hotline: received server message:\n\(message)") - self.chat.append(ChatMessage(text: message, type: .server, date: Date())) + let chatMessage = ChatMessage(text: message, type: .server, date: Date()) + self.recordChatMessage(chatMessage) } func hotlineReceivedPrivateMessage(userID: UInt16, message: String) { @@ -1173,7 +1204,8 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega if Prefs.shared.playSounds && Prefs.shared.playChatSound { SoundEffectPlayer.shared.playSoundEffect(.chatMessage) } - self.chat.append(ChatMessage(text: message, type: .message, date: Date())) + let chatMessage = ChatMessage(text: message, type: .message, date: Date()) + self.recordChatMessage(chatMessage) self.unreadPublicChat = true } @@ -1208,11 +1240,12 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega func hotlineUserDisconnected(userID: UInt16) { if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { let user = self.users.remove(at: existingUserIndex) - + if Prefs.shared.showJoinLeaveMessages { - self.chat.append(ChatMessage(text: "\(user.name) left", type: .status, date: Date())) + let chatMessage = ChatMessage(text: "\(user.name) left", type: .left, date: Date()) + self.recordChatMessage(chatMessage) } - + if Prefs.shared.playSounds && Prefs.shared.playLeaveSound { SoundEffectPlayer.shared.playSoundEffect(.userLogout) } @@ -1356,11 +1389,71 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } // MARK: - Utilities - + + private func sessionKey(for server: Server) -> ChatStore.SessionKey { + ChatStore.SessionKey(address: server.address.lowercased(), port: server.port) + } + + private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) { + if display { + self.chat.append(message) + } + + let shouldPersist = persist && message.type != .agreement + guard shouldPersist, let key = chatSessionKey else { return } + let entry = ChatStore.Entry( + id: message.id, + body: message.text, + username: message.username, + type: message.type.storageKey, + date: message.date + ) + let serverName = self.serverName ?? self.server?.name + + Task { + await ChatStore.shared.append(entry: entry, for: key, serverName: serverName) + } + } + + private func restoreChatHistory(for key: ChatStore.SessionKey) { + if restoredChatSessionKey == key { + return + } + + Task { [weak self] in + guard let self else { return } + let result = await ChatStore.shared.loadHistory(for: key) + await MainActor.run { + guard self.chatSessionKey == key, self.restoredChatSessionKey != key else { return } + let currentMessages = self.chat + let historyMessages = result.entries.compactMap { entry -> ChatMessage? in + guard let chatType = ChatMessageType(storageKey: entry.type) else { return nil } + let renderedText: String + if chatType == .message, let username = entry.username, !username.isEmpty { + renderedText = "\(username): \(entry.body)" + } + else { + renderedText = entry.body + } + return ChatMessage(text: renderedText, type: chatType, date: entry.date) + } + self.chat = historyMessages + currentMessages + self.unreadPublicChat = false + self.restoredChatSessionKey = key + } + } + } + + private func handleChatHistoryCleared() { + self.chat = [] + self.unreadPublicChat = false + self.restoredChatSessionKey = nil + } + func updateServerTitle() { self.serverTitle = self.serverName ?? self.server?.name ?? server?.address ?? "Server" } - + private func addOrUpdateHotlineUser(_ user: HotlineUser) { print("Hotline: users: \n\(self.users)") if let i = self.users.firstIndex(where: { $0.id == user.id }) { @@ -1377,7 +1470,8 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega print("Hotline: added user: \(user.name)") self.users.append(User(hotlineUser: user)) if Prefs.shared.showJoinLeaveMessages { - self.chat.append(ChatMessage(text: "\(user.name) joined", type: .status, date: Date())) + let chatMessage = ChatMessage(text: "\(user.name) joined", type: .joined, date: Date()) + self.recordChatMessage(chatMessage) } } } diff --git a/Hotline/iOS/ChatView.swift b/Hotline/iOS/ChatView.swift index dadd5e6..0ad41e0 100644 --- a/Hotline/iOS/ChatView.swift +++ b/Hotline/iOS/ChatView.swift @@ -63,6 +63,22 @@ struct ChatView: View { } .padding() } + else if msg.type == .signOut { + HStack { + Spacer() + Label { + Text(msg.text) + .font(.footnote) + } icon: { + Image(systemName: "arrow.up.circle.fill") + } + .labelStyle(.titleAndIcon) + .foregroundStyle(Color.red) + .opacity(0.8) + Spacer() + } + .padding(.vertical, 6) + } else { HStack(alignment: .firstTextBaseline) { if let username = msg.username { diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index cd84369..12e6c94 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -4,6 +4,150 @@ enum FocusedField: Int, Hashable { case chatInput } +struct ChatStatusMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "arrow.right") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .foregroundStyle(.primary) + .frame(width: 14, height: 14) + + Text(message.text) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.disabled) + + Spacer() + } + .opacity(0.3) + } +} + + +struct ChatJoinedMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .center, spacing: 8) { + 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: 8) { + 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 { + // if msg.text.isImageURL() { + // HStack(alignment: .bottom) { + // Text("**\(username):** ") + // + // let imageURL = URL(string: msg.text)! + // AsyncImage(url: imageURL) { phase in + // switch phase { + // case .failure: + // Text(LocalizedStringKey(msg.text.convertLinksToMarkdown())) + // .lineSpacing(4) + // .multilineTextAlignment(.leading) + // .textSelection(.enabled) + // .tint(Color("Link Color")) + // case .success(let img): + // Link(destination: imageURL) { + // img + // .resizable() + // .scaledToFit() + // .frame(maxWidth: 250, maxHeight: 150, alignment: .leading) + // .onAppear { + // reader.scrollTo(bottomID, anchor: .bottom) + // } + // } + // default: + // ProgressView().controlSize(.small) + // } + // } + // + // Spacer() + // } + // } + // else { + Text(LocalizedStringKey("**\(username):** \(message.text)".convertingLinksToMarkdown())) + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .tint(Color("Link Color")) + // } + } + else { + Text(message.text) + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .tint(Color("Link Color")) + } + Spacer() + } + } +} + struct ChatView: View { @Environment(Hotline.self) private var model: Hotline @Environment(\.colorScheme) var colorScheme @@ -20,7 +164,7 @@ struct ChatView: View { @State private var showingExporter: Bool = false @State private var chatDocument: TextFile = TextFile() - + var body: some View { NavigationStack { ScrollViewReader { reader in @@ -29,99 +173,64 @@ struct ChatView: View { // MARK: Scroll View GeometryReader { gm in ScrollView(.vertical) { - LazyVStack(alignment: .leading) { + LazyVStack(alignment: .leading, spacing: 8) { ForEach(model.chat) { msg in - - // MARK: Agreement if msg.type == .agreement { -#if os(iOS) + VStack(alignment: .center, spacing: 16) { if let bannerImage = self.model.bannerImage { - Image(uiImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 3)) + 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) + } } -#endif - ServerAgreementView(text: msg.text) - .padding(.bottom, 16) + + ServerAgreementView(text: msg.text) + } + .padding(.vertical, 24) } // MARK: Server Message else if msg.type == .server { ServerMessageView(message: msg.text) - .padding(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) } // MARK: Status else if msg.type == .status { - HStack { - Spacer() - Text(msg.text) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.disabled) - .opacity(0.3) - Spacer() - } - .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) + ChatStatusMessageView(message: msg) + } + 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 { - HStack(alignment: .firstTextBaseline) { - if let username = msg.username { -// if msg.text.isImageURL() { -// HStack(alignment: .bottom) { -// Text("**\(username):** ") -// -// let imageURL = URL(string: msg.text)! -// AsyncImage(url: imageURL) { phase in -// switch phase { -// case .failure: -// Text(LocalizedStringKey(msg.text.convertLinksToMarkdown())) -// .lineSpacing(4) -// .multilineTextAlignment(.leading) -// .textSelection(.enabled) -// .tint(Color("Link Color")) -// case .success(let img): -// Link(destination: imageURL) { -// img -// .resizable() -// .scaledToFit() -// .frame(maxWidth: 250, maxHeight: 150, alignment: .leading) -// .onAppear { -// reader.scrollTo(bottomID, anchor: .bottom) -// } -// } -// default: -// ProgressView().controlSize(.small) -// } -// } -// -// Spacer() -// } -// } -// else { - Text(LocalizedStringKey("**\(username):** \(msg.text)".convertingLinksToMarkdown())) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) -// } - } - else { - Text(msg.text) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) - } - Spacer() - } - .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) + ChatMessageView(message: msg) } } } .padding() - + VStack(spacing: 0) {}.id(bottomID) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -136,6 +245,9 @@ struct ChatView: View { .onChange(of: gm.size) { reader.scrollTo(bottomID, anchor: .bottom) } + .onChange(of: self.model.bannerImage) { + reader.scrollTo(bottomID, anchor: .bottom) + } } // MARK: Input Divider @@ -160,7 +272,7 @@ struct ChatView: View { .frame(maxWidth: .infinity, minHeight: 28) .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) .overlay(alignment: .leadingLastTextBaseline) { - Image(systemName: "chevron.right").opacity(0.4).offset(x: 16) + Image(systemName: "chevron.right").fontWeight(.semibold).opacity(0.4).offset(x: 16) } .onContinuousHover { phase in switch phase { @@ -178,17 +290,17 @@ struct ChatView: View { } } .background(Color(nsColor: .textBackgroundColor)) -// .toolbar { -// ToolbarItem(placement: .primaryAction) { -// Button { -// if prepareChatDocument() { -// showingExporter = true -// } -// } label: { -// Image(systemName: "square.and.arrow.up") -// }.help("Save Chat...") -// } -// } + // .toolbar { + // ToolbarItem(placement: .primaryAction) { + // Button { + // if prepareChatDocument() { + // 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): diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 3447b28..00a4e1e 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -234,7 +234,7 @@ struct FilesView: View { private var searchStatusMessage: String? { switch model.fileSearchStatus { - case .searching(let processed, let pending): + case .searching(let processed, _): let scanned = processed == 1 ? "folder" : "folders" return "Searched \(processed) \(scanned)..." case .completed(let processed): @@ -248,7 +248,6 @@ struct FilesView: View { if model.fileSearchResults.isEmpty { return nil } - let count = model.fileSearchResults.count let folderWord = processed == 1 ? "folder" : "folders" return "Search cancelled" case .failed(let message): diff --git a/Hotline/macOS/MessageBoardEditorView.swift b/Hotline/macOS/MessageBoardEditorView.swift index 029616f..474384e 100644 --- a/Hotline/macOS/MessageBoardEditorView.swift +++ b/Hotline/macOS/MessageBoardEditorView.swift @@ -20,7 +20,7 @@ struct MessageBoardEditorView: View { let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - await model.postToMessageBoard(text: cleanedText) + model.postToMessageBoard(text: cleanedText) let _ = await model.getMessageBoard() // let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) diff --git a/Hotline/macOS/ServerAgreementView.swift b/Hotline/macOS/ServerAgreementView.swift index a1f96d9..e72de7d 100644 --- a/Hotline/macOS/ServerAgreementView.swift +++ b/Hotline/macOS/ServerAgreementView.swift @@ -1,6 +1,6 @@ import SwiftUI -fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 280 +fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 340 struct ServerAgreementView: View { let text: String @@ -42,7 +42,7 @@ struct ServerAgreementView: View { #elseif os(macOS) .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) #endif - .overlay( + .overlay(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) { Group { if !expandable || expanded { @@ -54,25 +54,22 @@ struct ServerAgreementView: View { expanded = true } }, label: { - Color.black - .opacity(0.00001) - .frame(width: 32, height: 32) - .overlay( - Image(systemName: "arrow.up.left.and.arrow.down.right") - .resizable() - .scaledToFit() - .fontWeight(.semibold) - .frame(width: 12, height: 12) - .foregroundColor(.primary.opacity(0.8)) - , alignment: .center) + 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) } } } - , alignment: .bottomTrailing) - .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } diff --git a/Hotline/macOS/ServerMessageView.swift b/Hotline/macOS/ServerMessageView.swift index 8413a87..6c3c60e 100644 --- a/Hotline/macOS/ServerMessageView.swift +++ b/Hotline/macOS/ServerMessageView.swift @@ -24,7 +24,7 @@ struct ServerMessageView: View { #elseif os(macOS) .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) #endif - .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 49413fe..77b6dc8 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -307,9 +307,7 @@ struct ServerView: View { .keyboardShortcut(.cancelAction) Button("Connect") { - Task { - await connectToServer() - } + connectToServer() } .controlSize(.regular) diff --git a/Hotline/macOS/SettingsView.swift b/Hotline/macOS/SettingsView.swift index 81a2f8d..d301abd 100644 --- a/Hotline/macOS/SettingsView.swift +++ b/Hotline/macOS/SettingsView.swift @@ -3,6 +3,7 @@ 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() @@ -24,9 +25,27 @@ struct GeneralSettingsView: View { 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 -- cgit From 53686e30592fee566585e391738adee8b2ef2137 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 25 Oct 2025 15:26:05 -0700 Subject: Fixed some warnings. Add basic markdown formatting support to chat messages. Add some metadata support for chat log (though we're not using this yet). --- Hotline/Hotline/HotlineProtocol.swift | 4 +-- Hotline/Managers/ChatStore.swift | 26 ++++++++++++++++ Hotline/Models/ChatMessage.swift | 9 ++++-- Hotline/Models/Hotline.swift | 8 +++-- Hotline/Utility/FoundationExtensions.swift | 34 ++++++++++++++++----- Hotline/Utility/SwiftUIExtensions.swift | 26 ++++++++++++++++ Hotline/macOS/ChatView.swift | 49 ++++-------------------------- Hotline/macOS/FilesView.swift | 3 +- 8 files changed, 98 insertions(+), 61 deletions(-) (limited to 'Hotline/macOS/FilesView.swift') diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index ff91672..9b3a812 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -241,11 +241,11 @@ struct HotlineAccount: Identifiable { } if fieldType == .userLogin { - self.login = field.getObfuscatedString()! + self.login = field.getObfuscatedString() ?? "" } if fieldType == .userPassword { - self.password = field.getObfuscatedString()! + self.password = field.getObfuscatedString() ?? "" } if fieldType == .userAccess, let opts = field.getUInt64(){ diff --git a/Hotline/Managers/ChatStore.swift b/Hotline/Managers/ChatStore.swift index daf8ca0..59c9484 100644 --- a/Hotline/Managers/ChatStore.swift +++ b/Hotline/Managers/ChatStore.swift @@ -28,12 +28,23 @@ actor ChatStore { } } + struct EntryMetadata: Codable { + var images: [ImageMetadata]? + + struct ImageMetadata: Codable { + let url: String + let width: CGFloat? + let height: CGFloat? + } + } + struct Entry: Codable { let id: UUID let body: String let username: String? let type: String let date: Date + var metadata: EntryMetadata? } struct LoadResult { @@ -81,6 +92,21 @@ actor ChatStore { } } + func updateMetadata(_ metadata: EntryMetadata, for entryID: UUID, key: SessionKey) async { + do { + guard var logFile = try loadLogFile(for: key) else { return } + + if let index = logFile.entries.firstIndex(where: { $0.id == entryID }) { + logFile.entries[index].metadata = metadata + cache[key] = logFile + try persist(logFile, for: key) + } + } + catch { + print("ChatStore: failed to update metadata —", error) + } + } + func loadHistory(for key: SessionKey, limit: Int? = nil) async -> LoadResult { do { let logFile = try loadLogFile(for: key) diff --git a/Hotline/Models/ChatMessage.swift b/Hotline/Models/ChatMessage.swift index 744a5d2..1c210ce 100644 --- a/Hotline/Models/ChatMessage.swift +++ b/Hotline/Models/ChatMessage.swift @@ -48,19 +48,22 @@ extension ChatMessageType { } struct ChatMessage: Identifiable { - let id = UUID() - + let id: UUID + let text: String let type: ChatMessageType let date: Date let username: String? + var metadata: ChatStore.EntryMetadata? static let parser = /^\s*([^\:]+):\s*([\s\S]+)$/ init(text: String, type: ChatMessageType, date: Date) { + self.id = UUID() self.type = type self.date = date - + self.metadata = nil + if type == .message, let match = text.firstMatch(of: ChatMessage.parser) { diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 08b79a1..3783ba0 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1445,7 +1445,9 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega else { renderedText = entry.body } - return ChatMessage(text: renderedText, type: chatType, date: entry.date) + var message = ChatMessage(text: renderedText, type: chatType, date: entry.date) + message.metadata = entry.metadata + return message } self.chat = historyMessages + currentMessages self.lastPersistedMessageType = historyMessages.last?.type @@ -1530,12 +1532,12 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega guard let parent = self.findNews(in: self.news, at: path), !parent.children.isEmpty else { return nil } - + return parent.children.first { child in guard let childArticleID = child.articleID else { return false } - + return child.type == .article && child.articleID == childArticleID } } diff --git a/Hotline/Utility/FoundationExtensions.swift b/Hotline/Utility/FoundationExtensions.swift index 9fc9ae7..90a3032 100644 --- a/Hotline/Utility/FoundationExtensions.swift +++ b/Hotline/Utility/FoundationExtensions.swift @@ -8,6 +8,13 @@ enum Endianness { extension String { + func markdownToAttributedString() -> AttributedString { + let markdownText = self.convertingLinksToMarkdown() + let attr = (try? AttributedString(markdown: markdownText, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(self) + + return attr + } + func convertToAttributedStringWithLinks() -> AttributedString { let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: self) let matches = self.ranges(of: RegularExpressions.relaxedLink) @@ -54,17 +61,28 @@ extension String { } func convertingLinksToMarkdown() -> String { - var cp = String(self) - cp.replace(RegularExpressions.relaxedLink) { match -> String in +// var cp = String(self) + + self.replacing(RegularExpressions.relaxedLink) { match in let linkText = self[match.range] - var injectedScheme = "https://" - if let _ = try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText) { - injectedScheme = "" - } - return "[\(linkText)](\(injectedScheme)\(linkText))" + // Only add https:// if the link doesn't already have a scheme + let hasScheme = (try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText)) != nil + let url = hasScheme ? String(linkText) : "https://\(linkText)" + + return "[\(linkText)](\(url))" } - return cp + +// cp.replace(RegularExpressions.relaxedLink) { match -> String in +// let linkText = self[match.range] +// var injectedScheme = "https://" +// if let _ = try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText) { +// injectedScheme = "" +// } +// +// return "[\(linkText)](\(injectedScheme)\(linkText))" +// } +// return cp } } diff --git a/Hotline/Utility/SwiftUIExtensions.swift b/Hotline/Utility/SwiftUIExtensions.swift index 3b850e0..12217b2 100644 --- a/Hotline/Utility/SwiftUIExtensions.swift +++ b/Hotline/Utility/SwiftUIExtensions.swift @@ -1,7 +1,33 @@ import SwiftUI +import Foundation extension Color { init(hex: Int, opacity: Double = 1.0) { self.init(red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0, blue: Double(hex & 0xFF) / 255.0, opacity: opacity) } } + +extension AttributedString { + func setHangingIndent(firstLineHeadIndent: CGFloat = 0, otherLinesHeadIndent: CGFloat) -> AttributedString { +// var blah = self + +// guard var paragraph = self.paragraphStyle else { +// return +// } + + var p = self.paragraphStyle?.mutableCopy() as? NSMutableParagraphStyle + p?.headIndent = otherLinesHeadIndent + p?.firstLineHeadIndent = firstLineHeadIndent + +// paragraph.headIndent = otherLinesHeadIndent // indent for lines 2+ +// paragraph.firstLineHeadIndent = firstLineHeadIndent // usually 0 + + var blah = self + + + blah.paragraphStyle = p + + return blah + } +} + diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index a45e7ed..2ad9816 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -69,58 +69,21 @@ struct ChatDisconnectedMessageView: View { struct ChatMessageView: View { let message: ChatMessage - + var body: some View { HStack(alignment: .firstTextBaseline) { if let username = message.username { - // if msg.text.isImageURL() { - // HStack(alignment: .bottom) { - // Text("**\(username):** ") - // - // let imageURL = URL(string: msg.text)! - // AsyncImage(url: imageURL) { phase in - // switch phase { - // case .failure: - // Text(LocalizedStringKey(msg.text.convertLinksToMarkdown())) - // .lineSpacing(4) - // .multilineTextAlignment(.leading) - // .textSelection(.enabled) - // .tint(Color("Link Color")) - // case .success(let img): - // Link(destination: imageURL) { - // img - // .resizable() - // .scaledToFit() - // .frame(maxWidth: 250, maxHeight: 150, alignment: .leading) - // .onAppear { - // reader.scrollTo(bottomID, anchor: .bottom) - // } - // } - // default: - // ProgressView().controlSize(.small) - // } - // } - // - // Spacer() - // } - // } - // else { - Text(LocalizedStringKey("**\(username):** \(message.text)".convertingLinksToMarkdown())) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) - // } + Text("\(username): ").fontWeight(.semibold) + Text(message.text.markdownToAttributedString()) } else { Text(message.text) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) } Spacer() } + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .tint(Color("Link Color")) } } diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 00a4e1e..42232af 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -244,11 +244,10 @@ struct FilesView: View { return "No files found in \(processed) \(folderWord)" } return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" - case .cancelled(let processed): + case .cancelled(_): if model.fileSearchResults.isEmpty { return nil } - let folderWord = processed == 1 ? "folder" : "folders" return "Search cancelled" case .failed(let message): return "Search failed: \(message)" -- cgit 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.xcodeproj/project.pbxproj | 104 +++- 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 +-- 35 files changed, 2966 insertions(+), 2931 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/FilesView.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 6368fe0..a173d55 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -8,7 +8,7 @@ /* Begin PBXBuildFile section */ 11A726082BE0672A000C1DA7 /* FileDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726072BE0672A000C1DA7 /* FileDetails.swift */; }; - 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726092BE06759000C1DA7 /* FileDetailsView.swift */; }; + 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726092BE06759000C1DA7 /* FileDetailsView.swift */; platformFilters = (macos, ); }; 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */; }; DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D698C2B1E7CF700C71DF5 /* UsersView.swift */; platformFilter = ios; }; DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D698E2B1E841600C71DF5 /* MessageBoardView.swift */; platformFilter = ios; }; @@ -27,6 +27,11 @@ DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */; platformFilter = ios; }; DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */; }; DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; platformFilter = ios; }; + DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */; platformFilters = (macos, ); }; + DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689D2EB073A400DCB941 /* IconSettingsView.swift */; platformFilters = (macos, ); }; + DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */; platformFilters = (macos, ); }; + DA5268A32EB0741B00DCB941 /* FolderItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A22EB0741B00DCB941 /* FolderItemView.swift */; platformFilters = (macos, ); }; + DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A42EB0743000DCB941 /* FileItemView.swift */; platformFilters = (macos, ); }; DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */; platformFilters = (macos, ); }; DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC742BE4888300034857 /* InstantMessage.swift */; }; DA55AC772BE589F700034857 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC762BE589F700034857 /* AboutView.swift */; platformFilters = (macos, ); }; @@ -118,6 +123,11 @@ DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = ""; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; + DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; + DA52689D2EB073A400DCB941 /* IconSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSettingsView.swift; sourceTree = ""; }; + DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundSettingsView.swift; sourceTree = ""; }; + DA5268A22EB0741B00DCB941 /* FolderItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderItemView.swift; sourceTree = ""; }; + DA5268A42EB0743000DCB941 /* FileItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileItemView.swift; sourceTree = ""; }; DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLinkPreview.swift; sourceTree = ""; }; DA55AC742BE4888300034857 /* InstantMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantMessage.swift; sourceTree = ""; }; DA55AC762BE589F700034857 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -230,6 +240,67 @@ path = iOS; sourceTree = ""; }; + DA52689A2EB0737400DCB941 /* Settings */ = { + isa = PBXGroup; + children = ( + DA2863D72B37AD1C00A7D050 /* SettingsView.swift */, + DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */, + DA52689D2EB073A400DCB941 /* IconSettingsView.swift */, + DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + DA5268A12EB073E600DCB941 /* Files */ = { + isa = PBXGroup; + children = ( + DAE735002B2E71F2000C56F6 /* FilesView.swift */, + DA5268A42EB0743000DCB941 /* FileItemView.swift */, + DA5268A22EB0741B00DCB941 /* FolderItemView.swift */, + 11A726092BE06759000C1DA7 /* FileDetailsView.swift */, + DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, + DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, + ); + path = Files; + sourceTree = ""; + }; + DA5268A62EB0762300DCB941 /* Board */ = { + isa = PBXGroup; + children = ( + DAE734FC2B2E65E9000C56F6 /* MessageBoardView.swift */, + DA69807D2BFD449B003E434B /* MessageBoardEditorView.swift */, + ); + path = Board; + sourceTree = ""; + }; + DA5268A72EB0812E00DCB941 /* Accounts */ = { + isa = PBXGroup; + children = ( + 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */, + ); + path = Accounts; + sourceTree = ""; + }; + DA5268A82EB081AF00DCB941 /* Chat */ = { + isa = PBXGroup; + children = ( + DAE734FE2B2E6750000C56F6 /* ChatView.swift */, + DA6549992BEC280E00EDB697 /* ServerMessageView.swift */, + DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */, + ); + path = Chat; + sourceTree = ""; + }; + DA5268A92EB081DE00DCB941 /* News */ = { + isa = PBXGroup; + children = ( + DAE735042B3218D8000C56F6 /* NewsView.swift */, + DA2D98112BF29D5F0027E4BD /* NewsItemView.swift */, + DA72A0DC2B4CD0BF00A0F48A /* NewsEditorView.swift */, + ); + path = News; + sourceTree = ""; + }; DA9CAFAE2B126D5700CDA197 = { isa = PBXGroup; children = ( @@ -370,26 +441,18 @@ DAE734F72B2E4126000C56F6 /* macOS */ = { isa = PBXGroup; children = ( + DA55AC762BE589F700034857 /* AboutView.swift */, + DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, + DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, DAE734F82B2E4185000C56F6 /* ServerView.swift */, - DAE734FE2B2E6750000C56F6 /* ChatView.swift */, - DAE735042B3218D8000C56F6 /* NewsView.swift */, - DA2D98112BF29D5F0027E4BD /* NewsItemView.swift */, - DA72A0DC2B4CD0BF00A0F48A /* NewsEditorView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, - DAE734FC2B2E65E9000C56F6 /* MessageBoardView.swift */, - DA69807D2BFD449B003E434B /* MessageBoardEditorView.swift */, - DAE735002B2E71F2000C56F6 /* FilesView.swift */, - DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, - DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, - 11A726092BE06759000C1DA7 /* FileDetailsView.swift */, - DA2863D72B37AD1C00A7D050 /* SettingsView.swift */, - DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, - DA55AC762BE589F700034857 /* AboutView.swift */, - DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, - DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */, - DA6549992BEC280E00EDB697 /* ServerMessageView.swift */, - 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */, + DA5268A82EB081AF00DCB941 /* Chat */, + DA5268A62EB0762300DCB941 /* Board */, + DA5268A92EB081DE00DCB941 /* News */, + DA5268A12EB073E600DCB941 /* Files */, + DA5268A72EB0812E00DCB941 /* Accounts */, + DA52689A2EB0737400DCB941 /* Settings */, ); path = macOS; sourceTree = ""; @@ -486,6 +549,7 @@ DA6300972B24036B0034CBFD /* HotlineClient.swift in Sources */, DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */, DA0D69912B1E894800C71DF5 /* FilesView.swift in Sources */, + DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */, DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, DAB4D8802B4C8E9A0048A05C /* URLAdditions.swift in Sources */, @@ -503,6 +567,7 @@ DA55AC772BE589F700034857 /* AboutView.swift in Sources */, DA72A0E22B4DAA4000A0F48A /* NewsArticle.swift in Sources */, DAE734FB2B2E41F9000C56F6 /* TrackerView.swift in Sources */, + DA5268A32EB0741B00DCB941 /* FolderItemView.swift in Sources */, DAE136BA2B9D1147007D8307 /* HotlinePanelView.swift in Sources */, 11A726082BE0672A000C1DA7 /* FileDetails.swift in Sources */, DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */, @@ -524,8 +589,10 @@ DA69807E2BFD449B003E434B /* MessageBoardEditorView.swift in Sources */, DAE735012B2E71F2000C56F6 /* FilesView.swift in Sources */, DA55AC792BE6A1AD00034857 /* RegularExpressions.swift in Sources */, + DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */, 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */, DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */, + DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */, DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */, DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */, DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */, @@ -544,6 +611,7 @@ DAB4D8842B4CABEF0048A05C /* DataAdditions.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */, DAE735052B3218D8000C56F6 /* NewsView.swift in Sources */, + DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */, DAE734F92B2E4185000C56F6 /* ServerView.swift in Sources */, DA2D98122BF29D5F0027E4BD /* NewsItemView.swift in Sources */, DADDB28B2B22B31F0024040D /* Tracker.swift in Sources */, 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