aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hotline.xcodeproj/project.pbxproj12
-rw-r--r--Hotline/Hotline/HotlineClient.swift4
-rw-r--r--Hotline/Managers/ChatStore.swift246
-rw-r--r--Hotline/Models/ChatMessage.swift45
-rw-r--r--Hotline/Models/Hotline.swift132
-rw-r--r--Hotline/iOS/ChatView.swift16
-rw-r--r--Hotline/macOS/ChatView.swift288
-rw-r--r--Hotline/macOS/FilesView.swift3
-rw-r--r--Hotline/macOS/MessageBoardEditorView.swift2
-rw-r--r--Hotline/macOS/ServerAgreementView.swift27
-rw-r--r--Hotline/macOS/ServerMessageView.swift2
-rw-r--r--Hotline/macOS/ServerView.swift4
-rw-r--r--Hotline/macOS/SettingsView.swift19
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