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/Models/ChatMessage.swift | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) (limited to 'Hotline/Models/ChatMessage.swift') 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 { -- cgit From fefede829c12d9f69db72d70231291d973b0c1a2 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 24 Oct 2025 23:02:12 -0700 Subject: Further polish on chat. Try not to persist back to back disconnect messages. --- Hotline/Models/ChatMessage.swift | 5 --- Hotline/Models/Hotline.swift | 14 +++++- Hotline/iOS/ChatView.swift | 11 ----- Hotline/macOS/ChatView.swift | 97 ++++++++++++++-------------------------- 4 files changed, 47 insertions(+), 80 deletions(-) (limited to 'Hotline/Models/ChatMessage.swift') diff --git a/Hotline/Models/ChatMessage.swift b/Hotline/Models/ChatMessage.swift index 365f91f..744a5d2 100644 --- a/Hotline/Models/ChatMessage.swift +++ b/Hotline/Models/ChatMessage.swift @@ -2,7 +2,6 @@ import SwiftUI enum ChatMessageType { case agreement - case status case joined case left case message @@ -15,8 +14,6 @@ extension ChatMessageType { switch self { case .agreement: return "agreement" - case .status: - return "status" case .joined: return "joined" case .left: @@ -34,8 +31,6 @@ extension ChatMessageType { switch storageKey { case "agreement": self = .agreement - case "status": - self = .status case "joined": self = .joined case "left": diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 8cdeccd..08b79a1 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -196,6 +196,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? + @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? #if os(macOS) var bannerImage: NSImage? = nil #elseif os(iOS) @@ -244,6 +245,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega let key = sessionKey(for: server) self.chatSessionKey = key self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil self.chat = [] self.restoreChatHistory(for: key) @@ -1132,6 +1134,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.chatSessionKey = nil self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil } else if status == .loggedIn { if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { @@ -1395,12 +1398,19 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) { + let shouldPersist = persist && message.type != .agreement + if shouldPersist, + message.type == .signOut, + lastPersistedMessageType == .signOut { + return + } + if display { self.chat.append(message) } - let shouldPersist = persist && message.type != .agreement guard shouldPersist, let key = chatSessionKey else { return } + self.lastPersistedMessageType = message.type let entry = ChatStore.Entry( id: message.id, body: message.text, @@ -1438,6 +1448,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega return ChatMessage(text: renderedText, type: chatType, date: entry.date) } self.chat = historyMessages + currentMessages + self.lastPersistedMessageType = historyMessages.last?.type self.unreadPublicChat = false self.restoredChatSessionKey = key } @@ -1448,6 +1459,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.chat = [] self.unreadPublicChat = false self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil } func updateServerTitle() { diff --git a/Hotline/iOS/ChatView.swift b/Hotline/iOS/ChatView.swift index 0ad41e0..e320dbf 100644 --- a/Hotline/iOS/ChatView.swift +++ b/Hotline/iOS/ChatView.swift @@ -52,17 +52,6 @@ struct ChatView: View { .frame(maxWidth: .infinity) .padding() } - else if msg.type == .status { - HStack { - Spacer() - Text(msg.text) - .lineLimit(1) - .truncationMode(.middle) - .opacity(0.3) - Spacer() - } - .padding() - } else if msg.type == .signOut { HStack { Spacer() diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index 12e6c94..a45e7ed 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -4,35 +4,11 @@ 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) { + HStack(alignment: .center, spacing: 4) { Image(systemName: "arrow.right") .resizable() .scaledToFit() @@ -56,7 +32,7 @@ struct ChatLeftMessageView: View { let message: ChatMessage var body: some View { - HStack(alignment: .center, spacing: 8) { + HStack(alignment: .center, spacing: 4) { Image(systemName: "arrow.left") .resizable() .scaledToFit() @@ -206,14 +182,9 @@ struct ChatView: View { } .padding(.vertical, 24) } - // MARK: Server Message else if msg.type == .server { ServerMessageView(message: msg.text) } - // MARK: Status - else if msg.type == .status { - ChatStatusMessageView(message: msg) - } else if msg.type == .joined { ChatJoinedMessageView(message: msg) } @@ -313,38 +284,38 @@ struct ChatView: View { } } - 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 - } +// 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 { -- 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/Models/ChatMessage.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