import SwiftUI import UniformTypeIdentifiers import AppKit struct ServerMenuItem: Identifiable, Hashable { let id: UUID let type: ServerNavigationType let name: String let image: String init(type: ServerNavigationType, name: String, image: String) { self.id = UUID() self.type = type self.name = name self.image = image } func hash(into hasher: inout Hasher) { hasher.combine(id) } static func == (lhs: ServerMenuItem, rhs: ServerMenuItem) -> Bool { switch lhs.type { case .user(let lhsUID): switch rhs.type { case .user(let rhsUID): return lhsUID == rhsUID default: break } default: break } return lhs.id == rhs.id } } struct ListItemView: View { @Environment(\.controlActiveState) private var controlActiveState let icon: String? let title: String let unread: Bool var body: some View { HStack(spacing: 5) { 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) } Text(title) .lineLimit(1) .truncationMode(.tail) Spacer() if unread { Circle() .frame(width: 6, height: 6) .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 2)) .opacity(0.5) } } } } extension FocusedValues { @Entry var activeHotlineModel: HotlineState? @Entry var activeServerState: ServerState? } struct ServerView: View { @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) private var colorScheme @Environment(\.controlActiveState) private var controlActiveState @Environment(\.scenePhase) private var scenePhase @Environment(\.modelContext) private var modelContext @State private var model: HotlineState = HotlineState() @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 = "" @Binding var server: Server 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"), ] static var classicMenuItems: [ServerMenuItem] = [ ServerMenuItem(type: .chat, name: "Chat", image: "Section Chat"), 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 { VStack(alignment: .center) { Spacer() self.connectForm Spacer() } // .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle("Connect to Server") } else if case .failed(let error) = model.status { VStack { Image("Hotline") .resizable() .renderingMode(.template) .scaledToFit() .foregroundColor(Color(hex: 0xE10000)) .frame(width: 18) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) .padding(.trailing, 4) Text("Connection Failed") .font(.headline) Text(error) .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: 300) .padding() .navigationTitle("Connection Failed") } else if model.status != .loggedIn { HStack { Image("Hotline") .resizable() .renderingMode(.template) .scaledToFit() .foregroundColor(Color(hex: 0xE10000)) .frame(width: 18) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) .padding(.trailing, 4) ProgressView(value: connectionStatusToProgress(status: model.status)) { Text(connectionStatusToLabel(status: model.status)) } .accentColor(colorScheme == .dark ? .white : .black) } .frame(maxWidth: 300) .padding() .navigationTitle("Connecting to Server") } else { serverView .environment(model) .onChange(of: Prefs.shared.userIconID) { Task { try? await model.sendUserPreferences() } } .onChange(of: Prefs.shared.username) { Task { try? await model.sendUserPreferences() } } .onChange(of: Prefs.shared.refusePrivateMessages) { Task { try? await model.sendUserPreferences() } } .onChange(of: Prefs.shared.refusePrivateChat) { Task { try? await model.sendUserPreferences() } } .onChange(of: Prefs.shared.enableAutomaticMessage) { Task { try? await model.sendUserPreferences() } } .onChange(of: Prefs.shared.automaticMessage) { Task { try? await model.sendUserPreferences() } } .toolbar { if #available(macOS 26.0, *) { ToolbarItem(placement: .navigation) { Image("Server Large") .resizable() .scaledToFit() .frame(width: 28) .opacity(controlActiveState == .inactive ? 0.4 : 1.0) } .sharedBackgroundVisibility(.hidden) } else { ToolbarItem(placement: .navigation) { Image("Server Large") .resizable() .scaledToFit() .frame(width: 28) .opacity(controlActiveState == .inactive ? 0.4 : 1.0) } } } } } .onDisappear { Task { await model.disconnect() } } .onChange(of: model.serverTitle) { state.serverName = model.serverTitle } // .onChange(of: model.bannerImage) { // state.serverBanner = model.bannerImage // } // .onChange(of: model.bannerColors) { // guard let backgroundColor = model.bannerColors?.backgroundColor else { // state.bannerBackgroundColor = nil // return // } // state.bannerBackgroundColor = Color(nsColor: backgroundColor) // } .alert(model.errorMessage ?? "Server Error", isPresented: $model.errorDisplayed) { Button("OK") {} } .task { var address = server.address if server.port != HotlinePorts.DefaultServerPort { address += ":\(server.port)" } connectAddress = server.address connectLogin = server.login connectPassword = server.password // Connect to server automatically unless the option key is held down. if !NSEvent.modifierFlags.contains(.option) { connectToServer() } } .focusedSceneValue(\.activeHotlineModel, model) .focusedSceneValue(\.activeServerState, state) } var connectForm: some View { 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: $connectAddress) { Text("Address:") } .focused($focusedField, equals: .address) TextField(text: $connectLogin, prompt: Text("Optional")) { Text("Login:") } .focused($focusedField, equals: .login) SecureField(text: $connectPassword, prompt: Text("Optional")) { Text("Password:") } .focused($focusedField, equals: .password) 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") { connectToServer() } .controlSize(.regular) .buttonStyle(.automatic) .keyboardShortcut(.defaultAction) } .padding(.top, 8) } .formStyle(.grouped) .fixedSize(horizontal: false, vertical: true) .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 } .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) } .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 = "" let (host, port) = Server.parseServerAddressAndPort(connectAddress) let login: String? = connectLogin.isEmpty ? nil : connectLogin let password: String? = connectPassword.isEmpty ? nil : connectPassword if !host.isEmpty { let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password) Bookmark.add(newBookmark, context: modelContext) } } } } } } } 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 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) } } if model.transfers.count > 0 { Divider() self.transfersSection } if model.users.count > 0 { Divider() self.usersSection } } .onChange(of: state.selection) { switch(state.selection) { case .chat: model.markPublicChatAsRead() case .user(let userID): model.markInstantMessagesAsRead(userID: userID) default: break } } } var transfersSection: some View { // Section("Transfers") { ForEach(model.transfers) { transfer in TransferItemView(transfer: transfer) } // } } var usersSection: some View { // Section("\(model.users.count) Online") { ForEach(model.users) { user in HStack(spacing: 5) { if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) { Image(nsImage: iconImage) .frame(width: 16, height: 16) .padding(.leading, 2) .padding(.trailing, 2) } 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) .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary.opacity(0.5)) .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 2)) } } .opacity(user.isIdle ? 0.5 : 1.0) .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) } 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) } } } .toolbar(removing: .sidebarToggle) } // MARK: - @MainActor func connectToServer() { guard !server.address.isEmpty else { return } Task { @MainActor in do { // login() handles everything: connect, getUserList, sendPreferences, downloadBanner try await model.login( server: server, username: Prefs.shared.username, iconID: Prefs.shared.userIconID ) } catch { print("ServerView: Login failed: \(error)") } } } private func connectionStatusToProgress(status: HotlineConnectionStatus) -> Double { switch status { case .disconnected: return 0.0 case .connecting: return 0.4 case .connected: return 0.9 case .loggedIn: return 1.0 case .failed: return 0.0 } } private func connectionStatusToLabel(status: HotlineConnectionStatus) -> String { let n = server.name ?? server.address switch status { case .disconnected: return "Disconnected" case .connecting: return "Connecting to \(n)..." case .connected: return "Logging in to \(n)..." case .loggedIn: return "Logged in to \(n)" case .failed(let error): return "Failed: \(error)" } } } struct TransferItemView: View { let transfer: TransferInfo @Environment(\.controlActiveState) private var controlActiveState @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 let estimate = self.transfer.timeRemaining, estimate > 0.0 { return "\(round(self.transfer.progress * 100.0))% – \(estimate) seconds left" } else { return "\(round(self.transfer.progress * 100.0))% complete" } } return "" } var body: some View { HStack(alignment: .center, spacing: 5) { HStack(spacing: 0) { Spacer() if 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) .lineLimit(1) .truncationMode(.middle) Spacer() if self.hovered { Button { AppState.shared.cancelTransfer(id: transfer.id) } label: { Image(systemName: self.buttonHovered ? "xmark.circle.fill" : "xmark.circle") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) .opacity(self.buttonHovered ? 1.0 : 0.5) } .buttonStyle(.plain) .padding(0) .frame(width: 16, height: 16) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) .help(transfer.completed || transfer.failed ? "Remove" : "Cancel Transfer") .onHover { hovered in self.buttonHovered = hovered } } else if transfer.failed { Image(systemName: "exclamationmark.triangle.fill") .resizable() .symbolRenderingMode(.multicolor) .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) } else if transfer.completed { Image(systemName: "checkmark.circle.fill") .resizable() .symbolRenderingMode(.palette) .foregroundStyle(.white, .fileComplete) .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)) { self.hovered = hovered } } .onTapGesture(count: 2) { guard transfer.completed, let url = transfer.fileURL else { return } NSWorkspace.shared.activateFileViewerSelecting([url]) } .help(formattedProgressHelp()) } }