diff options
| author | Dustin Mierau <dustin@mierau.me> | 2024-01-15 20:20:47 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2024-01-15 20:20:47 -0800 |
| commit | 7c9f6686044035c022a17551151cf1cbed0dc665 (patch) | |
| tree | d41403b6f64d3375fdea8cee48d9db91c3ce26e0 /Hotline | |
| parent | faae9cd1b47c6f22f3bcf4b1a24a4f7df41af9ed (diff) | |
Add classic sound effects. Fix read user access bits. Better no content/no access views. Add Server menu. Add keep alive ping to server every 3 minutes.
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/Application.swift | 38 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineClient.swift | 38 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineProtocol.swift | 160 | ||||
| -rw-r--r-- | Hotline/Models/Hotline.swift | 27 | ||||
| -rw-r--r-- | Hotline/Sounds/chat-message.aiff | bin | 0 -> 4554 bytes | |||
| -rw-r--r-- | Hotline/Sounds/error.aiff | bin | 0 -> 4610 bytes | |||
| -rw-r--r-- | Hotline/Sounds/logged-in.aiff | bin | 0 -> 4166 bytes | |||
| -rw-r--r-- | Hotline/Sounds/new-news.aiff | bin | 0 -> 17514 bytes | |||
| -rw-r--r-- | Hotline/Sounds/server-message.aiff | bin | 0 -> 7914 bytes | |||
| -rw-r--r-- | Hotline/Sounds/transfer-complete.aiff | bin | 0 -> 21378 bytes | |||
| -rw-r--r-- | Hotline/Sounds/user-login.aiff | bin | 0 -> 6186 bytes | |||
| -rw-r--r-- | Hotline/Sounds/user-logout.aiff | bin | 0 -> 7080 bytes | |||
| -rw-r--r-- | Hotline/Utility/SoundEffects.swift | 42 | ||||
| -rw-r--r-- | Hotline/Utility/Utilities.swift | 2 | ||||
| -rw-r--r-- | Hotline/macOS/MessageBoardView.swift | 68 | ||||
| -rw-r--r-- | Hotline/macOS/NewsView.swift | 86 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 319 |
17 files changed, 555 insertions, 225 deletions
diff --git a/Hotline/Application.swift b/Hotline/Application.swift index e7445dc..fdada40 100644 --- a/Hotline/Application.swift +++ b/Hotline/Application.swift @@ -13,6 +13,10 @@ struct Application: App { #endif @State private var preferences = Prefs() + @State private var soundEffects = SoundEffectPlayer() + + @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? + @FocusedValue(\.activeServerState) private var activeServerState: ServerState? var body: some Scene { #if os(iOS) @@ -35,6 +39,7 @@ struct Application: App { ServerView(server: server) .frame(minWidth: 400, minHeight: 300) .environment(preferences) + .environment(soundEffects) } defaultValue: { Server(name: nil, description: nil, address: "") } @@ -47,6 +52,39 @@ struct Application: App { } .keyboardShortcut(.init("K"), modifiers: .command) } + CommandMenu("Server") { + Button("Disconnect") { + activeHotline?.disconnect() + } + .disabled(activeHotline?.status == .disconnected) + Divider() + Button("Broadcast Message...") { + // TODO: Implement broadcast message when user is allowed. + } + .disabled(true) + .keyboardShortcut(.init("B"), modifiers: .command) + Divider() + Button("Show Chat") { + activeServerState?.selection = .chat + } + .disabled(activeHotline?.status != .loggedIn) + .keyboardShortcut(.init("1"), modifiers: .command) + Button("Show News") { + activeServerState?.selection = .news + } + .disabled(activeHotline?.status != .loggedIn) + .keyboardShortcut(.init("2"), modifiers: .command) + Button("Show Message Board") { + activeServerState?.selection = .board + } + .disabled(activeHotline?.status != .loggedIn) + .keyboardShortcut(.init("3"), modifiers: .command) + Button("Show Files") { + activeServerState?.selection = .files + } + .disabled(activeHotline?.status != .loggedIn) + .keyboardShortcut(.init("4"), modifiers: .command) + } } // MARK: Settings Window diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 93d2485..2048149 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -82,13 +82,14 @@ class HotlineClient: NetSocketDelegate { private var serverAddress: String? = nil private var serverPort: UInt16? = nil -// private var connection: NWConnection? private var transactionLog: [UInt32:(HotlineTransactionType, ((HotlineTransaction, HotlineTransactionError?) -> Void)?)] = [:] private var socket: NetSocket? private var stage: HotlineClientStage = .handshake private var packet: HotlineTransaction? = nil + private var serverVersion: UInt16? = nil private var loginDetails: HotlineLogin? = nil + private var keepAliveTimer: Timer? = nil init() {} @@ -100,6 +101,7 @@ class HotlineClient: NetSocketDelegate { } @MainActor func netsocketDisconnected(socket: NetSocket, error: Error?) { + self.reset() self.updateConnectionStatus(.disconnected) self.stage = .handshake } @@ -135,6 +137,14 @@ class HotlineClient: NetSocketDelegate { self.socket?.write(HotlineClient.HandshakePacket) } + @MainActor private func startKeepAliveTimer() { + self.keepAliveTimer = Timer.scheduledTimer(withTimeInterval: 60 * 3, repeats: true) { [weak self] _ in + DispatchQueue.main.async { [weak self] in + self?.sendKeepAlive() + } + } + } + @MainActor func receiveHandshake() { guard let socket = self.socket, self.stage == .handshake, @@ -164,23 +174,31 @@ class HotlineClient: NetSocketDelegate { let session = self.loginDetails! self.loginDetails = nil - self.sendLogin(login: session.login ?? "", password: session.password ?? "", username: session.username, iconID: session.iconID) { err, serverName, serverVersion in + self.sendLogin(login: session.login ?? "", password: session.password ?? "", username: session.username, iconID: session.iconID) { [weak self] err, serverName, serverVersion in + self?.serverVersion = serverVersion + self?.startKeepAliveTimer() session.callback?(err, serverName, serverVersion) } self.receivePacket() } - @MainActor func disconnect() { + @MainActor private func reset() { self.transactionLog = [:] - self.packet = nil + self.keepAliveTimer?.invalidate() + self.keepAliveTimer = nil + self.socket?.close() self.socket?.delegate = nil self.socket = nil } + @MainActor func disconnect() { + self.reset() + } + // MARK: - Packets @MainActor private func sendPacket(_ t: HotlineTransaction, callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) { @@ -634,6 +652,18 @@ class HotlineClient: NetSocketDelegate { } } + @MainActor private func sendKeepAlive() { + print("HotlineClient: Sending keep alive") + if let v = self.serverVersion, v >= 185 { + let t = HotlineTransaction(type: .connectionKeepAlive) + self.sendPacket(t) + } + else { + let t = HotlineTransaction(type: .getUserNameList) + self.sendPacket(t) + } + } + // MARK: - Utility diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 5897fe3..3be58ac 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -18,47 +18,135 @@ struct HotlineUserOptions: OptionSet { struct HotlineUserAccessOptions: OptionSet { let rawValue: UInt64 - static let canRenameFolders = HotlineUserAccessOptions(rawValue: 1 << 0) - static let canDeleteFolders = HotlineUserAccessOptions(rawValue: 1 << 1) - static let canCreateFolders = HotlineUserAccessOptions(rawValue: 1 << 2) - static let canMoveFiles = HotlineUserAccessOptions(rawValue: 1 << 3) - static let canRenameFiles = HotlineUserAccessOptions(rawValue: 1 << 4) - static let canDownloadFiles = HotlineUserAccessOptions(rawValue: 1 << 5) - static let canUploadFiles = HotlineUserAccessOptions(rawValue: 1 << 6) - static let canDeleteFiles = HotlineUserAccessOptions(rawValue: 1 << 7) + static func accessIndexToBit(_ index: Int) -> Int { + return 63 - index + } + + static func printAccessOptions(_ val: HotlineUserAccessOptions) { + func formatBinaryString(_ binaryString: String) -> String { + var formattedString = "" + for (index, char) in binaryString.reversed().enumerated() { + if index % 8 == 0 && index != 0 { + formattedString.append("_") + } + formattedString.append(char) + } + return String(formattedString.reversed()) + } + + var formattedBits = String(val.rawValue, radix: 2) + if formattedBits.count < 64 { + formattedBits = String(repeating: "0", count: 64 - formattedBits.count) + formattedBits + } + formattedBits = formatBinaryString(formattedBits) + + print("Access Options for \(formattedBits):") + print("") + print("File System Maintenance") + print("Can download files: \(val.contains(.canDownloadFiles))") + print("Can download folders: \(val.contains(.canDownloadFolders))") + print("Can upload files: \(val.contains(.canUploadFiles))") + print("Can upload folders: \(val.contains(.canUploadFolders))") + print("Can upload anywhere: \(val.contains(.canUploadAnywhere))") + print("Can delete files: \(val.contains(.canDeleteFiles))") + print("Can rename files: \(val.contains(.canRenameFiles))") + print("Can move files: \(val.contains(.canMoveFiles))") + print("Can comment files: \(val.contains(.canSetFileComment))") + print("Can create folders: \(val.contains(.canCreateFolders))") + print("Can delete folders: \(val.contains(.canDeleteFolders))") + print("Can rename folders: \(val.contains(.canRenameFolders))") + print("Can move folders: \(val.contains(.canMoveFolders))") + print("Can comment folders: \(val.contains(.canSetFolderComment))") + print("Can view dropboxes: \(val.contains(.canViewDropBoxes))") + print("Can make aliases: \(val.contains(.canMakeAliases))") + + print("") + print("User Maintenance") + print("Can create accounts: \(val.contains(.canCreateUsers))") + print("Can delete accounts: \(val.contains(.canDeleteUsers))") + print("Can read accounts: \(val.contains(.canOpenUsers))") + print("Can modify accounts: \(val.contains(.canModifyUsers))") + print("Can get user info: \(val.contains(.canGetClientInfo))") + print("Can disconnect users: \(val.contains(.canDisconnectUsers))") + print("Cannot be disconnected: \(val.contains(.cantBeDisconnected))") + + print("") + print("Messaging") + print("Can send private messages: \(val.contains(.canSendPrivateMessages))") + print("Can broadcast: \(val.contains(.canBroadcast))") + + print("") + print("News") + print("Can read message board: \(val.contains(.canReadMessageBoard))") + print("Can post message board: \(val.contains(.canPostMessageBoard))") + print("Can delete news articles: \(val.contains(.canDeleteNewsArticles))") + print("Can create news categories: \(val.contains(.canCreateNewsCategories))") + print("Can delete news categories: \(val.contains(.canDeleteNewsCategories))") + print("Can create news bundles: \(val.contains(.canCreateNewsFolders))") + print("Can delete news bundles: \(val.contains(.canDeleteNewsFolders))") + + print("") + print("Chat") + print("Can initiate private chat: \(val.contains(.canCreateChat))") + print("Can read chat: \(val.contains(.canReadChat))") + print("Can send chat: \(val.contains(.canSendChat))") + + print("") + print("Miscellaneous") + print("Can use any name: \(val.contains(.canUseAnyName))") + print("Don't show agreement: \(val.contains(.canSkipAgreement))") + print("Can change own password: \(val.contains(.canChangeOwnPassword))") + + print("Can close chat: \(val.contains(.canCloseChat))") + print("Can shown in list: \(val.contains(.canShownInList))") + print("") + } - static let canDeleteUsers = HotlineUserAccessOptions(rawValue: 1 << 8) - static let canCreateUsers = HotlineUserAccessOptions(rawValue: 1 << 9) - static let canSendChat = HotlineUserAccessOptions(rawValue: 1 << 13) - static let canReadChat = HotlineUserAccessOptions(rawValue: 1 << 14) - static let canMoveFolders = HotlineUserAccessOptions(rawValue: 1 << 15) + static let canDeleteFiles = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(0)) + static let canUploadFiles = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(1)) + static let canDownloadFiles = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(2)) + static let canRenameFiles = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(3)) + static let canMoveFiles = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(4)) + static let canCreateFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(5)) + static let canDeleteFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(6)) + static let canRenameFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(7)) - static let cannotBeDisconnected = HotlineUserAccessOptions(rawValue: 1 << 16) - static let canDisconnectUsers = HotlineUserAccessOptions(rawValue: 1 << 17) - static let canPostNews = HotlineUserAccessOptions(rawValue: 1 << 18) - static let canReadNews = HotlineUserAccessOptions(rawValue: 1 << 19) - static let canModifyUsers = HotlineUserAccessOptions(rawValue: 1 << 22) - static let canReadUsers = HotlineUserAccessOptions(rawValue: 1 << 23) + static let canMoveFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(8)) + static let canReadChat = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(9)) + static let canSendChat = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(10)) + static let canCreateChat = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(11)) + static let canCloseChat = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(12)) + static let canShownInList = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(13)) + static let canCreateUsers = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(14)) + static let canDeleteUsers = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(15)) - static let canMakeAliases = HotlineUserAccessOptions(rawValue: 1 << 24) - static let canViewDropBoxes = HotlineUserAccessOptions(rawValue: 1 << 25) - static let canCommentFolders = HotlineUserAccessOptions(rawValue: 1 << 26) - static let canCommentFiles = HotlineUserAccessOptions(rawValue: 1 << 27) - static let dontShowAgreement = HotlineUserAccessOptions(rawValue: 1 << 28) - static let canUseAnyName = HotlineUserAccessOptions(rawValue: 1 << 29) - static let canUploadAnywhere = HotlineUserAccessOptions(rawValue: 1 << 30) - static let canGetUserInfo = HotlineUserAccessOptions(rawValue: 1 << 31) + static let canOpenUsers = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(16)) + static let canModifyUsers = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(17)) + static let canChangeOwnPassword = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(18)) + static let canSendPrivateMessages = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(19)) + static let canReadMessageBoard = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(20)) + static let canPostMessageBoard = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(21)) + static let canDisconnectUsers = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(22)) + static let cantBeDisconnected = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(23)) - static let canDownloadFolders = HotlineUserAccessOptions(rawValue: 1 << 32) - static let canUploadFolders = HotlineUserAccessOptions(rawValue: 1 << 33) - static let canDeleteNewsFolders = HotlineUserAccessOptions(rawValue: 1 << 34) - static let canCreateNewsFolders = HotlineUserAccessOptions(rawValue: 1 << 35) - static let canDeleteNewsCategories = HotlineUserAccessOptions(rawValue: 1 << 36) - static let canCreateNewsCategories = HotlineUserAccessOptions(rawValue: 1 << 37) - static let canDeleteNewsArticles = HotlineUserAccessOptions(rawValue: 1 << 38) - static let canBroadcast = HotlineUserAccessOptions(rawValue: 1 << 39) + static let canGetClientInfo = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(24)) + static let canUploadAnywhere = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(25)) + static let canUseAnyName = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(26)) + static let canSkipAgreement = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(27)) + static let canSetFileComment = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(28)) + static let canSetFolderComment = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(29)) + static let canViewDropBoxes = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(30)) + static let canMakeAliases = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(31)) - static let canSendMessages = HotlineUserAccessOptions(rawValue: 1 << 47) + static let canBroadcast = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(32)) + static let canDeleteNewsArticles = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(33)) + static let canCreateNewsCategories = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(34)) + static let canDeleteNewsCategories = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(35)) + static let canCreateNewsFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(36)) + static let canDeleteNewsFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(37)) + static let canUploadFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(38)) + static let canDownloadFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(39)) + static let canSendMessages = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(40)) } struct HotlineServer: Identifiable, Hashable { diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 814ade5..77d4d59 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -4,6 +4,7 @@ import SwiftUI final class Hotline: HotlineClientDelegate, HotlineFileClientDelegate { let trackerClient: HotlineTrackerClient let client: HotlineClient + let soundEffects: SoundEffectPlayer = SoundEffectPlayer() #if os(macOS) static func getClassicIcon(_ index: Int) -> NSImage? { @@ -176,11 +177,7 @@ final class Hotline: HotlineClientDelegate, HotlineFileClientDelegate { self.updateServerTitle() } } - var serverVersion: UInt16? { - didSet { - self.updateServerTitle() - } - } + var serverVersion: UInt16 = 123 var serverName: String? { didSet { self.updateServerTitle() @@ -192,6 +189,7 @@ final class Hotline: HotlineClientDelegate, HotlineFileClientDelegate { var access: HotlineUserAccessOptions? var agreed: Bool = false + var currentUser: User? = nil var users: [User] = [] var chat: [ChatMessage] = [] var messageBoard: [String] = [] @@ -247,7 +245,8 @@ final class Hotline: HotlineClientDelegate, HotlineFileClientDelegate { self.iconID = iconID 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 + print("Server info:", serverName, serverVersion) + self?.serverVersion = serverVersion ?? 123 if serverName != nil { self?.serverName = serverName } @@ -682,7 +681,7 @@ final class Hotline: HotlineClientDelegate, HotlineFileClientDelegate { print("Hotline: Connection status changed to: \(status)") if status == .disconnected { - self.serverVersion = nil + self.serverVersion = 123 self.serverName = nil self.access = nil @@ -703,6 +702,9 @@ final class Hotline: HotlineClientDelegate, HotlineFileClientDelegate { self.deleteAllTransfers() } + else if status == .loggedIn { + soundEffects.playSoundEffect(.loggedIn) + } self.status = status } @@ -721,6 +723,7 @@ final class Hotline: HotlineClientDelegate, HotlineFileClientDelegate { } func hotlineReceivedChatMessage(message: String) { + soundEffects.playSoundEffect(.chatMessage) self.chat.append(ChatMessage(text: message, type: .message, date: Date())) } @@ -756,12 +759,14 @@ final class Hotline: HotlineClientDelegate, HotlineFileClientDelegate { if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { let user = self.users.remove(at: existingUserIndex) self.chat.append(ChatMessage(text: "\(user.name) left", type: .status, date: Date())) + + soundEffects.playSoundEffect(.userLogout) } } func hotlineReceivedUserAccess(options: HotlineUserAccessOptions) { print("Hotline: got access options") - print(options, options.contains(.canSendChat), options.contains(.canBroadcast)) + HotlineUserAccessOptions.printAccessOptions(options) self.access = options } @@ -838,6 +843,8 @@ final class Hotline: HotlineClientDelegate, HotlineFileClientDelegate { let transfer = self.transfers[i] transfer.fileURL = at transfer.downloadCallback?(transfer, at) + + soundEffects.playSoundEffect(.transferComplete) } if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) { @@ -857,6 +864,10 @@ final class Hotline: HotlineClientDelegate, HotlineFileClientDelegate { self.users[i] = User(hotlineUser: user) } else { + if !self.users.isEmpty { + soundEffects.playSoundEffect(.userLogin) + } + print("Hotline: added user: \(user.name)") self.users.append(User(hotlineUser: user)) self.chat.append(ChatMessage(text: "\(user.name) joined", type: .status, date: Date())) diff --git a/Hotline/Sounds/chat-message.aiff b/Hotline/Sounds/chat-message.aiff Binary files differnew file mode 100644 index 0000000..73ea391 --- /dev/null +++ b/Hotline/Sounds/chat-message.aiff diff --git a/Hotline/Sounds/error.aiff b/Hotline/Sounds/error.aiff Binary files differnew file mode 100644 index 0000000..8a9f7f6 --- /dev/null +++ b/Hotline/Sounds/error.aiff diff --git a/Hotline/Sounds/logged-in.aiff b/Hotline/Sounds/logged-in.aiff Binary files differnew file mode 100644 index 0000000..5f59de0 --- /dev/null +++ b/Hotline/Sounds/logged-in.aiff diff --git a/Hotline/Sounds/new-news.aiff b/Hotline/Sounds/new-news.aiff Binary files differnew file mode 100644 index 0000000..5c56582 --- /dev/null +++ b/Hotline/Sounds/new-news.aiff diff --git a/Hotline/Sounds/server-message.aiff b/Hotline/Sounds/server-message.aiff Binary files differnew file mode 100644 index 0000000..152bebe --- /dev/null +++ b/Hotline/Sounds/server-message.aiff diff --git a/Hotline/Sounds/transfer-complete.aiff b/Hotline/Sounds/transfer-complete.aiff Binary files differnew file mode 100644 index 0000000..b22e0ae --- /dev/null +++ b/Hotline/Sounds/transfer-complete.aiff diff --git a/Hotline/Sounds/user-login.aiff b/Hotline/Sounds/user-login.aiff Binary files differnew file mode 100644 index 0000000..0dcfb6f --- /dev/null +++ b/Hotline/Sounds/user-login.aiff diff --git a/Hotline/Sounds/user-logout.aiff b/Hotline/Sounds/user-logout.aiff Binary files differnew file mode 100644 index 0000000..8999edc --- /dev/null +++ b/Hotline/Sounds/user-logout.aiff diff --git a/Hotline/Utility/SoundEffects.swift b/Hotline/Utility/SoundEffects.swift new file mode 100644 index 0000000..426bfa2 --- /dev/null +++ b/Hotline/Utility/SoundEffects.swift @@ -0,0 +1,42 @@ +import Foundation +import AVFAudio + +enum SoundEffects: String { + case loggedIn = "logged-in" + case chatMessage = "chat-message" + case transferComplete = "transfer-complete" + case userLogin = "user-login" + case userLogout = "user-logout" +} + +@Observable +class SoundEffectPlayer: NSObject, AVAudioPlayerDelegate { + var activeSounds: [AVAudioPlayer] = [] + + func playSoundEffect(_ name: SoundEffects) { + // Load a local sound file + guard let soundFileURL = Bundle.main.url( + forResource: name.rawValue, + withExtension: "aiff" + ) else { + return + } + + do { + let soundEffect = try AVAudioPlayer(contentsOf: soundFileURL) + soundEffect.delegate = self + + self.activeSounds.append(soundEffect) + + soundEffect.volume = 0.75 + soundEffect.play() + } + catch {} + } + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + if let i = self.activeSounds.firstIndex(of: player) { + self.activeSounds.remove(at: i) + } + } +} diff --git a/Hotline/Utility/Utilities.swift b/Hotline/Utility/Utilities.swift deleted file mode 100644 index fbf2875..0000000 --- a/Hotline/Utility/Utilities.swift +++ /dev/null @@ -1,2 +0,0 @@ -import Foundation - diff --git a/Hotline/macOS/MessageBoardView.swift b/Hotline/macOS/MessageBoardView.swift index e7fdeb5..27b4be7 100644 --- a/Hotline/macOS/MessageBoardView.swift +++ b/Hotline/macOS/MessageBoardView.swift @@ -9,34 +9,51 @@ struct MessageBoardView: View { var body: some View { NavigationStack { - ScrollView { - LazyVStack(alignment: .leading) { - ForEach(model.messageBoard, id: \.self) { - Text($0) - .lineLimit(100) - .lineSpacing(4) - .padding() - .textSelection(.enabled) - Divider() + if model.access?.contains(.canReadMessageBoard) != false { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(model.messageBoard, id: \.self) { + Text($0) + .lineLimit(100) + .lineSpacing(4) + .padding() + .textSelection(.enabled) + Divider() + } } + Spacer() } - Spacer() - } - .task { - if !model.messageBoardLoaded { - let _ = await model.getMessageBoard() + .task { + if !model.messageBoardLoaded { + let _ = await model.getMessageBoard() + } } - } - .overlay { - if !model.messageBoardLoaded { - VStack { - ProgressView() - .controlSize(.large) + .overlay { + if !model.messageBoardLoaded { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) } - .frame(maxWidth: .infinity) } + .background(Color(nsColor: .textBackgroundColor)) + } + else { + VStack { + Text("No Message Board") + .bold() + .foregroundStyle(.secondary) + .font(.title3) + Text("This server has the message board turned off.") + .foregroundStyle(.tertiary) + .font(.system(size: 13)) + } + .padding() } - .background(Color(nsColor: .textBackgroundColor)) + + + } .sheet(isPresented: $composerDisplayed) { TextEditor(text: $composerText) @@ -65,6 +82,11 @@ struct MessageBoardView: View { } } } +// .alert("Hello", isPresented: $showPermissionAlert) { +// Button("OK") { +// showPermissionAlert.toggle() +// } +// } message: { Text("WHAT") } .toolbar { ToolbarItem(placement:.primaryAction) { Button { @@ -72,6 +94,8 @@ struct MessageBoardView: View { } label: { Image(systemName: "square.and.pencil") } + .disabled(model.access?.contains(.canPostMessageBoard) == false) + .help("Post to Message Board") } } } diff --git a/Hotline/macOS/NewsView.swift b/Hotline/macOS/NewsView.swift index bfc0cc0..875765e 100644 --- a/Hotline/macOS/NewsView.swift +++ b/Hotline/macOS/NewsView.swift @@ -14,21 +14,59 @@ struct NewsView: View { @State private var editorOpen: Bool = false var body: some View { - NavigationStack { - VSplit( - top: { - newsBrowser - }, - bottom: { - articleViewer + Group { + if model.serverVersion < 151 { + VStack { + Text("No News") + .bold() + .foregroundStyle(.secondary) + .font(.title3) + Text("This server has news turned off.") + .foregroundStyle(.tertiary) + .font(.system(size: 13)) + } + .padding() + } + else { + NavigationStack { + VSplit( + top: { + if !model.newsLoaded { + loadingIndicator + } + else if model.news.isEmpty { + VStack { + Text("No News") + .bold() + .foregroundStyle(.secondary) + .font(.title3) + Text("This server has no news available.") + .foregroundStyle(.tertiary) + .font(.system(size: 13)) + } + .padding() + } + else { + newsBrowser + } + }, + bottom: { + articleViewer + } + ) + .fraction(splitFraction) + .constraints(minPFraction: 0.1, minSFraction: 0.3) + .hide(splitHidden) + .styling(color: colorScheme == .dark ? .black : Splitter.defaultColor, inset: 0, visibleThickness: 0.5, invisibleThickness: 5, hideSplitter: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) } - ) - .fraction(splitFraction) - .constraints(minPFraction: 0.1, minSFraction: 0.3) - .hide(splitHidden) - .styling(color: colorScheme == .dark ? .black : Splitter.defaultColor, inset: 0, visibleThickness: 0.5, invisibleThickness: 5, hideSplitter: true) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) + .task { + if !model.newsLoaded { + let _ = await model.getNewsList() + } + } + } } // .sheet(isPresented: $editorOpen) { // print("Sheet dismissed!") @@ -73,11 +111,6 @@ struct NewsView: View { .environment(\.defaultMinListRowHeight, 34) .listStyle(.inset) .alternatingRowBackgrounds(.enabled) - .task { - if !model.newsLoaded { - let _ = await model.getNewsList() - } - } .contextMenu(forSelectionType: NewsInfo.self) { items in // ... } primaryAction: { items in @@ -130,15 +163,18 @@ struct NewsView: View { } return .ignored } - .overlay { - if !model.newsLoaded { - VStack { - ProgressView() - .controlSize(.regular) + } + + var loadingIndicator: some View { + VStack { + HStack { + ProgressView { + Text("Loading News") } - .frame(maxWidth: .infinity) + .controlSize(.regular) } } + .frame(maxWidth: .infinity) } var articleViewer: some View { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index aaa9c4e..52d82d6 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -1,6 +1,15 @@ import SwiftUI import UniformTypeIdentifiers +@Observable +class ServerState { + var selection: ServerNavigationType + + init(selection: ServerNavigationType) { + self.selection = selection + } +} + enum MenuItemType { case chat case news @@ -10,26 +19,38 @@ enum MenuItemType { case user } -struct MenuItem: Identifiable, Hashable { +struct ServerMenuItem: Identifiable, Hashable { let id: UUID + let type: ServerNavigationType let name: String let image: String - let type: MenuItemType let userID: UInt? let serverVersion: UInt? - init(name: String, image: String, type: MenuItemType, userID: UInt? = nil, serverVersion: UInt? = nil) { + init(type: ServerNavigationType, name: String, image: String, userID: UInt? = nil, serverVersion: UInt? = nil) { self.id = UUID() + self.type = type self.name = name self.image = image - self.type = type self.userID = userID self.serverVersion = serverVersion } - static func == (lhs: MenuItem, rhs: MenuItem) -> Bool { - if lhs.type == .user && rhs.type == .user { - return lhs.userID == rhs.userID + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: ServerMenuItem, rhs: ServerMenuItem) -> Bool { + switch lhs.type { + case .user(let lhsUID): + switch rhs.type { + case .user(let rhsUID): + return lhsUID == rhsUID + default: + break + } + default: + break } return lhs.id == rhs.id } @@ -50,113 +71,61 @@ struct ListItemView: View { } } -struct TransferItemView: View { - let transfer: TransferInfo - - @Environment(Hotline.self) private var model: Hotline - @State private var hovered: Bool = false - @State private var buttonHovered: Bool = false - - private func formattedProgressHelp() -> String { - if self.transfer.completed { - return "File transfer complete" - } - else if self.transfer.failed { - return "File transfer failed" - } - else if self.transfer.progress > 0.0 { - if self.transfer.timeRemaining > 0.0 { - return "\(round(self.transfer.progress * 100.0))% – \(self.transfer.timeRemaining) seconds left" - } - else { - return "\(round(self.transfer.progress * 100.0))% complete" - } - } - return "" +struct ActiveHotlineModelFocusedValueKey: FocusedValueKey { + typealias Value = Hotline +} + +struct ActiveServerStateFocusedValueKey: FocusedValueKey { + typealias Value = ServerState +} + +extension FocusedValues { + var activeHotlineModel: Hotline? { + get { self[ActiveHotlineModelFocusedValueKey.self] } + set { self[ActiveHotlineModelFocusedValueKey.self] = newValue } } - var body: some View { - HStack(alignment: .center) { - HStack(spacing: 0) { - Spacer() - FileIconView(filename: transfer.title) - .frame(width: 16, height: 16) - Spacer() - } - .frame(width: 18) - - Text(transfer.title) - .lineLimit(1) - .truncationMode(.middle) - - Spacer() - - if self.hovered { - Button { - model.deleteTransfer(id: transfer.id) - } label: { - if transfer.completed { - Image(systemName: self.buttonHovered ? "xmark.circle.fill" : "xmark.circle") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 16, height: 16) - .opacity(self.buttonHovered ? 1.0 : 0.5) - } - else { - Image(systemName: self.buttonHovered ? "trash.circle.fill" : "trash.circle") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 16, height: 16) - .opacity(self.buttonHovered ? 1.0 : 0.5) - } - } - .buttonStyle(.plain) - .padding(0) - .frame(width: 16, height: 16) - .help(transfer.completed || transfer.failed ? "Remove" : "Cancel Transfer") - .onHover { hovered in - self.buttonHovered = hovered - } - } - else if transfer.failed { - Image(systemName: "exclamationmark.triangle") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 16, height: 16) - } - else if transfer.completed { - Image(systemName: "checkmark.circle.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 16, height: 16) - } - else if transfer.progress == 0.0 { - ProgressView() - .progressViewStyle(.circular) - .controlSize(.small) - } - else { - ProgressView(value: transfer.progress, total: 1.0) - .progressViewStyle(.circular) - .controlSize(.small) - } - } - .onHover { hovered in - self.hovered = hovered + var activeServerState: ServerState? { + get { self[ActiveServerStateFocusedValueKey.self] } + set { self[ActiveServerStateFocusedValueKey.self] = newValue } + } +} + +enum ServerNavigationType: Identifiable, Hashable, Equatable { + var id: String { + switch self { + case .chat: + return "Chat" + case .news: + return "News" + case .board: + return "Board" + case .files: + return "Files" + case .user(let userID): + return String(userID) } - .help(formattedProgressHelp()) } + + case chat + case news + case board + case files + case user(userID: UInt) } struct ServerView: View { @Environment(Prefs.self) private var preferences: Prefs + @Environment(SoundEffectPlayer.self) private var soundEffects: SoundEffectPlayer @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) private var colorScheme @Environment(\.controlActiveState) private var controlActiveState @State private var model: Hotline = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()) + @State private var state: ServerState = ServerState(selection: .chat) + @State private var agreementShown: Bool = false - @State private var selection: MenuItem? = ServerView.menuItems.first +// @State private var selection: ServerMenuItem? = ServerView.menuItems.first @State private var connectAddress: String = "" @State private var connectLogin: String = "" @@ -164,11 +133,11 @@ struct ServerView: View { @Binding var server: Server - static var menuItems = [ - MenuItem(name: "Chat", image: "bubble", type: .chat), - MenuItem(name: "News", image: "newspaper", type: .news, serverVersion: 150), - MenuItem(name: "Board", image: "pin", type: .messageBoard), - MenuItem(name: "Files", image: "folder", type: .files), + static var menuItems: [ServerMenuItem] = [ + ServerMenuItem(type: .chat, name: "Chat", image: "bubble"), + ServerMenuItem(type: .news, name: "News", image: "newspaper"), + ServerMenuItem(type: .board, name: "Board", image: "pin"), + ServerMenuItem(type: .files, name: "Files", image: "folder"), ] enum FocusFields { @@ -278,18 +247,18 @@ struct ServerView: View { } var navigationList: some View { - List(selection: $selection) { + List(selection: $state.selection) { ForEach(ServerView.menuItems) { menuItem in - if let minServerVersion = menuItem.serverVersion { - if let v = model.serverVersion, v >= minServerVersion { - ListItemView(icon: menuItem.image, title: menuItem.name) - .tag(menuItem) - } - } - else { +// if let minServerVersion = menuItem.serverVersion { +// if let v = model.serverVersion, v >= minServerVersion { +// ListItemView(icon: menuItem.image, title: menuItem.name) +// .tag(menuItem.type) +// } +// } +// else { ListItemView(icon: menuItem.image, title: menuItem.name) - .tag(menuItem) - } + .tag(menuItem.type) +// } } if model.transfers.count > 0 { @@ -330,7 +299,7 @@ struct ServerView: View { } .opacity(user.isIdle ? 0.6 : 1.0) .opacity(controlActiveState == .inactive ? 0.4 : 1.0) - .tag(MenuItem(name: user.name, image: "", type: .user, userID: user.id)) + .tag(ServerNavigationType.user(userID: user.id)) } } } @@ -341,8 +310,7 @@ struct ServerView: View { .frame(maxWidth: .infinity) .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 500) } detail: { - if let selection = selection { - switch selection.type { + switch state.selection { case .chat: ChatView() .navigationTitle(model.serverTitle) @@ -351,7 +319,7 @@ struct ServerView: View { NewsView() .navigationTitle(model.serverTitle) .navigationSplitViewColumnWidth(min: 250, ideal: 500) - case .messageBoard: + case .board: MessageBoardView() .navigationTitle(model.serverTitle) .navigationSplitViewColumnWidth(min: 250, ideal: 500) @@ -359,16 +327,11 @@ struct ServerView: View { FilesView() .navigationTitle(model.serverTitle) .navigationSplitViewColumnWidth(min: 250, ideal: 500) - case .tasks: - EmptyView() - case .user: - if let selectionUserID = selection.userID { - MessageView(userID: selectionUserID) - .navigationTitle(model.serverTitle) - .navigationSplitViewColumnWidth(min: 250, ideal: 500) - } + case .user(let userID): + MessageView(userID: userID) + .navigationTitle(model.serverTitle) + .navigationSplitViewColumnWidth(min: 250, ideal: 500) } - } } } @@ -433,6 +396,8 @@ struct ServerView: View { connectPassword = server.password connectToServer() } + .focusedSceneValue(\.activeHotlineModel, model) + .focusedSceneValue(\.activeServerState, state) } // MARK: - @@ -528,3 +493,101 @@ struct ServerView: View { //#Preview { // ServerView(server: Server(name: "", description: "", address: "", port: 0)) //} + +struct TransferItemView: View { + let transfer: TransferInfo + + @Environment(Hotline.self) private var model: Hotline + @State private var hovered: Bool = false + @State private var buttonHovered: Bool = false + + private func formattedProgressHelp() -> String { + if self.transfer.completed { + return "File transfer complete" + } + else if self.transfer.failed { + return "File transfer failed" + } + else if self.transfer.progress > 0.0 { + if self.transfer.timeRemaining > 0.0 { + return "\(round(self.transfer.progress * 100.0))% – \(self.transfer.timeRemaining) seconds left" + } + else { + return "\(round(self.transfer.progress * 100.0))% complete" + } + } + return "" + } + + var body: some View { + HStack(alignment: .center) { + HStack(spacing: 0) { + Spacer() + FileIconView(filename: transfer.title) + .frame(width: 16, height: 16) + Spacer() + } + .frame(width: 18) + + Text(transfer.title) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + if self.hovered { + Button { + model.deleteTransfer(id: transfer.id) + } label: { + if transfer.completed { + Image(systemName: self.buttonHovered ? "xmark.circle.fill" : "xmark.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + .opacity(self.buttonHovered ? 1.0 : 0.5) + } + else { + Image(systemName: self.buttonHovered ? "trash.circle.fill" : "trash.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + .opacity(self.buttonHovered ? 1.0 : 0.5) + } + } + .buttonStyle(.plain) + .padding(0) + .frame(width: 16, height: 16) + .help(transfer.completed || transfer.failed ? "Remove" : "Cancel Transfer") + .onHover { hovered in + self.buttonHovered = hovered + } + } + else if transfer.failed { + Image(systemName: "exclamationmark.triangle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + else if transfer.completed { + Image(systemName: "checkmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + else if transfer.progress == 0.0 { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + } + else { + ProgressView(value: transfer.progress, total: 1.0) + .progressViewStyle(.circular) + .controlSize(.small) + } + } + .onHover { hovered in + self.hovered = hovered + } + .help(formattedProgressHelp()) + } +} |