From 87e979fb48da4a46e64544cea3e7b7d5fc32caa1 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 14 May 2024 13:35:26 -0700 Subject: Newsgroup posting now works. Added reload button to news. More work on server and user icons. --- .../Server Large.imageset/Server Large.png | Bin 1617 -> 1292 bytes .../Server Large.imageset/Server Large@2x.png | Bin 4044 -> 2689 bytes .../Server Large.imageset/Server Large@3x.png | Bin 6988 -> 4259 bytes .../Server Message.imageset/4016.png | Bin 827 -> 812 bytes .../Server Message.imageset/4016@2x.png | Bin 1611 -> 1550 bytes .../Server Message.imageset/4016@3x.png | Bin 2430 -> 2350 bytes Hotline/Assets.xcassets/Server.imageset/Server.png | Bin 900 -> 842 bytes .../Assets.xcassets/Server.imageset/Server@2x.png | Bin 2051 -> 1778 bytes .../Assets.xcassets/Server.imageset/Server@3x.png | Bin 3433 -> 2813 bytes Hotline/Assets.xcassets/User.imageset/User.png | Bin 479 -> 433 bytes Hotline/Assets.xcassets/User.imageset/User@2x.png | Bin 824 -> 732 bytes Hotline/Assets.xcassets/User.imageset/User@3x.png | Bin 1202 -> 1050 bytes Hotline/Hotline/HotlineClient.swift | 33 ++-- Hotline/Models/Hotline.swift | 220 +++++++++++++-------- Hotline/Models/NewsInfo.swift | 44 ++++- Hotline/Utility/FoundationExtensions.swift | 7 + Hotline/iOS/NewsView.swift | 2 +- Hotline/macOS/AboutView.swift | 13 +- Hotline/macOS/ChatView.swift | 8 +- Hotline/macOS/FileDetailsView.swift | 2 +- Hotline/macOS/FilePreviewImageView.swift | 4 +- Hotline/macOS/FilesView.swift | 18 +- Hotline/macOS/HotlinePanelView.swift | 3 - Hotline/macOS/NewsEditorView.swift | 189 +++++++++++------- Hotline/macOS/NewsView.swift | 65 ++++-- Hotline/macOS/ServerMessageView.swift | 6 + 26 files changed, 398 insertions(+), 216 deletions(-) (limited to 'Hotline') diff --git a/Hotline/Assets.xcassets/Server Large.imageset/Server Large.png b/Hotline/Assets.xcassets/Server Large.imageset/Server Large.png index 25a97b0..0b5fbb7 100644 Binary files a/Hotline/Assets.xcassets/Server Large.imageset/Server Large.png and b/Hotline/Assets.xcassets/Server Large.imageset/Server Large.png differ diff --git a/Hotline/Assets.xcassets/Server Large.imageset/Server Large@2x.png b/Hotline/Assets.xcassets/Server Large.imageset/Server Large@2x.png index 63fe295..d99e32c 100644 Binary files a/Hotline/Assets.xcassets/Server Large.imageset/Server Large@2x.png and b/Hotline/Assets.xcassets/Server Large.imageset/Server Large@2x.png differ diff --git a/Hotline/Assets.xcassets/Server Large.imageset/Server Large@3x.png b/Hotline/Assets.xcassets/Server Large.imageset/Server Large@3x.png index 7b8cd6e..ce32a16 100644 Binary files a/Hotline/Assets.xcassets/Server Large.imageset/Server Large@3x.png and b/Hotline/Assets.xcassets/Server Large.imageset/Server Large@3x.png differ diff --git a/Hotline/Assets.xcassets/Server Message.imageset/4016.png b/Hotline/Assets.xcassets/Server Message.imageset/4016.png index 97e96a7..a11fbea 100644 Binary files a/Hotline/Assets.xcassets/Server Message.imageset/4016.png and b/Hotline/Assets.xcassets/Server Message.imageset/4016.png differ diff --git a/Hotline/Assets.xcassets/Server Message.imageset/4016@2x.png b/Hotline/Assets.xcassets/Server Message.imageset/4016@2x.png index bb97e20..ef90260 100644 Binary files a/Hotline/Assets.xcassets/Server Message.imageset/4016@2x.png and b/Hotline/Assets.xcassets/Server Message.imageset/4016@2x.png differ diff --git a/Hotline/Assets.xcassets/Server Message.imageset/4016@3x.png b/Hotline/Assets.xcassets/Server Message.imageset/4016@3x.png index d17f423..a2646ee 100644 Binary files a/Hotline/Assets.xcassets/Server Message.imageset/4016@3x.png and b/Hotline/Assets.xcassets/Server Message.imageset/4016@3x.png differ diff --git a/Hotline/Assets.xcassets/Server.imageset/Server.png b/Hotline/Assets.xcassets/Server.imageset/Server.png index 3b63a79..f8d8bcf 100644 Binary files a/Hotline/Assets.xcassets/Server.imageset/Server.png and b/Hotline/Assets.xcassets/Server.imageset/Server.png differ diff --git a/Hotline/Assets.xcassets/Server.imageset/Server@2x.png b/Hotline/Assets.xcassets/Server.imageset/Server@2x.png index ede8ce0..effc0f5 100644 Binary files a/Hotline/Assets.xcassets/Server.imageset/Server@2x.png and b/Hotline/Assets.xcassets/Server.imageset/Server@2x.png differ diff --git a/Hotline/Assets.xcassets/Server.imageset/Server@3x.png b/Hotline/Assets.xcassets/Server.imageset/Server@3x.png index eb3e466..5b17b06 100644 Binary files a/Hotline/Assets.xcassets/Server.imageset/Server@3x.png and b/Hotline/Assets.xcassets/Server.imageset/Server@3x.png differ diff --git a/Hotline/Assets.xcassets/User.imageset/User.png b/Hotline/Assets.xcassets/User.imageset/User.png index b675f22..c69a204 100644 Binary files a/Hotline/Assets.xcassets/User.imageset/User.png and b/Hotline/Assets.xcassets/User.imageset/User.png differ diff --git a/Hotline/Assets.xcassets/User.imageset/User@2x.png b/Hotline/Assets.xcassets/User.imageset/User@2x.png index a5d59af..03f73e9 100644 Binary files a/Hotline/Assets.xcassets/User.imageset/User@2x.png and b/Hotline/Assets.xcassets/User.imageset/User@2x.png differ diff --git a/Hotline/Assets.xcassets/User.imageset/User@3x.png b/Hotline/Assets.xcassets/User.imageset/User@3x.png index cd58773..ec35530 100644 Binary files a/Hotline/Assets.xcassets/User.imageset/User@3x.png and b/Hotline/Assets.xcassets/User.imageset/User@3x.png differ diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 07f24a4..1c72ab0 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -556,35 +556,28 @@ class HotlineClient: NetSocketDelegate { } } - @MainActor func postNewsArticle(title: String, text: String, path: [String] = [], parentID: UInt32? = nil, callback: (([HotlineNewsArticle]) -> Void)? = nil) { - var t = HotlineTransaction(type: .postNewsArticle) - if !path.isEmpty { - t.setFieldPath(type: .newsPath, val: path) - } - if let parentID = parentID { - t.setFieldUInt32(type: .newsArticleID, val: parentID) + @MainActor func postNewsArticle(title: String, text: String, path: [String] = [], parentID: UInt32 = 0, callback: ((Bool) -> Void)? = nil) { + guard !path.isEmpty else { + callback?(false) + return } + + var t = HotlineTransaction(type: .postNewsArticle) + t.setFieldPath(type: .newsPath, val: path) + t.setFieldUInt32(type: .newsArticleID, val: parentID) t.setFieldString(type: .newsArticleTitle, val: title) t.setFieldString(type: .newsArticleDataFlavor, val: "text/plain") t.setFieldUInt32(type: .newsArticleFlags, val: 0) t.setFieldString(type: .newsArticleData, val: text) + print("HotlineClient postings \(title) under \(parentID)") + self.sendPacket(t) { reply, err in - guard err == nil, - let articleData = reply.getField(type: .newsArticleListData) else { - callback?([]) + guard err == nil else { + callback?(false) return } - - var articles: [HotlineNewsArticle] = [] - let newsList = articleData.getNewsList() - for art in newsList.articles { - var blah = art - blah.path = path - articles.append(blah) - } - - callback?(articles) + callback?(true) } } diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 5dc61ab..4c9defa 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -131,6 +131,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate { var files: [FileInfo] = [] var filesLoaded: Bool = false var news: [NewsInfo] = [] + private var newsLookup: [String:NewsInfo] = [:] var newsLoaded: Bool = false var instantMessages: [UInt16:[InstantMessage]] = [:] var transfers: [TransferInfo] = [] @@ -307,88 +308,110 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate { } - @MainActor func getNewsList(at path: [String] = []) async -> [NewsInfo] { + @MainActor func getNewsList(at path: [String] = []) async { return await withCheckedContinuation { [weak self] continuation in - var requestCategories = true + let parentNewsGroup = self?.findNews(in: self?.news ?? [], at: path) - let existingNewsItem = self?.findNews(in: self?.news ?? [], at: path) - - if existingNewsItem != nil { - if existingNewsItem!.type != .bundle { - requestCategories = false - } - } - - if requestCategories { - self?.client.sendGetNewsCategories(path: path) { [weak self] categories in -// let parentNews = self?.findNews(in: self?.news ?? [], at: path) + // Send a categories request for bundle paths or root (empty path) + if path.isEmpty || parentNewsGroup?.type == .bundle { + print("Hotline: Requesting categories at: /\(path.joined(separator: "/"))") + + self?.client.sendGetNewsCategories(path: path) { @MainActor [weak self] categories in + // Create info for each category returned. + var newCategoryInfos: [NewsInfo] = [] - var newCategories: [NewsInfo] = [] + // Transform hotline categories into NewsInfo objects. for category in categories { - newCategories.append(NewsInfo(hotlineNewsCategory: category)) - } - - DispatchQueue.main.async { - if let parent = existingNewsItem { - parent.children = newCategories - } - else if path.isEmpty { - self?.newsLoaded = true - self?.news = newCategories + var newsCategoryInfo = NewsInfo(hotlineNewsCategory: category) + + if let lookupPath = newsCategoryInfo.lookupPath { + // Merge returned category info with existing category info. + if let existingCategoryInfo = self?.newsLookup[lookupPath] { + print("Hotline: Merging category into existing category at \(lookupPath)") + + existingCategoryInfo.count = newsCategoryInfo.count + existingCategoryInfo.name = newsCategoryInfo.name + existingCategoryInfo.path = newsCategoryInfo.path + existingCategoryInfo.categoryID = newsCategoryInfo.categoryID + newsCategoryInfo = existingCategoryInfo + } + else { + print("Hotline: New category added at \(lookupPath)") + self?.newsLookup[lookupPath] = newsCategoryInfo + } } - continuation.resume(returning: newCategories) + newCategoryInfos.append(newsCategoryInfo) + } + + if let parent = parentNewsGroup { + parent.children = newCategoryInfos + } + else if path.isEmpty { + self?.newsLoaded = true + self?.news = newCategoryInfos } + + continuation.resume() } } else { - self?.client.sendGetNewsArticles(path: path) { [weak self] articles in -// let parentNews = self?.findNews(in: self?.news ?? [], at: path) - print("GENERATING NEWS") + print("Hotline: Requesting articles at: /\(path.joined(separator: "/"))") + + self?.client.sendGetNewsArticles(path: path) { @MainActor [weak self] articles in + print("Hotline: Organizing news at \(path.joined(separator: "/"))") - var newArticles: [NewsInfo] = [] - for article in articles { - newArticles.append(NewsInfo(hotlineNewsArticle: article)) - } + // Create info for each article returned. + var newArticleInfos: [NewsInfo] = [] - let organizedNewsArticles: [NewsInfo] = self?.organizeNewsList(newArticles) ?? [] - - DispatchQueue.main.async { - if let parent = existingNewsItem { - print("UNDER PARENT:", parent.name) - parent.children = organizedNewsArticles - - print(parent.children) - } - else if path.isEmpty { - self?.news = organizedNewsArticles + for article in articles { + var newsArticleInfo = NewsInfo(hotlineNewsArticle: article) + + if let lookupPath = newsArticleInfo.lookupPath { + // Merge returned category info with existing category info. + if let existingArticleInfo = self?.newsLookup[lookupPath] { + print("Hotline: Merging article into existing article at \(lookupPath)") + + existingArticleInfo.count = newsArticleInfo.count + existingArticleInfo.name = newsArticleInfo.name + existingArticleInfo.path = newsArticleInfo.path + existingArticleInfo.articleUsername = newsArticleInfo.articleUsername + existingArticleInfo.articleDate = newsArticleInfo.articleDate + existingArticleInfo.articleFlavors = newsArticleInfo.articleFlavors + existingArticleInfo.articleID = newsArticleInfo.articleID + newsArticleInfo = existingArticleInfo + } + else { + print("Hotline: New article added at \(lookupPath)") + self?.newsLookup[lookupPath] = newsArticleInfo + } } - continuation.resume(returning: organizedNewsArticles) + newArticleInfos.append(newsArticleInfo) + } + + let organizedNewsArticles: [NewsInfo] = self?.organizeNewsArticles(newArticleInfos) ?? [] + if let parent = parentNewsGroup { + parent.children = organizedNewsArticles } + + continuation.resume() } } } } - func organizeNewsList(_ news: [NewsInfo]) -> [NewsInfo] { - var articleMap: [UInt: NewsInfo] = [:] - - // Create lookup table of each news item. - for article in news { - if let articleID = article.articleID { - articleMap[articleID] = article - } - } - + func organizeNewsArticles(_ flatArticles: [NewsInfo]) -> [NewsInfo] { // Place articles under their parent. var organized: [NewsInfo] = [] - for article in news { - if let parentID = article.parentID, - parentID != 0, - let parentArticle = articleMap[parentID] { - article.expanded = true - parentArticle.children.append(article) + for article in flatArticles { + if let parentLookupPath = article.parentArticleLookupPath, + let parentArticle = self.newsLookup[parentLookupPath] { +// article.expanded = true + if parentArticle.children.firstIndex(of: article) == nil { + article.expanded = true + parentArticle.children.append(article) + } } else { organized.append(article) @@ -398,30 +421,51 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate { return organized } - @MainActor func getNewsCategories(at path: [String] = []) async -> [NewsInfo] { + @MainActor func postNewsArticle(title: String, body: String, at path: [String], parentID: UInt32 = 0) async -> Bool { return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetNewsCategories(path: path) { [weak self] categories in - let parentNews = self?.findNews(in: self?.news ?? [], at: path) - - var newCategories: [NewsInfo] = [] - for category in categories { - newCategories.append(NewsInfo(hotlineNewsCategory: category)) - } - - DispatchQueue.main.async { - if let parent = parentNews { - parent.children = newCategories - } - else if path.isEmpty { - self?.news = newCategories - } - - continuation.resume(returning: newCategories) - } + guard let client = self?.client else { + continuation.resume(returning: false) + return } + + client.postNewsArticle(title: title, text: body, path: path, parentID: parentID, callback: { success in + print("Hotline: News article posted? \(success)") + continuation.resume(returning: success) + }) } } +// @MainActor func getNewsCategories(at path: [String] = []) async -> [NewsInfo] { +// return await withCheckedContinuation { [weak self] continuation in +// guard let client = self?.client else { +// continuation.resume(returning: []) +// return +// } +// +// client.sendGetNewsCategories(path: path) { [weak self] categories in +// let parentNews = self?.findNews(in: self?.news ?? [], at: path) +// +// var newCategories: [NewsInfo] = [] +// for category in categories { +// let categoryInfo: NewsInfo = NewsInfo(hotlineNewsCategory: category) +// newCategories.append(categoryInfo) +// self?.newsLookup[categoryInfo.path.joined(separator: "/")] = categoryInfo +// } +// +// DispatchQueue.main.async { +// if let parent = parentNews { +// parent.children = newCategories +// } +// else if path.isEmpty { +// self?.news = newCategories +// } +// +// continuation.resume(returning: newCategories) +// } +// } +// } +// } + @MainActor func getArticles(at path: [String]) async -> [NewsInfo] { return await withCheckedContinuation { [weak self] continuation in self?.client.sendGetNewsArticles(path: path) { articles in @@ -953,9 +997,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate { } private func findNews(in newsToSearch: [NewsInfo], at path: [String]) -> NewsInfo? { - guard !path.isEmpty, !newsToSearch.isEmpty else { return nil } - - let currentName = path[0] + guard !path.isEmpty, !newsToSearch.isEmpty, let currentName = path.first else { return nil } for news in newsToSearch { if news.name == currentName { @@ -971,4 +1013,18 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate { return nil } + + private func findNewsArticle(id articleID: UInt32, at path: [String]) -> NewsInfo? { + guard let parent = self.findNews(in: self.news, at: path), !parent.children.isEmpty else { + return nil + } + + return parent.children.first { child in + guard let childArticleID = child.articleID else { + return false + } + + return child.type == .article && child.articleID == childArticleID + } + } } diff --git a/Hotline/Models/NewsInfo.swift b/Hotline/Models/NewsInfo.swift index eecd550..6ee28b8 100644 --- a/Hotline/Models/NewsInfo.swift +++ b/Hotline/Models/NewsInfo.swift @@ -9,15 +9,15 @@ enum NewsInfoType { @Observable class NewsInfo: Identifiable, Hashable { let id: UUID = UUID() - let name: String - let count: UInt + var name: String + var count: UInt let type: NewsInfoType - let categoryID: UUID? - let articleID: UInt? - let parentID: UInt? + var categoryID: UUID? + var articleID: UInt? + var parentID: UInt? - let path: [String] + var path: [String] var expanded: Bool = false var children: [NewsInfo] = [] @@ -30,6 +30,38 @@ enum NewsInfoType { self.type == .bundle || self.type == .category || self.children.count > 0 } + var lookupPath: String? { + switch self.type { + case .bundle, .category: + return "/\(self.path.joined(separator: "/"))" + case .article: + guard let aid = self.articleID else { + return nil + } +// if let pid = self.parentID, pid != 0 { +// return "/\(self.path.joined(separator: "/"))/\(pid)/\(aid)" +// } + return "/\(self.path.joined(separator: "/"))/\(aid)" + } + } + + var parentArticleLookupPath: String? { + switch self.type { + case .bundle, .category: +// if self.path.count <= 1 { +// return "/" +// } +// let parentPath = self.path[0.. String { + if self.range(of: "^Re:", options: [.regularExpression, .caseInsensitive]) != nil { + return String(self) + } + return "Re: \(self)" + } + func convertToAttributedStringWithLinks() -> AttributedString { let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: self) let matches = self.ranges(of: RegularExpressions.relaxedLink) diff --git a/Hotline/iOS/NewsView.swift b/Hotline/iOS/NewsView.swift index 6d3ad45..460dcd3 100644 --- a/Hotline/iOS/NewsView.swift +++ b/Hotline/iOS/NewsView.swift @@ -220,7 +220,7 @@ struct NewsView: View { } .task { if !fetched { - let _ = await model.getNewsList() + await model.getNewsList() fetched = true } } diff --git a/Hotline/macOS/AboutView.swift b/Hotline/macOS/AboutView.swift index 1cf7dc2..baafcf8 100644 --- a/Hotline/macOS/AboutView.swift +++ b/Hotline/macOS/AboutView.swift @@ -89,20 +89,21 @@ struct AboutView: View { VStack(alignment: .leading, spacing: 4) { Link(destination: URL(string: "https://github.com/mierau/hotline")!) { - HStack(alignment: .center) { - Text("GitHub Contributors") + HStack(alignment: .center, spacing: 4) { + Text("Contributors") .lineLimit(1) .font(.system(size: 16)) .fontWeight(.semibold) .foregroundStyle(.black) - .opacity(0.5) - + .opacity(0.75) + Image(systemName: "arrow.forward.circle.fill") .resizable() + .fontWeight(.bold) .scaledToFit() - .frame(width: 16, height: 16) + .frame(width: 12, height: 12) .foregroundStyle(.black) - .opacity(0.5) + .opacity(0.75) } } .padding(.top, 24) diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index 0875106..62bab69 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -49,12 +49,8 @@ struct ChatView: View { } // MARK: Server Message else if msg.type == .server { - HStack { - Spacer() - ServerMessageView(message: msg.text) - Spacer() - } - .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) + ServerMessageView(message: msg.text) + .padding(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) } // MARK: Status else if msg.type == .status { diff --git a/Hotline/macOS/FileDetailsView.swift b/Hotline/macOS/FileDetailsView.swift index d3c10e8..aa7ab09 100644 --- a/Hotline/macOS/FileDetailsView.swift +++ b/Hotline/macOS/FileDetailsView.swift @@ -15,7 +15,7 @@ struct FileDetailsView: View { Form { HStack(alignment: .center){ FileIconView(filename: fd.name) - .frame(width: 16, height: 16) + .frame(width: 32, height: 32) TextField("", text: $filename) .disabled(!self.canRename()) } diff --git a/Hotline/macOS/FilePreviewImageView.swift b/Hotline/macOS/FilePreviewImageView.swift index ae9a825..b8ad37b 100644 --- a/Hotline/macOS/FilePreviewImageView.swift +++ b/Hotline/macOS/FilePreviewImageView.swift @@ -80,9 +80,9 @@ struct FilePreviewImageView: View { Button { let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) } label: { - Label("Save Image...", systemImage: "photo.badge.arrow.down") + Label("Download Image...", systemImage: "arrow.down") } - .help("Save Image") + .help("Download Image") } ToolbarItem(placement: .primaryAction) { diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index e3f5118..a225549 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -192,18 +192,18 @@ struct FilesView: View { } } .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - } label: { - Label("Delete", systemImage: "trash") - } - .help("Delete") - .disabled(true) - } +// ToolbarItem(placement: .primaryAction) { +// Button { +// } label: { +// Label("Delete", systemImage: "trash") +// } +// .help("Delete") +// .disabled(true) +// } ToolbarItem(placement: .primaryAction) { Button { - if let s = selection, !s.isFolder { + if let s = selection, s.isPreviewable { model.previewFile(s.name, path: s.path) { info in if let info = info { openPreviewWindow(info) diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index d486761..69d00aa 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -4,9 +4,6 @@ struct HotlinePanelView: View { @Environment(\.openWindow) var openWindow @Environment(\.colorScheme) var colorScheme - @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? - @FocusedValue(\.activeServerState) private var activeServerState: ServerState? - var body: some View { VStack(spacing: 0) { Image(nsImage: ApplicationState.shared.activeServerBanner ?? NSImage(named: "Default Banner")!) diff --git a/Hotline/macOS/NewsEditorView.swift b/Hotline/macOS/NewsEditorView.swift index 18c4a0d..ff33281 100644 --- a/Hotline/macOS/NewsEditorView.swift +++ b/Hotline/macOS/NewsEditorView.swift @@ -1,5 +1,9 @@ import SwiftUI -import UniformTypeIdentifiers + +private enum FocusFields { + case title + case body +} struct NewsEditorView: View { @Environment(\.controlActiveState) private var controlActiveState @@ -7,94 +11,145 @@ struct NewsEditorView: View { @Environment(\.dismiss) private var dismiss @Environment(Hotline.self) private var model: Hotline -// @Binding var article: NewsArticle? + let editorTitle: String + let isReply: Bool + let path: [String] + let parentID: UInt32 + @State var title: String = "" - @State var text: String = "" + @State private var text: String = "" + @State private var sending: Bool = false + + @FocusState private var focusedField: FocusFields? + + func sendArticle() async -> Bool { + sending = true + + let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) + if success { + await model.getNewsList(at: path) + } + + sending = false + + return success + } var body: some View { - NavigationStack { - VStack(alignment: .leading, spacing: 0) { - HStack { - Button { - dismiss() - } label: { - Image(systemName: "xmark.circle.fill") - .resizable() - .scaledToFit() - } - .buttonStyle(.plain) - .frame(width: 16, height: 16) - .padding() - - Spacer() - + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .opacity(0.5) + } + .buttonStyle(.plain) + .frame(width: 16, height: 16) + + Spacer() + + if !isReply { + Image("News Category") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .padding(.trailing, 6) + } + + Text(editorTitle) + .fontWeight(.semibold) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + if sending { + ProgressView() + .controlSize(.small) + .frame(width: 22, height: 22) + } + else { Button { + Task { + if await sendArticle() { + dismiss() + } + } } label: { - Image(systemName: "paperplane") + Image(systemName: "arrow.up.circle.fill") .resizable() + .renderingMode(.template) .scaledToFit() + .foregroundColor((title.isEmpty || text.isEmpty) ? .secondary : .accentColor) } .buttonStyle(.plain) - .frame(width: 16, height: 16) - .padding() + .frame(width: 22, height: 22) + .help("Post to Newsgroup") + .disabled(title.isEmpty || text.isEmpty) } + } + .frame(maxWidth: .infinity) + .padding([.leading, .top, .trailing]) + + TextField("Title", text: $title, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(3) + .padding() + .focusEffectDisabled() + .fontWeight(.semibold) .frame(maxWidth: .infinity) - TextField("Title", text: $title, axis: .vertical) - .textFieldStyle(.plain) + .border(Color.pink, width: 0) + .background(.tertiary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding() + .focused($focusedField, equals: .title) + + Divider() + + TextEditor(text: $text) + .textEditorStyle(.plain) + .font(.system(size: 14, design: .monospaced)) + .lineSpacing(3) + .padding(16) + .contentMargins(.top, -16.0, for: .scrollIndicators) + .contentMargins(.bottom, -16.0, for: .scrollIndicators) + .contentMargins(.trailing, -16.0, for: .scrollIndicators) + .scrollClipDisabled() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + .focused($focusedField, equals: .body) + + HStack(alignment: .center) { + Spacer() + + Text(String("**bold** _italics_ [link name](url) ![image name](url)")) + .foregroundStyle(.secondary) + .font(.caption) + .fontDesign(.monospaced) + .lineLimit(1) + .truncationMode(.middle) .padding() - .focusEffectDisabled() - .font(.title) - .frame(maxWidth: .infinity) - .border(Color.pink, width: 0) - Divider() - TextEditor(text: $text) - .textEditorStyle(.plain) - .font(.system(size: 14)) - .lineSpacing(3) - .padding(16) - .contentMargins(.top, -16.0, for: .scrollIndicators) - .contentMargins(.bottom, -16.0, for: .scrollIndicators) - .contentMargins(.trailing, -16.0, for: .scrollIndicators) - .scrollClipDisabled() - .frame(maxWidth: .infinity, maxHeight: .infinity) + + Spacer() } + .frame(maxWidth: .infinity) + .background(.tertiary.opacity(0.15)) } .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500, maxHeight: .infinity) .background(Color(nsColor: .textBackgroundColor)) .presentationCompactAdaptation(.sheet) .toolbarTitleDisplayMode(.inlineLarge) -// .toolbar { -// ToolbarItem(placement: .navigation) { -// Button("Post", action: {}) -// } -// ToolbarItem(placement: .automatic) { -// Button("Delete", action: { -// dismiss() -// }) -// } -// } - .task { -// if let info = info { -// preview = FilePreview(info: info) -// preview?.download() -// } - } .onAppear { -// if info == nil { -// Task { -// dismiss() -// } -// return -// } + if !title.isEmpty { + focusedField = .body + } } .onDisappear { -// preview?.cancel() dismiss() } -// .onChange(of: preview?.state) { -// if preview?.state == .failed { -// dismiss() -// } -// } } } diff --git a/Hotline/macOS/NewsView.swift b/Hotline/macOS/NewsView.swift index 8df741f..ed5d969 100644 --- a/Hotline/macOS/NewsView.swift +++ b/Hotline/macOS/NewsView.swift @@ -12,6 +12,8 @@ struct NewsView: View { @State private var splitHidden = SideHolder(.bottom) @State private var splitFraction = FractionHolder.usingUserDefaults(0.25, key: "News Split Fraction") @State private var editorOpen: Bool = false + @State private var replyOpen: Bool = false + @State private var loading: Bool = true var body: some View { Group { @@ -63,45 +65,82 @@ struct NewsView: View { } .task { if !model.newsLoaded { - let _ = await model.getNewsList() + await model.getNewsList() + loading = false } } } } .sheet(isPresented: $editorOpen) { - print("Sheet dismissed!") } content: { - NewsEditorView() - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - - } label: { - Image(systemName: "trash") + if let selection = selection { + switch selection.type { + case .article, .category: + NewsEditorView(editorTitle: selection.path.last ?? "New Post", isReply: false, path: selection.path, parentID: 0) + default: + EmptyView() } } - + else { + EmptyView() + } + } + .sheet(isPresented: $replyOpen) { + } content: { + if let selection = selection, selection.type == .article { + NewsEditorView(editorTitle: "Reply to \(selection.articleUsername ?? "Post")", isReply: true, path: selection.path, parentID: UInt32(selection.articleID!), title: selection.name.replyToString()) + } + else { + EmptyView() + } + } + .toolbar { ToolbarItem(placement: .primaryAction) { Button { if selection?.type == .category || selection?.type == .article { editorOpen = true -// openWindow(id: "news-editor", value: NewsArticle(parentID: nil, path: selection.path, title: "", body: "")) } } label: { Image(systemName: "square.and.pencil") } + .help("New Post") .disabled(selection?.type != .category && selection?.type != .article) } ToolbarItem(placement: .primaryAction) { Button { - + if selection?.type == .article { + replyOpen = true + } } label: { Image(systemName: "arrowshape.turn.up.left") } + .help("Reply to Post") .disabled(selection?.type != .article) } + + ToolbarItem(placement: .primaryAction) { + Button { + if let selectionPath = selection?.path { + Task { + loading = true + await model.getNewsList(at: selectionPath) + loading = false + } + } + else { + Task { + loading = true + await model.getNewsList() + loading = false + } + } + } label: { + Image(systemName: "arrow.clockwise") + } + .help("Reload Newsgroup") + .disabled(loading) + } } } diff --git a/Hotline/macOS/ServerMessageView.swift b/Hotline/macOS/ServerMessageView.swift index 742703f..8413a87 100644 --- a/Hotline/macOS/ServerMessageView.swift +++ b/Hotline/macOS/ServerMessageView.swift @@ -15,9 +15,15 @@ struct ServerMessageView: View { .lineSpacing(4) .multilineTextAlignment(.leading) .textSelection(.enabled) + Spacer() } .padding() + .frame(maxWidth: .infinity) +#if os(iOS) .background(Color("Agreement Background")) +#elseif os(macOS) + .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) +#endif .clipShape(RoundedRectangle(cornerRadius: 8)) } } -- cgit