aboutsummaryrefslogtreecommitdiff
path: root/Hotline/Models
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-10-24 22:47:44 -0700
committerDustin Mierau <dustin@mierau.me>2025-10-24 22:47:44 -0700
commitcf113ea053334175b93770b025c1f7d22eda6eab (patch)
tree8f0037e7e9ad3aa592c8475d962d37eb0c867703 /Hotline/Models
parent75ebba6ad7e45e636cd5bb3400d25d040cabad6c (diff)
A first pass at chat persistence. Also some chat UI cleanup.
Diffstat (limited to 'Hotline/Models')
-rw-r--r--Hotline/Models/ChatMessage.swift45
-rw-r--r--Hotline/Models/Hotline.swift132
2 files changed, 158 insertions, 19 deletions
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)
}
}
}