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/Managers/ChatStore.swift | 246 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 Hotline/Managers/ChatStore.swift (limited to 'Hotline/Managers') 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 + } +} -- 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/Managers') 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 2caf86e633c1a121c8e23d068ac817ed8e80f6a7 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 8 Nov 2025 19:51:48 -0800 Subject: Moved SoundEffects to Managers. Updated connect form in ServerView. Changed some icons in context menus for bookmarks. --- Hotline.xcodeproj/project.pbxproj | 2 +- Hotline/Library/SoundEffects.swift | 41 ------------------ Hotline/Managers/SoundEffects.swift | 40 +++++++++++++++++ Hotline/macOS/ServerView.swift | 73 +++++++++++++++++--------------- Hotline/macOS/Trackers/TrackerView.swift | 7 ++- 5 files changed, 85 insertions(+), 78 deletions(-) delete mode 100644 Hotline/Library/SoundEffects.swift create mode 100644 Hotline/Managers/SoundEffects.swift (limited to 'Hotline/Managers') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 3e600a4..f91bcf8 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -441,7 +441,6 @@ children = ( DAB4D8832B4CABEF0048A05C /* Extensions.swift */, DA5268AA2EB11EA300DCB941 /* ColorArt.swift */, - DAE735062B3251B3000C56F6 /* SoundEffects.swift */, DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */, DA6980822BFFD06C003E434B /* BookmarkDocument.swift */, DA501BE02EBE844F001714F8 /* Views */, @@ -494,6 +493,7 @@ DAC6B2DF2EAC6236004E2CBA /* Managers */ = { isa = PBXGroup; children = ( + DAE735062B3251B3000C56F6 /* SoundEffects.swift */, DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */, ); path = Managers; diff --git a/Hotline/Library/SoundEffects.swift b/Hotline/Library/SoundEffects.swift deleted file mode 100644 index 4964b94..0000000 --- a/Hotline/Library/SoundEffects.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation -import AppKit - -enum SoundEffect: String { - case loggedIn = "logged-in" - case chatMessage = "chat-message" - case transferComplete = "transfer-complete" - case userLogin = "user-login" - case userLogout = "user-logout" - case newNews = "new-news" - case serverMessage = "server-message" - case error = "error" - - static var all: [SoundEffect] = [.loggedIn, .chatMessage, .transferComplete, .userLogin, .userLogout, .newNews, .serverMessage, .error] -} - -@Observable -class SoundEffects { - static let shared = SoundEffects() - - private var preloadedSounds: [SoundEffect: NSSound] = [:] - - private init() { - // Preload sound effects - for effect in SoundEffect.all { - if let soundFileURL = Bundle.main.url(forResource: effect.rawValue, withExtension: "aiff"), - let sound = NSSound(contentsOf: soundFileURL, byReference: true) { - sound.volume = 0.75 - self.preloadedSounds[effect] = sound - } - } - } - - static func play(_ name: SoundEffect) { - Self.shared.play(name) - } - - func play(_ name: SoundEffect) { - self.preloadedSounds[name]?.play() - } -} diff --git a/Hotline/Managers/SoundEffects.swift b/Hotline/Managers/SoundEffects.swift new file mode 100644 index 0000000..85a1c0e --- /dev/null +++ b/Hotline/Managers/SoundEffects.swift @@ -0,0 +1,40 @@ +import Foundation +import AppKit + +enum SoundEffect: String { + case loggedIn = "logged-in" + case chatMessage = "chat-message" + case transferComplete = "transfer-complete" + case userLogin = "user-login" + case userLogout = "user-logout" + case newNews = "new-news" + case serverMessage = "server-message" + case error = "error" + + static var all: [SoundEffect] = [.loggedIn, .chatMessage, .transferComplete, .userLogin, .userLogout, .newNews, .serverMessage, .error] +} + +class SoundEffects { + static let shared = SoundEffects() + + static func play(_ name: SoundEffect) { + Self.shared.play(name) + } + + private var preloadedSounds: [SoundEffect: NSSound] = [:] + + private init() { + // Preload sound effects + for effect in SoundEffect.all { + if let soundFileURL = Bundle.main.url(forResource: effect.rawValue, withExtension: "aiff"), + let sound = NSSound(contentsOf: soundFileURL, byReference: true) { + sound.volume = 0.75 + self.preloadedSounds[effect] = sound + } + } + } + + func play(_ name: SoundEffect) { + self.preloadedSounds[name]?.play() + } +} diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index d2c503f..44274ea 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -121,8 +121,10 @@ struct ServerView: View { self.connectForm Spacer() } -// .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle("Connect to Server") + .onAppear { + self.focusedField = .address + } } else if case .failed(let error) = model.status { VStack { @@ -250,40 +252,47 @@ struct ServerView: View { } var connectForm: some View { - Form { - HStack(alignment: .top, spacing: 10) { - Image("Server Large") - .resizable() - .scaledToFit() - .frame(width: 28, height: 28) + VStack(alignment: .center, spacing: 0) { + Form { + HStack(alignment: .top, spacing: 10) { + Image("Server Large") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Connect to Server") + Text("Enter the address of a Hotline server to connect to.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } - VStack(alignment: .leading) { - Text("Connect to Server") - Text("Enter the address of a Hotline server to connect to.") - .foregroundStyle(.secondary) - .font(.subheadline) + TextField(text: $connectAddress) { + Text("Address") } + .focused($focusedField, equals: .address) + + TextField(text: $connectLogin, prompt: Text("Optional")) { + Text("Login") + } + .focused($focusedField, equals: .login) + + SecureField(text: $connectPassword, prompt: Text("Optional")) { + Text("Password") + } + .focused($focusedField, equals: .password) } - - TextField(text: $connectAddress) { - Text("Address:") - } - .focused($focusedField, equals: .address) - - TextField(text: $connectLogin, prompt: Text("Optional")) { - Text("Login:") - } - .focused($focusedField, equals: .login) - SecureField(text: $connectPassword, prompt: Text("Optional")) { - Text("Password:") - } - .focused($focusedField, equals: .password) + .formStyle(.grouped) + .fixedSize(horizontal: false, vertical: true) HStack { - Button("Save...") { + Button { if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { connectNameSheetPresented = true } + } label: { + Image(systemName: "bookmark.fill") } .disabled(connectAddress.isEmpty) .controlSize(.regular) @@ -302,15 +311,12 @@ struct ServerView: View { Button("Connect") { connectToServer() } - .controlSize(.regular) .buttonStyle(.automatic) .keyboardShortcut(.defaultAction) } - .padding(.top, 8) + .padding(.horizontal, 20) } - .formStyle(.grouped) - .fixedSize(horizontal: false, vertical: true) .onChange(of: connectAddress) { let (a, p) = Server.parseServerAddressAndPort(connectAddress) server.address = a @@ -322,14 +328,11 @@ struct ServerView: View { .onChange(of: connectPassword) { server.password = connectPassword } - .onAppear { - focusedField = .address - } .frame(maxWidth: 380) .padding() .sheet(isPresented: $connectNameSheetPresented) { VStack(alignment: .leading) { - Text("Name this server bookmark:") + Text("Save Bookmark") .foregroundStyle(.secondary) .padding(.bottom, 4) TextField("Bookmark Name", text: $connectName) diff --git a/Hotline/macOS/Trackers/TrackerView.swift b/Hotline/macOS/Trackers/TrackerView.swift index dbbacaa..e0ca87d 100644 --- a/Hotline/macOS/Trackers/TrackerView.swift +++ b/Hotline/macOS/Trackers/TrackerView.swift @@ -480,7 +480,12 @@ struct TrackerView: View { Button { Bookmark.delete(bookmark, context: modelContext) } label: { - Label(bookmark.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") + if bookmark.type == .tracker { + Label("Delete Tracker", systemImage: "xmark") + } + else { + Label("Delete Bookmark", systemImage: "bookmark.slash") + } } } -- cgit