aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2023-12-19 20:38:13 -0800
committerDustin Mierau <dustin@mierau.me>2023-12-19 20:38:13 -0800
commit4fd69c02a3e7b581bb9229865336c315153f3b18 (patch)
treee6e11d872424196f2b4033077902126ad5556e40 /Hotline
parent5e87b5927cd931d46fb5f72fb035480a95969a9f (diff)
Beginnings of a UI for macOS as well as some visual changes to iOS client. :)
Diffstat (limited to 'Hotline')
-rw-r--r--Hotline/Application.swift34
-rw-r--r--Hotline/Assets.xcassets/AppIcon.appiconset/Contents.json50
-rw-r--r--Hotline/Hotline/HotlineClient.swift20
-rw-r--r--Hotline/Hotline/HotlineProtocol.swift32
-rw-r--r--Hotline/HotlineApp.swift17
-rw-r--r--Hotline/Models/FileInfo.swift10
-rw-r--r--Hotline/Models/Hotline.swift113
-rw-r--r--Hotline/Models/NewsInfo.swift5
-rw-r--r--Hotline/Models/Server.swift13
-rw-r--r--Hotline/Models/User.swift2
-rw-r--r--Hotline/Utility/Utilities.swift2
-rw-r--r--Hotline/Utility/VisualEffectView.swift27
-rw-r--r--Hotline/iOS/ChatView.swift (renamed from Hotline/Views/ChatView.swift)0
-rw-r--r--Hotline/iOS/FilesView.swift (renamed from Hotline/Views/FilesView.swift)21
-rw-r--r--Hotline/iOS/MessageBoardView.swift (renamed from Hotline/Views/MessageBoardView.swift)0
-rw-r--r--Hotline/iOS/NewsView.swift (renamed from Hotline/Views/NewsView.swift)60
-rw-r--r--Hotline/iOS/ServerView.swift (renamed from Hotline/Views/ServerView.swift)0
-rw-r--r--Hotline/iOS/TrackerView.swift (renamed from Hotline/Views/TrackerView.swift)0
-rw-r--r--Hotline/iOS/UsersView.swift (renamed from Hotline/Views/UsersView.swift)0
-rw-r--r--Hotline/macOS/ChatView.swift191
-rw-r--r--Hotline/macOS/FilesView.swift174
-rw-r--r--Hotline/macOS/MessageBoardView.swift55
-rw-r--r--Hotline/macOS/MessageView.swift136
-rw-r--r--Hotline/macOS/NewsView.swift248
-rw-r--r--Hotline/macOS/ServerView.swift197
-rw-r--r--Hotline/macOS/TrackerView.swift191
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()
+}