aboutsummaryrefslogtreecommitdiff
path: root/Hotline/macOS/ServerView.swift
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/ServerView.swift
parentf466b21dc02f78c984ba6748e703f6780a7a0db4 (diff)
parent6a95b53616a4abfa306ddce43151cf4fefbd20ed (diff)
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'Hotline/macOS/ServerView.swift')
-rw-r--r--Hotline/macOS/ServerView.swift707
1 files changed, 302 insertions, 405 deletions
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 ""
}
}