diff options
| -rw-r--r-- | Hotline.xcodeproj/project.pbxproj | 12 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineClient.swift | 4 | ||||
| -rw-r--r-- | Hotline/Managers/ChatStore.swift | 246 | ||||
| -rw-r--r-- | Hotline/Models/ChatMessage.swift | 45 | ||||
| -rw-r--r-- | Hotline/Models/Hotline.swift | 132 | ||||
| -rw-r--r-- | Hotline/iOS/ChatView.swift | 16 | ||||
| -rw-r--r-- | Hotline/macOS/ChatView.swift | 288 | ||||
| -rw-r--r-- | Hotline/macOS/FilesView.swift | 3 | ||||
| -rw-r--r-- | Hotline/macOS/MessageBoardEditorView.swift | 2 | ||||
| -rw-r--r-- | Hotline/macOS/ServerAgreementView.swift | 27 | ||||
| -rw-r--r-- | Hotline/macOS/ServerMessageView.swift | 2 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 4 | ||||
| -rw-r--r-- | Hotline/macOS/SettingsView.swift | 19 |
13 files changed, 671 insertions, 129 deletions
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 = "<group>"; }; DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.swift; sourceTree = "<group>"; }; DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; }; + DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = "<group>"; }; DAC87F062C5010E80060FADF /* HotlineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineExtensions.swift; sourceTree = "<group>"; }; DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdate.swift; sourceTree = "<group>"; }; DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateView.swift; sourceTree = "<group>"; }; @@ -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 = "<group>"; }; + DAC6B2DF2EAC6236004E2CBA /* Managers */ = { + isa = PBXGroup; + children = ( + DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */, + ); + path = Managers; + sourceTree = "<group>"; + }; 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 |