aboutsummaryrefslogtreecommitdiff
path: root/Hotline/macOS
diff options
context:
space:
mode:
authorRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
committerRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
commit213710bf5bd6413c747bf126db50816ef5de5a6e (patch)
tree912f8cf87955a08077c92fea8ad934f50b7ab975 /Hotline/macOS
parentf466b21dc02f78c984ba6748e703f6780a7a0db4 (diff)
parent6a95b53616a4abfa306ddce43151cf4fefbd20ed (diff)
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'Hotline/macOS')
-rw-r--r--Hotline/macOS/AboutView.swift340
-rw-r--r--Hotline/macOS/AccountManagerView.swift405
-rw-r--r--Hotline/macOS/Accounts/AccountDetailsView.swift275
-rw-r--r--Hotline/macOS/Accounts/AccountManagerView.swift217
-rw-r--r--Hotline/macOS/AppUpdateView.swift187
-rw-r--r--Hotline/macOS/Board/MessageBoardEditorView.swift (renamed from Hotline/macOS/MessageBoardEditorView.swift)24
-rw-r--r--Hotline/macOS/Board/MessageBoardView.swift91
-rw-r--r--Hotline/macOS/BroadcastMessageSheet.swift66
-rw-r--r--Hotline/macOS/Chat/ChatView.swift345
-rw-r--r--Hotline/macOS/Chat/ServerAgreementView.swift (renamed from Hotline/macOS/ServerAgreementView.swift)31
-rw-r--r--Hotline/macOS/Chat/ServerMessageView.swift (renamed from Hotline/macOS/ServerMessageView.swift)12
-rw-r--r--Hotline/macOS/ChatView.swift242
-rw-r--r--Hotline/macOS/ConnectView.swift147
-rw-r--r--Hotline/macOS/FileDetailsView.swift149
-rw-r--r--Hotline/macOS/Files/FileDetailsSheet.swift172
-rw-r--r--Hotline/macOS/Files/FileItemView.swift67
-rw-r--r--Hotline/macOS/Files/FilePreviewImageView.swift (renamed from Hotline/macOS/FilePreviewImageView.swift)11
-rw-r--r--Hotline/macOS/Files/FilePreviewQuickLookView.swift114
-rw-r--r--Hotline/macOS/Files/FilePreviewTextView.swift (renamed from Hotline/macOS/FilePreviewTextView.swift)9
-rw-r--r--Hotline/macOS/Files/FilesView.swift529
-rw-r--r--Hotline/macOS/Files/FolderItemView.swift140
-rw-r--r--Hotline/macOS/Files/NewFolderPopover.swift51
-rw-r--r--Hotline/macOS/FilesView.swift481
-rw-r--r--Hotline/macOS/HotlinePanelView.swift175
-rw-r--r--Hotline/macOS/MessageBoardView.swift101
-rw-r--r--Hotline/macOS/MessageView.swift240
-rw-r--r--Hotline/macOS/News/NewsEditorView.swift (renamed from Hotline/macOS/NewsEditorView.swift)20
-rw-r--r--Hotline/macOS/News/NewsItemView.swift (renamed from Hotline/macOS/NewsItemView.swift)15
-rw-r--r--Hotline/macOS/News/NewsView.swift (renamed from Hotline/macOS/NewsView.swift)167
-rw-r--r--Hotline/macOS/ServerView.swift707
-rw-r--r--Hotline/macOS/Settings/GeneralSettingsView.swift67
-rw-r--r--Hotline/macOS/Settings/IconSettingsView.swift58
-rw-r--r--Hotline/macOS/Settings/SettingsView.swift31
-rw-r--r--Hotline/macOS/Settings/SoundSettingsView.swift40
-rw-r--r--Hotline/macOS/SettingsView.swift183
-rw-r--r--Hotline/macOS/TrackerView.swift498
-rw-r--r--Hotline/macOS/Trackers/BonjourServerRow.swift19
-rw-r--r--Hotline/macOS/Trackers/ServerBookmarkSheet.swift70
-rw-r--r--Hotline/macOS/Trackers/TrackerBookmarkServerView.swift40
-rw-r--r--Hotline/macOS/Trackers/TrackerBookmarkSheet.swift121
-rw-r--r--Hotline/macOS/Trackers/TrackerItemView.swift75
-rw-r--r--Hotline/macOS/Trackers/TrackerView.swift623
-rw-r--r--Hotline/macOS/TransfersView.swift411
-rw-r--r--Hotline/macOS/UserClientInfoSheet.swift25
44 files changed, 4867 insertions, 2924 deletions
diff --git a/Hotline/macOS/AboutView.swift b/Hotline/macOS/AboutView.swift
index a2771b9..80e66f5 100644
--- a/Hotline/macOS/AboutView.swift
+++ b/Hotline/macOS/AboutView.swift
@@ -1,11 +1,5 @@
import SwiftUI
-
-enum VersionCheckState {
- case needToCheck
- case checking
- case upToDate
- case updateAvailable(version: String)
-}
+import SwiftUIIntrospect
struct AboutContributor: Identifiable {
let id: UUID = UUID()
@@ -14,164 +8,66 @@ struct AboutContributor: Identifiable {
let pictureURL: URL?
}
-struct AboutView: View {
+struct AboutContributorView: View {
@Environment(\.openURL) private var openURL
-
- @State private var versionCheck: VersionCheckState = .needToCheck
- @State private var downloadURL: String = "https://github.com/mierau/hotline/releases/latest"
- @State private var contributors: [AboutContributor] = []
-
+
+ let contributor: AboutContributor
+
var body: some View {
- HStack(alignment: .center, spacing: 0) {
- VStack(alignment: .center, spacing: 0) {
- Spacer()
-
- Image("About Hotline")
- .padding(.top, 44)
-
- Text("Hotline")
- .font(.system(size: 28))
- .fontWeight(.bold)
- .padding(.top, 12)
- .kerning(-1.0)
- .foregroundColor(.white)
-
- let appDetails = getAppVersionAndBuild()
- Text("Version \(String(format: "%.1f", appDetails.version))b\(appDetails.build)")
- .foregroundColor(.white)
- .opacity(0.4)
-
- HStack(alignment: .center) {
- switch versionCheck {
- case .needToCheck:
- Button("Check for Updates") {
- Task {
- await checkForUpdate()
- }
- }
- .controlSize(.small)
- case .checking:
- HStack(spacing: 8) {
- ProgressView()
- .controlSize(.small)
- Text("Checking for updates...")
- .fontWeight(.semibold)
- }
- .foregroundStyle(.white)
- .tint(.white)
- case .upToDate:
- Label("Hotline is up to date.", systemImage: "checkmark.circle.fill")
- .foregroundStyle(.white)
- .fontWeight(.semibold)
- .tint(.white)
- .onTapGesture {
- versionCheck = .needToCheck
- }
- case .updateAvailable(let version):
- Button("Download Latest \(version)") {
- if let url = URL(string: downloadURL) {
- openURL(url)
- }
- }
- .controlSize(.small)
+ HStack {
+ if let pictureURL = contributor.pictureURL {
+ AsyncImage(url: pictureURL) { phase in
+ if let image = phase.image {
+ image
+ .interpolation(.high)
+ .resizable()
+ .scaledToFit()
+ .background(.white)
+ .frame(width: 32, height: 32)
+ } else if phase.error != nil {
+ Color.clear
+ .frame(width: 32, height: 32)
+ } else {
+ Color.white
+ .opacity(0.2)
+ .frame(width: 32, height: 32)
}
}
- .frame(height: 40)
-
- Spacer()
+ .frame(width: 32, height: 32)
+ .clipShape(Circle())
}
- .frame(width: 250)
-
- Spacer()
-
- ScrollView(.vertical) {
- VStack(alignment: .leading, spacing: 16) {
-
- VStack(alignment: .leading, spacing: 4) {
- Link(destination: URL(string: "https://github.com/mierau/hotline")!) {
- HStack(alignment: .center, spacing: 4) {
- Text("Contributors")
- .lineLimit(1)
- .font(.system(size: 16))
- .fontWeight(.semibold)
- .foregroundStyle(.black)
- .opacity(0.75)
-
- Image(systemName: "arrow.forward.circle.fill")
- .resizable()
- .fontWeight(.bold)
- .scaledToFit()
- .frame(width: 12, height: 12)
- .foregroundStyle(.black)
- .opacity(0.75)
- }
- }
- .padding(.top, 24)
-
- Text("Hotline is an open source project made possible by its contributors.")
- .font(.system(size: 11))
- .foregroundStyle(.black)
- .blendMode(.overlay)
- .padding(.trailing, 32)
- }
- .padding(.bottom, 8)
-
- ForEach(contributors) { contributor in
- Link(destination: contributor.webURL) {
- HStack {
- if let pictureURL = contributor.pictureURL {
- AsyncImage(url: pictureURL) { phase in
- if let image = phase.image {
- image
- .interpolation(.high)
- .resizable()
- .scaledToFit()
- .background(.white)
- .frame(width: 32, height: 32)
- } else if phase.error != nil {
- Color.clear
- .frame(width: 32, height: 32)
- } else {
- Color.white
- .opacity(0.2)
- .frame(width: 32, height: 32)
- }
- }
- .frame(width: 32, height: 32)
- .clipShape(Circle())
-
- // AsyncImage(url: pictureURL) { img in
- // img
- // .interpolation(.high)
- // .resizable()
- // .scaledToFit()
- // .background(.white)
- // } placeholder: {
- // Color.white.opacity(0.2)
- // .frame(width: 32, height: 32)
- // }
- // .frame(width: 32, height: 32)
- // .clipShape(Circle())
- }
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(contributor.username)
+ .fontWeight(.semibold)
+ .foregroundStyle(.white)
+ .lineLimit(1)
+
+ Text(contributor.webURL.absoluteString)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .font(.system(size: 11))
+ .foregroundStyle(.white.opacity(0.4))
+ }
+ }
+// }
+// .accessibilityAddTraits(.isLink)
+// .pointerStyle(.link)
+ }
+}
- VStack(alignment: .leading, spacing: 2) {
- Text(contributor.username)
- .fontWeight(.semibold)
- .foregroundStyle(.white)
- .lineLimit(1)
+struct AboutView: View {
+ @Environment(\.openURL) private var openURL
+
+ @State private var contributors: [AboutContributor] = []
- Text(contributor.webURL.absoluteString)
- .lineLimit(1)
- .truncationMode(.middle)
- .font(.system(size: 11))
- .foregroundStyle(.white.opacity(0.4))
- }
- }
- }
- }
+ var body: some View {
+ HStack(alignment: .center, spacing: 0) {
+ self.brandView
+ self.contributorsList
+ .background {
+ Color.black.blendMode(.softLight).opacity(0.3).ignoresSafeArea()
}
- }
- .scrollClipDisabled()
}
.frame(width: 570, height: 330)
.background(
@@ -184,12 +80,105 @@ struct AboutView: View {
.offset(x: 250)
}
)
- .background(Color.hotlineRed)
.task {
await loadContributors()
}
}
+
+ private var brandView: some View {
+ VStack(alignment: .center, spacing: 4) {
+ Spacer()
+
+ Image("About Hotline")
+
+ Text("Hotline")
+ .font(.system(size: 28))
+ .fontWeight(.bold)
+ .padding(.top, 12)
+ .kerning(-1.0)
+ .foregroundColor(.white)
+
+ let appDetails = getAppVersionAndBuild()
+ Button {
+ self.openURL(URL(string: "https://github.com/mierau/hotline/releases/tag/\(appDetails.version)beta\(appDetails.build)")!)
+ } label: {
+
+ Text("Version \(String(format: "%.1f", appDetails.version))b\(appDetails.build)")
+ .foregroundColor(.white.opacity(0.756))
+ .padding(.vertical, 4)
+ .padding(.horizontal, 12)
+ .background {
+ Capsule()
+ .fill(.white.opacity(0.5))
+ .blendMode(.softLight)
+ }
+ }
+ .buttonStyle(.plain)
+ .buttonBorderShape(.capsule)
+ .padding(.bottom, 16)
+
+ Spacer()
+ }
+ .frame(width: 250)
+ }
+
+ private var contributorsList: some View {
+ ScrollView(.vertical) {
+ VStack(alignment: .leading, spacing: 16) {
+ self.contributorHeaderView
+ ForEach(self.contributors) { contributor in
+ Button {
+ self.openURL(contributor.webURL)
+ } label: {
+ AboutContributorView(contributor: contributor)
+ }
+ .buttonStyle(.plain)
+ .accessibilityAddTraits(.isLink)
+ .pointerStyle(.link)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ }
+ .scrollClipDisabled()
+ .scrollContentBackground(.hidden)
+// .introspect(.scrollView, on: .macOS(.v10_15, .v11, .v12, .v13, .v14, .v15, .v26)) { v in
+// v.automaticallyAdjustsContentInsets = false
+// }
+ }
+
+ private var contributorHeaderView: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Link(destination: URL(string: "https://github.com/mierau/hotline")!) {
+ HStack(alignment: .center, spacing: 4) {
+ Text("Contributors")
+ .lineLimit(1)
+ .font(.system(size: 16))
+ .fontWeight(.semibold)
+ .foregroundStyle(.black)
+ .opacity(0.8)
+ Image(systemName: "arrow.forward.circle.fill")
+ .resizable()
+ .fontWeight(.bold)
+ .scaledToFit()
+ .frame(width: 12, height: 12)
+ .foregroundStyle(.black)
+ .opacity(0.4)
+ }
+ }
+ .accessibilityAddTraits(.isLink)
+ .pointerStyle(.link)
+
+ Text("Hotline is an open source project made possible by its contributors.")
+ .font(.system(size: 11))
+ .foregroundStyle(.black)
+ .opacity(0.5)
+ .padding(.trailing, 32)
+ }
+ .padding(.bottom, 8)
+ }
+
func loadContributors() async {
var newContributors: [AboutContributor] = []
@@ -220,41 +209,6 @@ struct AboutView: View {
}
}
- func checkForUpdate() async {
- let appDetails = getAppVersionAndBuild()
-
- self.versionCheck = .checking
-
- do {
- let url = URL(string: "https://api.github.com/repos/mierau/hotline/releases/latest")!
- let (data, _) = try await URLSession.shared.data(from: url)
- let versionExpression = /^([0-9\.]+)beta([0-9]+)/
-
- if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
- let tagName = json["tag_name"] as? String,
- let assets = json["assets"] as? [[String: Any]],
- let firstAsset = assets.first,
- let assetDownloadURL = firstAsset["browser_download_url"] as? String,
- let versionMatches = try? versionExpression.wholeMatch(in: tagName)
- {
- if let versionNumber = Double(versionMatches.1),
- let buildNumber = Int(versionMatches.2),
- versionNumber > appDetails.version || buildNumber > appDetails.build
- {
- let versionString = "\(versionMatches.1)b\(versionMatches.2)"
- self.versionCheck = .updateAvailable(version: versionString)
- downloadURL = assetDownloadURL
- } else {
- self.versionCheck = .upToDate
- }
- } else {
- self.versionCheck = .needToCheck
- }
- } catch {
- self.versionCheck = .needToCheck
- }
- }
-
func getAppVersionAndBuild() -> (version: Double, build: Int) {
let infoDictionary = Bundle.main.infoDictionary!
let version = Double(infoDictionary["CFBundleShortVersionString"]! as! String)!
diff --git a/Hotline/macOS/AccountManagerView.swift b/Hotline/macOS/AccountManagerView.swift
deleted file mode 100644
index 84ae6dc..0000000
--- a/Hotline/macOS/AccountManagerView.swift
+++ /dev/null
@@ -1,405 +0,0 @@
-import SwiftUI
-
-struct AccountManagerView: View {
- @Environment(Hotline.self) private var model: Hotline
-
- @State private var accounts: [HotlineAccount] = []
- @State private var selection: HotlineAccount?
- @State private var loading: Bool = true
-
- @State private var pendingName: String = ""
- @State private var pendingLogin: String = ""
- @State private var pendingPassword: String = ""
- @State private var pendingAccess = HotlineUserAccessOptions.defaultAccess
-
- @State private var toDelete: HotlineAccount?
-
- let placeholderPassword = "xxxxxxxxxxxxxxxxxx"
-
- var body: some View {
- HStack(spacing: 0) {
- ZStack {
- accountList
- if loading {
- ProgressView()
- }
- }
- if selection != nil {
- accountDetails
- } else {
- ZStack(alignment: .center) {
- Text("No Account Selected")
- .font(.title)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .padding()
- }
- .frame(maxWidth: .infinity)
- }
- }
- .environment(\.defaultMinListRowHeight, 34)
- .listStyle(.inset)
- .alternatingRowBackgrounds(.enabled)
- .task {
- if loading {
- accounts = await model.getAccounts()
- loading = false
- }
- }
- .toolbar {
- ToolbarItem(placement: .primaryAction) {
- Button {
- let newAccount = HotlineAccount(
- "unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess)
-
- pendingPassword = HotlineAccount.randomPassword()
- accounts.append(newAccount)
- selection = newAccount
- } label: {
- Label("New Account", systemImage: "plus")
- }
- .help("Create a new account")
- .disabled(model.access?.contains(.canCreateUsers) != true)
- }
-
- ToolbarItem(placement: .destructiveAction) {
- Button {
- toDelete = selection
- } label: {
- Label("Delete Account", systemImage: "trash")
- }
- .help("Delete account")
- .disabled(selection == nil || model.access?.contains(.canDeleteUsers) != true)
- }
- }
- }
-
- var accountDetails: some View {
- VStack(alignment: .center, spacing: 0) {
- ScrollView(.vertical) {
- Form {
- Section {
- TextField(text: $pendingName) {
- Text("Name")
- }
- TextField("Login", text: $pendingLogin)
- .disabled(selection?.persisted == true)
- if selection?.persisted == true {
- SecureField("Password", text: $pendingPassword)
- } else {
- TextField("Password", text: $pendingPassword)
- }
- }
- .textFieldStyle(.roundedBorder)
- .controlSize(.large)
-
- Section("File System Maintenance") {
- Toggle("Can Download Files", isOn: $pendingAccess.bind(.canDownloadFiles))
- .disabled(model.access?.contains(.canDownloadFiles) == false)
- Toggle("Can Download Folders", isOn: $pendingAccess.bind(.canDownloadFolders))
- .disabled(model.access?.contains(.canDownloadFolders) == false)
- Toggle("Can Upload Files", isOn: $pendingAccess.bind(.canUploadFiles))
- .disabled(model.access?.contains(.canUploadFiles) == false)
- Toggle("Can Upload Folders", isOn: $pendingAccess.bind(.canUploadFolders))
- .disabled(model.access?.contains(.canUploadFolders) == false)
- Toggle("Can Upload Anywhere", isOn: $pendingAccess.bind(.canUploadAnywhere))
- .disabled(model.access?.contains(.canUploadAnywhere) == false)
- Toggle("Can Delete Files", isOn: $pendingAccess.bind(.canDeleteFiles))
- .disabled(model.access?.contains(.canDeleteFiles) == false)
- Toggle("Can Rename Files", isOn: $pendingAccess.bind(.canRenameFiles))
- .disabled(model.access?.contains(.canRenameFiles) == false)
- Toggle("Can Move Files", isOn: $pendingAccess.bind(.canMoveFiles))
- .disabled(model.access?.contains(.canMoveFiles) == false)
- Toggle("Can Comment Files", isOn: $pendingAccess.bind(.canSetFileComment))
- .disabled(model.access?.contains(.canSetFileComment) == false)
- Toggle("Can Create Folders", isOn: $pendingAccess.bind(.canCreateFolders))
- .disabled(model.access?.contains(.canCreateFolders) == false)
- Toggle("Can Delete Folders", isOn: $pendingAccess.bind(.canDeleteFolders))
- .disabled(model.access?.contains(.canDeleteFolders) == false)
- Toggle("Can Rename Folders", isOn: $pendingAccess.bind(.canRenameFolders))
- .disabled(model.access?.contains(.canRenameFolders) == false)
- Toggle("Can Move Folders", isOn: $pendingAccess.bind(.canMoveFolders))
- .disabled(model.access?.contains(.canMoveFolders) == false)
- Toggle("Can Comment Folders", isOn: $pendingAccess.bind(.canSetFolderComment))
- .disabled(model.access?.contains(.canSetFolderComment) == false)
- Toggle("Can View Drop Boxes", isOn: $pendingAccess.bind(.canViewDropBoxes))
- .disabled(model.access?.contains(.canViewDropBoxes) == false)
- Toggle("Can Make Aliases", isOn: $pendingAccess.bind(.canMakeAliases))
- .disabled(model.access?.contains(.canMakeAliases) == false)
- }
-
- Section("User Maintenance") {
- Toggle("Can Create Accounts", isOn: $pendingAccess.bind(.canCreateUsers))
- .disabled(model.access?.contains(.canCreateUsers) == false)
- Toggle("Can Delete Accounts", isOn: $pendingAccess.bind(.canDeleteUsers))
- .disabled(model.access?.contains(.canDeleteUsers) == false)
- Toggle("Can Read Accounts", isOn: $pendingAccess.bind(.canOpenUsers))
- .disabled(model.access?.contains(.canOpenUsers) == false)
- Toggle("Can Modify Accounts", isOn: $pendingAccess.bind(.canModifyUsers))
- .disabled(model.access?.contains(.canModifyUsers) == false)
- Toggle("Can Get User Info", isOn: $pendingAccess.bind(.canGetClientInfo))
- .disabled(model.access?.contains(.canGetClientInfo) == false)
-
- Toggle("Can Disconnect Users", isOn: $pendingAccess.bind(.canDisconnectUsers))
- .disabled(model.access?.contains(.canDisconnectUsers) == false)
- Toggle("Cannot be Disconnected", isOn: $pendingAccess.bind(.cantBeDisconnected))
- .disabled(model.access?.contains(.cantBeDisconnected) == false)
- }
-
- Section("Messaging") {
- Toggle("Can Send Messages", isOn: $pendingAccess.bind(.canSendMessages))
- .disabled(model.access?.contains(.canSendMessages) == false)
- Toggle("Can Broadcast", isOn: $pendingAccess.bind(.canBroadcast))
- .disabled(model.access?.contains(.canBroadcast) == false)
- }
-
- Section("News") {
- Toggle("Can Read Articles", isOn: $pendingAccess.bind(.canReadMessageBoard))
- .disabled(model.access?.contains(.canReadMessageBoard) == false)
- Toggle("Can Post Articles", isOn: $pendingAccess.bind(.canPostMessageBoard))
- .disabled(model.access?.contains(.canPostMessageBoard) == false)
- Toggle("Can Delete Articles", isOn: $pendingAccess.bind(.canDeleteNewsArticles))
- .disabled(model.access?.contains(.canDeleteNewsArticles) == false)
- Toggle("Can Create Categories", isOn: $pendingAccess.bind(.canCreateNewsCategories))
- .disabled(model.access?.contains(.canCreateNewsCategories) == false)
- Toggle("Can Delete Categories", isOn: $pendingAccess.bind(.canDeleteNewsCategories))
- .disabled(model.access?.contains(.canDeleteNewsCategories) == false)
- Toggle("Can Create News Bundles", isOn: $pendingAccess.bind(.canCreateNewsFolders))
- .disabled(model.access?.contains(.canCreateNewsFolders) == false)
- Toggle("Can Delete News Bundles", isOn: $pendingAccess.bind(.canDeleteNewsFolders))
- .disabled(model.access?.contains(.canDeleteNewsFolders) == false)
- }
-
- Section("Chat") {
- Toggle("Can Initiate Private Chat", isOn: $pendingAccess.bind(.canCreateChat))
- .disabled(model.access?.contains(.canCreateChat) == false)
- Toggle("Can Read Chat", isOn: $pendingAccess.bind(.canReadChat))
- .disabled(model.access?.contains(.canReadChat) == false)
- Toggle("Can Send Chat", isOn: $pendingAccess.bind(.canSendChat))
- .disabled(model.access?.contains(.canSendChat) == false)
- }
-
- Section("Miscellaneous") {
- Toggle("Can Use Any Name", isOn: $pendingAccess.bind(.canUseAnyName))
- .disabled(model.access?.contains(.canUseAnyName) == false)
- Toggle("Don't Show Agreement", isOn: $pendingAccess.bind(.canSkipAgreement))
- .disabled(model.access?.contains(.canSkipAgreement) == false)
- }
- }
- .disabled(model.access?.contains(.canModifyUsers) == false)
- .formStyle(.grouped)
- .onChange(of: selection) {
- if let selection {
- pendingName = selection.name
- pendingLogin = selection.login
- pendingAccess = selection.access
-
- if selection.persisted {
- if selection.password == nil {
- pendingPassword = ""
- } else {
- pendingPassword = placeholderPassword
- }
- }
- }
- }
- .onAppear {
- if let selection {
- pendingName = selection.name
- pendingLogin = selection.login
- pendingAccess = selection.access
-
- if selection.persisted {
- if selection.password == nil {
- pendingPassword = ""
- } else {
- pendingPassword = placeholderPassword
- }
- } else {
- pendingPassword = HotlineAccount.randomPassword()
- }
- }
- }
- }
- .frame(maxWidth: .infinity)
-
- Divider()
-
- HStack {
- Button("Revert") {
- if let selection {
- pendingAccess = selection.access
- pendingName = selection.name
- pendingLogin = selection.login
-
- if selection.password != nil {
- pendingPassword = selection.password!
- }
- }
- }
- .controlSize(.large)
- .frame(minWidth: 75)
- .disabled(!self.isSaveable())
- // .padding()
-
- Spacer()
-
- Button("Save") {
- guard let selection else {
- return
- }
-
- // Update existing account
- if selection.persisted == true {
-
- if pendingPassword == placeholderPassword {
- Task { @MainActor in
- model.client.sendSetUser(
- name: pendingName, login: pendingLogin, newLogin: nil, password: nil,
- access: pendingAccess.rawValue)
- }
- } else {
- Task { @MainActor in
- model.client.sendSetUser(
- name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword,
- access: pendingAccess.rawValue)
- }
- }
-
- } else {
- // Create new existing account
- Task { @MainActor in
- model.client.sendCreateUser(
- name: pendingName, login: pendingLogin, password: pendingPassword,
- access: pendingAccess.rawValue)
- }
- self.selection?.password = pendingPassword
- pendingPassword = placeholderPassword
- }
-
- var account = HotlineAccount(pendingName, pendingLogin, pendingAccess)
- account.persisted = true
- account.password = placeholderPassword
-
- accounts = accounts.filter { $0.persisted == true && $0.login != selection.login }
-
- // Add new account to list
- accounts.append(account)
-
- // Re-sort accounts
- accounts.sort { $0.login < $1.login }
- self.selection = account
- }
- .controlSize(.large)
- .frame(minWidth: 75)
- .keyboardShortcut(.defaultAction)
- .disabled(!self.isSaveable())
- }
- .padding()
- }
-
- }
-
- var accountList: some View {
- List(accounts, id: \.self, selection: $selection) { account in
- HStack(spacing: 5) {
- if account.access.contains(.canDisconnectUsers) {
- Image("User Admin")
- .frame(width: 16, height: 16)
- .opacity(account.persisted ? 1.0 : 0.25)
- // .padding(.leading, 4)
- Text(account.login)
- .foregroundStyle(Color.hotlineRed)
- } else if account.access.rawValue == 0 {
- Image("User")
- .frame(width: 16, height: 16)
- // .padding(.leading, 4)
- Text(account.login)
- .foregroundStyle(.secondary)
- }
- // else if account.persisted == false {
- // HStack {
- // Image("User")
- // .frame(width: 16, height: 16)
- //// .padding(.leading, 4)
- // Text(account.login)
- // .italic()
- // }
- // }
- else {
- Image("User")
- .frame(width: 16, height: 16)
- .opacity(account.persisted ? 1.0 : 0.5)
- // .padding(.leading, 4)
- Text(account.login)
- }
- }
- }
- .frame(width: 250)
- .sheet(item: $toDelete) { item in
- Form {
- HStack {
- Image(systemName: "exclamationmark.triangle")
- .font(.system(size: 30))
- Text("Delete account \"\(item.name)\" and all associated files?")
- .lineSpacing(4)
- }
- }
- .frame(
- minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100,
- maxHeight: .infinity
- )
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") {
- toDelete = nil
- }
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button("Delete") {
- guard let userToDelete = toDelete else {
- return
- }
-
- self.toDelete = nil
- self.selection = nil
-
- if userToDelete.persisted {
- Task { @MainActor in
- model.client.sendDeleteUser(login: userToDelete.login)
- }
- }
-
- accounts = accounts.filter { $0.login != userToDelete.login }
-
- }
- }
- }
- }
- }
-
- private func isSaveable() -> Bool {
- guard let selection else {
- return false
- }
-
- // Disable save if login field is cleared
- if pendingLogin == "" {
- return false
- }
-
- // If the account initial has a password and it was updated
- if selection.password != nil && pendingPassword != placeholderPassword {
- return true
- }
-
- // If the account initial has no password, but was updated to have one
- if selection.password == nil && pendingPassword != "" {
- return true
- }
-
- // If the access bits or user name have been changed
- return pendingAccess.rawValue != selection.access.rawValue || selection.name != pendingName
- }
-}
diff --git a/Hotline/macOS/Accounts/AccountDetailsView.swift b/Hotline/macOS/Accounts/AccountDetailsView.swift
new file mode 100644
index 0000000..8514873
--- /dev/null
+++ b/Hotline/macOS/Accounts/AccountDetailsView.swift
@@ -0,0 +1,275 @@
+import SwiftUI
+
+fileprivate let PASSWORD_PLACEHOLDER = "xxxxxxxxxxxxxxxxxx"
+
+enum AccountDetailsError: Error {
+ case noLogin
+ case failedToSave
+
+ var alertTitle: String {
+ switch self {
+ case .noLogin: "A login is required"
+ case .failedToSave: "Failed to save account"
+ }
+ }
+
+ var alertMessage: String {
+ switch self {
+ case .noLogin: "Users with accounts are required to have a login. Please add one and try again."
+ case .failedToSave: "An error occurred while saving this account. Please try again."
+ }
+ }
+}
+
+struct AccountDetailsView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+ @Environment(\.dismiss) private var dismiss
+
+ @State var account: HotlineAccount = HotlineAccount(DEFAULT_ACCOUNT_NAME, "", HotlineUserAccessOptions.defaultAccess)
+
+ let saved: ((HotlineAccount) -> Void)?
+
+ @State private var password: String = ""
+ @State private var saving: Bool = false
+ @State private var alertTitle: String = ""
+ @State private var alertMessage: String = ""
+ @State private var alertShown: Bool = false
+
+ var body: some View {
+ self.detailsView
+ .alert(self.alertTitle, isPresented: self.$alertShown, actions: {
+ if #available(macOS 26.0, *) {
+ Button("OK", role: .confirm) {
+ self.alertShown = false
+ }
+ }
+ else {
+ Button("OK") {
+ self.alertShown = false
+ }
+ }
+
+ }, message: {
+ Text(self.alertMessage)
+ })
+ .onAppear {
+ // Display a placeholder for accounts that have been saved to the server
+ // because we don't have the account password on hand to display.
+ if self.account.persisted {
+ self.password = PASSWORD_PLACEHOLDER
+ }
+ }
+ .toolbar {
+ if self.saving {
+ ToolbarItem {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ ToolbarItem(placement: .cancellationAction) {
+ Button {
+ self.dismiss()
+ } label: {
+ Text("Cancel")
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ do {
+ try await self.save()
+ }
+ catch let error as AccountDetailsError {
+ self.alertTitle = error.alertTitle
+ self.alertMessage = error.alertMessage
+ self.alertShown = true
+ }
+ }
+ } label: {
+ Text(self.account.persisted ? "Save" : "Create")
+ }
+ .disabled(self.saving)
+ }
+ }
+ }
+
+ private func save() async throws {
+ guard !self.account.login.isBlank else {
+ throw AccountDetailsError.noLogin
+ }
+
+ self.account.name = self.account.name.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.account.login = self.account.login.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ self.saving = true
+ defer { self.saving = false }
+
+ // We create a name var here so we don't see the default account name
+ // flash in the UI while saving.
+ var accountName: String = self.account.name
+ if accountName.isBlank {
+ accountName = DEFAULT_ACCOUNT_NAME
+ }
+
+ do {
+ // Update existing account
+ if self.account.persisted {
+ if self.password == PASSWORD_PLACEHOLDER {
+ try await self.model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: nil, access: self.account.access.rawValue)
+ } else {
+ try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: self.password, access: self.account.access.rawValue)
+ }
+
+ } else {
+ // Create new existing account
+ try await model.createUser(name: accountName, login: self.account.login, password: self.password, access: self.account.access.rawValue)
+ }
+ }
+ catch {
+ throw AccountDetailsError.failedToSave
+ }
+
+ self.account.persisted = true
+ self.account.name = accountName
+ self.saved?(self.account)
+ }
+
+ private var isEditable: Bool {
+ self.model.access?.contains(.canModifyUsers) == true
+ }
+
+ private var detailsView: some View {
+ Form {
+ Section {
+ TextField(text: self.$account.name, prompt: Text(DEFAULT_ACCOUNT_NAME)) {
+ Text("Account")
+ }
+ }
+ .disabled(!self.isEditable)
+
+ Section {
+ TextField("Login", text: self.$account.login, prompt: Text("Required"))
+ .disabled(self.account.persisted)
+
+ if self.account.persisted {
+ SecureField("Password", text: self.$password, prompt: Text("Optional"))
+ } else {
+ TextField("Password", text: self.$password, prompt: Text("Optional"))
+ }
+ }
+ .sectionActions {
+ HStack {
+ Spacer()
+ Text("The following permissions define what users of this account can do on this server. Accounts that can disconnect other users are shown in red.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ Spacer()
+ }
+ }
+ .disabled(!self.isEditable)
+
+ Section("Files") {
+ Toggle("Download Files", isOn: self.$account.access.bind(.canDownloadFiles))
+// .disabled(self.model.access?.contains(.canDownloadFiles) == false)
+ Toggle("Download Folders", isOn: self.$account.access.bind(.canDownloadFolders))
+// .disabled(model.access?.contains(.canDownloadFolders) == false)
+ Toggle("Upload Files", isOn: self.$account.access.bind(.canUploadFiles))
+// .disabled(model.access?.contains(.canUploadFiles) == false)
+ Toggle("Upload Folders", isOn: self.$account.access.bind(.canUploadFolders))
+// .disabled(model.access?.contains(.canUploadFolders) == false)
+ Toggle("Upload Anywhere", isOn: self.$account.access.bind(.canUploadAnywhere))
+// .disabled(model.access?.contains(.canUploadAnywhere) == false)
+ Toggle("Delete Files", isOn: self.$account.access.bind(.canDeleteFiles))
+// .disabled(model.access?.contains(.canDeleteFiles) == false)
+ Toggle("Rename Files", isOn: self.$account.access.bind(.canRenameFiles))
+// .disabled(model.access?.contains(.canRenameFiles) == false)
+ Toggle("Move Files", isOn: self.$account.access.bind(.canMoveFiles))
+// .disabled(model.access?.contains(.canMoveFiles) == false)
+ Toggle("Comment Files", isOn: self.$account.access.bind(.canSetFileComment))
+// .disabled(model.access?.contains(.canSetFileComment) == false)
+ Toggle("Create Folders", isOn: self.$account.access.bind(.canCreateFolders))
+// .disabled(model.access?.contains(.canCreateFolders) == false)
+ Toggle("Delete Folders", isOn: self.$account.access.bind(.canDeleteFolders))
+// .disabled(model.access?.contains(.canDeleteFolders) == false)
+ Toggle("Rename Folders", isOn: self.$account.access.bind(.canRenameFolders))
+// .disabled(model.access?.contains(.canRenameFolders) == false)
+ Toggle("Move Folders", isOn: self.$account.access.bind(.canMoveFolders))
+// .disabled(model.access?.contains(.canMoveFolders) == false)
+ Toggle("Comment Folders", isOn: self.$account.access.bind(.canSetFolderComment))
+// .disabled(model.access?.contains(.canSetFolderComment) == false)
+ Toggle("View Drop Boxes", isOn: self.$account.access.bind(.canViewDropBoxes))
+// .disabled(model.access?.contains(.canViewDropBoxes) == false)
+ Toggle("Make Aliases", isOn: self.$account.access.bind(.canMakeAliases))
+// .disabled(model.access?.contains(.canMakeAliases) == false)
+ }
+ .disabled(!self.isEditable)
+
+ Section("User Maintenance") {
+ Toggle("Create Accounts", isOn: self.$account.access.bind(.canCreateUsers))
+// .disabled(model.access?.contains(.canCreateUsers) == false)
+ Toggle("Delete Accounts", isOn: self.$account.access.bind(.canDeleteUsers))
+// .disabled(model.access?.contains(.canDeleteUsers) == false)
+ Toggle("Read Accounts", isOn: self.$account.access.bind(.canOpenUsers))
+// .disabled(model.access?.contains(.canOpenUsers) == false)
+ Toggle("Modify Accounts", isOn: self.$account.access.bind(.canModifyUsers))
+// .disabled(model.access?.contains(.canModifyUsers) == false)
+ Toggle("Get User Info", isOn: self.$account.access.bind(.canGetClientInfo))
+// .disabled(model.access?.contains(.canGetClientInfo) == false)
+
+ Toggle("Disconnect Users", isOn: self.$account.access.bind(.canDisconnectUsers))
+// .disabled(model.access?.contains(.canDisconnectUsers) == false)
+ Toggle("Cannot be Disconnected", isOn: self.$account.access.bind(.cantBeDisconnected))
+// .disabled(model.access?.contains(.cantBeDisconnected) == false)
+ }
+ .disabled(!self.isEditable)
+
+ Section("Messaging") {
+ Toggle("Send Messages", isOn: self.$account.access.bind(.canSendMessages))
+// .disabled(model.access?.contains(.canSendMessages) == false)
+ Toggle("Broadcast", isOn: self.$account.access.bind(.canBroadcast))
+// .disabled(model.access?.contains(.canBroadcast) == false)
+ }
+ .disabled(!self.isEditable)
+
+ Section("News") {
+ Toggle("Read Articles", isOn: self.$account.access.bind(.canReadMessageBoard))
+// .disabled(model.access?.contains(.canReadMessageBoard) == false)
+ Toggle("Post Articles", isOn: self.$account.access.bind(.canPostMessageBoard))
+// .disabled(model.access?.contains(.canPostMessageBoard) == false)
+ Toggle("Delete Articles", isOn: self.$account.access.bind(.canDeleteNewsArticles))
+// .disabled(model.access?.contains(.canDeleteNewsArticles) == false)
+ Toggle("Create Categories", isOn: self.$account.access.bind(.canCreateNewsCategories))
+// .disabled(model.access?.contains(.canCreateNewsCategories) == false)
+ Toggle("Delete Categories", isOn: self.$account.access.bind(.canDeleteNewsCategories))
+// .disabled(model.access?.contains(.canDeleteNewsCategories) == false)
+ Toggle("Create News Bundles", isOn: self.$account.access.bind(.canCreateNewsFolders))
+// .disabled(model.access?.contains(.canCreateNewsFolders) == false)
+ Toggle("Delete News Bundles", isOn: self.$account.access.bind(.canDeleteNewsFolders))
+// .disabled(model.access?.contains(.canDeleteNewsFolders) == false)
+ }
+ .disabled(!self.isEditable)
+
+ Section("Chat") {
+ Toggle("Initiate Private Chat", isOn: self.$account.access.bind(.canCreateChat))
+// .disabled(model.access?.contains(.canCreateChat) == false)
+ Toggle("Read Chat", isOn: self.$account.access.bind(.canReadChat))
+// .disabled(model.access?.contains(.canReadChat) == false)
+ Toggle("Send Chat", isOn: self.$account.access.bind(.canSendChat))
+// .disabled(model.access?.contains(.canSendChat) == false)
+ }
+ .disabled(!self.isEditable)
+
+ Section("Miscellaneous") {
+ Toggle("Use Any Name", isOn: self.$account.access.bind(.canUseAnyName))
+// .disabled(model.access?.contains(.canUseAnyName) == false)
+ Toggle("Don't Show Agreement", isOn: self.$account.access.bind(.canSkipAgreement))
+// .disabled(model.access?.contains(.canSkipAgreement) == false)
+ }
+ .disabled(!self.isEditable)
+ }
+ .formStyle(.grouped)
+ }
+}
diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift
new file mode 100644
index 0000000..d288196
--- /dev/null
+++ b/Hotline/macOS/Accounts/AccountManagerView.swift
@@ -0,0 +1,217 @@
+import SwiftUI
+
+let DEFAULT_ACCOUNT_NAME = "Untitled Account"
+
+struct AccountManagerView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var accounts: [HotlineAccount] = []
+ @State private var selection: HotlineAccount?
+ @State private var loading: Bool = true
+
+ @State private var creatorShown: Bool = false
+ @State private var deleteConfirm: Bool = false
+ @State private var accountToEdit: HotlineAccount? = nil
+ @State private var accountToDelete: HotlineAccount? = nil
+
+ private func newAccount() {
+ self.creatorShown = true
+ }
+
+ private func editAccount(_ account: HotlineAccount) {
+ // Always get the latest version from the array to avoid stale data
+ if let currentAccount = self.accounts.first(where: { $0.id == account.id }) {
+ self.accountToEdit = currentAccount
+ }
+ }
+
+ private func deleteAccount(_ account: HotlineAccount) {
+ self.accountToDelete = account
+ self.deleteConfirm = true
+ }
+
+ var body: some View {
+ VStack(spacing: 8) {
+ HStack(alignment: .firstTextBaseline) {
+ Text("Accounts")
+ .font(.headline)
+
+ Spacer()
+
+ HStack {
+ Button {
+ self.newAccount()
+ } label: {
+ Image(systemName: "plus")
+ .padding(4)
+ }
+ .buttonBorderShape(.circle)
+ .help("New Account")
+
+ Button {
+ if let account = self.selection {
+ self.editAccount(account)
+ }
+ } label: {
+ Image(systemName: "pencil")
+ .padding(4)
+ }
+ .buttonBorderShape(.circle)
+ .disabled(self.selection == nil)
+ .help("Edit Account")
+
+ Button {
+ if let account = self.selection {
+ self.deleteAccount(account)
+ }
+ } label: {
+ Image(systemName: "trash")
+ .padding(4)
+ }
+ .tint(.hotlineRed)
+ .buttonBorderShape(.circle)
+ .disabled(self.selection == nil)
+ .help("Delete Account")
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 24)
+
+ self.accountList
+// .overlay {
+// if self.loading {
+// ProgressView()
+// .progressViewStyle(.linear)
+// .controlSize(.extraLarge)
+// .frame(width: 100)
+// }
+// }
+ }
+ .environment(\.defaultMinListRowHeight, 34)
+ .listStyle(.inset)
+ .alternatingRowBackgrounds(.enabled)
+ .task {
+ if self.loading {
+ do {
+ self.accounts = try await self.model.getAccounts()
+ }
+ catch {
+ self.dismiss()
+ }
+
+ self.loading = false
+ }
+ }
+ .toolbar {
+ if self.loading {
+ ToolbarItem {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ self.dismiss()
+ } label: {
+ Text("OK")
+ }
+ }
+ }
+ }
+
+ private var accountList: some View {
+ List(self.accounts, id: \.self, selection: self.$selection) { account in
+ HStack(spacing: 5) {
+ Image(account.access.contains(.canDisconnectUsers) ? "User Admin" : "User")
+ .frame(width: 16, height: 16)
+ .opacity((account.access.rawValue == 0) ? 0.5 : 1.0)
+ Text(account.name)
+ .foregroundStyle(account.access.contains(.canDisconnectUsers) ? Color.hotlineRed : ((account.access.rawValue == 0) ? Color.secondary : Color.primary))
+
+ Spacer()
+
+ Text(account.login)
+ .lineLimit(1)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .contextMenu(forSelectionType: HotlineAccount.self) { items in
+ Button {
+ if let item = items.first {
+ self.editAccount(item)
+ }
+ } label: {
+ Label("Edit Account...", systemImage: "pencil")
+ }
+ .disabled(items.isEmpty)
+
+ Divider()
+
+ Button(role: .destructive) {
+ if let item = items.first {
+ self.deleteAccount(item)
+ }
+ } label: {
+ Label("Delete Account...", systemImage: "trash")
+ }
+ .disabled(items.isEmpty)
+ } primaryAction: { items in
+ if let account = items.first {
+ self.editAccount(account)
+ }
+ }
+ .alert("Are you sure you want to delete the \"\(self.accountToDelete?.name ?? "unknown")\" account?", isPresented: self.$deleteConfirm, actions: {
+ Button("Delete", role: .destructive) {
+ guard let account = self.accountToDelete else {
+ return
+ }
+
+ self.accountToDelete = nil
+
+ Task {
+ self.selection = nil
+
+ if account.persisted {
+ try await self.model.deleteUser(login: account.login)
+ }
+
+ self.accounts = self.accounts.filter { $0.id != account.id }
+ self.deleteConfirm = false
+ }
+ }
+ }, message: {
+ Text("You cannot undo this action.")
+ })
+ .sheet(item: self.$accountToEdit) { account in
+ AccountDetailsView(account: account) { editedAccount in
+ if let i = self.accounts.firstIndex(of: editedAccount) {
+ self.accounts.remove(at: i)
+ self.accounts.insert(editedAccount, at: i)
+ }
+ self.accounts.sort { $0.name < $1.name }
+ self.selection = editedAccount
+ self.accountToEdit = nil
+ }
+ .id(account.id)
+ .environment(self.model)
+ .frame(width: 480)
+ .frame(minHeight: 300, idealHeight: 400)
+ .presentationSizing(.fitted)
+ }
+ .sheet(isPresented: self.$creatorShown) {
+ AccountDetailsView { newAccount in
+ self.accounts.append(newAccount)
+ self.accounts.sort { $0.name < $1.name }
+ self.selection = newAccount
+ }
+ .environment(self.model)
+ .frame(width: 480)
+ .frame(minHeight: 300, idealHeight: 400)
+ .presentationSizing(.fitted)
+ }
+ }
+}
+
+
diff --git a/Hotline/macOS/AppUpdateView.swift b/Hotline/macOS/AppUpdateView.swift
new file mode 100644
index 0000000..89312fc
--- /dev/null
+++ b/Hotline/macOS/AppUpdateView.swift
@@ -0,0 +1,187 @@
+import SwiftUI
+import MarkdownUI
+import AppKit
+import Observation
+
+struct AppUpdateView: View {
+ @Environment(\.dismiss) private var dismiss
+ @Bindable private var update = AppUpdate.shared
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 20) {
+ if let message = update.message {
+ messageOnlyView(message)
+ }
+ else if update.release != nil {
+ headerSection
+ releaseNotesSection
+ actionRow
+ }
+ else {
+ defaultPlaceholder
+ }
+ }
+ .padding(.horizontal, 24)
+ .padding(.bottom, 24)
+ .padding(.top, 8)
+ .frame(width: update.message != nil ? 380 : 520)
+ .frame(idealHeight: 360)
+ .onChange(of: update.showWindow) { _, show in
+ if !show {
+ dismiss()
+ }
+ }
+ .onDisappear {
+ update.handleWindowDismissed()
+ }
+ }
+
+ private var headerSection: some View {
+ HStack(alignment: .center, spacing: 8) {
+ Image(nsImage: NSApplication.shared.applicationIconImage)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 56, height: 56)
+ .shadow(color: Color.black.mix(with: .red, by: 0.4).opacity(0.15), radius: 3, y: 1.5)
+
+ VStack(alignment: .leading, spacing: 2) {
+ if let release = update.release {
+ Text("Hotline \(release.displayVersion)")
+ .font(.title2)
+ .fontWeight(.semibold)
+ }
+ Text("A new version of Hotline is available. 🎉")
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ private var releaseNotesSection: some View {
+ ScrollView(.vertical) {
+ Markdown(releaseNotesMarkdown())
+ .textSelection(.enabled)
+ .markdownTheme(.gitHub.text(text: {
+ FontSize(.em(0.85))
+ }))
+ .font(.system(size: 14))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 12)
+ .padding(.horizontal, 12)
+ }
+ .frame(minHeight: 220, maxHeight: 260)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ }
+
+ private var actionRow: some View {
+ HStack {
+ Button("Not Now") {
+ update.remindLater()
+ }
+ .buttonBorderShape(.capsule)
+ .keyboardShortcut(.escape, modifiers: [])
+ .controlSize(.large)
+ .disabled(update.isDownloading)
+
+ Spacer()
+
+ if update.isDownloading {
+ ProgressView()
+ .controlSize(.small)
+ .padding(.trailing, 12)
+ }
+
+ Button("Download") {
+ update.startDownload()
+ }
+ .buttonBorderShape(.capsule)
+ .keyboardShortcut(.defaultAction)
+ .controlSize(.large)
+ .disabled(update.isDownloading)
+ }
+ }
+
+ @ViewBuilder
+ private func messageOnlyView(_ message: AppUpdateMessage) -> some View {
+ let iconName = {
+ switch message.kind {
+ case .info:
+ return "info.circle"
+ case .success:
+ return "checkmark.circle.fill"
+ case .error:
+ return "exclamationmark.triangle.fill"
+ }
+ }()
+
+ HStack(alignment: .center, spacing: 12) {
+
+ if message.kind == .success {
+ Text("👍")
+ .font(.system(size: 42))
+ .shadow(color: .yellow.mix(with: .black, by: 0.3).opacity(0.2), radius: 4, y: 1.5)
+ }
+ else {
+ Image(systemName: iconName)
+ .resizable()
+ .scaledToFit()
+ .symbolRenderingMode(.multicolor)
+ .frame(width: 48, height: 48)
+ }
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(message.title)
+ .font(.title2)
+ .fontWeight(.semibold)
+ Text(message.detail)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+ }
+ }
+
+ private var defaultPlaceholder: some View {
+ VStack(alignment: .center, spacing: 12) {
+ Text("No update information available.")
+ .font(.headline)
+ Button("Close") {
+ update.acknowledgeMessage()
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private func releaseNotesMarkdown() -> String {
+ if let combined = update.releaseNotesCombined?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ combined.isEmpty == false {
+ return combined
+ }
+
+ let fallback = update.release?.notes.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return fallback.isEmpty ? "_No release notes provided._" : fallback
+ }
+}
+
+#Preview {
+ AppUpdate.shared.release = UpdateReleaseInfo(
+ tagName: "1.0beta1",
+ displayVersion: "1.0b1",
+ versionNumber: 1.0,
+ buildNumber: 1,
+ notes: """
+ - Added support for release notes in Markdown.
+ - Improved the update workflow for macOS users.
+ """,
+ downloadURL: URL(string: "https://example.com")!,
+ assetName: "Hotline.zip"
+ )
+ AppUpdate.shared.releases = [AppUpdate.shared.release!]
+ AppUpdate.shared.releaseNotesCombined = nil
+ AppUpdate.shared.showWindow = true
+ return AppUpdateView()
+}
diff --git a/Hotline/macOS/MessageBoardEditorView.swift b/Hotline/macOS/Board/MessageBoardEditorView.swift
index 9026c6f..34a4bbb 100644
--- a/Hotline/macOS/MessageBoardEditorView.swift
+++ b/Hotline/macOS/Board/MessageBoardEditorView.swift
@@ -8,8 +8,8 @@ struct MessageBoardEditorView: View {
@Environment(\.controlActiveState) private var controlActiveState
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
- @Environment(Hotline.self) private var model: Hotline
-
+ @Environment(HotlineState.self) private var model: HotlineState
+
@State private var text: String = ""
@State private var sending: Bool = false
@@ -20,14 +20,14 @@ struct MessageBoardEditorView: View {
let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
- model.postToMessageBoard(text: cleanedText)
- let _ = await model.getMessageBoard()
-
- // let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID)
- // if success {
- // await model.getNewsList(at: path)
- // }
-
+ try? await model.postToMessageBoard(text: cleanedText)
+ let _ = try? await model.getMessageBoard()
+
+// let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID)
+// if success {
+// await model.getNewsList(at: path)
+// }
+
sending = false
}
@@ -68,9 +68,9 @@ struct MessageBoardEditorView: View {
} else {
Button {
sending = true
- model.postToMessageBoard(text: text)
Task {
- let _ = await model.getMessageBoard()
+ try? await model.postToMessageBoard(text: text)
+ let _ = try? await model.getMessageBoard()
Task { @MainActor in
sending = false
dismiss()
diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift
new file mode 100644
index 0000000..be039d8
--- /dev/null
+++ b/Hotline/macOS/Board/MessageBoardView.swift
@@ -0,0 +1,91 @@
+import SwiftUI
+
+struct MessageBoardView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+
+ @State private var composerDisplayed: Bool = false
+ @State private var composerText: String = ""
+
+ var body: some View {
+ NavigationStack {
+ if self.model.access?.contains(.canReadMessageBoard) != true {
+ self.disabledBoardView
+ }
+ else if self.model.messageBoardLoaded && self.model.messageBoard.isEmpty {
+ self.emptyBoardView
+ }
+ else {
+ self.messageBoardView
+ }
+ }
+ .sheet(isPresented: $composerDisplayed) {
+ MessageBoardEditorView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .frame(idealWidth: 450, idealHeight: 350)
+ }
+ .toolbar {
+ ToolbarItem(placement:.primaryAction) {
+ Button {
+ self.composerDisplayed.toggle()
+ } label: {
+ Image(systemName: "square.and.pencil")
+ }
+ .disabled((self.model.access?.contains(.canPostMessageBoard) != true) || (self.model.access?.contains(.canReadMessageBoard) != true))
+ .help("Post to Message Board")
+ }
+ }
+ .task {
+ if !self.model.messageBoardLoaded {
+ let _ = try? await self.model.getMessageBoard()
+ }
+ }
+ }
+
+ private var disabledBoardView: some View {
+ ContentUnavailableView {
+ Label("No Message Board", systemImage: "quote.bubble")
+ } description: {
+ Text("This server has turned off their message board")
+ }
+ }
+
+ private var emptyBoardView: some View {
+ ContentUnavailableView {
+ Label("No Posts", systemImage: "quote.bubble")
+ } description: {
+ Text("Message board posts will appear here")
+ }
+ }
+
+ private var messageBoardView: some View {
+ ScrollView {
+ LazyVStack(alignment: .leading) {
+ ForEach(self.model.messageBoard, id: \.self) { msg in
+ Text(LocalizedStringKey(msg))
+ .tint(Color("Link Color"))
+ .lineLimit(100)
+ .lineSpacing(4)
+ .padding()
+ .textSelection(.enabled)
+ Divider()
+ }
+ }
+ Spacer()
+ }
+ .overlay {
+ if !self.model.messageBoardLoaded {
+ VStack {
+ ProgressView()
+ .controlSize(.large)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .background(Color(nsColor: .textBackgroundColor))
+ }
+}
+
+#Preview {
+ MessageBoardView()
+ .environment(HotlineState())
+}
diff --git a/Hotline/macOS/BroadcastMessageSheet.swift b/Hotline/macOS/BroadcastMessageSheet.swift
new file mode 100644
index 0000000..b3bd7ea
--- /dev/null
+++ b/Hotline/macOS/BroadcastMessageSheet.swift
@@ -0,0 +1,66 @@
+import SwiftUI
+
+fileprivate let CHARACTER_LIMIT: Int = 255
+
+struct BroadcastMessageSheet: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var sending: Bool = false
+
+ private var message: String {
+ self.model.broadcastMessage.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ var body: some View {
+ @Bindable var model = self.model
+
+ VStack {
+ TextField("Write a message...", text: $model.broadcastMessage, axis: .vertical)
+ .textFieldStyle(.plain)
+ .lineLimit(5, reservesSpace: true)
+ }
+ .padding(.leading, 32)
+ .padding(.top, 2)
+ .overlay(alignment: .topLeading) {
+ Image("Server Message")
+ }
+ .padding(16)
+ .frame(width: 400)
+ .toolbar {
+ if self.sending {
+ ToolbarItem {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ self.dismiss()
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Broadcast") {
+ let message = self.message
+ model.broadcastMessage = ""
+
+ guard !message.isBlank else {
+ return
+ }
+
+ Task {
+ self.sending = true
+ defer { self.sending = false }
+
+ try await model.sendBroadcast(message)
+
+ self.dismiss()
+ }
+ }
+ .disabled(self.message.isEmpty)
+ }
+ }
+ }
+}
diff --git a/Hotline/macOS/Chat/ChatView.swift b/Hotline/macOS/Chat/ChatView.swift
new file mode 100644
index 0000000..e1ec73a
--- /dev/null
+++ b/Hotline/macOS/Chat/ChatView.swift
@@ -0,0 +1,345 @@
+import SwiftUI
+
+enum FocusedField: Int, Hashable {
+ case chatInput
+}
+
+struct ChatJoinedMessageView: View {
+ let message: ChatMessage
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 4) {
+ 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: 4) {
+ 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 {
+ Text("\(username): ").fontWeight(.semibold) + Text(message.text.markdownToAttributedString())
+ }
+ else {
+ Text(message.text)
+ }
+ Spacer()
+ }
+ .lineSpacing(4)
+ .multilineTextAlignment(.leading)
+ .textSelection(.enabled)
+ .tint(Color("Link Color"))
+ }
+}
+
+struct ChatView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+ @Environment(\.colorScheme) var colorScheme
+ @Environment(\.dismiss) var dismiss
+
+ @State private var scrollPos: Int?
+ @State private var contentHeight: CGFloat = 0
+
+ @State private var searchQuery: String = ""
+ @State private var searchResults: [ChatMessage] = []
+ @State private var isSearching: Bool = false
+
+ @State private var stableBannerImage: Image?
+
+ @FocusState private var focusedField: FocusedField?
+
+ @Namespace var bottomID
+
+ private var bindableModel: Bindable<HotlineState> {
+ Bindable(model)
+ }
+
+// @State private var showingExporter: Bool = false
+//
+// @State private var chatDocument: TextFile = TextFile()
+
+ var displayedMessages: [ChatMessage] {
+ searchQuery.isEmpty ? model.chat : searchResults
+ }
+
+// private var blurredBannerImage: some View {
+// self.stableBannerImage?
+// .resizable()
+// .scaledToFit()
+// .frame(maxWidth: 468.0)
+// .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+// .offset(y: 1.5)
+// .blur(radius: 4)
+// .opacity(0.2)
+// }
+
+ private var bannerView: some View {
+ ZStack {
+ self.stableBannerImage?
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: 468.0)
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .offset(y: 1.5)
+ .blur(radius: 4)
+ .opacity(0.2)
+ self.stableBannerImage?
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: 468.0)
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+ }
+
+ var body: some View {
+ @Bindable var bindModel = model
+
+ NavigationStack {
+ ScrollViewReader { reader in
+ VStack(alignment: .leading, spacing: 0) {
+
+ // MARK: Scroll View
+ ScrollView(.vertical) {
+ LazyVStack(alignment: .leading, spacing: 8) {
+
+ ForEach(displayedMessages) { msg in
+ if msg.type == .agreement {
+ VStack(alignment: .center, spacing: 16) {
+ HStack(spacing: 0) {
+ Spacer(minLength: 0)
+ self.bannerView
+ Spacer(minLength: 0)
+ }
+
+ ServerAgreementView(text: msg.text)
+ }
+ .padding(.vertical, 24)
+ }
+ else if msg.type == .server {
+ ServerMessageView(message: msg.text)
+ }
+ 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 {
+ ChatMessageView(message: msg)
+ }
+ }
+ }
+ .padding()
+
+ VStack(spacing: 0) {}.id(bottomID)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+// .defaultScrollAnchor(.bottom, for: .initialOffset)
+ .defaultScrollAnchor(.bottom, for: .alignment)
+ .defaultScrollAnchor(.bottom, for: .sizeChanges)
+ .onChange(of: model.chat.count) {
+ // Re-run search when new messages arrive to keep filter active
+ if !searchQuery.isEmpty {
+ performSearch()
+ }
+ reader.scrollTo(bottomID, anchor: .bottom)
+ model.markPublicChatAsRead()
+ }
+ .onAppear {
+ self.focusedField = .chatInput
+ model.markPublicChatAsRead()
+ reader.scrollTo(bottomID, anchor: .bottom)
+ }
+ .onChange(of: self.model.bannerImage) {
+ reader.scrollTo(bottomID, anchor: .bottom)
+ }
+ .onChange(of: searchQuery) {
+ reader.scrollTo(bottomID, anchor: .bottom)
+ }
+ .onChange(of: isSearching) {
+ reader.scrollTo(bottomID, anchor: .bottom)
+ }
+
+ // MARK: Input Divider
+ Divider()
+
+ // MARK: Input Bar
+ HStack(alignment: .lastTextBaseline, spacing: 0) {
+ TextField("", text: $bindModel.chatInput, axis: .vertical)
+ .focused($focusedField, equals: .chatInput)
+ .textFieldStyle(.plain)
+ .lineLimit(1...5)
+ .multilineTextAlignment(.leading)
+ .onSubmit {
+ if !model.chatInput.isEmpty {
+ let message = model.chatInput
+ let announce = NSEvent.modifierFlags.contains(.shift)
+ Task {
+ try? await model.sendChat(message, announce: announce)
+ }
+ }
+ model.chatInput = ""
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ }
+ .frame(maxWidth: .infinity, minHeight: 28)
+ .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
+ .overlay(alignment: .leadingLastTextBaseline) {
+ Image(systemName: "chevron.right").fontWeight(.semibold).opacity(0.4).offset(x: 16)
+ }
+ .onContinuousHover { phase in
+ switch phase {
+ case .active(_):
+ NSCursor.iBeam.set()
+ case .ended:
+ NSCursor.arrow.set()
+ break
+ }
+ }
+ .onTapGesture(count: 1) {
+ focusedField = .chatInput
+ }
+ }
+ }
+ .searchable(text: $searchQuery, isPresented: $isSearching, placement: .toolbar, prompt: "Search")
+ .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden())
+ }
+ .background(Color(nsColor: .textBackgroundColor))
+// .navigationTitle(model.serverTitle)
+ .onChange(of: searchQuery) {
+ performSearch()
+ }
+ .onChange(of: model.bannerImage) { oldValue, newValue in
+ stableBannerImage = newValue
+ }
+ .onAppear {
+ stableBannerImage = model.bannerImage
+ }
+// .toolbar {
+// ToolbarItem(placement: .primaryAction) {
+// Button {
+// 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):
+// print("Saved to \(url)")
+//
+// case .failure(let error):
+// print(error.localizedDescription)
+// }
+// self.chatDocument.text = ""
+// }
+ }
+
+ private func performSearch() {
+ guard !searchQuery.isEmpty else {
+ searchResults = []
+ return
+ }
+
+ searchResults = model.searchChat(query: searchQuery)
+ }
+
+// private func prepareChatDocument() -> Bool {
+// var text: String = String()
+//
+// self.chatDocument.text = ""
+// for msg in model.chat {
+// if msg.type == .agreement {
+// text.append(msg.text)
+// text.append("\n\n")
+// }
+// else if msg.type == .message {
+// if let username = msg.username {
+// text.append("\(username): \(msg.text)")
+// }
+// else {
+// text.append(msg.text)
+// }
+// text.append("\n")
+// }
+// else if msg.type == .status {
+// text.append(msg.text)
+// text.append("\n")
+// }
+// }
+//
+// if text.isEmpty {
+// return false
+// }
+//
+// self.chatDocument.text = text
+//
+// return true
+// }
+}
+
+#Preview {
+ ChatView()
+ .environment(HotlineState())
+}
diff --git a/Hotline/macOS/ServerAgreementView.swift b/Hotline/macOS/Chat/ServerAgreementView.swift
index 77e9e15..c65a1cb 100644
--- a/Hotline/macOS/ServerAgreementView.swift
+++ b/Hotline/macOS/Chat/ServerAgreementView.swift
@@ -1,6 +1,6 @@
import SwiftUI
-private let MAX_AGREEMENT_HEIGHT: CGFloat = 280
+fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 340
struct ServerAgreementView: View {
let text: String
@@ -36,12 +36,12 @@ struct ServerAgreementView: View {
.scrollIndicators(.never)
.frame(maxWidth: .infinity, maxHeight: (expandable && expanded) ? nil : MAX_AGREEMENT_HEIGHT)
.scrollBounceBehavior(.basedOnSize)
- #if os(iOS)
- .background(Color("Agreement Background"))
- #elseif os(macOS)
- .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow))
- #endif
- .overlay(
+#if os(iOS)
+ .background(Color("Agreement Background"))
+#elseif os(macOS)
+ .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow))
+#endif
+ .overlay(alignment: .bottomTrailing) {
ZStack(alignment: .bottomTrailing) {
Group {
if !expandable || expanded {
@@ -65,14 +65,23 @@ struct ServerAgreementView: View {
.frame(width: 12, height: 12)
.foregroundColor(.primary.opacity(0.8)), alignment: .center)
}
- )
+ }, label: {
+ 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/Chat/ServerMessageView.swift
index 98f4043..6e8f5db 100644
--- a/Hotline/macOS/ServerMessageView.swift
+++ b/Hotline/macOS/Chat/ServerMessageView.swift
@@ -19,12 +19,12 @@ struct ServerMessageView: View {
}
.padding()
.frame(maxWidth: .infinity)
- #if os(iOS)
- .background(Color("Agreement Background"))
- #elseif os(macOS)
- .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow))
- #endif
- .clipShape(RoundedRectangle(cornerRadius: 8))
+#if os(iOS)
+ .background(Color("Agreement Background"))
+#elseif os(macOS)
+ .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow))
+#endif
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift
deleted file mode 100644
index 33a43b1..0000000
--- a/Hotline/macOS/ChatView.swift
+++ /dev/null
@@ -1,242 +0,0 @@
-import SwiftUI
-
-enum FocusedField: Int, Hashable {
- case chatInput
-}
-
-struct ChatView: View {
- @Environment(Hotline.self) private var model: Hotline
- @Environment(\.colorScheme) var colorScheme
- @Environment(\.dismiss) var dismiss
-
- @State var input: String = ""
- @State private var scrollPos: Int?
- @State private var contentHeight: CGFloat = 0
-
- @FocusState private var focusedField: FocusedField?
-
- @Namespace var bottomID
-
- @State private var showingExporter: Bool = false
-
- @State private var chatDocument: TextFile = TextFile()
-
- var body: some View {
- NavigationStack {
- ScrollViewReader { reader in
- VStack(alignment: .leading, spacing: 0) {
-
- // MARK: Scroll View
- GeometryReader { gm in
- ScrollView(.vertical) {
- LazyVStack(alignment: .leading) {
-
- ForEach(model.chat) { msg in
-
- // MARK: Agreement
- if msg.type == .agreement {
- #if os(iOS)
- if let bannerImage = self.model.bannerImage {
- Image(uiImage: bannerImage)
- .resizable()
- .scaledToFit()
- .frame(maxWidth: 468.0)
- .clipShape(RoundedRectangle(cornerRadius: 3))
- }
- #endif
- ServerAgreementView(text: msg.text)
- .padding(.bottom, 16)
- }
- // 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))
- } 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))
- }
- }
- }
- .padding()
-
- VStack(spacing: 0) {}.id(bottomID)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .defaultScrollAnchor(.bottom)
- .onChange(of: model.chat.count) {
- reader.scrollTo(bottomID, anchor: .bottom)
- model.markPublicChatAsRead()
- }
- .onAppear {
- reader.scrollTo(bottomID, anchor: .bottom)
- }
- .onChange(of: gm.size) {
- reader.scrollTo(bottomID, anchor: .bottom)
- }
- }
-
- // MARK: Input Divider
- Divider()
-
- // MARK: Input Bar
- HStack(alignment: .lastTextBaseline, spacing: 0) {
- TextField("", text: $input, axis: .vertical)
- .focused($focusedField, equals: .chatInput)
- .textFieldStyle(.plain)
- .lineLimit(1...5)
- .multilineTextAlignment(.leading)
- .onSubmit {
- if !self.input.isEmpty {
- model.sendChat(self.input, announce: NSEvent.modifierFlags.contains(.shift))
- }
- self.input = ""
- }
- .frame(maxWidth: .infinity)
- .padding()
- }
- .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)
- }
- .onContinuousHover { phase in
- switch phase {
- case .active(_):
- NSCursor.iBeam.set()
- case .ended:
- NSCursor.arrow.set()
- break
- }
- }
- .onTapGesture {
- focusedField = .chatInput
- }
- }
- }
- }
- .background(Color(nsColor: .textBackgroundColor))
- // .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):
- print("Saved to \(url)")
-
- case .failure(let error):
- print(error.localizedDescription)
- }
- self.chatDocument.text = ""
- }
- }
-
- private func prepareChatDocument() -> Bool {
- var text: String = String()
-
- self.chatDocument.text = ""
- for msg in model.chat {
- if msg.type == .agreement {
- text.append(msg.text)
- text.append("\n\n")
- } else if msg.type == .message {
- if let username = msg.username {
- text.append("\(username): \(msg.text)")
- } else {
- text.append(msg.text)
- }
- text.append("\n")
- } else if msg.type == .status {
- text.append(msg.text)
- text.append("\n")
- }
- }
-
- if text.isEmpty {
- return false
- }
-
- self.chatDocument.text = text
-
- return true
- }
-}
-
-#Preview {
- ChatView()
- .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
-}
diff --git a/Hotline/macOS/ConnectView.swift b/Hotline/macOS/ConnectView.swift
new file mode 100644
index 0000000..8419522
--- /dev/null
+++ b/Hotline/macOS/ConnectView.swift
@@ -0,0 +1,147 @@
+import SwiftUI
+
+struct ConnectView: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.modelContext) private var modelContext
+
+ @Binding var address: String
+ @Binding var login: String
+ @Binding var password: String
+
+ var action: (() -> Void)? = nil
+
+ @State private var bookmarkSheetPresented: Bool = false
+ @State private var bookmarkName: String = ""
+
+ private enum FocusFields {
+ case address
+ case login
+ case password
+ }
+
+ @FocusState private var focusedField: FocusFields?
+
+ var body: some View {
+ VStack(alignment: .center, spacing: 0) {
+ Form {
+ HStack(alignment: .top, spacing: 10) {
+ Image("Server Large")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 28, height: 28)
+
+ VStack(alignment: .leading) {
+ Text("Connect to Server")
+ Text("Enter the address of a Hotline server to connect to.")
+ .foregroundStyle(.secondary)
+ .font(.subheadline)
+ }
+ }
+
+ TextField(text: self.$address) {
+ Text("Address")
+ }
+ .focused($focusedField, equals: .address)
+
+ TextField(text: self.$login, prompt: Text("Optional")) {
+ Text("Login")
+ }
+ .focused($focusedField, equals: .login)
+
+ SecureField(text: self.$password, prompt: Text("Optional")) {
+ Text("Password")
+ }
+ .focused($focusedField, equals: .password)
+ }
+ .formStyle(.grouped)
+ .fixedSize(horizontal: false, vertical: true)
+
+ HStack {
+ Button {
+ if !self.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ self.bookmarkSheetPresented = true
+ }
+ } label: {
+ Image(systemName: "bookmark.fill")
+ }
+ .disabled(self.address.isEmpty)
+ .controlSize(.regular)
+ .buttonStyle(.automatic)
+ .help("Bookmark server")
+
+ Spacer()
+
+ Button("Cancel") {
+ self.dismiss()
+ }
+ .controlSize(.regular)
+ .buttonStyle(.automatic)
+ .keyboardShortcut(.cancelAction)
+
+ Button("Connect") {
+ self.action?()
+// self.connectToServer()
+ }
+ .controlSize(.regular)
+ .buttonStyle(.automatic)
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding(.horizontal, 20)
+ }
+// .onChange(of: self.address) {
+// let (a, p) = Server.parseServerAddressAndPort(connectAddress)
+// server.address = a
+// server.port = p
+// }
+// .onChange(of: connectLogin) {
+// server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines)
+// }
+// .onChange(of: connectPassword) {
+// server.password = connectPassword
+// }
+ .frame(maxWidth: 380)
+ .padding()
+ .onAppear {
+ self.focusedField = .address
+ }
+ .sheet(isPresented: self.$bookmarkSheetPresented) {
+ VStack(alignment: .leading) {
+ Text("Save Bookmark")
+ .foregroundStyle(.secondary)
+ .padding(.bottom, 4)
+ TextField("Bookmark Name", text: self.$bookmarkName)
+ .textFieldStyle(.roundedBorder)
+ .controlSize(.large)
+ }
+ .frame(width: 250)
+ .padding()
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ self.bookmarkSheetPresented = false
+ self.bookmarkName = ""
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ let name = String(self.bookmarkName.trimmingCharacters(in: .whitespacesAndNewlines))
+ if !name.isEmpty {
+ self.bookmarkSheetPresented = false
+ self.bookmarkName = ""
+
+ let (host, port) = Server.parseServerAddressAndPort(self.address)
+ let login: String? = self.login.isBlank ? nil : self.login
+ let password: String? = self.password.isBlank ? nil : self.password
+
+ if !host.isEmpty {
+ let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password)
+ Bookmark.add(newBookmark, context: modelContext)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Hotline/macOS/FileDetailsView.swift b/Hotline/macOS/FileDetailsView.swift
deleted file mode 100644
index 16b7a60..0000000
--- a/Hotline/macOS/FileDetailsView.swift
+++ /dev/null
@@ -1,149 +0,0 @@
-import Foundation
-import SwiftUI
-
-struct FileDetailsView: View {
- @Environment(Hotline.self) private var model: Hotline
- @Environment(\.presentationMode) var presentationMode
-
- var fd: FileDetails
-
- @State private var comment: String = ""
- @State private var filename: String = ""
-
- var body: some View {
- VStack(alignment: .leading) {
- Form {
- HStack(alignment: .center) {
- FileIconView(filename: fd.name, fileType: nil)
- .frame(width: 32, height: 32)
- TextField("", text: $filename)
- .disabled(!self.canRename())
- }
- HStack(alignment: .center) {
- Text("Type:").bold().padding(.leading, 43)
- Text(fd.type)
- }
- HStack(alignment: .center) {
- Text("Creator:").bold().padding(.leading, 26)
- Text(fd.creator)
- }
- HStack(alignment: .center) {
- Text("Size:").bold().bold().padding(.leading, 48)
- Text(self.formattedSize(byteCount: fd.size))
- }
- HStack(alignment: .center) {
- Text("Created:").bold().padding(.leading, 24)
- Text("\(FileDetailsView.dateFormatter.string(from: fd.created))")
- }
- HStack(alignment: .center) {
- Text("Modified:").bold().padding(.leading, 19)
- Text("\(FileDetailsView.dateFormatter.string(from: fd.modified))")
- }
- HStack(alignment: .center) {
- Text("Comments:").bold().padding(.top, 8)
- .padding(.leading, 5)
- }
-
- VStack(alignment: .trailing) {
- TextEditor(text: $comment)
- .padding(.leading, 2)
- .padding(.top, 1)
- .font(.system(size: 13))
- .background(Color(nsColor: .textBackgroundColor))
- .border(Color.secondary, width: 1)
- .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous))
- .disabled(!self.canSetComment())
- }
- }
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") {
- presentationMode.wrappedValue.dismiss()
- }
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button {
- var editedFilename: String?
- if filename != fd.name {
- editedFilename = filename
- }
-
- var editedComment: String?
- if comment != fd.comment {
- editedComment = comment
- }
-
- model.client.sendSetFileInfo(
- fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment)
- presentationMode.wrappedValue.dismiss()
-
- // TODO: Update the file list if the filename was changed
- } label: {
- Text("Save")
- }.disabled(!isEdited())
- }
- }
-
- .onAppear {
- self.filename = fd.name
- self.comment = fd.comment
- }
- .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24))
- }
- .frame(minWidth: 400, minHeight: 400)
- }
-
- static var dateFormatter: DateFormatter = {
- var dateFormatter = DateFormatter()
- dateFormatter.dateStyle = .long
- dateFormatter.timeStyle = .short
-
- // Original format: Fri, Aug 20, 2021, 5:14:07 PM
- return dateFormatter
- }()
-
- static var byteCountSizeFormatter: NumberFormatter = {
- let numberFormatter = NumberFormatter()
- numberFormatter.numberStyle = .decimal
- return numberFormatter
- }()
-
- static let byteFormatter = ByteCountFormatter()
-
- private func formattedFileSize(_ fileSize: UInt) -> String {
- FileView.byteFormatter.allowedUnits = [.useAll]
- FileView.byteFormatter.countStyle = .file
- return FileView.byteFormatter.string(fromByteCount: Int64(fileSize))
- }
-
- // Format byte count Int into string like: 23.4M (24,601,664 bytes)
- private func formattedSize(byteCount: Int) -> String {
- let formattedByteCount =
- FileDetailsView.byteCountSizeFormatter.string(from: NSNumber(value: byteCount)) ?? "0"
- return
- "\(FileView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)"
- }
-
- private func isEdited() -> Bool {
- return self.filename != fd.name || self.comment != fd.comment
- }
-
- private func canRename() -> Bool {
- if self.fd.type == "fldr" {
- return model.access?.contains(.canRenameFolders) == true
- }
- return model.access?.contains(.canRenameFiles) == true
- }
-
- private func canSetComment() -> Bool {
- if self.fd.type == "fldr" {
- return model.access?.contains(.canSetFolderComment) == true
- }
- return model.access?.contains(.canSetFileComment) == true
- }
-}
-
-//#Preview {
-// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now ))
-//}
diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift
new file mode 100644
index 0000000..1bfacb2
--- /dev/null
+++ b/Hotline/macOS/Files/FileDetailsSheet.swift
@@ -0,0 +1,172 @@
+import Foundation
+import SwiftUI
+
+struct FileDetailsSheet: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+ @Environment(\.dismiss) private var dismiss
+
+ var details: FileDetails
+
+ @State private var saving: Bool = false
+ @State private var comment: String = ""
+ @State private var filename: String = ""
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack(alignment: .center, spacing: 16){
+ if self.isFolder {
+ FolderIconView()
+ .frame(width: 32, height: 32)
+ }
+ else {
+ FileIconView(filename: self.details.name, fileType: nil)
+ .frame(width: 32, height: 32)
+ }
+
+ TextField("File Name", text: $filename)
+ .disabled(!self.canRename)
+ }
+
+ let rows: [(String, String)] = [
+ ("Type", self.details.type),
+ ("Creator", self.details.creator),
+ ("Size", self.formattedSize(byteCount: self.details.size)),
+ ("Created", Self.dateFormatter.string(from: self.details.created)),
+ ("Modified", Self.dateFormatter.string(from: self.details.modified))
+ ]
+
+ Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
+ ForEach(rows, id: \.0) { label, value in
+ GridRow {
+ Text(label)
+ .font(.body.bold())
+ .gridColumnAlignment(.trailing) // right-align label column
+ Text(value)
+ .font(.body)
+ .gridColumnAlignment(.leading) // left-align value column
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.leading, 32 + 16)
+
+ TextField(text: self.$comment, prompt: Text("Comments"), axis: .vertical) {
+ EmptyView()
+ }
+ .font(.body)
+ .lineLimit(10, reservesSpace: true)
+ .padding(.leading, 32 + 16)
+ .disabled(!self.canSetComment)
+ }
+ .padding(.vertical, 24)
+ .padding(.horizontal, 24)
+ .frame(width: 400)
+ .toolbar {
+ if self.saving {
+ ToolbarItem {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ self.dismiss()
+ }
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Button{
+ var editedFilename: String?
+ if self.filename != self.details.name {
+ editedFilename = self.filename
+ }
+
+ var editedComment: String?
+ if self.comment != self.details.comment {
+ editedComment = self.comment
+ }
+
+ Task {
+ self.saving = true
+ defer { self.saving = false }
+
+ if editedComment != nil || editedFilename != nil {
+ if try await self.model.setFileInfo(fileName: self.details.name, path: self.details.path, fileNewName: editedFilename, comment: editedComment) {
+ try await self.model.getFileList(path: self.details.path)
+ }
+ }
+
+ // We dismiss even if there is an error for now
+ // This is not ideal as we may lose a user's written comment
+ // or new file name, but SwiftUI doesn't show the current
+ // alert above this sheet so we'll need a different way of
+ // handling errors to make this work. Until then...
+ self.dismiss()
+ }
+ } label: {
+ Text("Save")
+ }
+ }
+ }
+ .onAppear {
+ self.filename = self.details.name
+ self.comment = self.details.comment
+ }
+ }
+
+ private var isFolder: Bool {
+ self.details.type == "Folder" || self.details.type == "fldr"
+ }
+
+ private func isEdited() -> Bool {
+ return self.filename != self.details.name || self.comment != self.details.comment
+ }
+
+ private var canRename: Bool {
+ if self.isFolder {
+ return self.model.access?.contains(.canRenameFolders) == true
+ }
+ return self.model.access?.contains(.canRenameFiles) == true
+ }
+
+ private var canSetComment: Bool {
+ if self.isFolder {
+ return self.model.access?.contains(.canSetFolderComment) == true
+ }
+ return self.model.access?.contains(.canSetFileComment) == true
+ }
+
+ static var dateFormatter: DateFormatter = {
+ var dateFormatter = DateFormatter()
+ dateFormatter.dateStyle = .long
+ dateFormatter.timeStyle = .short
+
+ // Original format: Fri, Aug 20, 2021, 5:14:07 PM
+ return dateFormatter
+ }()
+
+ static var byteCountSizeFormatter: NumberFormatter = {
+ let numberFormatter = NumberFormatter()
+ numberFormatter.numberStyle = .decimal
+ return numberFormatter
+ }()
+
+ static let byteFormatter = ByteCountFormatter()
+
+ private func formattedFileSize(_ fileSize: UInt) -> String {
+ FileItemView.byteFormatter.allowedUnits = [.useAll]
+ FileItemView.byteFormatter.countStyle = .file
+ return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize))
+ }
+
+ // Format byte count Int into string like: 23.4M (24,601,664 bytes)
+ private func formattedSize(byteCount: Int) -> String {
+ let formattedByteCount = Self.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0"
+ return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)"
+ }
+}
+
+//#Preview {
+// FileDetailsView(details: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now ))
+//}
diff --git a/Hotline/macOS/Files/FileItemView.swift b/Hotline/macOS/Files/FileItemView.swift
new file mode 100644
index 0000000..31a8af7
--- /dev/null
+++ b/Hotline/macOS/Files/FileItemView.swift
@@ -0,0 +1,67 @@
+import SwiftUI
+
+struct FileItemView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+
+ var file: FileInfo
+ let depth: Int
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 0) {
+ Spacer()
+ .frame(width: CGFloat(depth * (12 + 2)))
+
+ Spacer()
+ .frame(width: 10)
+ .padding(.leading, 4)
+ .padding(.trailing, 8)
+
+ HStack(alignment: .center) {
+ if file.isUnavailable {
+ Image(systemName: "questionmark.app.fill")
+ .frame(width: 16, height: 16)
+ .opacity(0.5)
+ }
+ else {
+ FileIconView(filename: file.name, fileType: file.type)
+ .frame(width: 16, height: 16)
+ }
+ }
+ .frame(width: 16)
+ .padding(.trailing, 6)
+
+ Text(file.name)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .opacity(file.isUnavailable ? 0.5 : 1.0)
+
+ Spacer()
+ if !file.isUnavailable {
+ Text(formattedFileSize(file.fileSize))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .padding(.trailing, 6)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+
+ if file.expanded {
+ ForEach(file.children!, id: \.self) { childFile in
+ if childFile.isFolder {
+ FolderItemView(file: childFile, depth: self.depth + 1).tag(file.id)
+ }
+ else {
+ FileItemView(file: childFile, depth: self.depth + 1).tag(file.id)
+ }
+ }
+ }
+ }
+
+ static let byteFormatter = ByteCountFormatter()
+
+ private func formattedFileSize(_ fileSize: UInt) -> String {
+ FileItemView.byteFormatter.allowedUnits = [.useAll]
+ FileItemView.byteFormatter.countStyle = .file
+ return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize))
+ }
+}
diff --git a/Hotline/macOS/FilePreviewImageView.swift b/Hotline/macOS/Files/FilePreviewImageView.swift
index 3aa37d3..5469e23 100644
--- a/Hotline/macOS/FilePreviewImageView.swift
+++ b/Hotline/macOS/Files/FilePreviewImageView.swift
@@ -11,8 +11,8 @@ struct FilePreviewImageView: View {
@Environment(\.dismiss) var dismiss
@Binding var info: PreviewFileInfo?
-
- @State var preview: FilePreview? = nil
+
+ @State var preview: FilePreviewState? = nil
@FocusState private var focusField: FilePreviewFocus?
var body: some View {
@@ -76,7 +76,10 @@ struct FilePreviewImageView: View {
if let info = info {
ToolbarItem(placement: .primaryAction) {
Button {
- let _ = preview?.data?.saveAsFileToDownloads(filename: info.name)
+ if let fileURL = preview?.fileURL,
+ let data = try? Data(contentsOf: fileURL) {
+ let _ = data.saveAsFileToDownloads(filename: info.name)
+ }
} label: {
Label("Download Image...", systemImage: "arrow.down")
}
@@ -94,7 +97,7 @@ struct FilePreviewImageView: View {
}
.task {
if let info = info {
- preview = FilePreview(info: info)
+ preview = FilePreviewState(info: info)
preview?.download()
}
}
diff --git a/Hotline/macOS/Files/FilePreviewQuickLookView.swift b/Hotline/macOS/Files/FilePreviewQuickLookView.swift
new file mode 100644
index 0000000..a504a7a
--- /dev/null
+++ b/Hotline/macOS/Files/FilePreviewQuickLookView.swift
@@ -0,0 +1,114 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct FilePreviewQuickLookView: View {
+ enum FilePreviewFocus: Hashable {
+ case window
+ }
+
+ @Environment(\.controlActiveState) private var controlActiveState
+ @Environment(\.colorScheme) private var colorScheme
+ @Environment(\.dismiss) private var dismiss
+
+ @Binding var info: PreviewFileInfo?
+ @State private var preview: FilePreviewState? = nil
+
+ @FocusState private var focusField: FilePreviewFocus?
+
+ var body: some View {
+ Group {
+ if self.preview?.state != .loaded {
+ VStack(alignment: .center, spacing: 0) {
+ Spacer()
+ ProgressView(value: max(0.0, min(1.0, self.preview?.progress ?? 0.0)))
+ .focusable(false)
+ .progressViewStyle(.circular)
+ .controlSize(.extraLarge)
+ .frame(maxWidth: 300, alignment: .center)
+ .padding(.bottom, 48)
+ Spacer()
+ }
+ .background(Color(nsColor: .textBackgroundColor))
+ .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity)
+ .padding()
+ }
+ else {
+ if let fileURL = self.preview?.fileURL {
+ QuickLookPreviewView(fileURL: fileURL)
+ .frame(minWidth: 400, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity)
+ }
+ else {
+ VStack(alignment: .center, spacing: 0) {
+ Spacer()
+
+ Image(systemName: "eye.trianglebadge.exclamationmark")
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: .infinity)
+ .frame(height: 48)
+ .padding(.bottom)
+ Group {
+ Text("This file type is not previewable")
+ .bold()
+ Text("Try downloading and opening this file in another application.")
+ .foregroundStyle(Color.secondary)
+ }
+ .font(.system(size: 14.0))
+ .frame(maxWidth: 300)
+ .multilineTextAlignment(.center)
+
+ Spacer()
+ Spacer()
+ }
+ .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity)
+ .padding()
+ }
+ }
+ }
+ .focusable()
+ .focusEffectDisabled()
+ .background(Color(nsColor: .textBackgroundColor))
+ .focused(self.$focusField, equals: .window)
+ .navigationTitle(self.info?.name ?? "File Preview")
+ .applyNavigationDocumentIfPresent(self.preview?.fileURL)
+ .toolbar {
+ if let fileURL = self.preview?.fileURL {
+ if let info = info {
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ FileManager.default.copyToDownloads(from: fileURL, using: info.name, bounceDock: true)
+ } label: {
+ Label("Download File...", systemImage: "arrow.down")
+ }
+ .help("Download File")
+ }
+ }
+ }
+ }
+ .task {
+ if let info = self.info {
+ self.preview = FilePreviewState(info: info)
+ self.preview?.download()
+ }
+ }
+ .onAppear {
+ guard self.info != nil else {
+ self.dismiss()
+ return
+ }
+
+ self.focusField = .window
+ }
+ .onDisappear {
+ self.preview?.cancel()
+ self.preview?.cleanup()
+ self.dismiss()
+ }
+ .onChange(of: self.preview?.state) {
+ if self.preview?.state == .failed {
+ self.dismiss()
+ }
+ }
+ .preferredColorScheme(.dark)
+ }
+}
diff --git a/Hotline/macOS/FilePreviewTextView.swift b/Hotline/macOS/Files/FilePreviewTextView.swift
index 6dc1529..2f9a85c 100644
--- a/Hotline/macOS/FilePreviewTextView.swift
+++ b/Hotline/macOS/Files/FilePreviewTextView.swift
@@ -11,7 +11,7 @@ struct FilePreviewTextView: View {
@Environment(\.dismiss) var dismiss
@Binding var info: PreviewFileInfo?
- @State var preview: FilePreview? = nil
+ @State var preview: FilePreviewState? = nil
@FocusState private var focusField: FilePreviewFocus?
var body: some View {
@@ -87,7 +87,10 @@ struct FilePreviewTextView: View {
if let info = info {
ToolbarItem(placement: .primaryAction) {
Button {
- let _ = preview?.data?.saveAsFileToDownloads(filename: info.name)
+ if let fileURL = preview?.fileURL,
+ let data = try? Data(contentsOf: fileURL) {
+ let _ = data.saveAsFileToDownloads(filename: info.name)
+ }
} label: {
Label("Save Text File...", systemImage: "square.and.arrow.down")
}
@@ -98,7 +101,7 @@ struct FilePreviewTextView: View {
}
.task {
if let info = info {
- preview = FilePreview(info: info)
+ preview = FilePreviewState(info: info)
preview?.download()
}
}
diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift
new file mode 100644
index 0000000..c9c4c24
--- /dev/null
+++ b/Hotline/macOS/Files/FilesView.swift
@@ -0,0 +1,529 @@
+import SwiftUI
+import UniformTypeIdentifiers
+import AppKit
+
+struct FilesView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+ @Environment(\.openWindow) private var openWindow
+
+ @State private var selection: FileInfo?
+ @State private var fileDetails: FileDetails?
+ @State private var uploadFileSelectorDisplayed: Bool = false
+ @State private var searchText: String = ""
+ @State private var isSearching: Bool = false
+ @State private var dragOver: Bool = false
+ @State private var confirmDeleteShown: Bool = false
+ @State private var newFolderShown: Bool = false
+
+ var body: some View {
+ NavigationStack {
+ List(self.displayedFiles, id: \.self, selection: self.$selection) { file in
+ if file.isFolder {
+ FolderItemView(file: file, depth: 0).tag(file.id)
+ }
+ else {
+ FileItemView(file: file, depth: 0).tag(file.id)
+ }
+ }
+ .environment(\.defaultMinListRowHeight, 28)
+ .listStyle(.inset)
+ .alternatingRowBackgrounds(.enabled)
+ .onDrop(of: [.fileURL], isTargeted: self.$dragOver) { items in
+ guard self.model.access?.contains(.canUploadFiles) == true,
+ let item = items.first,
+ let identifier = item.registeredTypeIdentifiers.first else {
+ return false
+ }
+
+ item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in
+ DispatchQueue.main.async {
+ if let urlData = urlData as? Data,
+ let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true) {
+
+ // Access security-scoped resource for drag-and-drop
+ let didStartAccessing = fileURL.startAccessingSecurityScopedResource()
+ defer {
+ if didStartAccessing {
+ fileURL.stopAccessingSecurityScopedResource()
+ }
+ }
+
+ self.upload(file: fileURL, to: [])
+ }
+ }
+ }
+
+ return true
+ }
+ .task {
+ if !self.model.filesLoaded {
+ let _ = try? await self.model.getFileList()
+ }
+ }
+ .contextMenu(forSelectionType: FileInfo.self) { items in
+ let selectedFile = items.first
+
+ Button {
+ if let s = selectedFile {
+ downloadFile(s)
+ }
+ } label: {
+ Label("Download", systemImage: "arrow.down")
+ }
+ .disabled(selectedFile == nil)
+
+ Divider()
+
+ Button {
+ if let s = selectedFile {
+ getFileInfo(s)
+ }
+ } label: {
+ Label("Get Info", systemImage: "info.circle")
+ }
+ .disabled(selectedFile == nil)
+
+ Button {
+ if let s = selectedFile {
+ previewFile(s)
+ }
+ } label: {
+ Label("Preview", systemImage: "eye")
+ }
+ .disabled(selectedFile == nil || (selectedFile != nil && !selectedFile!.isPreviewable))
+
+ if model.access?.contains(.canDeleteFiles) == true {
+ Divider()
+
+ Button {
+ self.confirmDeleteShown = true
+ } label: {
+ Label("Delete...", systemImage: "trash")
+ }
+ .disabled(selectedFile == nil)
+ }
+ } primaryAction: { items in
+ guard let clickedFile = items.first else {
+ return
+ }
+
+ self.selection = clickedFile
+ if clickedFile.isFolder {
+ clickedFile.expanded.toggle()
+ }
+ else {
+ downloadFile(clickedFile)
+ }
+ }
+ .onKeyPress(.rightArrow) {
+ if let s = selection, s.isFolder {
+ s.expanded = true
+ return .handled
+ }
+ return .ignored
+ }
+ .onKeyPress(.leftArrow) {
+ if let s = selection, s.isFolder {
+ s.expanded = false
+ return .handled
+ }
+ return .ignored
+ }
+ .onKeyPress(.space) {
+ if let s = selection, s.isPreviewable {
+ previewFile(s)
+ return .handled
+ }
+ return .ignored
+ }
+ .overlay {
+ if !model.filesLoaded {
+ VStack {
+ ProgressView()
+ .controlSize(.large)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search")
+ .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden())
+ .toolbar {
+ ToolbarItem {
+ Button {
+ if let selectedFile = selection, selectedFile.isPreviewable {
+ self.previewFile(selectedFile)
+ }
+ } label: {
+ Label("Preview", systemImage: "eye")
+ }
+ .help("Preview")
+ .disabled(selection == nil || selection?.isPreviewable != true)
+ }
+
+ ToolbarItem {
+ Button {
+ if let selectedFile = selection {
+ self.getFileInfo(selectedFile)
+ }
+ } label: {
+ Label("Get Info", systemImage: "info.circle")
+ }
+ .help("Get Info")
+ .disabled(selection == nil)
+ }
+
+ ToolbarItem {
+ Button {
+ self.uploadFileSelectorDisplayed = true
+ } label: {
+ Label("Upload", systemImage: "arrow.up")
+ }
+ .help("Upload")
+ .disabled(model.access?.contains(.canUploadFiles) != true)
+ }
+
+ ToolbarItem {
+ Button {
+ if let selectedFile = selection {
+ self.downloadFile(selectedFile)
+ }
+ } label: {
+ Label("Download", systemImage: "arrow.down")
+ }
+ .help("Download")
+ .disabled(selection == nil || model.access?.contains(.canDownloadFiles) != true)
+ }
+
+ if #available(macOS 26.0, *) {
+ ToolbarSpacer()
+ }
+
+ if self.model.access?.contains(.canCreateFolders) == true {
+ ToolbarItem {
+ Button {
+ self.newFolderShown = true
+ } label: {
+ Label("New Folder", systemImage: "folder.badge.plus")
+ }
+ .help("New Folder")
+ .popover(isPresented: self.$newFolderShown, arrowEdge: .bottom) {
+ NewFolderPopover { folderName in
+ self.newFolder(name: folderName, parent: self.selection)
+ }
+ }
+ }
+ }
+
+ if self.model.access?.contains(.canDeleteFiles) == true || self.model.access?.contains(.canDeleteFolders) == true {
+ ToolbarItem {
+ Button {
+ self.confirmDeleteShown = true
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ .disabled(self.selection == nil)
+ .help("Delete")
+ }
+ }
+ }
+ }
+ .alert("Are you sure you want to permanently delete \"\(self.selection?.name ?? "this file")\"?", isPresented: self.$confirmDeleteShown, actions: {
+ Button("Delete", role: .destructive) {
+ if let s = self.selection {
+ Task {
+ await self.deleteFile(s)
+ }
+ }
+ }
+ }, message: {
+ Text("You cannot undo this action.")
+ })
+ .sheet(item: self.$fileDetails) { item in
+ FileDetailsSheet(details: item)
+ }
+ .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in
+ switch results {
+ case .success(let fileURLS):
+ guard fileURLS.count > 0,
+ let fileURL = fileURLS.first
+ else {
+ return
+ }
+
+ var uploadPath: [String] = []
+
+ if let selection = selection {
+ if selection.isFolder {
+ uploadPath = selection.path
+ }
+ else {
+ uploadPath = Array<String>(selection.path)
+ uploadPath.removeLast()
+ }
+ }
+
+ print("UPLOAD PATH: \(uploadPath)")
+ self.upload(file: fileURL, to: uploadPath)
+// uploadFile(file: fileURL, to: uploadPath)
+
+ case .failure(let error):
+ print(error)
+ }
+ })
+ .onSubmit(of: .search) {
+ #if os(macOS)
+ let shiftPressed = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false
+ if shiftPressed {
+ model.clearFileListCache()
+ }
+ #endif
+
+ let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ model.cancelFileSearch()
+ return
+ }
+ searchText = trimmed
+ model.startFileSearch(query: trimmed)
+ }
+ .onChange(of: searchText) { _, newValue in
+ if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ if isShowingSearchResults {
+ model.cancelFileSearch()
+ }
+ }
+ }
+ .onChange(of: model.fileSearchQuery) { _, newValue in
+ if newValue != searchText {
+ searchText = newValue
+ }
+ }
+ .onAppear {
+ if searchText != model.fileSearchQuery {
+ searchText = model.fileSearchQuery
+ }
+ }
+ .safeAreaInset(edge: .top) {
+ if isShowingSearchResults, let message = searchStatusMessage {
+ HStack(alignment: .center, spacing: 6) {
+ if case .searching(_, _) = model.fileSearchStatus {
+ ProgressView()
+ .controlSize(.small)
+ .accentColor(.white)
+ .tint(.white)
+ }
+ else if case .completed = model.fileSearchStatus {
+ Image(systemName: "checkmark.circle.fill")
+ .resizable()
+ .symbolRenderingMode(.monochrome)
+ .foregroundStyle(.white)
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 16, height: 16)
+ }
+ else if case .failed = model.fileSearchStatus {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .resizable()
+ .symbolRenderingMode(.monochrome)
+ .foregroundStyle(.white)
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 16, height: 16)
+ }
+
+ Text(message)
+ .lineLimit(1)
+ .font(.body)
+ .foregroundStyle(.white)
+
+ Spacer()
+
+ if let pathMessage = searchStatusPath {
+ Text(pathMessage)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .font(.footnote)
+// .fontWeight(.semibold)
+ .foregroundStyle(.white)
+ .opacity(0.5)
+ .padding(.top, 2)
+ }
+ }
+ .padding(.trailing, 14)
+ .padding(.leading, 8)
+ .padding(.vertical, 8)
+ .background {
+ Group {
+ if case .completed = model.fileSearchStatus {
+ Color.fileComplete
+ }
+ else {
+ Color(nsColor: .controlAccentColor)
+ }
+ }
+ .clipShape(.capsule(style: .continuous))
+ }
+ .padding(.horizontal, 8)
+ .padding(.top, 8)
+ }
+ }
+ }
+
+ private var isShowingSearchResults: Bool {
+ switch model.fileSearchStatus {
+ case .idle:
+ return !model.fileSearchResults.isEmpty
+ case .cancelled(_):
+ return !model.fileSearchResults.isEmpty
+ default:
+ return true
+ }
+ }
+
+ private var displayedFiles: [FileInfo] {
+ isShowingSearchResults ? model.fileSearchResults : model.files
+ }
+
+ private var searchStatusMessage: String? {
+ switch model.fileSearchStatus {
+ case .searching(let processed, _):
+ let scanned = processed == 1 ? "folder" : "folders"
+ return "Searched \(processed) \(scanned)..."
+ case .completed(let processed):
+ let count = model.fileSearchResults.count
+ let folderWord = processed == 1 ? "folder" : "folders"
+ if count == 0 {
+ return "No files found in \(processed) \(folderWord)"
+ }
+ return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)"
+ case .cancelled(_):
+ if model.fileSearchResults.isEmpty {
+ return nil
+ }
+ return "Search cancelled"
+ case .failed(let message):
+ return "Search failed: \(message)"
+ case .idle:
+ return nil
+ }
+ }
+
+ private var searchStatusPath: String? {
+ guard let path = model.fileSearchCurrentPath else {
+ return nil
+ }
+ if path.isEmpty {
+ return "/"
+ }
+ return path.joined(separator: "/")
+ }
+
+ private func openPreviewWindow(_ previewInfo: PreviewFileInfo) {
+ switch previewInfo.previewType {
+ case .image:
+ self.openWindow(id: "preview-quicklook", value: previewInfo)
+ case .text:
+ self.openWindow(id: "preview-quicklook", value: previewInfo)
+ case .unknown:
+ self.openWindow(id: "preview-quicklook", value: previewInfo)
+ return
+ }
+ }
+
+ @MainActor private func newFolder(name: String, parent: FileInfo?) {
+ Task {
+ var parentFolder: FileInfo? = nil
+ if parent?.isFolder == true {
+ parentFolder = parent
+ }
+
+ let path: [String] = parentFolder?.path ?? []
+ if try await self.model.newFolder(name: name, parentPath: path) {
+ try await self.model.getFileList(path: path)
+ }
+ }
+ }
+
+ @MainActor private func getFileInfo(_ file: FileInfo) {
+ Task {
+ if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) {
+ self.fileDetails = fileInfo
+ }
+ }
+ }
+
+ @MainActor private func downloadFile(_ file: FileInfo) {
+ if file.isFolder {
+ self.model.downloadFolder(file.name, path: file.path)
+ }
+ else {
+ self.model.downloadFile(file.name, path: file.path)
+ }
+ }
+
+ @MainActor private func uploadFile(file fileURL: URL, to path: [String]) throws {
+ self.model.uploadFile(url: fileURL, path: path) { info in
+ Task {
+ // Refresh file listing to display newly uploaded file.
+ try? await self.model.getFileList(path: path)
+ }
+ }
+ }
+
+ @MainActor private func upload(file fileURL: URL, to path: [String]) {
+ var fileIsDirectory: ObjCBool = false
+ guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false), isDirectory: &fileIsDirectory) else {
+ return
+ }
+
+ if fileIsDirectory.boolValue {
+ self.model.uploadFolder(url: fileURL, path: path, complete: { info in
+ Task {
+ // Refresh file listing to display newly uploaded file.
+ try? await model.getFileList(path: path)
+ }
+ })
+ }
+ else {
+ self.model.uploadFile(url: fileURL, path: path) { info in
+ Task {
+ // Refresh file listing to display newly uploaded file.
+ try? await model.getFileList(path: path)
+ }
+ }
+ }
+ }
+
+ @MainActor private func previewFile(_ file: FileInfo) {
+ guard file.isPreviewable else {
+ return
+ }
+
+ self.model.previewFile(file.name, path: file.path) { info in
+ if let info = info {
+ var extendedInfo = info
+ extendedInfo.creator = file.creator
+ extendedInfo.type = file.type
+ self.openPreviewWindow(extendedInfo)
+ }
+ }
+ }
+
+ private func deleteFile(_ file: FileInfo) async {
+ var parentPath: [String] = []
+ if file.path.count > 1 {
+ parentPath = Array(file.path[0..<file.path.count-1])
+ }
+
+ do {
+ try await self.model.deleteFile(file.name, path: file.path)
+ try await self.model.getFileList(path: parentPath)
+ }
+ catch {
+ print("Error deleting file: \(error)")
+ }
+ }
+}
+
+#Preview {
+ FilesView()
+ .environment(HotlineState())
+}
diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift
new file mode 100644
index 0000000..f82bb11
--- /dev/null
+++ b/Hotline/macOS/Files/FolderItemView.swift
@@ -0,0 +1,140 @@
+import SwiftUI
+
+struct FolderItemView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+
+ @State var loading = false
+ @State var dragOver = false
+
+ var file: FileInfo
+ let depth: Int
+
+ @MainActor private func uploadFile(file fileURL: URL) {
+ var filePath: [String] = [String](self.file.path)
+ if !self.file.isFolder {
+ filePath.removeLast()
+ }
+
+ print("UPLOADING TO PATH: ", filePath)
+
+ self.model.uploadFile(url: fileURL, path: filePath) { info in
+ Task {
+ // Refresh file listing to display newly uploaded file.
+ try? await model.getFileList(path: filePath)
+ }
+ }
+ }
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 0) {
+ Spacer()
+ .frame(width: CGFloat(depth * (12 + 2)))
+
+ Button {
+ if file.isFolder {
+ file.expanded.toggle()
+ }
+ } label: {
+ Text(Image(systemName: file.expanded ? "chevron.down" : "chevron.right"))
+ .bold()
+ .font(.system(size: 10))
+ .foregroundStyle(dragOver ? Color.white : Color.primary)
+ .opacity(0.5)
+ }
+ .buttonStyle(.plain)
+ .frame(width: 10)
+ .padding(.leading, 4)
+ .padding(.trailing, 8)
+
+ HStack(alignment: .center) {
+ if file.isUnavailable {
+ Image(systemName: "questionmark.app.fill")
+ .frame(width: 16, height: 16)
+ .opacity(0.5)
+ }
+ else if file.isAdminDropboxFolder {
+ Image("Admin Drop Box")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ else if file.isDropboxFolder {
+ Image("Drop Box")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ else {
+ Image("Folder")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ }
+ .frame(width: 16)
+ .padding(.trailing, 6)
+
+ Text(file.name)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .foregroundStyle(dragOver ? Color.white : Color.primary)
+ .opacity(file.isUnavailable ? 0.5 : 1.0)
+
+ if loading {
+ ProgressView().controlSize(.mini).padding([.leading, .trailing], 5)
+ }
+ Spacer()
+ if !file.isUnavailable {
+ Text(file.fileSize == 0 ? "Empty" : "^[\(file.fileSize) \("file")](inflect: true)")
+ .foregroundStyle(dragOver ? Color.white.opacity(0.75) : Color.secondary)
+ .lineLimit(1)
+ .padding(.trailing, 6)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(
+ RoundedRectangle(cornerRadius: 4.0)
+ .fill(dragOver ? Color(nsColor: NSColor.selectedContentBackgroundColor) : Color.clear)
+ .padding(.horizontal, -6)
+ .padding(.vertical, -4)
+ )
+ .onChange(of: file.expanded) {
+ loading = false
+ if file.expanded && file.fileSize > 0 {
+ Task {
+ loading = true
+ let _ = try? await model.getFileList(path: file.path)
+ loading = false
+ }
+ }
+ }
+ .onDrop(of: [.fileURL], isTargeted: $dragOver) { items in
+ guard let item = items.first,
+ let identifier = item.registeredTypeIdentifiers.first else {
+ return false
+ }
+
+ item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in
+ DispatchQueue.main.async {
+ if let urlData = urlData as? Data,
+ let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true) {
+ self.uploadFile(file: fileURL)
+ }
+ }
+ }
+
+ return true
+ }
+
+ if file.expanded {
+ ForEach(file.children!, id: \.self) { childFile in
+ if childFile.isFolder {
+ FolderItemView(file: childFile, depth: self.depth + 1).tag(file.id)
+ }
+ else {
+ FileItemView(file: childFile, depth: self.depth + 1).tag(file.id)
+ }
+ }
+ }
+ }
+}
diff --git a/Hotline/macOS/Files/NewFolderPopover.swift b/Hotline/macOS/Files/NewFolderPopover.swift
new file mode 100644
index 0000000..4e282f5
--- /dev/null
+++ b/Hotline/macOS/Files/NewFolderPopover.swift
@@ -0,0 +1,51 @@
+import SwiftUI
+
+struct NewFolderPopover: View {
+ @Environment(\.dismiss) private var dismiss
+
+ let action: ((String) -> Void)?
+
+ @State private var folderName: String = "Untitled Folder"
+
+ var body: some View {
+ VStack(spacing: 16) {
+ TextField("Folder Name", text: self.$folderName)
+ .onSubmit(of: .text) {
+ self.createFolder()
+ }
+
+ HStack(spacing: 8) {
+ Spacer()
+
+ Button("Cancel", role: .cancel) {
+ self.dismiss()
+ }
+ .buttonStyle(.bordered)
+ .buttonBorderShape(.capsule)
+
+ if #available(macOS 26.0, *) {
+ Button("New Folder", role: .confirm) {
+ self.createFolder()
+ }
+ .buttonStyle(.borderedProminent)
+ .buttonBorderShape(.capsule)
+ }
+ else {
+ Button("OK") {
+ self.dismiss()
+ self.action?(self.folderName)
+ }
+ .buttonStyle(.borderedProminent)
+ .buttonBorderShape(.capsule)
+ }
+ }
+ }
+ .frame(width: 250)
+ .padding()
+ }
+
+ private func createFolder() {
+ self.dismiss()
+ self.action?(self.folderName)
+ }
+}
diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift
deleted file mode 100644
index fd5826f..0000000
--- a/Hotline/macOS/FilesView.swift
+++ /dev/null
@@ -1,481 +0,0 @@
-import SwiftUI
-import UniformTypeIdentifiers
-
-struct FolderView: View {
- @Environment(Hotline.self) private var model: Hotline
-
- @State var loading = false
- @State var dragOver = false
-
- var file: FileInfo
- let depth: Int
-
- @MainActor private func uploadFile(file fileURL: URL) {
- var filePath: [String] = [String](self.file.path)
- if !self.file.isFolder {
- filePath.removeLast()
- }
-
- print("UPLOADING TO PATH: ", filePath)
-
- model.uploadFile(url: fileURL, path: filePath) { info in
- Task {
- // Refresh file listing to display newly uploaded file.
- let _ = await model.getFileList(path: filePath)
- }
- }
- }
-
- var body: some View {
- HStack(alignment: .center, spacing: 0) {
- Spacer()
- .frame(width: CGFloat(depth * (12 + 2)))
-
- Button {
- if file.isFolder {
- file.expanded.toggle()
- }
- } label: {
- Text(Image(systemName: file.expanded ? "chevron.down" : "chevron.right"))
- .bold()
- .font(.system(size: 10))
- .foregroundStyle(dragOver ? Color.white : Color.primary)
- .opacity(0.5)
- }
- .buttonStyle(.plain)
- .frame(width: 10)
- .padding(.leading, 4)
- .padding(.trailing, 8)
-
- HStack(alignment: .center) {
- if file.isUnavailable {
- Image(systemName: "questionmark.app.fill")
- .frame(width: 16, height: 16)
- .opacity(0.5)
- } else if file.isAdminDropboxFolder {
- Image("Admin Drop Box")
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16)
- } else if file.isDropboxFolder {
- Image("Drop Box")
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16)
- } else {
- Image("Folder")
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16)
- }
- }
- .frame(width: 16)
- .padding(.trailing, 6)
-
- Text(file.name)
- .lineLimit(1)
- .truncationMode(.tail)
- .foregroundStyle(dragOver ? Color.white : Color.primary)
- .opacity(file.isUnavailable ? 0.5 : 1.0)
-
- if loading {
- ProgressView().controlSize(.small).padding([.leading, .trailing], 5)
- }
- Spacer()
- if !file.isUnavailable {
- Text(file.fileSize == 0 ? "Empty" : "^[\(file.fileSize) \("file")](inflect: true)")
- .foregroundStyle(dragOver ? Color.white.opacity(0.75) : Color.secondary)
- .lineLimit(1)
- .padding(.trailing, 6)
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(
- RoundedRectangle(cornerRadius: 4.0)
- .fill(dragOver ? Color(nsColor: NSColor.selectedContentBackgroundColor) : Color.clear)
- .padding(.horizontal, -6)
- .padding(.vertical, -4)
- )
- .onChange(of: file.expanded) {
- loading = false
- if file.expanded && file.fileSize > 0 {
- Task {
- loading = true
- let _ = await model.getFileList(path: file.path)
- loading = false
- }
- }
- }
- .onDrop(of: [.fileURL], isTargeted: $dragOver) { items in
- guard let item = items.first,
- let identifier = item.registeredTypeIdentifiers.first
- else {
- return false
- }
-
- item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in
- DispatchQueue.main.async {
- if let urlData = urlData as? Data,
- let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true)
- {
- uploadFile(file: fileURL)
- }
- }
- }
-
- return true
- }
-
- if file.expanded {
- ForEach(file.children!, id: \.self) { childFile in
- if childFile.isFolder {
- FolderView(file: childFile, depth: self.depth + 1).tag(file.id)
- } else {
- FileView(file: childFile, depth: self.depth + 1).tag(file.id)
- }
- }
- }
- }
-}
-
-struct FileView: View {
- @Environment(Hotline.self) private var model: Hotline
-
- var file: FileInfo
- let depth: Int
-
- var body: some View {
- HStack(alignment: .center, spacing: 0) {
- Spacer()
- .frame(width: CGFloat(depth * (12 + 2)))
-
- Spacer()
- .frame(width: 10)
- .padding(.leading, 4)
- .padding(.trailing, 8)
-
- HStack(alignment: .center) {
- if file.isUnavailable {
- Image(systemName: "questionmark.app.fill")
- .frame(width: 16, height: 16)
- .opacity(0.5)
- } else {
- FileIconView(filename: file.name, fileType: file.type)
- .frame(width: 16, height: 16)
- }
- }
- .frame(width: 16)
- .padding(.trailing, 6)
-
- Text(file.name)
- .lineLimit(1)
- .truncationMode(.tail)
- .opacity(file.isUnavailable ? 0.5 : 1.0)
-
- Spacer()
- if !file.isUnavailable {
- Text(formattedFileSize(file.fileSize))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .padding(.trailing, 6)
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
-
- if file.expanded {
- ForEach(file.children!, id: \.self) { childFile in
- if childFile.isFolder {
- FolderView(file: childFile, depth: self.depth + 1).tag(file.id)
- } else {
- FileView(file: childFile, depth: self.depth + 1).tag(file.id)
- }
- }
- }
- }
-
- static let byteFormatter = ByteCountFormatter()
-
- private func formattedFileSize(_ fileSize: UInt) -> String {
- FileView.byteFormatter.allowedUnits = [.useAll]
- FileView.byteFormatter.countStyle = .file
- return FileView.byteFormatter.string(fromByteCount: Int64(fileSize))
- }
-}
-
-struct FilesView: View {
- @Environment(Hotline.self) private var model: Hotline
- @Environment(\.openWindow) private var openWindow
-
- @State private var selection: FileInfo?
- @State private var fileDetails: FileDetails?
- @State private var uploadFileSelectorDisplayed: Bool = false
-
- private func openPreviewWindow(_ previewInfo: PreviewFileInfo) {
- switch previewInfo.previewType {
- case .image:
- openWindow(id: "preview-image", value: previewInfo)
- case .text:
- openWindow(id: "preview-text", value: previewInfo)
- default:
- return
- }
- }
-
- @MainActor private func getFileInfo(_ file: FileInfo) {
- Task {
- if let fileInfo = await model.getFileDetails(file.name, path: file.path) {
- Task { @MainActor in
- self.fileDetails = fileInfo
- }
- }
- }
- }
-
- @MainActor private func downloadFile(_ file: FileInfo) {
- guard !file.isFolder else {
- return
- }
-
- model.downloadFile(file.name, path: file.path)
- }
-
- @MainActor private func uploadFile(file fileURL: URL, to path: [String]) {
- model.uploadFile(url: fileURL, path: path) { info in
- Task {
- // Refresh file listing to display newly uploaded file.
- let _ = await model.getFileList(path: path)
- }
- }
- }
-
- @MainActor private func previewFile(_ file: FileInfo) {
- guard file.isPreviewable else {
- return
- }
-
- model.previewFile(file.name, path: file.path) { info in
- if let info = info {
- openPreviewWindow(info)
- }
- }
- }
-
- private func deleteFile(_ file: FileInfo) async {
- var parentPath: [String] = []
- if file.path.count > 1 {
- parentPath = Array(file.path[0..<file.path.count - 1])
- }
-
- if await model.deleteFile(file.name, path: file.path) {
- let _ = await model.getFileList(path: parentPath)
- }
- }
-
- // Friendship Quest Remix
- private func isInHomeFolder(_ file: FileInfo?) -> Bool {
- if let file {
- return file.path.first == "~" && file.path.count > 1
- }
- return false
- }
-
- var body: some View {
- NavigationStack {
- List(model.files, id: \.self, selection: $selection) { file in
- if file.isFolder {
- FolderView(file: file, depth: 0).tag(file.id)
- } else {
- FileView(file: file, depth: 0).tag(file.id)
- }
- }
- .environment(\.defaultMinListRowHeight, 28)
- .listStyle(.inset)
- .alternatingRowBackgrounds(.enabled)
- .task {
- if !model.filesLoaded {
- let _ = await model.getFileList()
- }
- }
- .contextMenu(forSelectionType: FileInfo.self) { items in
- let selectedFile = items.first
-
- Button {
- if let s = selectedFile, !s.isFolder {
- downloadFile(s)
- }
- } label: {
- Label("Download", systemImage: "arrow.down")
- }
- .disabled(selectedFile == nil || (selectedFile != nil && selectedFile!.isFolder))
-
- Divider()
-
- Button {
- if let s = selectedFile {
- getFileInfo(s)
- }
- } label: {
- Label("Get Info", systemImage: "info.circle")
- }
- .disabled(selectedFile == nil)
-
- Button {
- if let s = selectedFile {
- previewFile(s)
- }
- } label: {
- Label("Preview", systemImage: "eye")
- }
- .disabled(selectedFile == nil || (selectedFile != nil && !selectedFile!.isPreviewable))
-
- if model.access?.contains(.canDeleteFiles) == true
- // Friendship Quest Remix
- || isInHomeFolder(selectedFile)
- {
- Divider()
-
- Button {
- if let s = selectedFile {
- Task {
- await deleteFile(s)
- }
- }
- } label: {
- Label("Delete", systemImage: "trash")
- }
- .disabled(selectedFile == nil)
- }
- } primaryAction: { items in
- guard let clickedFile = items.first else {
- return
- }
-
- self.selection = clickedFile
- if clickedFile.isFolder {
- clickedFile.expanded.toggle()
- } else {
- downloadFile(clickedFile)
- }
- }
- .onKeyPress(.rightArrow) {
- if let s = selection, s.isFolder {
- s.expanded = true
- return .handled
- }
- return .ignored
- }
- .onKeyPress(.leftArrow) {
- if let s = selection, s.isFolder {
- s.expanded = false
- return .handled
- }
- return .ignored
- }
- .onKeyPress(.space) {
- if let s = selection, s.isPreviewable {
- previewFile(s)
- return .handled
- }
- return .ignored
- }
- .overlay {
- if !model.filesLoaded {
- VStack {
- ProgressView()
- .controlSize(.large)
- }
- .frame(maxWidth: .infinity)
- }
- }
- .toolbar {
- ToolbarItem(placement: .primaryAction) {
- Button {
- if let selectedFile = selection, selectedFile.isPreviewable {
- previewFile(selectedFile)
- }
- } label: {
- Label("Preview", systemImage: "eye")
- }
- .help("Preview")
- .disabled(selection == nil || selection?.isPreviewable == false)
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button {
- if let selectedFile = selection {
- getFileInfo(selectedFile)
- }
- } label: {
- Label("Get Info", systemImage: "info.circle")
- }
- .help("Get Info")
- .disabled(selection == nil)
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button {
- uploadFileSelectorDisplayed = true
- } label: {
- Label("Upload", systemImage: "arrow.up")
- }
- .help("Upload")
- .disabled(model.access?.contains(.canUploadFiles) != true)
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button {
- if let selectedFile = selection, !selectedFile.isFolder {
- downloadFile(selectedFile)
- }
- } label: {
- Label("Download", systemImage: "arrow.down")
- }
- .help("Download")
- .disabled(
- selection == nil || selection?.isFolder == true
- || model.access?.contains(.canDownloadFiles) != true)
- }
- }
- }
- .sheet(item: $fileDetails) { item in
- FileDetailsView(fd: item)
- }
- .fileImporter(
- isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data],
- allowsMultipleSelection: false,
- onCompletion: { results in
- switch results {
- case .success(let fileURLS):
- guard fileURLS.count > 0 else {
- return
- }
-
- let fileURL = fileURLS.first!
-
- print(fileURL)
-
- var uploadPath: [String] = []
-
- if let selection = selection {
- if selection.isFolder {
- uploadPath = selection.path
- } else {
- uploadPath = [String](selection.path)
- uploadPath.removeLast()
- }
- }
-
- print("UPLOAD PATH: \(uploadPath)")
- uploadFile(file: fileURL, to: uploadPath)
-
- case .failure(let error):
- print(error)
- }
- })
- }
-}
-
-#Preview {
- FilesView()
- .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
-}
diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift
index 2617bc8..aec00df 100644
--- a/Hotline/macOS/HotlinePanelView.swift
+++ b/Hotline/macOS/HotlinePanelView.swift
@@ -1,23 +1,44 @@
import SwiftUI
+import Kingfisher
struct HotlinePanelView: View {
@Environment(\.openWindow) var openWindow
@Environment(\.colorScheme) var colorScheme
+ @Environment(\.appState) private var appState
+
+ private var activeServerState: ServerState? {
+ self.appState.activeServerState
+ }
+
+ private var activeHotline: HotlineState? {
+ self.appState.activeHotline
+ }
+
+ private var bannerImage: Image {
+ self.activeHotline?.bannerImage ?? Image("Default Banner")
+ }
+
+ private var backgroundColor: Color {
+ Color(nsColor: self.activeHotline?.bannerColors?.backgroundColor ?? NSColor.controlBackgroundColor)
+ }
+
+ private var bannerFileURL: URL? {
+ self.activeHotline?.bannerFileURL
+ }
+
+ private var bannerIsAnimated: Bool {
+ self.activeHotline?.bannerImageFormat == .gif
+ }
var body: some View {
VStack(spacing: 0) {
- Image(
- nsImage: ApplicationState.shared.activeServerBanner ?? NSImage(named: "Default Banner")!
- )
- .interpolation(.high)
- .resizable()
- .scaledToFill()
- .frame(width: 468, height: 60)
- .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60)
- .clipped()
- .background(.black)
- // .clipShape(RoundedRectangle(cornerRadius: 6.0))
- // .padding([.top, .leading, .trailing], 4)
+
+ self.bannerView
+ .id("banner image view")
+ .animation(.default, value: self.bannerFileURL)
+ .background {
+ Color.black
+ }
HStack(spacing: 12) {
Button {
@@ -36,71 +57,86 @@ struct HotlinePanelView: View {
.help("Hotline Servers")
Button {
- ApplicationState.shared.activeServerState?.selection = .chat
- } label: {
+ self.activeServerState?.selection = .chat
+ }
+ label: {
Image("Section Chat")
.resizable()
.scaledToFit()
}
.buttonStyle(.plain)
.frame(width: 20, height: 20)
- .disabled(ApplicationState.shared.activeServerState == nil)
+ .disabled(self.activeServerState == nil)
.help("Public Chat")
Button {
- ApplicationState.shared.activeServerState?.selection = .board
- } label: {
+ self.activeServerState?.selection = .board
+ }
+ label: {
Image("Section Board")
.resizable()
.scaledToFit()
}
.buttonStyle(.plain)
.frame(width: 20, height: 20)
- .disabled(ApplicationState.shared.activeServerState == nil)
+ .disabled(self.activeServerState == nil)
.help("Message Board")
Button {
- ApplicationState.shared.activeServerState?.selection = .news
- } label: {
+ self.activeServerState?.selection = .news
+ }
+ label: {
Image("Section News")
.resizable()
.scaledToFit()
}
.buttonStyle(.plain)
.frame(width: 20, height: 20)
- .disabled(
- ApplicationState.shared.activeServerState == nil
- || (ApplicationState.shared.activeHotline?.serverVersion ?? 0) < 151
- )
+ .disabled(self.activeServerState == nil || (self.activeHotline?.serverVersion ?? 0) < 151)
.help("News")
Button {
- ApplicationState.shared.activeServerState?.selection = .files
- } label: {
+ self.activeServerState?.selection = .files
+ }
+ label: {
Image("Section Files")
.resizable()
.scaledToFit()
}
.buttonStyle(.plain)
.frame(width: 20, height: 20)
- .disabled(ApplicationState.shared.activeServerState == nil)
+ .disabled(self.activeServerState == nil)
.help("Files")
Spacer()
- if ApplicationState.shared.activeHotline?.access?.contains(.canOpenUsers) == true {
+ if self.activeHotline?.access?.contains(.canOpenUsers) == true {
Button {
- ApplicationState.shared.activeServerState?.selection = .accounts
- } label: {
+// self.activeServerState?.selection = .accounts
+ self.activeServerState?.accountsShown = true
+ }
+ label: {
Image("Section Users")
.resizable()
.scaledToFit()
}
.buttonStyle(.plain)
.frame(width: 20, height: 20)
- .disabled(ApplicationState.shared.activeServerState == nil)
- .help("Accounts")
+ .disabled(self.activeServerState == nil)
+ .help("Manage Accounts")
+ }
+
+ Button {
+ self.openWindow(id: "transfers")
}
+ label: {
+ Image("Section Transfers")
+ .resizable()
+ .scaledToFit()
+ }
+ .buttonStyle(.plain)
+ .frame(width: 20, height: 20)
+ .help("File Transfers")
SettingsLink(label: {
Image("Section Settings")
@@ -114,22 +150,24 @@ struct HotlinePanelView: View {
.padding(.top, 12)
.padding(.bottom, 12)
.padding([.leading, .trailing], 12)
- // .background(Color.red.opacity(0.5).blendMode(.multiply))
-
- // GroupBox {
- // HStack(spacing: 0) {
- // Text("Not Connected")
- // .font(.system(size: 10.0))
- // .lineLimit(1)
- // .truncationMode(.tail)
- // .opacity(0.5)
- // .padding(.vertical, 0.0)
- // .padding(.horizontal, 4.0)
- //
- // Spacer()
- // }
- // }
- // .padding([.leading, .bottom, .trailing], 4.0)
+ .background(self.backgroundColor)
+ .foregroundStyle(.primary)
+ .animation(.default, value: self.backgroundColor)
+
+// GroupBox {
+// HStack(spacing: 0) {
+// Text("Not Connected")
+// .font(.system(size: 10.0))
+// .lineLimit(1)
+// .truncationMode(.tail)
+// .opacity(0.5)
+// .padding(.vertical, 0.0)
+// .padding(.horizontal, 4.0)
+//
+// Spacer()
+// }
+// }
+// .padding([.leading, .bottom, .trailing], 4.0)
}
// .frame(width: 468)
// .background(colorScheme == .dark ? .black : .white)
@@ -138,9 +176,48 @@ struct HotlinePanelView: View {
// .cornerRadius(10.0)
// )
}
+
+ private var bannerView: some View {
+ ZStack {
+ if self.bannerIsAnimated {
+ KFAnimatedImage
+ .url(self.bannerFileURL)
+ .placeholder {
+ Image("Default Banner")
+ }
+ .cacheMemoryOnly()
+ .cacheOriginalImage()
+ .scaledToFill()
+ .frame(width: 468, height: 60)
+ .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60)
+ .clipped()
+ .transition(.opacity)
+ .id("animated banner \(self.bannerFileURL?.absoluteString ?? "")")
+ }
+ else {
+ KFImage
+ .url(self.bannerFileURL)
+ .resizable()
+ .interpolation(.high)
+ .placeholder {
+ Image("Default Banner")
+ }
+ .cacheMemoryOnly()
+ .cacheOriginalImage()
+ .scaledToFill()
+ .frame(width: 468, height: 60)
+ .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60)
+ .clipped()
+ .transition(.opacity)
+ .id("static banner \(self.bannerFileURL?.absoluteString ?? "")")
+ }
+ }
+ .animation(.default, value: self.bannerIsAnimated)
+ .animation(.default, value: self.bannerFileURL)
+ }
}
#Preview {
HotlinePanelView()
- .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
+ .environment(HotlineState())
}
diff --git a/Hotline/macOS/MessageBoardView.swift b/Hotline/macOS/MessageBoardView.swift
deleted file mode 100644
index 115e4e3..0000000
--- a/Hotline/macOS/MessageBoardView.swift
+++ /dev/null
@@ -1,101 +0,0 @@
-import SwiftUI
-
-struct MessageBoardView: View {
- @Environment(Hotline.self) private var model: Hotline
-
- @State private var composerDisplayed: Bool = false
- @State private var composerText: String = ""
-
- var body: some View {
- NavigationStack {
- if model.access?.contains(.canReadMessageBoard) != false {
- ScrollView {
- LazyVStack(alignment: .leading) {
- ForEach(model.messageBoard, id: \.self) { msg in
- Text(LocalizedStringKey(msg))
- .tint(Color("Link Color"))
- .lineLimit(100)
- .lineSpacing(4)
- .padding()
- .textSelection(.enabled)
- Divider()
- }
- }
- Spacer()
- }
- .task {
- if !model.messageBoardLoaded {
- let _ = await model.getMessageBoard()
- }
- }
- .overlay {
- if !model.messageBoardLoaded {
- VStack {
- ProgressView()
- .controlSize(.large)
- }
- .frame(maxWidth: .infinity)
- }
- }
- .background(Color(nsColor: .textBackgroundColor))
- } else {
- ZStack(alignment: .center) {
- Text("No Message Board")
- .font(.title)
- .multilineTextAlignment(.center)
- .foregroundStyle(.secondary)
- .padding()
- }
- .frame(maxWidth: .infinity)
- }
- }
- .sheet(isPresented: $composerDisplayed) {
- MessageBoardEditorView()
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .frame(idealWidth: 450, idealHeight: 350)
- // RichTextEditor(text: $composerText)
- // .richEditorFont(NSFont.systemFont(ofSize: 16.0))
- // .richEditorAutomaticDashSubstitution(false)
- // .richEditorAutomaticQuoteSubstitution(false)
- // .richEditorAutomaticSpellingCorrection(false)
- // .background(Color(nsColor: .textBackgroundColor))
- // .frame(maxWidth: .infinity, maxHeight: .infinity)
- // .frame(idealWidth: 450, idealHeight: 350)
- // .toolbar {
- // ToolbarItem(placement: .cancellationAction) {
- // Button("Cancel") {
- // composerDisplayed.toggle()
- // }
- // }
- //
- // ToolbarItem(placement: .primaryAction) {
- // Button("Post") {
- // composerDisplayed.toggle()
- // let text = composerText
- // composerText = ""
- // model.postToMessageBoard(text: text)
- // Task {
- // await model.getMessageBoard()
- // }
- // }
- // }
- // }
- }
- .toolbar {
- ToolbarItem(placement: .primaryAction) {
- Button {
- composerDisplayed.toggle()
- } label: {
- Image(systemName: "square.and.pencil")
- }
- .disabled(model.access?.contains(.canPostMessageBoard) == false)
- .help("Post to Message Board")
- }
- }
- }
-}
-
-#Preview {
- MessageBoardView()
- .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
-}
diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift
index f62f922..5c27a6d 100644
--- a/Hotline/macOS/MessageView.swift
+++ b/Hotline/macOS/MessageView.swift
@@ -1,121 +1,183 @@
import SwiftUI
struct MessageView: View {
- @Environment(Hotline.self) private var model: Hotline
+ @Environment(HotlineState.self) private var model: HotlineState
@Environment(\.colorScheme) private var colorScheme
@State private var input: String = ""
@State private var scrollPos: Int?
@State private var contentHeight: CGFloat = 0
+ @State private var userInfo: HotlineUserClientInfo?
+ @State private var disconnectConfirmShown: Bool = false
+ @State private var username: String?
+
@Namespace private var bottomID
@FocusState private var focusedField: FocusedField?
var userID: UInt16
var body: some View {
- ScrollViewReader { reader in
- VStack(alignment: .leading, spacing: 0) {
-
- // MARK: Scroll View
- GeometryReader { gm in
- ScrollView(.vertical) {
- LazyVStack(alignment: .leading) {
- ForEach(model.instantMessages[userID] ?? [InstantMessage]()) { msg in
- HStack(alignment: .firstTextBaseline) {
- if msg.direction == .outgoing {
- Spacer()
- }
-
- Text(LocalizedStringKey(msg.text))
- .lineSpacing(4)
- .multilineTextAlignment(.leading)
- .textSelection(.enabled)
- .tint(
- msg.direction == .outgoing
- ? Color("Outgoing Message Link") : Color("Link Color")
- )
- .foregroundStyle(
- msg.direction == .outgoing
- ? Color("Outgoing Message Text") : Color("Incoming Message Text")
- )
- .padding(EdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14))
- .background(
- msg.direction == .outgoing
- ? Color("Outgoing Message Background")
- : Color("Incoming Message Background")
- )
- .clipShape(RoundedRectangle(cornerRadius: 16))
-
- if msg.direction == .incoming {
- Spacer()
- }
- }
- .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0))
- }
- }
- .padding()
-
- VStack(spacing: 0) {}.id(bottomID)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .defaultScrollAnchor(.bottom)
- .onChange(of: model.instantMessages[userID]?.count) {
- reader.scrollTo(bottomID, anchor: .bottom)
- model.markInstantMessagesAsRead(userID: userID)
+ VStack(alignment: .leading, spacing: 0) {
+
+ // MARK: Scroll View
+ self.messageList
+
+ // MARK: Input Divider
+ Divider()
+
+ // MARK: Input Bar
+ self.inputBar
+ }
+ .background(Color(nsColor: .textBackgroundColor))
+ .onAppear {
+ self.model.markInstantMessagesAsRead(userID: self.userID)
+
+ let user = self.model.users.first(where: { $0.id == self.userID })
+ self.username = user?.name
+ }
+
+ .onAppear {
+ self.model.markInstantMessagesAsRead(userID: userID)
+ }
+ .toolbar {
+ if self.model.access?.contains(.canGetClientInfo) == true {
+ ToolbarItem {
+ Button {
+ self.getUserInfo()
+ } label: {
+ Image(systemName: "info.circle")
}
- .onAppear {
- reader.scrollTo(bottomID, anchor: .bottom)
- model.markInstantMessagesAsRead(userID: userID)
+ .help("View \(self.username ?? "user")'s information")
+ }
+ }
+
+ if self.model.access?.contains(.canDisconnectUsers) == true {
+ ToolbarItem {
+ Button {
+ self.disconnectConfirmShown = true
+ } label: {
+ Image(systemName: "nosign")
}
- .onChange(of: gm.size) {
- reader.scrollTo(bottomID, anchor: .bottom)
+ .help("Disconnect \(self.username ?? "this user")")
+ }
+ }
+ }
+ .sheet(item: self.$userInfo) { info in
+ UserClientInfoSheet(info: info)
+ }
+ .alert("Are you sure you want to disconnect \(self.username ?? "this user")?", isPresented: self.$disconnectConfirmShown) {
+ Button("Disconnect", role: .destructive) {
+ self.disconnectUser()
+ }
+ } message: {
+ Text("They will be disconnected from the server, but may reconnect.")
+ }
+ }
+
+ private func getUserInfo() {
+ Task {
+ if let info = try await self.model.getClientInfoText(id: self.userID) {
+ self.userInfo = info
+ }
+ }
+ }
+
+ private func disconnectUser() {
+ Task {
+ try await self.model.disconnectUser(id: self.userID, options: nil)
+ }
+ }
+
+ private var inputBar: some View {
+ HStack(alignment: .lastTextBaseline, spacing: 0) {
+ TextField("Message \(self.username ?? "")", text: $input, axis: .vertical)
+ .focused($focusedField, equals: .chatInput)
+ .textFieldStyle(.plain)
+ .lineLimit(1...5)
+ .multilineTextAlignment(.leading)
+ .onSubmit {
+ if !self.input.isEmpty {
+ let message = self.input
+ let uid = self.userID
+ Task {
+ try? await self.model.sendInstantMessage(message, userID: uid)
+ }
}
+ self.input = ""
}
-
- // MARK: Input Divider
- Divider()
-
- // MARK: Input Bar
- HStack(alignment: .lastTextBaseline, spacing: 0) {
- let user = model.users.first(where: { $0.id == userID })
- TextField("Message \(user?.name ?? "")", text: $input, axis: .vertical)
- .focused($focusedField, equals: .chatInput)
- .textFieldStyle(.plain)
- .lineLimit(1...5)
- .multilineTextAlignment(.leading)
- .onSubmit {
- if !self.input.isEmpty {
- model.sendInstantMessage(self.input, userID: self.userID)
+ .frame(maxWidth: .infinity)
+ .padding()
+ }
+ .frame(maxWidth: .infinity, minHeight: 28)
+ .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
+ .overlay(alignment: .leadingFirstTextBaseline) {
+ Image(systemName: "chevron.right").opacity(0.4).offset(x: 16)
+ }
+ .onContinuousHover { phase in
+ switch phase {
+ case .active(_):
+ NSCursor.iBeam.set()
+ case .ended:
+ NSCursor.arrow.set()
+ break
+ }
+ }
+ .onTapGesture {
+ focusedField = .chatInput
+ }
+ }
+
+ private var messageList: some View {
+ ScrollViewReader { reader in
+ GeometryReader { gm in
+ ScrollView(.vertical) {
+ LazyVStack(alignment: .leading) {
+ ForEach(self.model.instantMessages[self.userID] ?? [InstantMessage]()) { msg in
+ HStack(alignment: .firstTextBaseline) {
+ if msg.direction == .outgoing {
+ Spacer()
+ }
+
+ Text(LocalizedStringKey(msg.text))
+ .lineSpacing(4)
+ .multilineTextAlignment(.leading)
+ .textSelection(.enabled)
+ .tint(msg.direction == .outgoing ? Color("Outgoing Message Link") : Color("Link Color"))
+ .foregroundStyle(msg.direction == .outgoing ? Color("Outgoing Message Text") : Color("Incoming Message Text"))
+ .padding(EdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14))
+ .background(msg.direction == .outgoing ? Color("Outgoing Message Background") : Color("Incoming Message Background"))
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+
+ if msg.direction == .incoming {
+ Spacer()
+ }
}
- self.input = ""
+ .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0))
}
- .frame(maxWidth: .infinity)
- .padding()
+ }
+ .padding()
+
+ VStack(spacing: 0) {}.id(bottomID)
}
- .frame(maxWidth: .infinity, minHeight: 28)
- .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
- .overlay(alignment: .leadingFirstTextBaseline) {
- Image(systemName: "chevron.right").opacity(0.4).offset(x: 16)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .defaultScrollAnchor(.bottom)
+ .onChange(of: self.model.instantMessages[self.userID]?.count) {
+ reader.scrollTo(self.bottomID, anchor: .bottom)
+ self.model.markInstantMessagesAsRead(userID: self.userID)
}
- .onContinuousHover { phase in
- switch phase {
- case .active(_):
- NSCursor.iBeam.set()
- case .ended:
- NSCursor.arrow.set()
- break
- }
+ .onAppear {
+ reader.scrollTo(self.bottomID, anchor: .bottom)
+ self.model.markInstantMessagesAsRead(userID: self.userID)
}
- .onTapGesture {
- focusedField = .chatInput
+ .onChange(of: gm.size) {
+ reader.scrollTo(self.bottomID, anchor: .bottom)
}
}
- .background(Color(nsColor: .textBackgroundColor))
}
}
}
#Preview {
ChatView()
- .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
+ .environment(HotlineState())
}
diff --git a/Hotline/macOS/NewsEditorView.swift b/Hotline/macOS/News/NewsEditorView.swift
index 9a7242e..62902e1 100644
--- a/Hotline/macOS/NewsEditorView.swift
+++ b/Hotline/macOS/News/NewsEditorView.swift
@@ -9,8 +9,8 @@ struct NewsEditorView: View {
@Environment(\.controlActiveState) private var controlActiveState
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
- @Environment(Hotline.self) private var model: Hotline
-
+ @Environment(HotlineState.self) private var model: HotlineState
+
let editorTitle: String
let isReply: Bool
let path: [String]
@@ -25,15 +25,15 @@ struct NewsEditorView: View {
func sendArticle() async -> Bool {
sending = true
- let success = await model.postNewsArticle(
- title: title, body: text, at: path, parentID: parentID)
- if success {
- await model.getNewsList(at: path)
+ do {
+ try await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID)
+ try? await model.getNewsList(at: path)
+ sending = false
+ return true
+ } catch {
+ sending = false
+ return false
}
-
- sending = false
-
- return success
}
var body: some View {
diff --git a/Hotline/macOS/NewsItemView.swift b/Hotline/macOS/News/NewsItemView.swift
index ff7495b..d477348 100644
--- a/Hotline/macOS/NewsItemView.swift
+++ b/Hotline/macOS/News/NewsItemView.swift
@@ -1,8 +1,8 @@
import SwiftUI
struct NewsItemView: View {
- @Environment(Hotline.self) private var model: Hotline
-
+ @Environment(HotlineState.self) private var model: HotlineState
+
var news: NewsInfo
let depth: Int
@@ -134,7 +134,7 @@ struct NewsItemView: View {
}
Task {
- await model.getNewsList(at: news.path)
+ try? await model.getNewsList(at: news.path)
}
}
@@ -147,11 +147,6 @@ struct NewsItemView: View {
}
#Preview {
- NewsItemView(
- news: NewsInfo(
- hotlineNewsArticle: HotlineNewsArticle(
- id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now,
- flavors: [("", 1)], path: ["Guest"])), depth: 0
- )
- .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
+ NewsItemView(news: NewsInfo(hotlineNewsArticle: HotlineNewsArticle(id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now, flavors: [("", 1)], path: ["Guest"])), depth: 0)
+ .environment(HotlineState())
}
diff --git a/Hotline/macOS/NewsView.swift b/Hotline/macOS/News/NewsView.swift
index d710c62..00b6555 100644
--- a/Hotline/macOS/NewsView.swift
+++ b/Hotline/macOS/News/NewsView.swift
@@ -3,15 +3,14 @@ import SplitView
import SwiftUI
struct NewsView: View {
- @Environment(Hotline.self) private var model: Hotline
+ @Environment(HotlineState.self) private var model: HotlineState
@Environment(\.openWindow) private var openWindow
@Environment(\.colorScheme) private var colorScheme
@State private var selection: NewsInfo?
@State private var articleText: String?
- @State private var splitHidden = SideHolder(.bottom)
- @State private var splitFraction = FractionHolder.usingUserDefaults(
- 0.25, key: "News Split Fraction")
+ @State private var splitHidden: SideHolder = SideHolder(.bottom)
+ @State private var splitFraction = FractionHolder.usingUserDefaults(0.25, key: "News Split Fraction")
@State private var editorOpen: Bool = false
@State private var replyOpen: Bool = false
@State private var loading: Bool = false
@@ -19,34 +18,19 @@ struct NewsView: View {
var body: some View {
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 {
+ disabledNewsView
+ }
+ else if !model.newsLoaded {
+ loadingIndicator
+ }
+ else if model.news.isEmpty {
+ emptyNewsView
+ }
+ else {
NavigationStack {
VSplit(
top: {
- if !model.newsLoaded {
- loadingIndicator
- } else if model.news.isEmpty {
- ZStack(alignment: .center) {
- Text("No News")
- .font(.title)
- .multilineTextAlignment(.center)
- .foregroundStyle(.secondary)
- .padding()
- }
- .frame(maxWidth: .infinity)
- } else {
- newsBrowser
- }
+ newsBrowser
},
bottom: {
articleViewer
@@ -62,13 +46,13 @@ struct NewsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .textBackgroundColor))
}
- .task {
- if !model.newsLoaded {
- loading = true
- await model.getNewsList()
- loading = false
- }
- }
+ }
+ }
+ .task {
+ if !model.newsLoaded {
+ loading = true
+ try? await model.getNewsList()
+ loading = false
}
}
.sheet(isPresented: $editorOpen) {
@@ -98,7 +82,7 @@ struct NewsView: View {
}
}
.toolbar {
- ToolbarItem(placement: .primaryAction) {
+ ToolbarItem {
Button {
if selection?.type == .category || selection?.type == .article {
editorOpen = true
@@ -106,11 +90,11 @@ struct NewsView: View {
} label: {
Image(systemName: "square.and.pencil")
}
- .help("New Post")
+ .help("New post under current topic")
.disabled(selection?.type != .category && selection?.type != .article)
}
-
- ToolbarItem(placement: .primaryAction) {
+
+ ToolbarItem {
Button {
if selection?.type == .article {
replyOpen = true
@@ -118,34 +102,76 @@ struct NewsView: View {
} label: {
Image(systemName: "arrowshape.turn.up.left")
}
- .help("Reply to Post")
+ .help("Reply to selected post")
.disabled(selection?.type != .article)
}
-
- ToolbarItem(placement: .primaryAction) {
+
+ if #available(macOS 26.0, *) {
+ ToolbarSpacer(.fixed)
+ }
+
+ ToolbarItem {
+ Button {
+// if selection?.type == .category || selection?.type == .article {
+// editorOpen = true
+// }
+ } label: {
+ Image(systemName: "newspaper")
+ }
+ .help("Create a new topic")
+ }
+
+ ToolbarItem {
+ Button {
+// if selection?.type == .category || selection?.type == .article {
+// editorOpen = true
+// }
+ } label: {
+ Image(systemName: "tray")
+ }
+ .help("Create a new category")
+ }
+
+ ToolbarItem {
Button {
loading = true
if let selectionPath = selection?.path {
Task {
- await model.getNewsList(at: selectionPath)
+ try? await model.getNewsList(at: selectionPath)
loading = false
}
} else {
Task {
- await model.getNewsList()
+ try? await model.getNewsList()
loading = false
}
}
} label: {
Image(systemName: "arrow.clockwise")
}
- .help("Reload News")
+ .help("Reload selected topic")
.disabled(loading)
}
}
}
-
- var newsBrowser: some View {
+
+ private var disabledNewsView: some View {
+ ContentUnavailableView {
+ Label("No News", systemImage: "newspaper")
+ } description: {
+ Text("This server has turned off newsgroups")
+ }
+ }
+
+ private var emptyNewsView: some View {
+ ContentUnavailableView {
+ Label("No News", systemImage: "newspaper")
+ } description: {
+ Text("This server has no newsgroups")
+ }
+ }
+
+ private var newsBrowser: some View {
List(model.news, id: \.self, selection: $selection) { newsItem in
NewsItemView(news: newsItem, depth: 0).tag(newsItem.id)
}
@@ -187,9 +213,7 @@ struct NewsView: View {
let articleID = article.articleID
{
Task {
- if let articleText = await self.model.getNewsArticle(
- id: articleID, at: article.path, flavor: articleFlavor)
- {
+ if let articleText = try? await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) {
self.articleText = articleText
}
}
@@ -223,8 +247,8 @@ struct NewsView: View {
return .ignored
}
}
-
- var loadingIndicator: some View {
+
+ private var loadingIndicator: some View {
VStack {
HStack {
ProgressView {
@@ -235,8 +259,8 @@ struct NewsView: View {
}
.frame(maxWidth: .infinity)
}
-
- var articleViewer: some View {
+
+ private var articleViewer: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if let selection = selection, selection.type == .article {
@@ -272,23 +296,20 @@ struct NewsView: View {
.lineSpacing(6)
.padding(.top, 16)
}
- } else {
- HStack(alignment: .center) {
- Spacer()
- HStack(alignment: .center, spacing: 8) {
- // Image(systemName: "doc.append")
- // .resizable()
- // .scaledToFit()
- // .foregroundStyle(.tertiary)
- // .frame(width: 16, height: 16)
- Text("Select a news post to read")
- .foregroundStyle(.tertiary)
- .font(.system(size: 13))
- }
- Spacer()
- }
- .padding()
- .padding(.top, 48)
+ }
+ else {
+ EmptyView()
+// HStack(alignment: .center) {
+// Spacer()
+// HStack(alignment: .center, spacing: 8) {
+// Text("Select a news article to read")
+// .foregroundStyle(.tertiary)
+// .font(.subheadline)
+// }
+// Spacer()
+// }
+// .padding()
+// .padding(.top, 48)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
@@ -301,5 +322,5 @@ struct NewsView: View {
#Preview {
NewsView()
- .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
+ .environment(HotlineState())
}
diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift
index 3a25925..4026e92 100644
--- a/Hotline/macOS/ServerView.swift
+++ b/Hotline/macOS/ServerView.swift
@@ -1,28 +1,6 @@
import SwiftUI
import UniformTypeIdentifiers
-
-@Observable
-class ServerState: Equatable {
- var id: UUID = UUID()
- var selection: ServerNavigationType
-
- init(selection: ServerNavigationType) {
- self.selection = selection
- }
-
- static func == (lhs: ServerState, rhs: ServerState) -> Bool {
- return lhs.id == rhs.id
- }
-}
-
-enum MenuItemType {
- case chat
- case news
- case messageBoard
- case files
- case tasks
- case user
-}
+import AppKit
struct ServerMenuItem: Identifiable, Hashable {
let id: UUID
@@ -69,10 +47,8 @@ struct ListItemView: View {
if let i = icon {
Image(i)
.resizable()
- // .renderingMode(.template)
.scaledToFit()
.frame(width: 20, height: 20)
- // .padding(.leading, 2)
.opacity(controlActiveState == .inactive ? 0.5 : 1.0)
}
@@ -90,80 +66,39 @@ struct ListItemView: View {
}
}
-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 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 .accounts:
- return "Accounts"
- case .user(let userID):
- return String(userID)
- }
- }
-
- case chat
- case news
- case board
- case files
- case accounts
- case user(userID: UInt16)
+ @Entry var activeHotlineModel: HotlineState?
+ @Entry var activeServerState: ServerState?
}
struct ServerView: View {
- @Environment(\.dismiss) var dismiss
+ @Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@Environment(\.controlActiveState) private var controlActiveState
@Environment(\.scenePhase) private var scenePhase
@Environment(\.modelContext) private var modelContext
+
+ @Binding var server: Server
+
+ @State private var model: HotlineState = HotlineState()
@State private var reconnectTimer: Task<Void, Never>?
@State private var shouldReconnect: Bool = false
- @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 connectAddress: String = ""
@State private var connectLogin: String = ""
@State private var connectPassword: String = ""
- @State private var connectNameSheetPresented: Bool = false
- @State private var connectName: String = ""
- @State private var autoconnect: Bool = false
-
- @Binding var server: Server
+ @State private var connectionDisplayed: Bool = false
+// @State private var accountsShown: Bool = false
+ @State private var autoconnect: Bool = false
static var menuItems: [ServerMenuItem] = [
ServerMenuItem(type: .chat, name: "Chat", image: "Section Chat"),
ServerMenuItem(type: .board, name: "Board", image: "Section Board"),
ServerMenuItem(type: .news, name: "News", image: "Section News"),
ServerMenuItem(type: .files, name: "Files", image: "Section Files"),
- ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"),
+// ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"),
]
static var classicMenuItems: [ServerMenuItem] = [
@@ -171,21 +106,19 @@ struct ServerView: View {
ServerMenuItem(type: .board, name: "Board", image: "Section Board"),
ServerMenuItem(type: .files, name: "Files", image: "Section Files"),
]
-
- enum FocusFields {
- case address
- case login
- case password
- }
-
- @FocusState private var focusedField: FocusFields?
-
+
var body: some View {
Group {
- if model.status == .disconnected {
- connectForm
- .navigationTitle("Connect to Server")
- } else if model.status != .loggedIn {
+ if self.model.status == .disconnected {
+ VStack(alignment: .center) {
+ Spacer()
+ self.connectForm
+ Spacer()
+ }
+ .presentedWindowToolbarStyle(.unified(showsTitle: false))
+ .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle)
+ }
+ else if self.model.status.isLoggingIn {
HStack {
Image("Hotline")
.resizable()
@@ -193,41 +126,80 @@ struct ServerView: View {
.scaledToFit()
.foregroundColor(Color(hex: 0xE10000))
.frame(width: 18)
- .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
+ .opacity(self.controlActiveState == .inactive ? 0.5 : 1.0)
.padding(.trailing, 4)
- ProgressView(value: connectionStatusToProgress(status: model.status)) {
- Text(connectionStatusToLabel(status: model.status))
+ ProgressView(value: connectionStatusToProgress(status: self.model.status)) {
+ Text(connectionStatusToLabel(status: self.model.status))
}
- .accentColor(colorScheme == .dark ? .white : .black)
+ .accentColor(self.colorScheme == .dark ? .white : .black)
}
.frame(maxWidth: 300)
.padding()
- .navigationTitle("Connecting to Server")
- } else {
- serverView
- .environment(model)
- .onChange(of: Prefs.shared.userIconID) { sendPreferences() }
- .onChange(of: Prefs.shared.username) { sendPreferences() }
- .onChange(of: Prefs.shared.refusePrivateMessages) { sendPreferences() }
- .onChange(of: Prefs.shared.refusePrivateChat) { sendPreferences() }
- .onChange(of: Prefs.shared.enableAutomaticMessage) { sendPreferences() }
- .onChange(of: Prefs.shared.automaticMessage) { sendPreferences() }
+ .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle)
+ }
+ else if self.model.status == .loggedIn {
+ self.serverView
+ .environment(self.model)
+ .onChange(of: Prefs.shared.userIconID) {
+ Task { try? await self.model.sendUserPreferences() }
+ }
+ .onChange(of: Prefs.shared.username) {
+ Task { try? await self.model.sendUserPreferences() }
+ }
+ .onChange(of: Prefs.shared.refusePrivateMessages) {
+ Task { try? await self.model.sendUserPreferences() }
+ }
+ .onChange(of: Prefs.shared.refusePrivateChat) {
+ Task { try? await self.model.sendUserPreferences() }
+ }
+ .onChange(of: Prefs.shared.enableAutomaticMessage) {
+ Task { try? await self.model.sendUserPreferences() }
+ }
+ .onChange(of: Prefs.shared.automaticMessage) {
+ Task { try? await self.model.sendUserPreferences() }
+ }
+ .sheet(isPresented: self.$state.broadcastShown) {
+ BroadcastMessageSheet()
+ .environment(self.model)
+ .presentationSizing(.fitted)
+ }
+ .sheet(isPresented: self.$state.accountsShown) {
+ AccountManagerView()
+ .environment(self.model)
+ .frame(width: 400, height: 450)
+ .presentationSizing(.fitted)
+ }
.toolbar {
- ToolbarItem(placement: .navigation) {
- Image("Server Large")
- // .renderingMode(.template)
-
- .resizable()
- .scaledToFit()
- .frame(width: 28)
- .opacity(controlActiveState == .inactive ? 0.4 : 1.0)
+ if #available(macOS 26.0, *) {
+ ToolbarItem(placement: .navigation) {
+ Image("Server Large")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 28)
+ .opacity(self.controlActiveState == .inactive ? 0.4 : 1.0)
+ }
+ .sharedBackgroundVisibility(.hidden)
+ }
+ else {
+ ToolbarItem(placement: .navigation) {
+ Image("Server Large")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 28)
+ .opacity(self.controlActiveState == .inactive ? 0.4 : 1.0)
+ }
}
}
}
}
.onDisappear {
- model.disconnect()
+ Task {
+ await self.model.disconnect()
+ }
+ }
+ .onChange(of: self.model.serverTitle) {
+ self.state.serverName = self.model.serverTitle
}
.onChange(of: model.status) {
if model.status == .loggedIn {
@@ -239,181 +211,84 @@ struct ServerView: View {
startReconnectTimer()
}
}
- .alert(model.errorMessage ?? "Server Error", isPresented: $model.errorDisplayed) {
- Button("OK") {}
- }
- .task {
- var address = server.address
- if server.port != HotlinePorts.DefaultServerPort {
- address += ":\(server.port)"
+ .onAppear {
+ var address = self.server.address
+ if self.server.port != HotlinePorts.DefaultServerPort {
+ address += ":\(self.server.port)"
}
- connectAddress = server.address
- connectLogin = server.login
- connectPassword = server.password
-
+ self.connectAddress = self.server.address
+ self.connectLogin = self.server.login
+ self.connectPassword = self.server.password
+
// Connect to server automatically unless the option key is held down.
if !NSEvent.modifierFlags.contains(.option) {
- connectToServer()
+ self.connectToServer()
+ }
+
+ self.connectionDisplayed = true
+ }
+ .alert("Something Went Wrong", isPresented: self.$model.errorDisplayed) {
+ Button("OK") {}
+ } message: {
+ if let message = self.model.errorMessage,
+ !message.isBlank {
+ Text(message)
}
}
.focusedSceneValue(\.activeHotlineModel, model)
.focusedSceneValue(\.activeServerState, state)
}
-
- private func startReconnectTimer() {
- reconnectTimer = Task {
- while !Task.isCancelled {
- connectToServer()
- try? await Task.sleep(for: .seconds(5))
- }
+
+ private var connectForm: some View {
+ ConnectView(address: self.$connectAddress, login: self.$connectLogin, password: self.$connectPassword) {
+ self.connectToServer()
}
- }
-
- var connectForm: some View {
- VStack(alignment: .center) {
- GroupBox {
- Form {
- Group {
- TextField(text: $connectAddress) {
- Text("Address:")
- }
- .focused($focusedField, equals: .address)
-
- Text(
- "Type the address of the Hotline server you would like to connect to. If you have an account on that server, type your login and password too."
- )
- .font(.caption)
- .foregroundStyle(.secondary)
- .padding(.bottom, 4)
-
- TextField(text: $connectLogin, prompt: Text("Optional")) {
- Text("Login:")
- }
- .focused($focusedField, equals: .login)
- SecureField(text: $connectPassword, prompt: Text("Optional")) {
- Text("Password:")
- }
- .focused($focusedField, equals: .password)
- }
- .textFieldStyle(.roundedBorder)
- .controlSize(.large)
-
- HStack {
- Button("Save...") {
- if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- connectNameSheetPresented = true
- }
- }
- .disabled(connectAddress.isEmpty)
- .controlSize(.regular)
- .buttonStyle(.automatic)
- .help("Bookmark server")
-
- Spacer()
-
- Button("Cancel") {
- dismiss()
- }
- .controlSize(.regular)
- .buttonStyle(.automatic)
- .keyboardShortcut(.cancelAction)
-
- Button("Connect") {
- Task {
- connectToServer()
- }
- }
-
- .controlSize(.regular)
- .buttonStyle(.automatic)
- .keyboardShortcut(.defaultAction)
- }
- .padding(.top, 8)
-
- }
- .padding()
- .onChange(of: connectAddress) {
- let (a, p) = Server.parseServerAddressAndPort(connectAddress)
- server.address = a
- server.port = p
- }
- .onChange(of: connectLogin) {
- server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines)
- }
- .onChange(of: connectPassword) {
- server.password = connectPassword
- }
- }
- .onAppear {
- focusedField = .address
- }
+ .focusSection()
+ .onChange(of: self.connectAddress) {
+ let (a, p) = Server.parseServerAddressAndPort(self.connectAddress)
+ self.server.address = a
+ self.server.port = p
}
- .frame(maxWidth: 380)
- .padding()
- .sheet(isPresented: $connectNameSheetPresented) {
- VStack(alignment: .leading) {
- Text("Name this server bookmark:")
- .foregroundStyle(.secondary)
- .padding(.bottom, 4)
- TextField("Bookmark Name", text: $connectName)
- .textFieldStyle(.roundedBorder)
- .controlSize(.large)
- Toggle("Connect on Startup", isOn: $autoconnect)
- }
- .frame(width: 250)
- .padding()
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") {
- connectNameSheetPresented = false
- connectName = ""
- }
- }
-
- ToolbarItem(placement: .confirmationAction) {
- Button("Save") {
- let name = String(connectName.trimmingCharacters(in: .whitespacesAndNewlines))
- if !name.isEmpty {
- connectNameSheetPresented = false
- connectName = ""
- Task.detached {
- let (host, port) = await Server.parseServerAddressAndPort(connectAddress)
- let login: String? = await connectLogin.isEmpty ? nil : connectLogin
- let password: String? = await connectPassword.isEmpty ? nil : connectPassword
-
- if !host.isEmpty {
- let newBookmark = await Bookmark(
- type: .server, name: name, address: host, port: port, login: login,
- password: password, autoconnect: autoconnect)
- await MainActor.run {
- Bookmark.add(newBookmark, context: modelContext)
- }
- }
- }
- }
- }
- }
- }
- .onAppear {
- autoconnect = false
- }
+ .onChange(of: self.connectLogin) {
+ self.server.login = self.connectLogin.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ .onChange(of: self.connectPassword) {
+ self.server.password = self.connectPassword
+ }
+ .onAppear {
+ autoconnect = false
}
}
- var navigationList: some View {
+ private var navigationList: some View {
List(selection: $state.selection) {
// Don't show news on older servers.
ForEach(model.serverVersion < 151 ? ServerView.classicMenuItems : ServerView.menuItems) {
menuItem in
if menuItem.type == .chat {
- ListItemView(icon: menuItem.image, title: menuItem.name, unread: model.unreadPublicChat)
- .tag(menuItem.type)
- } else if menuItem.type == .accounts {
- if model.access?.contains(.canOpenUsers) == true {
- ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(
- menuItem.type)
- }
- } else {
+ ListItemView(icon: menuItem.image, title: menuItem.name, unread: model.unreadPublicChat).tag(menuItem.type)
+ }
+// else if menuItem.type == .board {
+// if self.model.access?.contains(.canReadMessageBoard) == true {
+// ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type)
+// }
+// }
+// else if menuItem.type == .accounts {
+// if model.access?.contains(.canOpenUsers) == true {
+// ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type)
+// }
+// }
+ else if menuItem.type == .files {
+ ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type)
+ .overlay(alignment: .trailing) {
+ if case .searching(_, _) = model.fileSearchStatus {
+ ProgressView()
+ .controlSize(.mini)
+ .padding(.trailing, 4)
+ }
+ }
+ }
+ else {
ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type)
}
}
@@ -443,34 +318,32 @@ struct ServerView: View {
}
var transfersSection: some View {
- // Section("Transfers") {
ForEach(model.transfers) { transfer in
- TransferItemView(transfer: transfer)
+ ServerTransferRow(transfer: transfer)
}
- // }
}
var usersSection: some View {
- // Section("\(model.users.count) Online") {
ForEach(model.users) { user in
HStack(spacing: 5) {
- if let iconImage = Hotline.getClassicIcon(Int(user.iconID)) {
+ if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) {
Image(nsImage: iconImage)
.frame(width: 16, height: 16)
.padding(.leading, 2)
.padding(.trailing, 2)
- } else {
+ }
+ else {
Image("User")
.frame(width: 16, height: 16)
.padding(.leading, 2)
.padding(.trailing, 2)
}
-
+
Text(user.name)
.foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary)
-
+
Spacer()
-
+
if model.hasUnreadInstantMessages(userID: user.id) {
Circle()
.frame(width: 6, height: 6)
@@ -482,91 +355,82 @@ struct ServerView: View {
.opacity(controlActiveState == .inactive ? 0.5 : 1.0)
.tag(ServerNavigationType.user(userID: user.id))
}
- // }
}
var serverView: some View {
NavigationSplitView {
self.navigationList
- .frame(maxWidth: .infinity)
- .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 500)
+ .navigationSplitViewColumnWidth(200)
+// .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 400)
+ .toolbar(removing: .sidebarToggle)
+// .toolbar {
+// if self.model.access?.contains(.canOpenUsers) == true {
+// ToolbarItem(placement: .primaryAction) {
+// Button {
+// self.state.accountsShown = true
+// } label: {
+// Label("Manage Accounts", systemImage: "gear")
+// }
+// .help("Manage Accounts")
+// }
+// }
+// }
} detail: {
- switch state.selection {
- case .chat:
- ChatView()
- .navigationTitle(model.serverTitle)
- .navigationSubtitle("Public Chat")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- case .news:
- NewsView()
- .navigationTitle(model.serverTitle)
- .navigationSubtitle("Newsgroups")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- case .board:
- MessageBoardView()
- .navigationTitle(model.serverTitle)
- .navigationSubtitle("Message Board")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- case .files:
- FilesView()
- .navigationTitle(model.serverTitle)
- .navigationSubtitle("Shared Files")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- case .accounts:
- AccountManagerView()
- .navigationTitle(model.serverTitle)
- .navigationSubtitle("Accounts")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- case .user(let userID):
- let user = model.users.first(where: { $0.id == userID })
- MessageView(userID: userID)
- .navigationTitle(model.serverTitle)
- .navigationSubtitle(user?.name ?? "Private Message")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- .onAppear {
- model.markInstantMessagesAsRead(userID: userID)
- }
- }
+ switch state.selection {
+ case .chat:
+ ChatView()
+ case .news:
+ NewsView()
+ case .board:
+ MessageBoardView()
+ case .files:
+ FilesView()
+ case .user(let userID):
+ MessageView(userID: userID)
+ }
}
- .toolbar(removing: .sidebarToggle)
-
+ .navigationTitle(self.model.serverTitle)
}
-
+
// MARK: -
@MainActor func connectToServer() {
- guard !server.address.isEmpty else {
+ guard !self.server.address.isEmpty else {
return
}
- model.login(server: server, username: Prefs.shared.username, iconID: Prefs.shared.userIconID) {
- success in
- if !success {
- print("FAILED LOGIN??")
- model.disconnect()
- } else {
- sendPreferences()
- model.getUserList()
- model.downloadBanner()
+
+ // Set status here so it's immediate (not waiting to enter task).
+ self.model.status = .connecting
+
+ Task { @MainActor in
+ do {
+ try await self.model.login(
+ server: server,
+ username: Prefs.shared.username,
+ iconID: Prefs.shared.userIconID
+ )
+ } catch {
+ print("ServerView: Login failed: \(error)")
}
}
}
-
- private func connectionStatusToProgress(status: HotlineClientStatus) -> Double {
+
+ private func connectionStatusToProgress(status: HotlineConnectionStatus) -> Double {
switch status {
case .disconnected:
return 0.0
case .connecting:
return 0.4
case .connected:
- return 0.75
- case .loggingIn:
return 0.9
case .loggedIn:
return 1.0
+ case .failed:
+ return 0.0
}
}
- private func connectionStatusToLabel(status: HotlineClientStatus) -> String {
+ private func connectionStatusToLabel(status: HotlineConnectionStatus) -> String {
let n = server.name ?? server.address
switch status {
case .disconnected:
@@ -574,88 +438,69 @@ struct ServerView: View {
case .connecting:
return "Connecting to \(n)..."
case .connected:
- return "Connected to \(n)"
- case .loggingIn:
return "Logging in to \(n)..."
case .loggedIn:
return "Logged in to \(n)"
+ case .failed(let error):
+ return "Failed: \(error)"
}
}
-
- @MainActor func sendPreferences() {
- if self.model.status == .loggedIn {
- var options: HotlineUserOptions = HotlineUserOptions()
-
- if Prefs.shared.refusePrivateMessages {
- options.update(with: .refusePrivateMessages)
- }
-
- if Prefs.shared.refusePrivateChat {
- options.update(with: .refusePrivateChat)
- }
-
- if Prefs.shared.enableAutomaticMessage {
- options.update(with: .automaticResponse)
- }
-
- print("Updating preferences with server")
-
- self.model.sendUserInfo(
- username: Prefs.shared.username, iconID: Prefs.shared.userIconID, options: options,
- autoresponse: Prefs.shared.automaticMessage)
- }
- }
+
}
-//#Preview {
-// ServerView(server: Server(name: "", description: "", address: "", port: 0))
-//}
-
-struct TransferItemView: View {
+struct ServerTransferRow: View {
let transfer: TransferInfo
@Environment(\.controlActiveState) private var controlActiveState
- @Environment(Hotline.self) private var model: Hotline
+ @Environment(HotlineState.self) private var model: HotlineState
@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 ""
- }
-
+ @State private var detailsShown: Bool = false
+
var body: some View {
HStack(alignment: .center, spacing: 5) {
HStack(spacing: 0) {
Spacer()
- FileIconView(filename: transfer.title, fileType: nil)
- .frame(width: 16, height: 16)
- .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
- // .padding(.leading, 2)
+ if self.transfer.isFolder {
+ Image("Folder")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
+ }
+ else {
+ FileIconView(filename: transfer.title, fileType: nil)
+ .frame(width: 16, height: 16)
+ .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
+ }
Spacer()
}
.frame(width: 20)
-
- Text(transfer.title)
+
+ Text(self.transfer.folderName ?? self.transfer.title)
.lineLimit(1)
.truncationMode(.middle)
-
- Spacer()
-
+
+ Spacer(minLength: 0)
+
+ if !self.transfer.done {
+ if self.transfer.progress == 0.0 {
+ ProgressView()
+ .progressViewStyle(.linear)
+ .controlSize(.extraLarge)
+ .frame(maxWidth: 40)
+ }
+ else {
+ ProgressView(value: self.transfer.progress, total: 1.0)
+ .progressViewStyle(.linear)
+ .controlSize(.extraLarge)
+ .frame(maxWidth: 40)
+ }
+ }
+
if self.hovered {
Button {
- model.deleteTransfer(id: transfer.id)
+ AppState.shared.cancelTransfer(id: transfer.id)
} label: {
Image(systemName: self.buttonHovered ? "xmark.circle.fill" : "xmark.circle")
.resizable()
@@ -686,21 +531,73 @@ struct TransferItemView: View {
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.opacity(controlActiveState == .inactive ? 0.5 : 1.0)
- } 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
- withAnimation(.easeOut(duration: 0.25)) {
+ withAnimation(.snappy(duration: 0.25, extraBounce: 0.3)) {
self.hovered = hovered
}
+// self.detailsShown = hovered
+ }
+ .onTapGesture(count: 2) {
+ guard transfer.completed, let url = transfer.fileURL else {
+ return
+ }
+
+ NSWorkspace.shared.activateFileViewerSelecting([url])
+ }
+ .popover(isPresented: .constant(self.detailsShown && !self.transfer.done), arrowEdge: .trailing) {
+ let rows: [(String, String)] = [
+ ("document", self.transfer.title),
+ ("info", self.transfer.displaySize),
+ (self.transfer.isUpload ? "arrow.up" : "arrow.down", self.transfer.displaySpeed ?? "--"),
+ ("clock", self.transfer.displayTimeRemaining ?? "--")
+ ]
+
+ Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
+ ForEach(rows, id: \.0) { imageName, label in
+ GridRow {
+ Image(systemName: imageName)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ .gridColumnAlignment(.trailing)
+ Text(label)
+ .monospacedDigit()
+ .gridColumnAlignment(.leading)
+ }
+ }
+ }
+ .frame(minWidth: 200, maxWidth: 350, alignment: .leading)
+ .padding()
+ }
+ }
+
+ private var formattedProgressHelp: String {
+ if self.transfer.completed {
+ return "File transfer complete"
+ }
+ else if self.transfer.failed {
+ return "File transfer failed"
+ }
+ else if self.transfer.cancelled {
+ return "File transfer cancelled"
+ }
+ else if self.transfer.progress > 0.0 {
+ var parts: [String] = []
+
+ if let speed = self.transfer.displaySpeed {
+ parts.append(speed)
+ }
+
+ if let timeRemaining = self.transfer.displayTimeRemaining {
+ parts.append(timeRemaining)
+ }
+
+ if parts.count > 0 {
+ return parts.joined(separator: " • ")
+ }
}
- .help(formattedProgressHelp())
+ return ""
}
}
diff --git a/Hotline/macOS/Settings/GeneralSettingsView.swift b/Hotline/macOS/Settings/GeneralSettingsView.swift
new file mode 100644
index 0000000..6be73aa
--- /dev/null
+++ b/Hotline/macOS/Settings/GeneralSettingsView.swift
@@ -0,0 +1,67 @@
+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()
+
+ var body: some View {
+ @Bindable var preferences = Prefs.shared
+
+ Form {
+ TextField("Your Name", text: $username, prompt: Text("guest"))
+ Toggle("Show Join/Leave in Chat", isOn: $preferences.showJoinLeaveMessages)
+ Toggle("Refuse private messages", isOn: $preferences.refusePrivateMessages)
+ Toggle("Refuse private chat", isOn: $preferences.refusePrivateChat)
+ Toggle("Automatic Response", isOn: $preferences.enableAutomaticMessage)
+ if preferences.enableAutomaticMessage {
+ TextField("", text: $preferences.automaticMessage, prompt: Text("Write a response message"))
+ .lineLimit(2)
+ .multilineTextAlignment(.leading)
+ .frame(maxWidth: .infinity)
+ .onSubmit(of: .text) {
+ 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
+ }
+ .onDisappear {
+ preferences.username = self.username
+ self.usernameChanged = false
+ }
+ .onChange(of: username) { oldValue, newValue in
+ self.usernameChanged = true
+ }
+ .onReceive(saveTimer) { _ in
+ if self.usernameChanged {
+ self.usernameChanged = false
+ preferences.username = self.username
+ }
+ }
+ }
+}
diff --git a/Hotline/macOS/Settings/IconSettingsView.swift b/Hotline/macOS/Settings/IconSettingsView.swift
new file mode 100644
index 0000000..2bb92c4
--- /dev/null
+++ b/Hotline/macOS/Settings/IconSettingsView.swift
@@ -0,0 +1,58 @@
+import SwiftUI
+
+struct IconSettingsView: View {
+ @State private var hoveredUserIconID: Int = -1
+
+ var body: some View {
+ @Bindable var preferences = Prefs.shared
+
+ Form {
+ ScrollViewReader { scrollProxy in
+ ScrollView {
+ LazyVGrid(columns: [
+ GridItem(.fixed(4+32+4)),
+ GridItem(.fixed(4+32+4)),
+ GridItem(.fixed(4+32+4)),
+ GridItem(.fixed(4+32+4)),
+ GridItem(.fixed(4+32+4)),
+ GridItem(.fixed(4+32+4)),
+ GridItem(.fixed(4+32+4))
+ ], spacing: 0) {
+ ForEach(HotlineState.classicIconSet, id: \.self) { iconID in
+ HStack {
+ Image("Classic/\(iconID)")
+ .resizable()
+ .interpolation(.none)
+ .scaledToFit()
+ .frame(width: 32, height: 16)
+ .help("Icon \(String(iconID))")
+ }
+ .tag(iconID)
+ .frame(width: 32, height: 32)
+ .padding(4)
+ .background(iconID == preferences.userIconID ? Color.accentColor : (iconID == hoveredUserIconID ? Color.accentColor.opacity(0.1) : Color(nsColor: .textBackgroundColor)))
+ .clipShape(RoundedRectangle(cornerRadius: 5))
+ .onTapGesture {
+ preferences.userIconID = iconID
+ }
+ .onHover { hovered in
+ if hovered {
+ self.hoveredUserIconID = iconID
+ }
+ }
+ }
+ }
+ .padding()
+ }
+ .background(Color(nsColor: .textBackgroundColor))
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor), lineWidth: 1))
+ .onAppear {
+ scrollProxy.scrollTo(preferences.userIconID, anchor: .center)
+ }
+ .frame(height: 355)
+ }
+ }
+ .padding()
+ }
+}
diff --git a/Hotline/macOS/Settings/SettingsView.swift b/Hotline/macOS/Settings/SettingsView.swift
new file mode 100644
index 0000000..b2a2948
--- /dev/null
+++ b/Hotline/macOS/Settings/SettingsView.swift
@@ -0,0 +1,31 @@
+import SwiftUI
+
+struct SettingsView: View {
+ private enum Tabs: Hashable {
+ case general, icon
+ }
+
+ var body: some View {
+ TabView {
+ GeneralSettingsView()
+ .tabItem {
+ Label("General", systemImage: "person.text.rectangle")
+ }
+ .tag(Tabs.general)
+ IconSettingsView()
+ .tabItem {
+ Label("Icon", systemImage: "person")
+ }
+ .tag(Tabs.icon)
+ SoundSettingsView()
+ .tabItem {
+ Label("Sound", systemImage: "speaker.wave.3")
+ }
+ .tag(Tabs.icon)
+ }
+ }
+}
+
+#Preview {
+ SettingsView()
+}
diff --git a/Hotline/macOS/Settings/SoundSettingsView.swift b/Hotline/macOS/Settings/SoundSettingsView.swift
new file mode 100644
index 0000000..b99db77
--- /dev/null
+++ b/Hotline/macOS/Settings/SoundSettingsView.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+
+struct SoundSettingsView: View {
+ var body: some View {
+ @Bindable var preferences = Prefs.shared
+
+ Form {
+ Toggle("Enable Sounds", isOn: $preferences.playSounds)
+ .controlSize(.large)
+
+ Section("Sounds") {
+ Toggle("Chat", isOn: $preferences.playChatSound)
+ .disabled(!preferences.playSounds)
+
+ Toggle("File Transfers", isOn: $preferences.playFileTransferCompleteSound)
+ .disabled(!preferences.playSounds)
+
+ Toggle("Private Message", isOn: $preferences.playPrivateMessageSound)
+ .disabled(!preferences.playSounds)
+
+ Toggle("Join", isOn: $preferences.playJoinSound)
+ .disabled(!preferences.playSounds)
+
+ Toggle("Leave", isOn: $preferences.playLeaveSound)
+ .disabled(!preferences.playSounds)
+
+ Toggle("Logged in", isOn: $preferences.playLoggedInSound)
+ .disabled(!preferences.playSounds)
+
+ Toggle("Error", isOn: $preferences.playErrorSound)
+ .disabled(!preferences.playSounds)
+
+ Toggle("Chat Invitation", isOn: $preferences.playChatInvitationSound)
+ .disabled(!preferences.playSounds)
+ }
+ }
+ .formStyle(.grouped)
+ .frame(width: 392, height: 433)
+ }
+}
diff --git a/Hotline/macOS/SettingsView.swift b/Hotline/macOS/SettingsView.swift
deleted file mode 100644
index c67ba85..0000000
--- a/Hotline/macOS/SettingsView.swift
+++ /dev/null
@@ -1,183 +0,0 @@
-import SwiftUI
-
-struct GeneralSettingsView: View {
- @State private var username: String = ""
- @State private var usernameChanged: Bool = false
-
- let saveTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect()
-
- var body: some View {
- @Bindable var preferences = Prefs.shared
-
- Form {
- TextField("Your Name", text: $username, prompt: Text("guest"))
- Toggle("Show Join/Leave in Chat", isOn: $preferences.showJoinLeaveMessages)
- Toggle("Refuse private messages", isOn: $preferences.refusePrivateMessages)
- Toggle("Refuse private chat", isOn: $preferences.refusePrivateChat)
- Toggle("Automatic Response", isOn: $preferences.enableAutomaticMessage)
- if preferences.enableAutomaticMessage {
- TextField("", text: $preferences.automaticMessage, prompt: Text("Write a response message"))
- .lineLimit(2)
- .multilineTextAlignment(.leading)
- .frame(maxWidth: .infinity)
- .onSubmit(of: .text) {
- preferences.username = self.username
- }
- }
- }
- .padding()
- .frame(width: 392)
- .onAppear {
- self.username = preferences.username
- self.usernameChanged = false
- }
- .onDisappear {
- preferences.username = self.username
- self.usernameChanged = false
- }
- .onChange(of: username) { oldValue, newValue in
- self.usernameChanged = true
- }
- .onReceive(saveTimer) { _ in
- if self.usernameChanged {
- self.usernameChanged = false
- preferences.username = self.username
- }
- }
- }
-}
-
-struct IconSettingsView: View {
- @State private var hoveredUserIconID: Int = -1
-
- var body: some View {
- @Bindable var preferences = Prefs.shared
-
- Form {
- ScrollViewReader { scrollProxy in
- ScrollView {
- LazyVGrid(
- columns: [
- GridItem(.fixed(4 + 32 + 4)),
- GridItem(.fixed(4 + 32 + 4)),
- GridItem(.fixed(4 + 32 + 4)),
- GridItem(.fixed(4 + 32 + 4)),
- GridItem(.fixed(4 + 32 + 4)),
- GridItem(.fixed(4 + 32 + 4)),
- GridItem(.fixed(4 + 32 + 4)),
- ], spacing: 0
- ) {
- ForEach(Hotline.classicIconSet, id: \.self) { iconID in
- HStack {
- Image("Classic/\(iconID)")
- .resizable()
- .interpolation(.none)
- .scaledToFit()
- .frame(width: 32, height: 16)
- .help("Icon \(String(iconID))")
- }
- .tag(iconID)
- .frame(width: 32, height: 32)
- .padding(4)
- .background(
- iconID == preferences.userIconID
- ? Color.accentColor
- : (iconID == hoveredUserIconID
- ? Color.accentColor.opacity(0.1) : Color(nsColor: .textBackgroundColor))
- )
- .clipShape(RoundedRectangle(cornerRadius: 5))
- .onTapGesture {
- preferences.userIconID = iconID
- }
- .onHover { hovered in
- if hovered {
- self.hoveredUserIconID = iconID
- }
- }
- }
- }
- .padding()
- }
- .background(Color(nsColor: .textBackgroundColor))
- .clipShape(RoundedRectangle(cornerRadius: 6))
- .overlay(
- RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- )
- .onAppear {
- scrollProxy.scrollTo(preferences.userIconID, anchor: .center)
- }
- .frame(height: 355)
- }
- }
- .padding()
- }
-}
-
-struct SoundSettingsView: View {
- var body: some View {
- @Bindable var preferences = Prefs.shared
-
- Form {
- Toggle("Enable Sounds", isOn: $preferences.playSounds)
- .controlSize(.large)
-
- Section("Sounds") {
- Toggle("Chat", isOn: $preferences.playChatSound)
- .disabled(!preferences.playSounds)
-
- Toggle("File Transfers", isOn: $preferences.playFileTransferCompleteSound)
- .disabled(!preferences.playSounds)
-
- Toggle("Private Message", isOn: $preferences.playPrivateMessageSound)
- .disabled(!preferences.playSounds)
-
- Toggle("Join", isOn: $preferences.playJoinSound)
- .disabled(!preferences.playSounds)
-
- Toggle("Leave", isOn: $preferences.playLeaveSound)
- .disabled(!preferences.playSounds)
-
- Toggle("Logged in", isOn: $preferences.playLoggedInSound)
- .disabled(!preferences.playSounds)
-
- Toggle("Error", isOn: $preferences.playErrorSound)
- .disabled(!preferences.playSounds)
-
- Toggle("Chat Invitation", isOn: $preferences.playChatInvitationSound)
- .disabled(!preferences.playSounds)
- }
- }
- .formStyle(.grouped)
- .frame(width: 392, height: 433)
- }
-}
-
-struct SettingsView: View {
- private enum Tabs: Hashable {
- case general, icon
- }
-
- var body: some View {
- TabView {
- GeneralSettingsView()
- .tabItem {
- Label("General", systemImage: "person.text.rectangle")
- }
- .tag(Tabs.general)
- IconSettingsView()
- .tabItem {
- Label("Icon", systemImage: "person")
- }
- .tag(Tabs.icon)
- SoundSettingsView()
- .tabItem {
- Label("Sound", systemImage: "speaker.wave.3")
- }
- .tag(Tabs.icon)
- }
- }
-}
-
-#Preview {
- SettingsView()
-}
diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift
deleted file mode 100644
index 0943d63..0000000
--- a/Hotline/macOS/TrackerView.swift
+++ /dev/null
@@ -1,498 +0,0 @@
-import Foundation
-import SwiftData
-import SwiftUI
-import UniformTypeIdentifiers
-
-struct TrackerView: View {
- @Environment(\.colorScheme) private var colorScheme
- @Environment(\.openWindow) private var openWindow
- @Environment(\.controlActiveState) private var controlActiveState
- @Environment(\.modelContext) private var modelContext
-
- @State private var refreshing = false
- @State private var trackerSheetPresented: Bool = false
- @State private var trackerSheetBookmark: Bookmark? = nil
- @State private var attemptedPrepopulate: Bool = false
- @State private var fileDropActive = false
- @State private var bookmarkExportActive = false
- @State private var bookmarkExport: BookmarkDocument? = nil
-
- @Query(sort: \Bookmark.order) private var bookmarks: [Bookmark]
- @Binding var selection: Bookmark?
-
- var body: some View {
- List(selection: $selection) {
- ForEach(bookmarks, id: \.self) { bookmark in
- TrackerItemView(bookmark: bookmark)
- .onAppear {
- Task {
- if bookmark.autoconnect, let server = bookmark.server {
- openWindow(id: "server", value: server)
- }
- }
- }
- .tag(bookmark)
-
- if bookmark.type == .tracker && bookmark.expanded {
- ForEach(bookmark.servers, id: \.self) { trackedServer in
- TrackerItemView(bookmark: trackedServer)
- .moveDisabled(true)
- .deleteDisabled(true)
- .tag(trackedServer)
- }
- }
- }
- .onMove { movedIndexes, destinationIndex in
- Bookmark.move(movedIndexes, to: destinationIndex, context: modelContext)
- }
- .onDelete { deletedIndexes in
- Bookmark.delete(at: deletedIndexes, context: modelContext)
- }
- }
- .onDeleteCommand {
- if let bookmark = selection,
- bookmark.type != .temporary
- {
- Bookmark.delete(bookmark, context: modelContext)
- }
- }
- .environment(\.defaultMinListRowHeight, 34)
- .listStyle(.inset)
- .alternatingRowBackgrounds(.enabled)
- .onChange(of: ApplicationState.shared.cloudKitReady) {
- if attemptedPrepopulate {
- print("Tracker: Already attempted to prepopulate bookmarks")
- return
- }
-
- print("Tracker: Prepopulating bookmarks")
-
- attemptedPrepopulate = true
-
- // Make sure default bookmarks are there when empty.
- Bookmark.populateDefaults(context: modelContext)
- }
- .onAppear {
- // Bookmark.deleteAll(context: modelContext)
- }
- .contextMenu(forSelectionType: Bookmark.self) { items in
- if let item = items.first {
- if item.type == .temporary {
- Button {
- let newBookmark = Bookmark(
- type: .server, name: item.name, address: item.address, port: item.port,
- login: item.login, password: item.password)
- Bookmark.add(newBookmark, context: modelContext)
- } label: {
- Label("Bookmark", systemImage: "bookmark")
- }
-
- Divider()
- }
-
- Button {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(item.displayAddress, forType: .string)
- } label: {
- Label("Copy Address", systemImage: "doc.on.doc")
- }
-
- if item.type == .tracker || item.type == .server {
- Divider()
-
- if item.type == .tracker {
- Button {
- trackerSheetBookmark = item
- } label: {
- Label("Edit Tracker...", systemImage: "pencil")
- }
- }
-
- if item.type == .server {
- Button {
- bookmarkExport = BookmarkDocument(bookmark: item)
- bookmarkExportActive = true
- } label: {
- Label("Export Bookmark...", systemImage: "bookmark.square")
- }
- }
-
- Divider()
-
- Button {
- Bookmark.delete(item, context: modelContext)
- } label: {
- Label(
- item.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash")
- }
- }
- }
- } primaryAction: { items in
- guard let clickedItem = items.first else {
- return
- }
-
- if clickedItem.type == .tracker {
- if NSEvent.modifierFlags.contains(.option) {
- trackerSheetBookmark = clickedItem
- } else {
- clickedItem.expanded.toggle()
- }
- } else if let server = clickedItem.server {
- openWindow(id: "server", value: server)
- }
- }
- .fileExporter(
- isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data],
- defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm",
- onCompletion: { result in
- switch result {
- case .success(let fileURL):
- print("Hotline Bookmark: Successfully exported:", fileURL)
- case .failure(let err):
- print("Hotline Bookmark: Failed to export:", err)
- }
-
- bookmarkExport = nil
- bookmarkExportActive = false
- }, onCancellation: {}
- )
- .onKeyPress(.rightArrow) {
- if let bookmark = selection,
- bookmark.type == .tracker
- {
- bookmark.expanded = true
- return .handled
- }
- return .ignored
- }
- .onKeyPress(.leftArrow) {
- if let bookmark = selection,
- bookmark.type == .tracker
- {
- bookmark.expanded = false
- return .handled
- }
- return .ignored
- }
- .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in
- for provider in providers {
- let _ = provider.loadDataRepresentation(for: UTType.fileURL) { dataRepresentation, err in
- // HOTLINE CREATOR CODE: 1213484099
- // HOTLINE BOOKMARK TYPE CODE: 1213489773
-
- if let filePathData = dataRepresentation,
- let filePath = String(data: filePathData, encoding: .utf8),
- let fileURL = URL(string: filePath)
- {
-
- print("Hotline Bookmark: Dropped from ", fileURL.path(percentEncoded: false))
-
- DispatchQueue.main.async {
- if let newBookmark = Bookmark(fileURL: fileURL) {
- print("Hotline Bookmark: Added bookmark.")
- Bookmark.add(newBookmark, context: modelContext)
- } else {
- print("Hotline Bookmark: Failed to parse.")
- }
- }
- }
- }
- }
-
- return true
- }
- .sheet(item: $trackerSheetBookmark) { item in
- TrackerBookmarkSheet(item)
- }
- .sheet(isPresented: $trackerSheetPresented) {
- TrackerBookmarkSheet()
- }
- .navigationTitle("Servers")
- .toolbar {
- ToolbarItem(placement: .navigation) {
- Image("Hotline")
- .resizable()
- .renderingMode(.template)
- .scaledToFit()
- .foregroundColor(Color(hex: 0xE10000))
- .frame(width: 9)
- .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button {
- refreshing = true
- refresh()
- refreshing = false
- } label: {
- Label("Refresh", systemImage: "arrow.clockwise")
- }
- .disabled(refreshing)
- .help("Refresh Trackers")
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button {
- trackerSheetPresented = true
- } label: {
- Label("Add Tracker", systemImage: "point.3.filled.connected.trianglepath.dotted")
- }
- .help("Add Tracker")
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button {
- openWindow(id: "server")
- } label: {
- Label("Connect to Server", systemImage: "globe.americas.fill")
- }
- .help("Connect to Server")
- }
- }
- .onOpenURL(perform: { url in
- if let s = Server(url: url) {
- openWindow(id: "server", value: s)
- }
- })
- }
-
- func refresh() {
- // When a tracker is selected, refresh only that tracker.
- if let selectedBookmark = selection,
- selectedBookmark.type == .tracker
- {
- if !selectedBookmark.expanded {
- selectedBookmark.expanded = true
- } else {
- Task {
- await selectedBookmark.fetchServers()
- }
- }
- return
- }
-
- // Otherwise refresh/expand all trackers.
- for bookmark in self.bookmarks {
- if bookmark.type == .tracker {
- if !bookmark.expanded {
- bookmark.expanded = true
- } else {
- Task {
- await bookmark.fetchServers()
- }
- }
- }
- }
- }
-}
-
-struct TrackerBookmarkSheet: View {
- @Environment(\.dismiss) private var dismiss
- @Environment(\.modelContext) private var modelContext
-
- @State private var bookmark: Bookmark? = nil
- @State private var trackerAddress: String = ""
- @State private var trackerName: String = ""
-
- init() {
-
- }
-
- init(_ editingBookmark: Bookmark) {
- _bookmark = .init(initialValue: editingBookmark)
- _trackerAddress = .init(initialValue: editingBookmark.displayAddress)
- _trackerName = .init(initialValue: editingBookmark.name)
- }
-
- var body: some View {
- VStack(alignment: .leading) {
- Text("Type the address and name of a Hotline Tracker:")
- .foregroundStyle(.secondary)
- .padding(.bottom, 8)
- Form {
- Group {
- TextField(text: $trackerAddress) {
- Text("Address:")
- }
- TextField(text: $trackerName, prompt: Text("Optional")) {
- Text("Name:")
- }
- }
- .textFieldStyle(.roundedBorder)
- .controlSize(.large)
- }
- }
- .frame(width: 300)
- .fixedSize(horizontal: true, vertical: true)
- .padding()
- .toolbar {
- ToolbarItem(placement: .confirmationAction) {
- Button(self.bookmark != nil ? "Save Tracker" : "Add Tracker") {
- var displayName = trackerName.trimmingCharacters(in: .whitespacesAndNewlines)
- let (host, port) = Tracker.parseTrackerAddressAndPort(trackerAddress)
-
- if displayName.isEmpty {
- displayName = host
- }
-
- if !displayName.isEmpty && !host.isEmpty {
- if !host.isEmpty {
- if self.bookmark != nil {
- // We're editing an existing bookmark.
- self.bookmark?.name = displayName
- self.bookmark?.address = host
- self.bookmark?.port = port
- } else {
- // We're creating a new bookmark.
- let newBookmark = Bookmark(
- type: .tracker, name: displayName, address: host, port: port)
- Bookmark.add(newBookmark, context: modelContext)
- }
-
- self.trackerName = ""
- self.trackerAddress = ""
-
- dismiss()
- }
- }
- }
- }
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") {
- self.trackerName = ""
- self.trackerAddress = ""
-
- dismiss()
- }
- }
- }
- }
-}
-
-struct TrackerItemView: View {
- let bookmark: Bookmark
-
- @State private var onlineAnimationMaxState: Bool = true
-
- var body: some View {
- HStack(alignment: .center, spacing: 6) {
- if bookmark.type == .tracker {
- Button {
- bookmark.expanded.toggle()
- } label: {
- Text(Image(systemName: bookmark.expanded ? "chevron.down" : "chevron.right"))
- .bold()
- .font(.system(size: 10))
- .opacity(0.5)
- .frame(alignment: .center)
- }
- .buttonStyle(.plain)
- .frame(width: 10)
- .padding(.leading, 4)
- .padding(.trailing, 2)
- }
-
- switch bookmark.type {
- case .tracker:
- Image("Tracker")
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16, alignment: .center)
- Text(bookmark.name).bold().lineLimit(1).truncationMode(.tail)
- if bookmark.loading {
- ProgressView()
- .padding([.leading, .trailing], 2)
- .controlSize(.small)
- }
- Spacer(minLength: 0)
- case .server:
- Image(systemName: "bookmark.fill")
- .resizable()
- .renderingMode(.template)
- .aspectRatio(contentMode: .fit)
- .frame(width: 11, height: 11, alignment: .center)
- .opacity(0.5)
- .padding(.leading, 3)
- Image(systemName: "bolt.fill")
- .resizable()
- .renderingMode(.template)
- .aspectRatio(contentMode: .fit)
- .frame(width: 13, height: 13, alignment: .center)
- .opacity(0.5)
- .padding(.leading, 3)
- .padding(.trailing, 2)
- .foregroundStyle(bookmark.autoconnect ? Color.accentColor : Color.gray)
- .onTapGesture {
- bookmark.autoconnect.toggle()
- try? bookmark.modelContext?.save()
- }
- Image("Server")
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16, alignment: .center)
- Text(bookmark.name).lineLimit(1).truncationMode(.tail)
- Spacer(minLength: 0)
- case .temporary:
- Spacer()
- .frame(width: 14 + 8 + 16)
- Image("Server")
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16, alignment: .center)
- Text(bookmark.name).lineLimit(1).truncationMode(.tail)
- if let serverDescription = bookmark.serverDescription {
- Text(serverDescription)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.tail)
- }
- Spacer(minLength: 0)
- if let serverUserCount = bookmark.serverUserCount,
- serverUserCount > 0
- {
- Text(String(serverUserCount))
- .foregroundStyle(.secondary)
- .lineLimit(1)
-
- Circle()
- .fill(.fileComplete)
- .frame(width: 7, height: 7)
- .opacity(onlineAnimationMaxState ? 1.0 : 0.2)
- .onAppear {
- withAnimation(.easeInOut(duration: 1.0).repeatForever()) {
- onlineAnimationMaxState.toggle()
- }
- }
- .padding(.trailing, 6)
- }
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .onChange(of: bookmark.expanded) {
- guard bookmark.type == .tracker else {
- return
- }
-
- if bookmark.expanded {
- Task {
- await bookmark.fetchServers()
- }
- }
- }
- }
-}
-
-#if DEBUG
- private struct TrackerViewPreview: View {
- @State var selection: Bookmark? = nil
-
- var body: some View {
- TrackerView(selection: $selection)
- }
- }
-
- #Preview {
- TrackerViewPreview()
- }
-#endif
diff --git a/Hotline/macOS/Trackers/BonjourServerRow.swift b/Hotline/macOS/Trackers/BonjourServerRow.swift
new file mode 100644
index 0000000..d99b23b
--- /dev/null
+++ b/Hotline/macOS/Trackers/BonjourServerRow.swift
@@ -0,0 +1,19 @@
+import SwiftUI
+
+struct BonjourServerRow: View {
+ @Environment(\.openWindow) private var openWindow
+
+ let server: BonjourState.BonjourServer
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 6) {
+ Image("Server")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16, alignment: .center)
+ Text(self.server.displayName).lineLimit(1).truncationMode(.tail)
+ Spacer(minLength: 0)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
diff --git a/Hotline/macOS/Trackers/ServerBookmarkSheet.swift b/Hotline/macOS/Trackers/ServerBookmarkSheet.swift
new file mode 100644
index 0000000..6ad1657
--- /dev/null
+++ b/Hotline/macOS/Trackers/ServerBookmarkSheet.swift
@@ -0,0 +1,70 @@
+import SwiftUI
+
+struct ServerBookmarkSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.modelContext) private var modelContext
+
+ @State private var bookmark: Bookmark
+ @State private var serverName: String = ""
+ @State private var serverAddress: String = ""
+ @State private var serverLogin: String = ""
+ @State private var serverPassword: String = ""
+
+ init(_ editingBookmark: Bookmark) {
+ _bookmark = .init(initialValue: editingBookmark)
+ _serverName = .init(initialValue: editingBookmark.name)
+ _serverAddress = .init(initialValue: editingBookmark.displayAddress)
+ _serverLogin = .init(initialValue: editingBookmark.login ?? "")
+ _serverPassword = .init(initialValue: editingBookmark.password ?? "")
+ }
+
+ var body: some View {
+ Form {
+ Section {
+ TextField(text: $serverName) {
+ Text("Name")
+ }
+ }
+
+ Section {
+ TextField(text: $serverAddress) {
+ Text("Address")
+ }
+ TextField(text: $serverLogin, prompt: Text("Optional")) {
+ Text("Login")
+ }
+ SecureField(text: $serverPassword, prompt: Text("Optional")) {
+ Text("Password")
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .frame(width: 350)
+ .fixedSize(horizontal: true, vertical: true)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ let displayName = self.serverName.trimmingCharacters(in: .whitespacesAndNewlines)
+ let (host, port) = Server.parseServerAddressAndPort(self.serverAddress)
+ let login = self.serverLogin.trimmingCharacters(in: .whitespacesAndNewlines)
+ let password = self.serverPassword
+
+ if !displayName.isEmpty && !host.isEmpty {
+ self.bookmark.name = displayName
+ self.bookmark.address = host
+ self.bookmark.port = port
+ self.bookmark.login = login.isEmpty ? nil : login
+ self.bookmark.password = password.isEmpty ? nil : password
+
+ self.dismiss()
+ }
+ }
+ }
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ self.dismiss()
+ }
+ }
+ }
+ }
+}
diff --git a/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift
new file mode 100644
index 0000000..93aea15
--- /dev/null
+++ b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+
+struct TrackerBookmarkServerView: View {
+ let server: BookmarkServer
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 6) {
+ Image("Server")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16, alignment: .center)
+ Text(self.server.name ?? "Server").lineLimit(1).truncationMode(.tail)
+ if let serverDescription = self.server.description {
+ Text(serverDescription)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+ Spacer(minLength: 0)
+ if self.server.users > 0 {
+ Text(String(self.server.users))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+
+ Circle()
+ .fill(.fileComplete)
+ .frame(width: 7, height: 7)
+ .keyframeAnimator(initialValue: 1.0, repeating: true) { content, opacity in
+ content.opacity(opacity)
+ } keyframes: { _ in
+ CubicKeyframe(1.0, duration: 2.0) // Stay visible for 1 second
+ CubicKeyframe(0.6, duration: 0.5) // Fade out quickly
+ CubicKeyframe(1.0, duration: 0.5) // Fade in quickly
+ }
+ .padding(.trailing, 6)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
diff --git a/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift
new file mode 100644
index 0000000..d66a466
--- /dev/null
+++ b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift
@@ -0,0 +1,121 @@
+import SwiftUI
+
+struct TrackerBookmarkSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.modelContext) private var modelContext
+
+ @State private var bookmark: Bookmark? = nil
+ @State private var trackerAddress: String = ""
+ @State private var trackerName: String = ""
+
+ init() {
+
+ }
+
+ init(_ editingBookmark: Bookmark) {
+ _bookmark = .init(initialValue: editingBookmark)
+ _trackerAddress = .init(initialValue: editingBookmark.displayAddress)
+ _trackerName = .init(initialValue: editingBookmark.name)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ Form {
+ if self.bookmark == nil {
+ HStack(alignment: .top, spacing: 10) {
+ GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0)
+ .frame(width: 28, height: 28)
+
+ VStack(alignment: .leading) {
+ Text("Add a Hotline Tracker")
+
+ Text("Enter the address and name of a Hotline Tracker you want to add.")
+ .foregroundStyle(.secondary)
+ .font(.subheadline)
+ }
+ }
+ }
+ else {
+ HStack(alignment: .top, spacing: 10) {
+ GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0)
+ .frame(width: 28, height: 28)
+
+ VStack(alignment: .leading) {
+ Text("Edit Hotline Tracker")
+
+ Text("Change the address and name of your Hotline Tracker.")
+ .foregroundStyle(.secondary)
+ .font(.subheadline)
+ }
+ }
+ }
+
+ Group {
+ TextField(text: $trackerAddress) {
+ Text("Address")
+ }
+ TextField(text: $trackerName, prompt: Text("Optional")) {
+ Text("Name")
+ }
+ }
+// .textFieldStyle(.roundedBorder)
+ .controlSize(.large)
+ }
+ .formStyle(.grouped)
+ }
+ .frame(width: 350)
+ .fixedSize(horizontal: true, vertical: true)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ self.saveTracker()
+ } label: {
+ if self.bookmark != nil {
+ Text("Save")
+ }
+ else {
+ Text("Add")
+ }
+ }
+ }
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ self.trackerName = ""
+ self.trackerAddress = ""
+
+ self.dismiss()
+ }
+ }
+ }
+ }
+
+ private func saveTracker() {
+ var displayName = trackerName.trimmingCharacters(in: .whitespacesAndNewlines)
+ let (host, port) = Tracker.parseTrackerAddressAndPort(trackerAddress)
+
+ if displayName.isEmpty {
+ displayName = host
+ }
+
+ if !displayName.isEmpty && !host.isEmpty {
+ if !host.isEmpty {
+ if self.bookmark != nil {
+ // We're editing an existing bookmark.
+ self.bookmark?.name = displayName
+ self.bookmark?.address = host
+ self.bookmark?.port = port
+ }
+ else {
+ // We're creating a new bookmark.
+ let newBookmark = Bookmark(type: .tracker, name: displayName, address: host, port: port)
+ Bookmark.add(newBookmark, context: modelContext)
+ }
+
+ self.trackerName = ""
+ self.trackerAddress = ""
+
+ self.dismiss()
+ }
+ }
+ }
+}
diff --git a/Hotline/macOS/Trackers/TrackerItemView.swift b/Hotline/macOS/Trackers/TrackerItemView.swift
new file mode 100644
index 0000000..37093d2
--- /dev/null
+++ b/Hotline/macOS/Trackers/TrackerItemView.swift
@@ -0,0 +1,75 @@
+import SwiftUI
+
+struct TrackerItemView: View {
+ let bookmark: Bookmark
+ let isExpanded: Bool
+ let isLoading: Bool
+ let count: Int
+ let onToggleExpanded: () -> Void
+ @Environment(\.appearsActive) private var appearsActive
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 6) {
+ if bookmark.type == .tracker {
+ Button {
+ self.onToggleExpanded()
+ } label: {
+ Text(Image(systemName: self.isExpanded ? "chevron.down" : "chevron.right"))
+ .bold()
+ .font(.system(size: 10))
+ .opacity(0.5)
+ .frame(alignment: .center)
+ }
+ .buttonStyle(.plain)
+ .frame(width: 10)
+ .padding(.leading, 4)
+ .padding(.trailing, 2)
+ }
+
+ switch bookmark.type {
+ case .tracker:
+ Image("Tracker")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16, alignment: .center)
+ Text(bookmark.name).bold().lineLimit(1).truncationMode(.tail)
+ if isLoading {
+ ProgressView()
+ .padding([.leading, .trailing], 2)
+ .controlSize(.mini)
+ }
+ Spacer(minLength: 0)
+ if isExpanded && count > 0 {
+ HStack(spacing: 4) {
+ Text(String(count))
+
+ SpinningGlobeView()
+ .fontWeight(.semibold)
+ .frame(width: 12, height: 12)
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .foregroundStyle(.secondary)
+// .background(.quinary)
+ .clipShape(.capsule)
+ }
+ case .server:
+ Image(systemName: "bookmark.fill")
+ .resizable()
+ .scaledToFit()
+ .foregroundStyle(Color.secondary)
+ .frame(width: 11, height: 11, alignment: .center)
+ .opacity(0.75)
+ .padding(.leading, 3)
+ .padding(.trailing, 2)
+ Image("Server")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16, alignment: .center)
+ Text(bookmark.name).lineLimit(1).truncationMode(.tail)
+ Spacer(minLength: 0)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
diff --git a/Hotline/macOS/Trackers/TrackerView.swift b/Hotline/macOS/Trackers/TrackerView.swift
new file mode 100644
index 0000000..e0ca87d
--- /dev/null
+++ b/Hotline/macOS/Trackers/TrackerView.swift
@@ -0,0 +1,623 @@
+import SwiftUI
+import SwiftData
+import Foundation
+import UniformTypeIdentifiers
+
+enum TrackerSelection: Hashable {
+ case bookmark(Bookmark)
+ case bookmarkServer(BookmarkServer)
+ case bonjourGroup
+ case bonjourServer(BonjourState.BonjourServer)
+
+ var server: Server? {
+ switch self {
+ case .bookmark(let b): b.server
+ case .bookmarkServer(let t): t.server
+ case .bonjourGroup: nil
+ case .bonjourServer(let b): b.server
+ }
+ }
+}
+
+struct TrackerView: View {
+ @Environment(\.colorScheme) private var colorScheme
+ @Environment(\.openWindow) private var openWindow
+ @Environment(\.controlActiveState) private var controlActiveState
+ @Environment(\.modelContext) private var modelContext
+
+ @State private var refreshing = false
+ @State private var trackerSheetPresented: Bool = false
+ @State private var trackerSheetBookmark: Bookmark? = nil
+ @State private var serverSheetBookmark: Bookmark? = nil
+ @State private var attemptedPrepopulate: Bool = false
+ @State private var fileDropActive = false
+ @State private var bookmarkExportActive = false
+ @State private var bookmarkExport: BookmarkDocument? = nil
+ @State private var expandedTrackers: Set<Bookmark> = []
+ @State private var trackerServers: [Bookmark: [BookmarkServer]] = [:]
+ @State private var loadingTrackers: Set<Bookmark> = []
+ @State private var fetchTasks: [Bookmark: Task<Void, Never>] = [:]
+ @State private var searchText: String = ""
+ @State private var isSearching = false
+
+ @Query(sort: \Bookmark.order) private var bookmarks: [Bookmark]
+ @Binding var selection: TrackerSelection?
+
+ private var filteredBookmarks: [Bookmark] {
+ guard !self.searchText.isEmpty else {
+ return self.bookmarks
+ }
+
+ let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init)
+
+ return self.bookmarks.filter { bookmark in
+ // Always show tracker bookmarks (filter only their servers)
+ if bookmark.type == .tracker {
+ return true
+ }
+
+ // Filter server bookmarks by search text
+ return self.bookmarkMatchesSearch(bookmark, searchWords: searchWords)
+ }
+ }
+
+ private func bookmarkMatchesSearch(_ bookmark: Bookmark, searchWords: [String]) -> Bool {
+ let searchableText = "\(bookmark.name) \(bookmark.address)".lowercased()
+
+ // All search words must match
+ return searchWords.allSatisfy { word in
+ searchableText.contains(word)
+ }
+ }
+
+ private func filteredServers(for bookmark: Bookmark) -> [BookmarkServer] {
+ let servers = self.trackerServers[bookmark] ?? []
+ print("TrackerView.filteredServers: Looking up servers for \(bookmark.name), found \(servers.count) servers")
+
+ guard !self.searchText.isEmpty else {
+ return servers
+ }
+
+ let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init)
+
+ return servers.filter { server in
+ let searchableText = "\(server.name ?? "") \(server.address) \(server.description ?? "")".lowercased()
+
+ // All search words must match
+ return searchWords.allSatisfy { word in
+ searchableText.contains(word)
+ }
+ }
+ }
+
+ var bonjourRowView: some View {
+ HStack(alignment: .center, spacing: 6) {
+ Button {
+ AppState.shared.bonjourState.isExpanded.toggle()
+ } label: {
+ Text(Image(systemName: AppState.shared.bonjourState.isExpanded ? "chevron.down" : "chevron.right"))
+ .bold()
+ .font(.system(size: 10))
+ .opacity(0.5)
+ .frame(alignment: .center)
+ }
+ .buttonStyle(.plain)
+ .frame(width: 10)
+ .padding(.leading, 4)
+ .padding(.trailing, 2)
+
+ Image(systemName: "bonjour")
+ .resizable()
+ .scaledToFit()
+ .symbolRenderingMode(.multicolor)
+ .frame(width: 16, height: 16, alignment: .center)
+ Text("Bonjour").bold().lineLimit(1).truncationMode(.tail)
+
+ if AppState.shared.bonjourState.isBrowsing {
+ ProgressView()
+ .controlSize(.mini)
+ }
+
+ Spacer(minLength: 0)
+
+ if AppState.shared.bonjourState.isExpanded && !AppState.shared.bonjourState.discoveredServers.isEmpty {
+ HStack(spacing: 4) {
+ Text(String(AppState.shared.bonjourState.discoveredServers.count))
+
+ SpinningGlobeView()
+ .fontWeight(.semibold)
+ .frame(width: 12, height: 12)
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .foregroundStyle(.secondary)
+// .background(.quinary)
+ .clipShape(.capsule)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .onChange(of: AppState.shared.bonjourState.isExpanded) { oldState, newState in
+ if newState {
+ AppState.shared.bonjourState.startBrowsing()
+ }
+ else {
+ AppState.shared.bonjourState.stopBrowsing()
+ }
+ }
+ }
+
+ var body: some View {
+ List(selection: $selection) {
+ ForEach(filteredBookmarks, id: \.self) { bookmark in
+ TrackerItemView(
+ bookmark: bookmark,
+ isExpanded: self.expandedTrackers.contains(bookmark),
+ isLoading: self.loadingTrackers.contains(bookmark),
+ count: self.trackerServers[bookmark]?.count ?? 0
+ ) {
+ self.toggleExpanded(for: bookmark)
+ }
+ .tag(TrackerSelection.bookmark(bookmark))
+
+ if bookmark.type == .tracker && self.expandedTrackers.contains(bookmark) {
+ ForEach(self.filteredServers(for: bookmark), id: \.self) { trackedServer in
+ TrackerBookmarkServerView(server: trackedServer)
+ .moveDisabled(true)
+ .deleteDisabled(true)
+ .tag(TrackerSelection.bookmarkServer(trackedServer))
+ .padding(.leading, 16 + 8 + 10)
+ }
+ }
+ }
+ .onMove { movedIndexes, destinationIndex in
+ Bookmark.move(movedIndexes, to: destinationIndex, context: modelContext)
+ }
+ .onDelete { deletedIndexes in
+ Bookmark.delete(at: deletedIndexes, context: modelContext)
+ }
+
+ self.bonjourRowView
+ .tag(TrackerSelection.bonjourGroup)
+
+ if AppState.shared.bonjourState.isExpanded {
+ ForEach(AppState.shared.bonjourState.discoveredServers, id: \.self) { record in
+ BonjourServerRow(server: record)
+ .tag(TrackerSelection.bonjourServer(record))
+ .padding(.leading, 16 + 8 + 10)
+ }
+ }
+ }
+ .onDeleteCommand {
+ switch self.selection {
+ case .bookmark(let bookmark):
+ Bookmark.delete(bookmark, context: modelContext)
+ default:
+ break
+ }
+
+// if let bookmark = selection,
+// bookmark.type != .temporary {
+// Bookmark.delete(bookmark, context: modelContext)
+// }
+ }
+ .environment(\.defaultMinListRowHeight, 34)
+ .listStyle(.inset)
+ .alternatingRowBackgrounds(.enabled)
+ .onChange(of: AppState.shared.cloudKitReady) {
+ if attemptedPrepopulate {
+ print("Tracker: Already attempted to prepopulate bookmarks")
+ return
+ }
+
+ print("Tracker: Prepopulating bookmarks")
+
+ attemptedPrepopulate = true
+
+ // Make sure default bookmarks are there when empty.
+ Bookmark.populateDefaults(context: modelContext)
+ }
+ .onAppear {
+// Bookmark.deleteAll(context: modelContext)
+ }
+ .contextMenu(forSelectionType: TrackerSelection.self) { items in
+ if let item = items.first {
+ switch item {
+ case .bookmark(let bookmark):
+ self.bookmarkContextMenu(bookmark)
+ case .bookmarkServer(let server):
+ self.bookmarkServerContextMenu(server)
+ case .bonjourGroup:
+ EmptyView()
+ case .bonjourServer(let bonjourServer):
+ self.bonjourServerContextMenu(bonjourServer)
+ }
+ }
+ } primaryAction: { items in
+ guard let clickedItem = items.first else {
+ return
+ }
+
+ switch clickedItem {
+ case .bookmark(let bookmark):
+ if bookmark.type == .server {
+ if let s = bookmark.server {
+ openWindow(id: "server", value: s)
+ }
+ }
+ else if bookmark.type == .tracker {
+ if NSEvent.modifierFlags.contains(.option) {
+ trackerSheetBookmark = bookmark
+ }
+ else {
+ self.toggleExpanded(for: bookmark)
+ }
+ }
+
+ case .bookmarkServer(let bookmarkServer):
+ openWindow(id: "server", value: bookmarkServer.server)
+
+ case .bonjourGroup:
+ AppState.shared.bonjourState.isExpanded.toggle()
+
+ case .bonjourServer(let bonjourServer):
+ if let server = bonjourServer.server {
+ openWindow(id: "server", value: server)
+ }
+
+ }
+ }
+ .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in
+ switch result {
+ case .success(let fileURL):
+ print("Hotline Bookmark: Successfully exported:", fileURL)
+ case .failure(let err):
+ print("Hotline Bookmark: Failed to export:", err)
+ }
+
+ bookmarkExport = nil
+ bookmarkExportActive = false
+ }, onCancellation: {})
+ .onKeyPress(.rightArrow) {
+ switch self.selection {
+ case .bookmark(let bookmark):
+ if bookmark.type == .tracker {
+ self.setExpanded(true, for: bookmark)
+ return .handled
+ }
+ default:
+ break
+ }
+
+ return .ignored
+ }
+ .onKeyPress(.leftArrow) {
+ switch self.selection {
+ case .bookmark(let bookmark):
+ if bookmark.type == .tracker {
+ self.setExpanded(false, for: bookmark)
+ return .handled
+ }
+ default:
+ break
+ }
+
+ return .ignored
+ }
+ .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in
+ for provider in providers {
+ let _ = provider.loadDataRepresentation(for: UTType.fileURL) { dataRepresentation, err in
+ // HOTLINE CREATOR CODE: 1213484099
+ // HOTLINE BOOKMARK TYPE CODE: 1213489773
+
+ if let filePathData = dataRepresentation,
+ let filePath = String(data: filePathData, encoding: .utf8),
+ let fileURL = URL(string: filePath) {
+
+ print("Hotline Bookmark: Dropped from ", fileURL.path(percentEncoded: false))
+
+ DispatchQueue.main.async {
+ if let newBookmark = Bookmark(fileURL: fileURL) {
+ print("Hotline Bookmark: Added bookmark.")
+ Bookmark.add(newBookmark, context: modelContext)
+ }
+ else {
+ print("Hotline Bookmark: Failed to parse.")
+ }
+ }
+ }
+ }
+ }
+
+ return true
+ }
+ .sheet(item: $trackerSheetBookmark) { item in
+ TrackerBookmarkSheet(item)
+ }
+ .sheet(isPresented: $trackerSheetPresented) {
+ TrackerBookmarkSheet()
+ }
+ .sheet(item: $serverSheetBookmark) { item in
+ ServerBookmarkSheet(item)
+ }
+ .navigationTitle("Servers")
+ .toolbar {
+ if #available(macOS 26.0, *) {
+ ToolbarItem(placement: .navigation) {
+ self.hotlineLogoImage
+ }
+ .sharedBackgroundVisibility(.hidden)
+ }
+ else {
+ ToolbarItem(placement: .navigation) {
+ self.hotlineLogoImage
+ }
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ self.refreshing = true
+ self.refresh()
+ self.refreshing = false
+ } label: {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ }
+ .disabled(refreshing)
+ .help("Refresh Trackers")
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ trackerSheetPresented = true
+ } label: {
+ Label("Add Tracker", systemImage: "point.3.filled.connected.trianglepath.dotted")
+ }
+ .help("Add Tracker")
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ openWindow(id: "server")
+ } label: {
+ Label("Connect to Server", systemImage: "globe.americas.fill")
+ }
+ .help("Connect to Server")
+ }
+ }
+ .onOpenURL(perform: { url in
+ if let s = Server(url: url) {
+ openWindow(id: "server", value: s)
+ }
+ })
+ .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search")
+ .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden())
+ }
+
+ private var hotlineLogoImage: some View {
+ Image("Hotline")
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFit()
+ .foregroundColor(Color(hex: 0xE10000))
+ .frame(width: 9)
+ .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
+ }
+
+ @ViewBuilder
+ func bookmarkServerContextMenu(_ server: BookmarkServer) -> some View {
+ Button {
+ NSPasteboard.general.clearContents()
+ let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)"
+ NSPasteboard.general.setString("hotline://\(displayAddress)", forType: .string)
+ } label: {
+ Label("Copy Link", systemImage: "link")
+ }
+
+ Button {
+ NSPasteboard.general.clearContents()
+ let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)"
+ NSPasteboard.general.setString(displayAddress, forType: .string)
+ } label: {
+ Label("Copy Address", systemImage: "doc.on.doc")
+ }
+
+ Divider()
+
+ Button {
+ let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil)
+ Bookmark.add(newBookmark, context: modelContext)
+ } label: {
+ Label("Bookmark", systemImage: "bookmark")
+ }
+ }
+
+ @ViewBuilder
+ func bookmarkContextMenu(_ bookmark: Bookmark) -> some View {
+ Button {
+ let linkString: String = switch bookmark.type {
+ case .tracker: "hotlinetracker://\(bookmark.displayAddress)"
+ case .server: "hotline://\(bookmark.displayAddress)"
+ }
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(linkString, forType: .string)
+ } label: {
+ Label("Copy Link", systemImage: "link")
+ }
+
+ Button {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(bookmark.displayAddress, forType: .string)
+ } label: {
+ Label("Copy Address", systemImage: "doc.on.doc")
+ }
+
+ Divider()
+
+ if bookmark.type == .tracker {
+ Button {
+ trackerSheetBookmark = bookmark
+ } label: {
+ Label("Edit Tracker...", systemImage: "pencil")
+ }
+ }
+
+ if bookmark.type == .server {
+ Button {
+ serverSheetBookmark = bookmark
+ } label: {
+ Label("Edit Bookmark...", systemImage: "pencil")
+ }
+
+ Button {
+ bookmarkExport = BookmarkDocument(bookmark: bookmark)
+ bookmarkExportActive = true
+ } label: {
+ Label("Export Bookmark...", systemImage: "square.and.arrow.down")
+ }
+ }
+
+ Divider()
+
+ Button {
+ Bookmark.delete(bookmark, context: modelContext)
+ } label: {
+ if bookmark.type == .tracker {
+ Label("Delete Tracker", systemImage: "xmark")
+ }
+ else {
+ Label("Delete Bookmark", systemImage: "bookmark.slash")
+ }
+ }
+ }
+
+ @ViewBuilder
+ func bonjourServerContextMenu(_ bonjourServer: BonjourState.BonjourServer) -> some View {
+ Button {
+ guard let server = bonjourServer.server else {
+ return
+ }
+ NSPasteboard.general.clearContents()
+ let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)"
+ NSPasteboard.general.setString("hotline://\(displayAddress)", forType: .string)
+ } label: {
+ Label("Copy Link", systemImage: "link")
+ }
+
+ Button {
+ guard let server = bonjourServer.server else {
+ return
+ }
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(server.displayAddress, forType: .string)
+ } label: {
+ Label("Copy Address", systemImage: "doc.on.doc")
+ }
+
+ Divider()
+
+ Button {
+ guard let server = bonjourServer.server else {
+ return
+ }
+ let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil)
+ Bookmark.add(newBookmark, context: modelContext)
+ } label: {
+ Label("Bookmark", systemImage: "bookmark")
+ }
+ }
+
+ func refresh() {
+ // When a tracker is selected, refresh only that tracker.
+ if let trackerSelection = self.selection {
+ switch trackerSelection {
+ case .bookmark(let bookmark):
+ if bookmark.type == .tracker {
+ if self.expandedTrackers.contains(bookmark) {
+ // Already expanded, cancel old fetch and start new one
+ self.fetchTasks[bookmark]?.cancel()
+ let task = Task {
+ await self.fetchServers(for: bookmark)
+ }
+ self.fetchTasks[bookmark] = task
+ } else {
+ // Not expanded, expand it (which also fetches)
+ self.setExpanded(true, for: bookmark)
+ }
+ return
+ }
+ break
+ default:
+ break
+ }
+ }
+
+ // Otherwise refresh/expand all trackers.
+ for bookmark in self.bookmarks {
+ if bookmark.type == .tracker {
+ if self.expandedTrackers.contains(bookmark) {
+ // Already expanded, cancel old fetch and start new one
+ self.fetchTasks[bookmark]?.cancel()
+ let task = Task {
+ await self.fetchServers(for: bookmark)
+ }
+ self.fetchTasks[bookmark] = task
+ } else {
+ // Not expanded, expand it (which also fetches)
+ self.setExpanded(true, for: bookmark)
+ }
+ }
+ }
+ }
+
+ func toggleExpanded(for bookmark: Bookmark) {
+ guard bookmark.type == .tracker else { return }
+ self.setExpanded(!self.expandedTrackers.contains(bookmark), for: bookmark)
+ }
+
+ func setExpanded(_ expanded: Bool, for bookmark: Bookmark) {
+ guard bookmark.type == .tracker else { return }
+
+ if expanded && !self.expandedTrackers.contains(bookmark) {
+ self.expandedTrackers.insert(bookmark)
+ let task = Task {
+ await self.fetchServers(for: bookmark)
+ }
+ self.fetchTasks[bookmark] = task
+ } else if !expanded && self.expandedTrackers.contains(bookmark) {
+ // Cancel ongoing fetch and clear data
+ self.fetchTasks[bookmark]?.cancel()
+ self.fetchTasks[bookmark] = nil
+ self.expandedTrackers.remove(bookmark)
+ self.trackerServers[bookmark] = nil
+ self.loadingTrackers.remove(bookmark)
+ }
+ }
+
+ private func fetchServers(for bookmark: Bookmark) async {
+ print("TrackerView.fetchServers: Starting fetch for bookmark: \(bookmark.name)")
+ self.loadingTrackers.insert(bookmark)
+ let servers = await bookmark.fetchServers()
+ print("TrackerView.fetchServers: Got \(servers.count) servers from bookmark.fetchServers()")
+ await MainActor.run {
+ print("TrackerView.fetchServers: Assigning \(servers.count) servers to trackerServers[\(bookmark.name)]")
+ self.trackerServers[bookmark] = servers
+ self.loadingTrackers.remove(bookmark)
+ self.fetchTasks[bookmark] = nil // Clean up completed task
+ print("TrackerView.fetchServers: trackerServers now has \(self.trackerServers.count) entries")
+ print("TrackerView.fetchServers: Verification - trackerServers[\(bookmark.name)] now has \(self.trackerServers[bookmark]?.count ?? -1) servers")
+ }
+ }
+}
+
+#if DEBUG
+private struct TrackerViewPreview: View {
+ @State var selection: TrackerSelection? = nil
+
+ var body: some View {
+ TrackerView(selection: $selection)
+ }
+}
+
+#Preview {
+ TrackerViewPreview()
+}
+#endif
diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift
new file mode 100644
index 0000000..1fefe5a
--- /dev/null
+++ b/Hotline/macOS/TransfersView.swift
@@ -0,0 +1,411 @@
+import SwiftUI
+
+struct TransfersView: View {
+ @Environment(\.appState) private var appState
+
+ @State private var selectedTransfers = Set<TransferInfo>()
+
+ var body: some View {
+ VStack(spacing: 0) {
+ if self.appState.transfers.isEmpty {
+ self.emptyState
+ } else {
+ self.transfersList
+ }
+ }
+ .frame(minWidth: 500, minHeight: 200)
+ .navigationTitle("Transfers")
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ if self.selectedTransfers.isEmpty {
+ NSWorkspace.shared.open(URL.downloadsDirectory)
+ }
+ else {
+ let fileURLs = self.selectedTransfers.compactMap(\.fileURL)
+ if !fileURLs.isEmpty {
+ NSWorkspace.shared.activateFileViewerSelecting(fileURLs)
+ }
+ }
+ } label: {
+ Label("Show Downloads", systemImage: "folder")
+ }
+ .help("Show Downloads")
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ for transfer in self.selectedTransfers {
+ self.appState.cancelTransfer(id: transfer.id)
+ }
+ self.selectedTransfers = []
+ } label: {
+ Label(self.selectedTransfers.count == 1 ? "Remove Transfer" : "Remove Transfers", systemImage: "xmark")
+ }
+ .disabled(self.selectedTransfers.isEmpty)
+ .help(self.selectedTransfers.count == 1 ? "Remove Transfer" : "Remove Transfers")
+ }
+ }
+ }
+
+ // MARK: - Empty State
+
+ private var emptyState: some View {
+ ContentUnavailableView {
+ Label("No Transfers", systemImage: "arrow.up.arrow.down")
+ } description: {
+ Text("Your Hotline file transfers will appear here")
+ }
+ }
+
+ // MARK: - Transfers List
+
+ private var transfersList: some View {
+ List(selection: self.$selectedTransfers) {
+ ForEach(self.appState.transfers) { transfer in
+ TransferRow(transfer: transfer)
+ .id(transfer)
+ }
+ }
+ .listStyle(.inset)
+ .environment(\.defaultMinListRowHeight, 56)
+ .contextMenu(forSelectionType: TransferInfo.self) { items in
+ self.contextMenuForItems(items)
+ } primaryAction: { items in
+ self.performPrimaryAction(for: items)
+ }
+ }
+
+ // MARK: - Double Click
+
+ private func performPrimaryAction(for items: Set<TransferInfo>) {
+ if let fileURL = items.first?.fileURL {
+ NSWorkspace.shared.open(fileURL)
+ }
+ }
+
+ // MARK: - Context Menu
+
+ @ViewBuilder
+ private func contextMenuForItems(_ items: Set<TransferInfo>) -> some View {
+ if items.allSatisfy(\.completed) {
+ let fileURLs: [URL] = items.compactMap(\.fileURL)
+
+ Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") {
+ self.appState.cancelTransfers(ids: items.map(\.id))
+ self.selectedTransfers = []
+ }
+
+ Divider()
+
+ Button("Open", systemImage: "arrow.up.right.square") {
+ for fileURL in fileURLs {
+ NSWorkspace.shared.open(fileURL)
+ }
+ }
+
+ self.openWithMenu(for: fileURLs)
+
+ Button("Show in Finder", systemImage: "finder") {
+ NSWorkspace.shared.activateFileViewerSelecting(fileURLs)
+ }
+
+ Divider()
+
+ Button("Move to Trash", systemImage: "trash") {
+ self.appState.cancelTransfers(ids: items.map(\.id))
+ NSWorkspace.shared.recycle(fileURLs)
+ self.selectedTransfers = []
+ }
+ } else {
+ Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") {
+ self.appState.cancelTransfers(ids: items.map(\.id))
+ self.selectedTransfers = []
+ }
+
+ Divider()
+
+ Button("Move to Trash", systemImage: "trash") {
+ self.appState.cancelTransfers(ids: items.map(\.id))
+
+ let fileURLs: [URL] = items.compactMap(\.fileURL)
+ if !fileURLs.isEmpty {
+ NSWorkspace.shared.recycle(fileURLs)
+ }
+
+ self.selectedTransfers = []
+ }
+ }
+ }
+
+ private func getOpenWithApps(for fileURLs: [URL], defaultAppURL: URL? = nil) -> [(name: String, url: URL)] {
+ // If no files provided, there is no common app to open them
+ guard !fileURLs.isEmpty else { return [] }
+
+ // Build a list of app URL sets for each file URL
+ let appSets: [Set<URL>] = fileURLs.map { url in
+ let apps = NSWorkspace.shared.urlsForApplications(toOpen: url)
+ return Set(apps)
+ }
+
+ // Compute the intersection across all file URL app sets
+ guard var intersection = appSets.first else { return [] }
+ for set in appSets.dropFirst() {
+ intersection.formIntersection(set)
+ }
+
+ // Optionally remove the default app from the list
+ if let defaultAppURL {
+ intersection.remove(defaultAppURL)
+ }
+
+ // Map to display names and sort by name
+ let result: [(name: String, url: URL)] = intersection.compactMap { url in
+ let appName = FileManager.default
+ .displayName(atPath: url.path)
+ .replacing(".app", with: "")
+ return (name: appName, url: url)
+ }.sorted { $0.name < $1.name }
+
+ return result
+ }
+
+ private func getDefaultApp(for fileURLs: [URL]) -> (name: String, url: URL)? {
+ // No files -> no default app
+ guard !fileURLs.isEmpty else { return nil }
+
+ // Single file: use the system default directly
+ if fileURLs.count == 1, let url = NSWorkspace.shared.urlForApplication(toOpen: fileURLs[0]) {
+ let name = FileManager.default
+ .displayName(atPath: url.path)
+ .replacing(".app", with: "")
+ return (name, url)
+ }
+
+ // Build the intersection of apps that can open ALL files
+ let appSets: [Set<URL>] = fileURLs.map { url in
+ Set(NSWorkspace.shared.urlsForApplications(toOpen: url))
+ }
+ guard var intersection = appSets.first else { return nil }
+ for set in appSets.dropFirst() {
+ intersection.formIntersection(set)
+ if intersection.isEmpty { return nil }
+ }
+
+ // Tally the system default app for each file
+ var defaultCounts: [URL: Int] = [:]
+ for fileURL in fileURLs {
+ if let def = NSWorkspace.shared.urlForApplication(toOpen: fileURL) {
+ defaultCounts[def, default: 0] += 1
+ }
+ }
+
+ // Prefer the app that's the default for the majority of files, provided it can open all
+ if let bestByMajority = intersection.max(by: { (a, b) -> Bool in
+ let ca = defaultCounts[a, default: 0]
+ let cb = defaultCounts[b, default: 0]
+ if ca == cb {
+ // Tie-breaker deferred to later
+ return false
+ }
+ return ca < cb
+ }),
+ defaultCounts[bestByMajority, default: 0] > 0 {
+ let name = FileManager.default
+ .displayName(atPath: bestByMajority.path)
+ .replacing(".app", with: "")
+ return (name, bestByMajority)
+ }
+
+ return nil
+ }
+
+ private func openWithMenu(for fileURLs: [URL]) -> some View {
+ Menu("Open With") {
+ let defaultApp: (name: String, url: URL)? = self.getDefaultApp(for: fileURLs)
+ let apps: [(name: String, url: URL)] = self.getOpenWithApps(for: fileURLs, defaultAppURL: defaultApp?.url)
+
+ if let defaultApp {
+ Button {
+ NSWorkspace.shared.open(fileURLs, withApplicationAt: defaultApp.url, configuration: NSWorkspace.OpenConfiguration())
+ } label: {
+ Label {
+ Text(defaultApp.name)
+ } icon: {
+ Image(nsImage: NSWorkspace.shared.icon(forFile: defaultApp.url.path))
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ }
+
+ if !apps.isEmpty {
+ Divider()
+ }
+ }
+
+ if !apps.isEmpty {
+ ForEach(apps, id: \.url) { app in
+ Button {
+ NSWorkspace.shared.open(fileURLs, withApplicationAt: app.url, configuration: NSWorkspace.OpenConfiguration())
+ } label: {
+ Label {
+ Text(app.name)
+ } icon: {
+ Image(nsImage: NSWorkspace.shared.icon(forFile: app.url.path))
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ }
+ }
+ }
+ }
+ }
+
+}
+
+// MARK: - Transfer Row
+
+struct TransferRow: View {
+ @Environment(\.appState) private var appState
+
+ @Bindable var transfer: TransferInfo
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 8) {
+ if self.transfer.isFolder {
+ self.folderIconView
+ }
+ else {
+ self.fileIconView
+ }
+
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(self.transfer.title)
+ .font(.headline)
+ .lineLimit(1)
+ .truncationMode(.tail)
+
+ Spacer()
+
+ if !self.transfer.done {
+ self.statsView
+ }
+ }
+
+ // Progress bar and status
+ if self.transfer.cancelled {
+ Text("Cancelled")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ else if self.transfer.failed {
+ Text("Failed")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ else if self.transfer.completed {
+ Text("Downloaded")
+ .font(.subheadline)
+ .foregroundStyle(.fileComplete)
+ }
+ else {
+ if self.transfer.progress == 0 {
+ ProgressView()
+ .progressViewStyle(.linear)
+ .controlSize(.large)
+ }
+ else {
+ ProgressView(value: self.transfer.progress, total: 1.0)
+ .progressViewStyle(.linear)
+ .controlSize(.large)
+ }
+
+ }
+ }
+ }
+ }
+
+ // MARK: -
+
+ private var statsView: some View {
+ HStack(spacing: 8) {
+ // Progress percentage
+ // Text("\(Int(self.transfer.progress * 100))%")
+
+ // Speed
+ if let speed = self.transfer.displaySpeed {
+ Label(speed, systemImage: self.transfer.isUpload ? "arrow.up" : "arrow.down")
+ }
+
+ // Time remaining
+ if let timeRemaining = self.transfer.displayTimeRemaining {
+ Label(timeRemaining, systemImage: "clock")
+ }
+
+ // File size
+ Label(self.transfer.displaySize, systemImage: "document")
+ }
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+ }
+
+ private var folderIconView: some View {
+ FolderIconView()
+ .frame(width: 32, height: 32)
+ .overlay(alignment: .bottomTrailing) {
+ if self.transfer.cancelled || self.transfer.failed {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .resizable()
+ .symbolRenderingMode(.multicolor)
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ else if self.transfer.completed {
+ Image(systemName: "checkmark.circle.fill")
+ .resizable()
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, .fileComplete)
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ else {
+ FileIconView(filename: self.transfer.title, fileType: nil)
+ .frame(width: 16, height: 16)
+ }
+ }
+ }
+
+ private var fileIconView: some View {
+ FileIconView(filename: self.transfer.title, fileType: nil)
+ .frame(width: 32, height: 32)
+ .overlay(alignment: .bottomTrailing) {
+ if self.transfer.cancelled || self.transfer.failed {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .resizable()
+ .symbolRenderingMode(.multicolor)
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ else if self.transfer.completed {
+ Image(systemName: "checkmark.circle.fill")
+ .resizable()
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, .fileComplete)
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ }
+ }
+}
+
+// MARK: - Preview
+
+#Preview {
+ TransfersView()
+ .environment(AppState.shared)
+}
+
diff --git a/Hotline/macOS/UserClientInfoSheet.swift b/Hotline/macOS/UserClientInfoSheet.swift
new file mode 100644
index 0000000..8fa4db8
--- /dev/null
+++ b/Hotline/macOS/UserClientInfoSheet.swift
@@ -0,0 +1,25 @@
+import SwiftUI
+
+struct UserClientInfoSheet: View {
+ @Environment(\.dismiss) private var dismiss
+
+ let info: HotlineUserClientInfo
+
+ var body: some View {
+ ScrollView(.vertical) {
+ Text(self.info.details)
+ .fontDesign(.monospaced)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .padding()
+ }
+ .frame(width: 350, height: 400)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("OK") {
+ self.dismiss()
+ }
+ }
+ }
+ }
+}