diff options
| author | Dustin Mierau <dustin@mierau.me> | 2023-12-19 20:38:13 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2023-12-19 20:38:13 -0800 |
| commit | 4fd69c02a3e7b581bb9229865336c315153f3b18 (patch) | |
| tree | e6e11d872424196f2b4033077902126ad5556e40 /Hotline | |
| parent | 5e87b5927cd931d46fb5f72fb035480a95969a9f (diff) | |
Beginnings of a UI for macOS as well as some visual changes to iOS client. :)
Diffstat (limited to 'Hotline')
26 files changed, 1482 insertions, 116 deletions
diff --git a/Hotline/Application.swift b/Hotline/Application.swift new file mode 100644 index 0000000..2b25ba5 --- /dev/null +++ b/Hotline/Application.swift @@ -0,0 +1,34 @@ +import SwiftUI +import SwiftData + +@main +struct Application: App { + @State private var appState = HotlineState() + + #if os(iOS) + private var model = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()) + #endif + + var body: some Scene { + #if os(iOS) + WindowGroup { + TrackerView() + .environment(appState) + .environment(model) + } + #elseif os(macOS) + WindowGroup { + TrackerView() + } + WindowGroup(for: Server.self) { $server in + if let s = server { + ServerView(server: s) + .frame(minWidth: 400, minHeight: 300) + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + } + } + .defaultSize(width: 700, height: 800) + + #endif + } +} diff --git a/Hotline/Assets.xcassets/AppIcon.appiconset/Contents.json b/Hotline/Assets.xcassets/AppIcon.appiconset/Contents.json index fbb9876..3a132e9 100644 --- a/Hotline/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Hotline/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -5,6 +5,56 @@ "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" } ], "info" : { diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 533a5fb..0d6941b 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -108,7 +108,7 @@ class HotlineClient { return } - print("LOGGED INTO SERVER: \(serverName) \(serverVersion)") + print("LOGGED INTO SERVER: \(String(describing: serverName?.debugDescription)) \(serverVersion.debugDescription)") self?.sendSetClientUserInfo(username: username, iconID: iconID) self?.sendGetUserList() @@ -236,6 +236,7 @@ class HotlineClient { private func sendTransaction(_ t: HotlineTransaction, sent sentCallback: ((Bool) -> Void)? = nil, reply replyCallback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) { guard let c = connection else { print("NO CONNECTION?????") + sentCallback?(false) return } @@ -585,9 +586,9 @@ class HotlineClient { let articleData = r.getField(type: .newsArticleData) let articleString = articleData?.getString() - print("GOT ARTICLE", articleString) - - reply?(articleString) + DispatchQueue.main.async { + reply?(articleString) + } }) } @@ -603,11 +604,12 @@ class HotlineClient { } var articles: [HotlineNewsArticle] = [] - var articleData = r.getField(type: .newsArticleListData) +// let articleData = r.getField(type: .newsArticleListData) - print("ARTICLE DATA?", articleData) +// print("ARTICLE DATA?", articleData) - if let newsList = articleData?.getNewsList() { + if let articleData = r.getField(type: .newsArticleListData) { + let newsList = articleData.getNewsList() for art in newsList.articles { var blah = art blah.path = path @@ -687,7 +689,9 @@ class HotlineClient { return } - replyCallback?(transaction, nil) + DispatchQueue.main.async { + replyCallback?(transaction, nil) + } } private func processTransaction(_ transaction: HotlineTransaction) { diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index f155c0e..065fec0 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -69,6 +69,7 @@ struct HotlineNewsArticle: Identifiable { let flags: UInt32 let title: String let username: String + let date: Date? var flavors: [(String, UInt16)] = [] var path: [String] = [] @@ -99,7 +100,7 @@ struct HotlineNewsList: Identifiable { var baseIndex = Int(8 + nl + dl) - for i in 0..<Int(self.count) { + for _ in 0..<Int(self.count) { let articleID = data.readUInt32(at: baseIndex)! baseIndex += 4 @@ -127,7 +128,12 @@ struct HotlineNewsList: Identifiable { let poster = data.readString(at: baseIndex, length: Int(posterLength))! baseIndex += Int(posterLength) - var newArticle = HotlineNewsArticle(id: articleID, parentID: parentID, flags: flags, title: title, username: poster) + // Parse date info + let yearData = timestampData.readUInt16(at: 0)! + let millisecondData = timestampData.readUInt16(at: 2)! + let secondsData = timestampData.readUInt32(at: 4)! + let articleData = convertHotlineDate(year: yearData, seconds: secondsData, milliseconds: millisecondData) + var newArticle = HotlineNewsArticle(id: articleID, parentID: parentID, flags: flags, title: title, username: poster, date: articleData) print("ARTICLE ID: \(articleID)") print("PARENT ID: \(parentID)") @@ -473,7 +479,7 @@ struct HotlineTransaction { var flags: UInt8 = 0 var isReply: UInt8 = 0 var type: HotlineTransactionType - var id: UInt32 = HotlineTransaction.nextID() + var id: UInt32 var errorCode: UInt32 = 0 var totalSize: UInt32 = UInt32(HotlineTransaction.headerSize) var dataSize: UInt32 = 0 @@ -482,6 +488,7 @@ struct HotlineTransaction { init(type: HotlineTransactionType) { self.type = type + self.id = HotlineTransaction.nextID() } init(type: HotlineTransactionType, flags: UInt8, isReply: UInt8, id: UInt32, errorCode: UInt32, totalSize: UInt32, dataSize: UInt32) { @@ -617,11 +624,6 @@ enum HotlineTransactionFieldType: UInt16 { case newsArticleRecursiveDelete = 337 // Integer } -func transactionTypeHasReply(_ type: HotlineTransactionType) -> Bool { - - return false -} - enum HotlineTransactionType: UInt16 { case reply = 0 case error = 100 @@ -685,3 +687,17 @@ enum HotlineTransactionType: UInt16 { case connectionKeepAlive = 500 } +// MARK: - Utilities + +private func convertHotlineDate(year: UInt16, seconds: UInt32, milliseconds: UInt16) -> Date? { + let days = round((Double(seconds) + (Double(milliseconds) * 1000.0)) / 86400.0) + + var components = DateComponents() + components.timeZone = .gmt + components.year = Int(year) + components.month = 1 + components.day = 1 + Int(days) + components.second = Int(seconds) - Int(days * 86400.0) + + return Calendar.current.date(from: components) +} diff --git a/Hotline/HotlineApp.swift b/Hotline/HotlineApp.swift deleted file mode 100644 index 61f44bb..0000000 --- a/Hotline/HotlineApp.swift +++ /dev/null @@ -1,17 +0,0 @@ -import SwiftUI -import SwiftData - -@main -struct HotlineApp: App { - @State private var appState = HotlineState() - - private var model = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()) - - var body: some Scene { - WindowGroup { - TrackerView() - .environment(appState) - .environment(model) - } - } -} diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift index 0e2c76d..5976b8f 100644 --- a/Hotline/Models/FileInfo.swift +++ b/Hotline/Models/FileInfo.swift @@ -1,7 +1,7 @@ import SwiftUI -@Observable class FileInfo: Identifiable { - let id: UUID = UUID() +@Observable class FileInfo: Identifiable, Hashable { + let id: UUID let path: [String] let name: String @@ -11,9 +11,11 @@ import SwiftUI let fileSize: UInt let isFolder: Bool + var expanded: Bool = false var children: [FileInfo]? = nil init(hotlineFile: HotlineFile) { + self.id = UUID() self.path = hotlineFile.path self.name = hotlineFile.name self.type = hotlineFile.type @@ -28,4 +30,8 @@ import SwiftUI static func == (lhs: FileInfo, rhs: FileInfo) -> Bool { return lhs.id == rhs.id } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } } diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index c7474b2..f9ae41d 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -29,8 +29,11 @@ import SwiftUI var users: [User] = [] var chat: [ChatMessage] = [] var messageBoard: [String] = [] + var messageBoardLoaded: Bool = false var files: [FileInfo] = [] + var filesLoaded: Bool = false var news: [NewsInfo] = [] + var newsLoaded: Bool = false // MARK: - @@ -92,6 +95,8 @@ import SwiftUI } } + self.messageBoardLoaded = true + return self.messageBoard } @@ -110,14 +115,18 @@ import SwiftUI newFiles.append(FileInfo(hotlineFile: f)) } - if let parent = parentFile { - parent.children = newFiles - } - else if path.isEmpty { - self?.files = newFiles + DispatchQueue.main.async { + if let parent = parentFile { + parent.children = newFiles + } + else if path.isEmpty { + self?.filesLoaded = true + + self?.files = newFiles + } + + continuation.resume(returning: newFiles) } - - continuation.resume(returning: newFiles) }) } } @@ -180,20 +189,25 @@ import SwiftUI newCategories.append(NewsInfo(hotlineNewsCategory: category)) } - if let parent = existingNewsItem { - parent.children = newCategories - } - else if path.isEmpty { - self?.news = newCategories + DispatchQueue.main.async { + if let parent = existingNewsItem { + parent.children = newCategories + } + else if path.isEmpty { + self?.newsLoaded = true + self?.news = newCategories + } + + continuation.resume(returning: newCategories) } - - continuation.resume(returning: newCategories) }) } else { self?.client.sendGetNewsArticles(path: path, sent: { success in if !success { - continuation.resume(returning: []) + DispatchQueue.main.async { + continuation.resume(returning: []) + } return } @@ -207,17 +221,19 @@ import SwiftUI newArticles.append(NewsInfo(hotlineNewsArticle: article)) } - if let parent = existingNewsItem { - print("UNDER PARENT:", parent.name) - parent.children = newArticles + DispatchQueue.main.async { + if let parent = existingNewsItem { + print("UNDER PARENT:", parent.name) + parent.children = newArticles + + print(parent.children) + } + else if path.isEmpty { + self?.news = newArticles + } - print(parent.children) + continuation.resume(returning: newArticles) } - else if path.isEmpty { - self?.news = newArticles - } - - continuation.resume(returning: newArticles) }) } } @@ -227,7 +243,9 @@ import SwiftUI return await withCheckedContinuation { [weak self] continuation in self?.client.sendGetNewsCategories(path: path, sent: { success in if !success { - continuation.resume(returning: []) + DispatchQueue.main.async { + continuation.resume(returning: []) + } return } @@ -240,14 +258,16 @@ import SwiftUI newCategories.append(NewsInfo(hotlineNewsCategory: category)) } - if let parent = parentNews { - parent.children = newCategories - } - else if path.isEmpty { - self?.news = newCategories + DispatchQueue.main.async { + if let parent = parentNews { + parent.children = newCategories + } + else if path.isEmpty { + self?.news = newCategories + } + + continuation.resume(returning: newCategories) } - - continuation.resume(returning: newCategories) }) } } @@ -256,28 +276,16 @@ import SwiftUI return await withCheckedContinuation { [weak self] continuation in self?.client.sendGetNewsArticles(path: path, sent: { success in if !success { - continuation.resume(returning: []) + DispatchQueue.main.async { + continuation.resume(returning: []) + } return } - }, reply: { [weak self] articles in - print("ARTICLES?", articles) - - continuation.resume(returning: []) + }, reply: { articles in + DispatchQueue.main.async { + continuation.resume(returning: []) + } }) -// self?.client.sendGetNewsCategories(sent: { success in -// if !success { -// continuation.resume(returning: []) -// return -// } -// }, reply: { [weak self] categories in -// var newCategories: [NewsCategory] = [] -// for category in categories { -// newCategories.append(NewsCategory(hotlineNewsCategory: category)) -// } -// self?.news = newCategories -// -// continuation.resume(returning: newCategories) -// }) } } @@ -302,8 +310,11 @@ import SwiftUI self.users = [] self.chat = [] self.messageBoard = [] + self.messageBoardLoaded = false self.files = [] + self.filesLoaded = false self.news = [] + self.newsLoaded = false } self.status = status diff --git a/Hotline/Models/NewsInfo.swift b/Hotline/Models/NewsInfo.swift index ad6bfcd..daab71f 100644 --- a/Hotline/Models/NewsInfo.swift +++ b/Hotline/Models/NewsInfo.swift @@ -17,9 +17,12 @@ enum NewsInfoType { let articleID: UInt? let path: [String] + var expanded: Bool = false var children: [NewsInfo] = [] var articleFlavors: [String]? + var articleUsername: String? + var articleDate: Date? init(hotlineNewsCategory: HotlineNewsCategory) { self.categoryID = hotlineNewsCategory.id @@ -46,6 +49,8 @@ enum NewsInfoType { self.type = .article self.articleFlavors = hotlineNewsArticle.flavors.map { $0.0 } + self.articleUsername = hotlineNewsArticle.username + self.articleDate = hotlineNewsArticle.date } func hash(into hasher: inout Hasher) { diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift index 8b5ea56..495d236 100644 --- a/Hotline/Models/Server.swift +++ b/Hotline/Models/Server.swift @@ -1,9 +1,9 @@ import SwiftUI -@Observable final class Server: Identifiable, Equatable { +@Observable final class Server: Identifiable, Equatable, Hashable, Codable { static let defaultPort: Int = 5500 - let id: UUID = UUID() + let id: UUID let name: String? let description: String? let users: Int @@ -11,6 +11,7 @@ import SwiftUI let port: Int init(name: String?, description: String?, address: String, port: Int, users: Int = 0) { + self.id = UUID() self.name = name self.description = description self.address = address @@ -18,11 +19,11 @@ import SwiftUI self.users = users } - static func == (lhs: Server, rhs: Server) -> Bool { - return lhs.id == rhs.id + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) } - static func == (lhs: HotlineServer, rhs: Server) -> Bool { - return lhs.name == rhs.name && lhs.address == rhs.address && lhs.port == rhs.port + static func == (lhs: Server, rhs: Server) -> Bool { + return lhs.id == rhs.id } } diff --git a/Hotline/Models/User.swift b/Hotline/Models/User.swift index 9011e56..1a921e2 100644 --- a/Hotline/Models/User.swift +++ b/Hotline/Models/User.swift @@ -1,7 +1,7 @@ import SwiftUI struct UserStatus: OptionSet { - let rawValue: Int + let rawValue: UInt static let idle = UserStatus(rawValue: 1 << 0) static let admin = UserStatus(rawValue: 1 << 1) diff --git a/Hotline/Utility/Utilities.swift b/Hotline/Utility/Utilities.swift new file mode 100644 index 0000000..fbf2875 --- /dev/null +++ b/Hotline/Utility/Utilities.swift @@ -0,0 +1,2 @@ +import Foundation + diff --git a/Hotline/Utility/VisualEffectView.swift b/Hotline/Utility/VisualEffectView.swift new file mode 100644 index 0000000..4f72a6e --- /dev/null +++ b/Hotline/Utility/VisualEffectView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +public struct VisualEffectView: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + public init( + material: NSVisualEffectView.Material = .contentBackground, + blendingMode: NSVisualEffectView.BlendingMode = .withinWindow + ) { + self.material = material + self.blendingMode = blendingMode + } + + public func makeNSView(context: Context) -> NSVisualEffectView { + let visualEffectView = NSVisualEffectView() + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + visualEffectView.state = NSVisualEffectView.State.active + return visualEffectView + } + + public func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) { + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + } +} diff --git a/Hotline/Views/ChatView.swift b/Hotline/iOS/ChatView.swift index e8c7754..e8c7754 100644 --- a/Hotline/Views/ChatView.swift +++ b/Hotline/iOS/ChatView.swift diff --git a/Hotline/Views/FilesView.swift b/Hotline/iOS/FilesView.swift index 8ad3837..5815ca2 100644 --- a/Hotline/Views/FilesView.swift +++ b/Hotline/iOS/FilesView.swift @@ -39,7 +39,7 @@ struct FileView: View { else { HStack { HStack(alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/) { - Image(uiImage: fileIcon(name: file.name)) + fileIcon(name: file.name) .renderingMode(.template) } .frame(minWidth: 25) @@ -59,30 +59,35 @@ struct FileView: View { return FileView.byteFormatter.string(fromByteCount: Int64(fileSize)) } - private func fileIcon(name: String) -> UIImage { + private func fileIcon(name: String) -> Image { // func utTypeForFilename(_ filename: String) -> UTType? { let fileExtension = (name as NSString).pathExtension if let fileType = UTType(filenameExtension: fileExtension) { print("\(name) \(fileExtension) = \(fileType)") if fileType.isSubtype(of: .movie) { - return UIImage(systemName: "play.rectangle")! + return Image(systemName: "play.rectangle") +// return UIImage(systemName: "play.rectangle")! } else if fileType.isSubtype(of: .image) { - return UIImage(systemName: "photo")! + return Image(systemName: "photo") +// return UIImage(systemName: "photo")! } else if fileType.isSubtype(of: .archive) { - return UIImage(systemName: "doc.zipper")! + return Image(systemName: "doc.zipper") +// return UIImage(systemName: "doc.zipper")! } else if fileType.isSubtype(of: .text) { - return UIImage(systemName: "doc.text")! + return Image(systemName: "doc.text") +// return UIImage(systemName: "doc.text")! } else { - return UIImage(systemName: "doc")! + return Image(systemName: "doc") +// return UIImage(systemName: "doc")! } } - return UIImage(systemName: "doc")! + return Image(systemName: "doc") } } diff --git a/Hotline/Views/MessageBoardView.swift b/Hotline/iOS/MessageBoardView.swift index 0d3968f..0d3968f 100644 --- a/Hotline/Views/MessageBoardView.swift +++ b/Hotline/iOS/MessageBoardView.swift diff --git a/Hotline/Views/NewsView.swift b/Hotline/iOS/NewsView.swift index bd3ae1c..d3e0bbd 100644 --- a/Hotline/Views/NewsView.swift +++ b/Hotline/iOS/NewsView.swift @@ -1,14 +1,19 @@ import SwiftUI import UniformTypeIdentifiers - - struct NewsItemView: View { @Environment(Hotline.self) private var model: Hotline @Environment(NewsItemSelection.self) private var selectedArticle: NewsItemSelection let news: NewsInfo + static var dateFormatter: DateFormatter = { + var dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MM/dd/yy, h:mm a" + dateFormatter.timeZone = .gmt + return dateFormatter + }() + @State var expanded = false var body: some View { @@ -16,19 +21,13 @@ struct NewsItemView: View { DisclosureGroup(isExpanded: $expanded) { ForEach(news.children) { childNews in NewsItemView(news: childNews) - .environment(self.selectedArticle) .frame(height: 38) + .environment(self.selectedArticle) } } label: { HStack { - if news.type == .bundle { - Text(Image(systemName: "tray.2.fill")) - } - else { - Text(Image(systemName: "tray.full.fill")) - } Text(news.name) - .fontWeight(.medium) + .fontWeight(.bold) .lineLimit(1) .truncationMode(.tail) Spacer() @@ -50,9 +49,8 @@ struct NewsItemView: View { } else { HStack { - Text(Image(systemName: "doc.text")) + Text(Image(systemName: "quote.opening")) Text(news.name) - .fontWeight(.medium) .lineLimit(1) .truncationMode(.tail) Spacer() @@ -91,6 +89,7 @@ struct NewsView: View { @State private var articleSelection = NewsItemSelection() @State private var articleText = "" + // @State private var selectedArticleID: UInt? var articleList: some View { @@ -103,8 +102,8 @@ struct NewsView: View { else { List(model.news) { category in NewsItemView(news: category) - .environment(self.articleSelection) .frame(height: 38) + .environment(self.articleSelection) } .scrollBounceBehavior(.basedOnSize) } @@ -161,10 +160,41 @@ struct NewsView: View { // Reader View ScrollView(.vertical) { - HStack(alignment: .top, spacing: 0) { + VStack(alignment: .leading) { + + if let news = self.articleSelection.selectedArticle { + if let poster = news.articleUsername, let postDate = news.articleDate { + HStack(alignment: .firstTextBaseline) { + Text(poster) + .foregroundStyle(.secondary) + .font(.subheadline) + .lineLimit(1) + .truncationMode(.tail) + .textSelection(.enabled) + .padding() + Spacer() + Text("\(NewsItemView.dateFormatter.string(from: postDate))") + .foregroundStyle(.secondary) + .font(.subheadline) + .lineLimit(1) + .textSelection(.enabled) + .padding() + } + .background(RoundedRectangle(cornerSize: CGSize(width: 8, height: 8)).fill(Color(uiColor: .tertiarySystemFill))) +// Capsule(style: .circular).fill(.secondary)) +// .padding(.bottom, 16) + .padding(.bottom, 8) + } + + Text(news.name).font(.title3) + .textSelection(.enabled) + + Divider() + } + Text(self.articleText) .multilineTextAlignment(.leading) - Spacer() + .padding(.top, 16) } .padding() } diff --git a/Hotline/Views/ServerView.swift b/Hotline/iOS/ServerView.swift index 09ab877..09ab877 100644 --- a/Hotline/Views/ServerView.swift +++ b/Hotline/iOS/ServerView.swift diff --git a/Hotline/Views/TrackerView.swift b/Hotline/iOS/TrackerView.swift index 6204fd2..6204fd2 100644 --- a/Hotline/Views/TrackerView.swift +++ b/Hotline/iOS/TrackerView.swift diff --git a/Hotline/Views/UsersView.swift b/Hotline/iOS/UsersView.swift index d1f3318..d1f3318 100644 --- a/Hotline/Views/UsersView.swift +++ b/Hotline/iOS/UsersView.swift diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift new file mode 100644 index 0000000..5e6faaf --- /dev/null +++ b/Hotline/macOS/ChatView.swift @@ -0,0 +1,191 @@ +import SwiftUI + +enum FocusedField: Int, Hashable { + case chatInput +} + +struct ChatView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.colorScheme) var colorScheme + + @State var input: String = "" + @State private var scrollPos: Int? + @State private var contentHeight: CGFloat = 0 + + @FocusState private var focusedField: FocusedField? + + @Namespace var bottomID + + 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 !msg.text.isEmpty { + HStack { + VStack(alignment: .leading) { + Text(msg.text) + .textSelection(.enabled) + .padding() + .opacity(0.75) + } + .frame(minWidth: 40, maxWidth: 400, alignment: .center) + .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow).cornerRadius(24)) + .padding() + } + .frame(maxWidth: .infinity) + .padding() + } + } + // MARK: Status + else if msg.type == .status { + HStack { + Spacer() + Text(msg.text) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.disabled) + .opacity(0.3) + Spacer() + } + .padding() + } + else { + HStack(alignment: .firstTextBaseline) { + if let username = msg.username { + Text("**\(username):** \(msg.text)") + .textSelection(.enabled) + } + else { + Text(msg.text) + .textSelection(.enabled) + } + Spacer() + } + .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + } + } + EmptyView().id(bottomID) + } + // .padding(.bottom, 12) + } + // .padding(.bottom, 60) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .defaultScrollAnchor(.bottom) + .onChange(of: model.chat.count) { + withAnimation { + reader.scrollTo(bottomID, anchor: .bottom) + } + print("SCROLLED TO BOTTOM") + } + .onAppear { + print("SCROLLED TO BOTTOM ON APPEAR") + reader.scrollTo(bottomID, anchor: .bottom) +// focusedField = .chatInput + } + .onChange(of: gm.size) { + reader.scrollTo(bottomID, anchor: .bottom) + } + } + } + + // MARK: Input Divider + Divider() + .padding(.bottom, 0) + + // MARK: Input Bar + HStack(alignment: .lastTextBaseline, spacing: 0) { + TextField("", text: $input, axis: .vertical) + .focused($focusedField, equals: .chatInput) + .textFieldStyle(.plain) + .lineLimit(1...5) + .multilineTextAlignment(.leading) +// .frame(maxWidth: .infinity) + .onSubmit { + if !self.input.isEmpty { + model.sendChat(self.input) + } + self.input = "" + } + .frame(maxWidth: .infinity) + .padding() + } + .frame(maxWidth: .infinity, minHeight: 28) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 8, 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 + } + + +// .overlay(alignment: .bottom) { +// // MARK: Input Bar +// VStack(alignment: .leading, spacing: 0) { +// +// } +// } + // } + + // VStack(alignment: .center) { + // Spacer() + + + // .onKeyPress(keys: [.return]) { event in + // print(event) + // guard + // event.phase == .down, + // event.key == .return else { + // return .ignored + // } + // + // if event.modifiers.contains(.shift) { + // print("TRYING TO ADD NEW LINE") + // self.input += "\n" + // return .handled + // } + // + // if !self.input.isEmpty { + // model.sendChat(self.input) + // } + // self.input = "" + // + // return .handled + // } + // .onSubmit { + // if !self.input.isEmpty { + // model.sendChat(self.input) + // } + // self.input = "" + // } + + } + } + .background(Color(nsColor: .textBackgroundColor)) + // } + } +} + +#Preview { + ChatView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift new file mode 100644 index 0000000..534dd10 --- /dev/null +++ b/Hotline/macOS/FilesView.swift @@ -0,0 +1,174 @@ +import SwiftUI +import UniformTypeIdentifiers + +@Observable +class FileSelection: Equatable { + var selectedFile: FileInfo? = nil + + static func == (lhs: FileSelection, rhs: FileSelection) -> Bool { + return lhs.selectedFile == rhs.selectedFile + } +} + + +struct FileView: View { + @Environment(Hotline.self) private var model: Hotline + + @State var expanded = false + @State var loading = false + + var file: FileInfo + let depth: Int + + var body: some View { + HStack { + if file.isFolder { + Button { + if file.isFolder { + file.expanded.toggle() + } + } label: { + Image(systemName: file.expanded ? "chevron.down" : "chevron.right") + .renderingMode(.template) + .frame(width: 10, height: 10) + .aspectRatio(contentMode: .fit) + .opacity(0.5) + } + .buttonStyle(.plain) + .frame(width: 12) + } + else { + HStack { + + }.frame(width: 12) + } + HStack(alignment: .center) { + if file.isFolder { + Image(systemName: "folder.fill") + } + else { + fileIcon(name: file.name) + .resizable() + .scaledToFill() + .frame(width: 16, height: 16) + } + } + .frame(width: 15) + Text(file.name).lineLimit(1).truncationMode(.tail) + Spacer() + if file.isFolder { + if loading { + ProgressView().controlSize(.small).padding(.trailing, 4) + } + Text("^[\(file.fileSize) file](inflect: true)") + .foregroundStyle(.secondary) + .lineLimit(1) + } + else { + Text(formattedFileSize(file.fileSize)).foregroundStyle(.secondary).lineLimit(1) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.leading, CGFloat(depth * (12 + 10))) + .onChange(of: file.expanded) { + loading = false + if file.isFolder { + print("EXPANDED \(file.name)? \(expanded)") + if file.expanded { + Task { + loading = true + let _ = await model.getFileList(path: file.path) + loading = false + } + } + } + } + + if file.expanded { + ForEach(file.children!, id: \.self) { childFile in + FileView(file: childFile, depth: self.depth + 1).tag(file.id) +// .environment(self.selectedFile) +// .frame(height: 34) + } + } + } + + static let byteFormatter = ByteCountFormatter() + + private func formattedFileSize(_ fileSize: UInt) -> String { + // let bcf = ByteCountFormatter() + FileView.byteFormatter.allowedUnits = [.useAll] + FileView.byteFormatter.countStyle = .file + return FileView.byteFormatter.string(fromByteCount: Int64(fileSize)) + } + + private func fileIcon(name: String) -> Image { + let fileExtension = (name as NSString).pathExtension + return Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: fileExtension) ?? UTType.content)) + } +} + +struct FilesView: View { + @Environment(Hotline.self) private var model: Hotline + + @State private var selection: FileInfo? + + var body: some View { + NavigationStack { + List(model.files, id: \.self, selection: $selection) { file in + FileView(file: file, depth: 0).tag(file.id) +// .environment(self.fileSelection) +// .frame(height: 34) + } + .environment(\.defaultMinListRowHeight, 34) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .task { + if !model.filesLoaded { + let _ = await model.getFileList() + } + } + .contextMenu(forSelectionType: FileInfo.self) { items in + // ... + } primaryAction: { items in + print("ITEMS?", items) + guard let clickedFile = items.first else { + return + } + + self.selection = clickedFile + if clickedFile.isFolder { + clickedFile.expanded.toggle() + } + } + .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 + } + .overlay { + if !model.filesLoaded { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + } + } +} + +#Preview { + FilesView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/MessageBoardView.swift b/Hotline/macOS/MessageBoardView.swift new file mode 100644 index 0000000..d72b122 --- /dev/null +++ b/Hotline/macOS/MessageBoardView.swift @@ -0,0 +1,55 @@ +import SwiftUI + +struct MessageBoardView: View { + @Environment(Hotline.self) private var model: Hotline + + @State private var initialLoadComplete = false + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(model.messageBoard, id: \.self) { + Text($0) + .lineLimit(100) + .padding() + .textSelection(.enabled) + Divider() + } + } + Spacer() + } + .task { + if !model.messageBoardLoaded { + let _ = await model.getMessageBoard() +// self.initialLoadComplete = true + print("INITIAL LOAD?") + } + } + .overlay { + if !model.messageBoardLoaded { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + .background(Color(nsColor: .textBackgroundColor)) + } + .toolbar { + ToolbarItem(placement:.primaryAction) { + Button { + + } label: { + Image(systemName: "square.and.pencil") + } + } + } + } +} + +#Preview { + MessageBoardView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift new file mode 100644 index 0000000..19b48f2 --- /dev/null +++ b/Hotline/macOS/MessageView.swift @@ -0,0 +1,136 @@ +import SwiftUI + +//extension View { +// func endEditing() { +// UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) +// } +//} + +struct MessageView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.colorScheme) var colorScheme + + @State var input: String = "" + @State private var scrollPos: Int? + @State private var contentHeight: CGFloat = 0 + + @Namespace var bottomID + + let userID: UInt + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + ScrollViewReader { reader in + ScrollView { + LazyVStack(alignment: .leading) { +// ForEach(model.chat) { msg in +// if msg.type == .agreement { +// VStack(alignment: .leading) { +// VStack(alignment: .leading, spacing: 0) { +// Text(msg.text) +// .textSelection(.enabled) +// .padding() +// .opacity(0.75) +// HStack { +// Spacer() +// Text((model.serverTitle) + " Server Agreement") +// .font(.caption) +// .fontWeight(.medium) +// .opacity(0.4) +// .lineLimit(1) +// .truncationMode(.middle) +// Spacer() +// } +// .padding() +// .background(colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.9)) +// } +// .background(colorScheme == .dark ? Color(white: 0.1) : Color(white: 0.96)) +// .cornerRadius(16) +// .frame(maxWidth: .infinity) +// } +// .padding() +// } +// else if msg.type == .status { +// HStack { +// Spacer() +// Text(msg.text) +// .lineLimit(1) +// .truncationMode(.middle) +// .opacity(0.3) +// Spacer() +// } +// .padding() +// } +// else { +// HStack(alignment: .firstTextBaseline) { +// if let username = msg.username { +// Text("**\(username):** \(msg.text)") +// } +// else { +// Text(msg.text) +// .textSelection(.enabled) +// } +// Spacer() +// } +// .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) +// } +// } + EmptyView().id(bottomID) + } + .padding(.bottom, 12) + } + .defaultScrollAnchor(.bottom) + .onChange(of: model.chat.count) { + withAnimation { + reader.scrollTo(bottomID, anchor: .bottom) + } + print("SCROLLED TO BOTTOM") + } + .onAppear { + print("SCROLLED TO BOTTOM ON APPEAR") + reader.scrollTo(bottomID, anchor: .bottom) + } + .scrollDismissesKeyboard(.interactively) + .onTapGesture { +// self.endEditing() + } + .textSelection(.enabled) + } + + Divider() + + HStack(alignment: .top) { + Image(systemName: "chevron.right").opacity(0.4) + TextField("", text: $input, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...5) + .onSubmit { +// if !self.input.isEmpty { +// model.sendChat(self.input) +// } + self.input = "" + } + .frame(maxWidth: .infinity) + Button { +// if !self.input.isEmpty { +// model.sendChat(self.input) +// } + self.input = "" + } label: { + Image(systemName: self.input.isEmpty ? "arrow.up.circle" : "arrow.up.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 24.0, height: 24.0) + .opacity(self.input.isEmpty ? 0.4 : 1.0) + } + }.padding() + } + } + } +} + +#Preview { + ChatView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/NewsView.swift b/Hotline/macOS/NewsView.swift new file mode 100644 index 0000000..b5752d3 --- /dev/null +++ b/Hotline/macOS/NewsView.swift @@ -0,0 +1,248 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct NewsItemView: View { + @Environment(Hotline.self) private var model: Hotline + + @State var expanded = false + + var news: NewsInfo + let depth: Int + + static var dateFormatter: DateFormatter = { + var dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MM/dd/yy, h:mm a" + dateFormatter.timeZone = .gmt + return dateFormatter + }() + + var body: some View { + HStack { + if news.type == .bundle || news.type == .category { + Button { + news.expanded.toggle() + } label: { + Image(systemName: news.expanded ? "chevron.down" : "chevron.right") + .renderingMode(.template) + .frame(width: 10, height: 10) + .aspectRatio(contentMode: .fit) + .opacity(0.5) + } + .buttonStyle(.plain) + .frame(width: 12) + } + if news.type == .article { + HStack(alignment: .center) { + Image(systemName: "quote.opening") + } + .frame(width: 15) + } + Text(news.name) + .fontWeight((news.type == .bundle || news.type == .category) ? .bold : .regular) + .lineLimit(1) + .truncationMode(.tail) + if news.type == .article && news.articleUsername != nil { + Text(news.articleUsername!).foregroundStyle(.secondary).lineLimit(1) + } + if news.type == .bundle || news.type == .category { + Text("^[\(news.count) \(news.type == .bundle ? "Category" : "Post")](inflect: true)") + +// Text("\(news.count) \(news.type == .bundle ? "Categories" : "Posts")") + .foregroundStyle(.secondary) + .lineLimit(1) + .padding([.leading, .trailing], 8) + .padding([.top, .bottom], 2) + .background(Capsule(style: .circular).stroke(.secondary.opacity(0.3), lineWidth: 1)) + } + Spacer() + if news.type == .article && news.articleUsername != nil { + if let d = news.articleDate { + Text(NewsItemView.dateFormatter.string(from: d)).foregroundStyle(.secondary) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.leading, CGFloat(depth * (12 + 10))) + .onChange(of: news.expanded) { +// if news.isFolder { + print("EXPANDED \(news.name)? \(expanded)") + if news.type == .bundle || news.type == .category { + Task { + await model.getNewsList(at: news.path) +// await model.getFileList(path: file.path) + } + } +// } + } + + if news.expanded { + ForEach(news.children, id: \.self) { childNews in + NewsItemView(news: childNews, depth: self.depth + 1).tag(childNews.id) + } + } + } + +// static let byteFormatter = ByteCountFormatter() +// +// private func formattedFileSize(_ fileSize: UInt) -> String { +// // let bcf = ByteCountFormatter() +// FileView.byteFormatter.allowedUnits = [.useAll] +// FileView.byteFormatter.countStyle = .file +// return FileView.byteFormatter.string(fromByteCount: Int64(fileSize)) +// } +// +// private func fileIcon(name: String) -> Image { +// let fileExtension = (name as NSString).pathExtension +// return Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: fileExtension) ?? UTType.content)) +// } +} + +struct NewsView: View { + @Environment(Hotline.self) private var model: Hotline + + @State private var selection: NewsInfo? + @State private var articleText: String? + + var body: some View { + NavigationStack { + VSplitView { + + // MARK: News Browser + List(model.news, id: \.self, selection: $selection) { newsItem in + NewsItemView(news: newsItem, depth: 0).tag(newsItem.id) + } + + .frame(maxWidth: .infinity, minHeight: 100, idealHeight: 100) + .environment(\.defaultMinListRowHeight, 34) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .task { + if !model.newsLoaded { + let _ = await model.getNewsList() + } + } + .contextMenu(forSelectionType: NewsInfo.self) { items in + // ... + } primaryAction: { items in + print("ITEMS?", items) + guard let clickedNews = items.first else { + return + } + + self.selection = clickedNews + if clickedNews.type == .bundle || clickedNews.type == .category { + clickedNews.expanded.toggle() + } + } + .onChange(of: selection) { + if + let article = selection, + article.type == .article { + self.articleText = nil + if +// let article = self.articleSelection.selectedArticle, + let articleFlavor = article.articleFlavors?.first, + let articleID = article.articleID { + Task { + if let articleText = await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { + self.articleText = articleText + } + } + } + } + } + .onKeyPress(.rightArrow) { + if let s = selection, s.type == .bundle || s.type == .category { + s.expanded = true + return .handled + } + return .ignored + } + .onKeyPress(.leftArrow) { + if let s = selection, s.type == .bundle || s.type == .category { + s.expanded = false + return .handled + } + return .ignored + } + .overlay { + if !model.newsLoaded { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + + // MARK: Article Viewer + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if let news = selection { + if news.type == .article { + + Text(news.name).font(.title) + .textSelection(.enabled) + .padding(.bottom, 8) + + if let poster = news.articleUsername, let postDate = news.articleDate { + HStack(alignment: .firstTextBaseline) { + Text(poster) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .textSelection(.enabled) + .padding(.bottom, 16) + Spacer() + Text("\(NewsItemView.dateFormatter.string(from: postDate))") + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .textSelection(.enabled) + .padding(.bottom, 16) + } + } + + Divider() + + if let newsText = self.articleText { + Text(newsText) + .textSelection(.enabled) + .lineSpacing(9) + .padding(.top, 16) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .frame(maxWidth: .infinity, minHeight: 200) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + } + .toolbar { + ToolbarItem(placement:.primaryAction) { + Button { + + } label: { + Image(systemName: "square.and.pencil") + } + } + + ToolbarItem(placement:.primaryAction) { + Button { + + } label: { + Image(systemName: "arrowshape.turn.up.left") + } + } + } + } +} + +#Preview { + NewsView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift new file mode 100644 index 0000000..eca6f54 --- /dev/null +++ b/Hotline/macOS/ServerView.swift @@ -0,0 +1,197 @@ +import SwiftUI + +enum MenuItemType { + case banner + case chat + case news + case messageBoard + case files + case tasks + case user +} + +struct MenuItem: Identifiable, Hashable { + let id: UUID + let name: String + let image: String + let type: MenuItemType + let userID: UInt? + let serverVersion: UInt? + + init(name: String, image: String, type: MenuItemType, userID: UInt? = nil, serverVersion: UInt? = nil) { + self.id = UUID() + self.name = name + self.image = image + self.type = type + self.userID = userID + self.serverVersion = serverVersion + } + + static func == (lhs: MenuItem, rhs: MenuItem) -> Bool { + if lhs.type == .user && rhs.type == .user { + return lhs.userID == rhs.userID + } + return lhs.id == rhs.id + } +} + +struct ListItemView: View { + let icon: String + let title: String + + var body: some View { + HStack { + Image(systemName: icon) + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + Text(title) +// Spacer() + } + //.tag(item.tag) + } +} + +private func connectionStatusToProgress(status: HotlineClientStatus) -> Double { + switch status { + case .disconnected: + return 0.0 + case .connecting: + return 0.1 + case .connected: + return 0.25 + case .loggingIn: + return 0.5 + case .loggedIn: + return 1.0 + } +} + +struct ServerView: View { + @Environment(Hotline.self) private var model: Hotline + @State private var selectedCategoryId: MenuItem.ID? + + let server: Server + + @State private var selection: MenuItem? = ServerView.menuItems.first + + static var menuItems = [ + MenuItem(name: "Chat", image: "bubble", type: .chat), + MenuItem(name: "News", image: "newspaper", type: .news, serverVersion: 150), + MenuItem(name: "Board", image: "note.text", type: .messageBoard), + MenuItem(name: "Files", image: "folder", type: .files), + MenuItem(name: "Tasks", image: "arrow.up.circle", type: .tasks), + ] + + + +// @State private var selection: String? + + var body: some View { + NavigationSplitView { +// Divider() + + List(selection: $selection) { + + HStack { + Text(server.name ?? "") + .fontWeight(.medium) + .lineLimit(2) + .font(.title3) + .multilineTextAlignment(.center) + .padding() + } + .selectionDisabled() + .frame(maxWidth: .infinity, minHeight: 60) + .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow).cornerRadius(16)) +// .background(.white.opacity(0.2)) + .cornerRadius(10) +// .shadow(color: .black.opacity(0.1), radius: 3, y: 2) + .tag(MenuItem(name: "title", image: "", type: .banner)) + .padding(.bottom, 16) + + if model.status != .loggedIn { + ProgressView(value: connectionStatusToProgress(status: model.status)) + .padding() + .selectionDisabled() + } + + if model.status == .loggedIn { + ForEach(ServerView.menuItems) { menuItem in + if let minServerVersion = menuItem.serverVersion { + if let v = model.serverVersion, v >= minServerVersion { + ListItemView(icon: menuItem.image, title: menuItem.name) + .tag(menuItem) + } + } + else { + ListItemView(icon: menuItem.image, title: menuItem.name) + .tag(menuItem) + } + } + + if model.users.count > 0 { + Section("Users") { + ForEach(model.users) { user in + HStack { + Text("🙂") + .font(.headline) + if user.status.contains(.admin) { + Text(user.name) + .foregroundStyle(.red, .red.opacity(0.3)) + } + else if user.status.contains(.idle) { + Text(user.name) + .opacity(0.5) + } + else { + Text(user.name) + } + Spacer() + } + .tag(MenuItem(name: user.name, image: "", type: .user, userID: user.id)) + } + } + } + } + } + } detail: { + if let selection = self.selection { + switch selection.type { + case .banner: + EmptyView() + case .chat: + ChatView() + case .news: + NewsView() + case .messageBoard: + MessageBoardView() + case .files: + FilesView() + case .tasks: + EmptyView() + case .user: + if let selectionUserID = selection.userID { + MessageView(userID: selectionUserID) + } + } + } + } + .navigationTitle("") + .onAppear { + Task { + await model.login(server: self.server, login: "", password: "", username: "bolt", iconID: 128) + } + } + .onDisappear { + print("DISCONNECTING FROM SERVER") + Task { + model.disconnect() + } + } + } +} + +//#Preview { +// ServerView(server: Server(name: "", description: "", address: "", port: 0)) +//} diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift new file mode 100644 index 0000000..684dd6a --- /dev/null +++ b/Hotline/macOS/TrackerView.swift @@ -0,0 +1,191 @@ +import SwiftUI + +struct TrackerView: View { + + // @Environment(\.modelContext) private var modelContext + // @Query private var items: [Item] + +// @Environment(Hotline.self) private var model: Hotline + @Environment(\.colorScheme) var colorScheme + @Environment(\.openWindow) private var openWindow + + private var client = HotlineTrackerClient() + + @MainActor + func updateServers() async { + let fetchedServers: [HotlineServer] = await self.client.fetchServers(address: "hltracker.com", port: Tracker.defaultPort) + + var newServers: [Server] = [] + + for s in fetchedServers { + if let serverName = s.name { + newServers.append(Server(name: serverName, description: s.description, address: s.address, port: Int(s.port), users: Int(s.users))) + } + } + + self.servers = newServers + } + +// private var model = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()) + + // @State private var tracker = Tracker(address: "hltracker.com", service: trackerService) + + @State private var servers: [Server] = [] +// @State private var selectedServer: Server? + + @State private var selection: Server.ID? = nil + + @State private var scrollOffset: CGFloat = CGFloat.zero + @State private var initialLoadComplete = false + @State private var refreshing = false + @State private var topBarOpacity: Double = 1.0 + @State private var connectVisible = false + @State private var connectDismissed = true + @State private var serverVisible = false + + func shouldDisplayDescription(server: Server) -> Bool { + guard let desc = server.description else { + return false + } + + return desc.count > 0 && desc != server.name + } + + func connectionStatusToProgress(status: HotlineClientStatus) -> Double { + switch status { + case .disconnected: + return 0.0 + case .connecting: + return 0.1 + case .connected: + return 0.25 + case .loggingIn: + return 0.5 + case .loggedIn: + return 1.0 + } + } + + func inverseLerp(lower: Double, upper: Double, v: Double) -> Double { + return (v - lower) / (upper - lower) + } + +// func updateServers() async { + // "hltracker.com" + // "tracker.preterhuman.net" + // "hotline.ubersoft.org" + // "tracked.nailbat.com" + // "hotline.duckdns.org" + // "tracked.agent79.org" +// self.servers = await model.getServerList(tracker: "hltracker.com") +// } + + var body: some View { + // ZStack(alignment: .center) { + // VStack(alignment: .center) { + // ZStack(alignment: .top) { + // HStack(alignment: .center) { + // Button { + // connectVisible = true + // connectDismissed = false + // } label: { + // Text(Image(systemName: "gearshape.fill")) + // .symbolRenderingMode(.hierarchical) + // .foregroundColor(.primary) + // .font(.title2) + // .padding(.leading, 16) + // } + // .sheet(isPresented: $connectVisible) { + // connectDismissed = true + // } content: { + //// TrackerConnectView() + // } + // Spacer() + // } + // .frame(height: 40.0) + // Image("Hotline") + // .resizable() + // .renderingMode(.template) + // .foregroundColor(Color(hex: 0xE10000)) + // .scaledToFit() + // .frame(width: 40.0, height: 40.0) + // HStack(alignment: .center) { + // Spacer() + // Button { + // connectVisible = true + // connectDismissed = false + // } label: { + // Text(Image(systemName: "point.3.connected.trianglepath.dotted")) + // .symbolRenderingMode(.hierarchical) + // .foregroundColor(.primary) + // .font(.title2) + // .padding(.trailing, 16) + // } + // .sheet(isPresented: $connectVisible) { + // connectDismissed = true + // } content: { + // TrackerConnectView() + // } + // } + // .frame(height: 40.0) + // } + // .padding() + // + // Spacer() + // } + // .opacity(inverseLerp(lower: -50, upper: 0, v: scrollOffset)) + // .opacity(scrollOffset > 65 ? 0.0 : 1.0) + // .opacity(topBarOpacity) + // .zIndex(scrollOffset > 0 ? 1 : 3) + Table(of: Server.self, selection: $selection) { + TableColumn("Name") { server in + HStack { + Text(Image(systemName: "globe.americas.fill")) + Text(server.name!) + } + } + .width(min: 80, ideal: 150) + + TableColumn("Status") { server in + if server.users > 0 { + Text("\(server.users)") + } + else { + Text("") + } + + } + .width(50) + .alignment(.center) + + TableColumn("Description") { server in + Text(server.description ?? "") + } + } rows: { + ForEach(self.servers) { server in + TableRow(server) + } + } + .contextMenu(forSelectionType: Server.ID.self) { items in + // ... + } primaryAction: { items in + guard + let selectionID = items.first, + let selectedServer = self.servers.first(where: { $0.id == selectionID }) + else { + return + } + + openWindow(value: selectedServer) + } + .navigationTitle("Servers") + .task { + await updateServers() + initialLoadComplete = true + } + } +} + +#Preview { + TrackerView() +} |