aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2024-01-15 20:20:47 -0800
committerDustin Mierau <dustin@mierau.me>2024-01-15 20:20:47 -0800
commit7c9f6686044035c022a17551151cf1cbed0dc665 (patch)
treed41403b6f64d3375fdea8cee48d9db91c3ce26e0 /Hotline
parentfaae9cd1b47c6f22f3bcf4b1a24a4f7df41af9ed (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.swift38
-rw-r--r--Hotline/Hotline/HotlineClient.swift38
-rw-r--r--Hotline/Hotline/HotlineProtocol.swift160
-rw-r--r--Hotline/Models/Hotline.swift27
-rw-r--r--Hotline/Sounds/chat-message.aiffbin0 -> 4554 bytes
-rw-r--r--Hotline/Sounds/error.aiffbin0 -> 4610 bytes
-rw-r--r--Hotline/Sounds/logged-in.aiffbin0 -> 4166 bytes
-rw-r--r--Hotline/Sounds/new-news.aiffbin0 -> 17514 bytes
-rw-r--r--Hotline/Sounds/server-message.aiffbin0 -> 7914 bytes
-rw-r--r--Hotline/Sounds/transfer-complete.aiffbin0 -> 21378 bytes
-rw-r--r--Hotline/Sounds/user-login.aiffbin0 -> 6186 bytes
-rw-r--r--Hotline/Sounds/user-logout.aiffbin0 -> 7080 bytes
-rw-r--r--Hotline/Utility/SoundEffects.swift42
-rw-r--r--Hotline/Utility/Utilities.swift2
-rw-r--r--Hotline/macOS/MessageBoardView.swift68
-rw-r--r--Hotline/macOS/NewsView.swift86
-rw-r--r--Hotline/macOS/ServerView.swift319
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
new file mode 100644
index 0000000..73ea391
--- /dev/null
+++ b/Hotline/Sounds/chat-message.aiff
Binary files differ
diff --git a/Hotline/Sounds/error.aiff b/Hotline/Sounds/error.aiff
new file mode 100644
index 0000000..8a9f7f6
--- /dev/null
+++ b/Hotline/Sounds/error.aiff
Binary files differ
diff --git a/Hotline/Sounds/logged-in.aiff b/Hotline/Sounds/logged-in.aiff
new file mode 100644
index 0000000..5f59de0
--- /dev/null
+++ b/Hotline/Sounds/logged-in.aiff
Binary files differ
diff --git a/Hotline/Sounds/new-news.aiff b/Hotline/Sounds/new-news.aiff
new file mode 100644
index 0000000..5c56582
--- /dev/null
+++ b/Hotline/Sounds/new-news.aiff
Binary files differ
diff --git a/Hotline/Sounds/server-message.aiff b/Hotline/Sounds/server-message.aiff
new file mode 100644
index 0000000..152bebe
--- /dev/null
+++ b/Hotline/Sounds/server-message.aiff
Binary files differ
diff --git a/Hotline/Sounds/transfer-complete.aiff b/Hotline/Sounds/transfer-complete.aiff
new file mode 100644
index 0000000..b22e0ae
--- /dev/null
+++ b/Hotline/Sounds/transfer-complete.aiff
Binary files differ
diff --git a/Hotline/Sounds/user-login.aiff b/Hotline/Sounds/user-login.aiff
new file mode 100644
index 0000000..0dcfb6f
--- /dev/null
+++ b/Hotline/Sounds/user-login.aiff
Binary files differ
diff --git a/Hotline/Sounds/user-logout.aiff b/Hotline/Sounds/user-logout.aiff
new file mode 100644
index 0000000..8999edc
--- /dev/null
+++ b/Hotline/Sounds/user-logout.aiff
Binary files differ
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())
+ }
+}