diff options
| author | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-11-27 23:38:04 +0100 |
|---|---|---|
| committer | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-11-27 23:38:04 +0100 |
| commit | 213710bf5bd6413c747bf126db50816ef5de5a6e (patch) | |
| tree | 912f8cf87955a08077c92fea8ad934f50b7ab975 /Hotline/macOS/News/NewsView.swift | |
| parent | f466b21dc02f78c984ba6748e703f6780a7a0db4 (diff) | |
| parent | 6a95b53616a4abfa306ddce43151cf4fefbd20ed (diff) | |
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'Hotline/macOS/News/NewsView.swift')
| -rw-r--r-- | Hotline/macOS/News/NewsView.swift | 326 |
1 files changed, 326 insertions, 0 deletions
diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift new file mode 100644 index 0000000..00b6555 --- /dev/null +++ b/Hotline/macOS/News/NewsView.swift @@ -0,0 +1,326 @@ +import MarkdownUI +import SplitView +import SwiftUI + +struct NewsView: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.openWindow) private var openWindow + @Environment(\.colorScheme) private var colorScheme + + @State private var selection: NewsInfo? + @State private var articleText: String? + @State private var splitHidden: SideHolder = 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 = false + + var body: some View { + Group { + if model.serverVersion < 151 { + disabledNewsView + } + else if !model.newsLoaded { + loadingIndicator + } + else if model.news.isEmpty { + emptyNewsView + } + else { + NavigationStack { + VSplit( + top: { + newsBrowser + }, + bottom: { + articleViewer + } + ) + .fraction(splitFraction) + .constraints(minPFraction: 0.1, minSFraction: 0.3) + .hide(splitHidden) + .styling( + color: colorScheme == .dark ? .black : Splitter.defaultColor, inset: 0, + visibleThickness: 0.5, invisibleThickness: 5, hideSplitter: true + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + } + } + } + .task { + if !model.newsLoaded { + loading = true + try? await model.getNewsList() + loading = false + } + } + .sheet(isPresented: $editorOpen) { + } content: { + 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 { + Button { + if selection?.type == .category || selection?.type == .article { + editorOpen = true + } + } label: { + Image(systemName: "square.and.pencil") + } + .help("New post under current topic") + .disabled(selection?.type != .category && selection?.type != .article) + } + + ToolbarItem { + Button { + if selection?.type == .article { + replyOpen = true + } + } label: { + Image(systemName: "arrowshape.turn.up.left") + } + .help("Reply to selected post") + .disabled(selection?.type != .article) + } + + if #available(macOS 26.0, *) { + ToolbarSpacer(.fixed) + } + + ToolbarItem { + Button { +// if selection?.type == .category || selection?.type == .article { +// editorOpen = true +// } + } label: { + Image(systemName: "newspaper") + } + .help("Create a new topic") + } + + ToolbarItem { + Button { +// if selection?.type == .category || selection?.type == .article { +// editorOpen = true +// } + } label: { + Image(systemName: "tray") + } + .help("Create a new category") + } + + ToolbarItem { + Button { + loading = true + if let selectionPath = selection?.path { + Task { + try? await model.getNewsList(at: selectionPath) + loading = false + } + } else { + Task { + try? await model.getNewsList() + loading = false + } + } + } label: { + Image(systemName: "arrow.clockwise") + } + .help("Reload selected topic") + .disabled(loading) + } + } + } + + private var disabledNewsView: some View { + ContentUnavailableView { + Label("No News", systemImage: "newspaper") + } description: { + Text("This server has turned off newsgroups") + } + } + + private var emptyNewsView: some View { + ContentUnavailableView { + Label("No News", systemImage: "newspaper") + } description: { + Text("This server has no newsgroups") + } + } + + private var newsBrowser: some View { + List(model.news, id: \.self, selection: $selection) { newsItem in + NewsItemView(news: newsItem, depth: 0).tag(newsItem.id) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .environment(\.defaultMinListRowHeight, 28) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .contextMenu(forSelectionType: NewsInfo.self) { items in + let selectedItem = items.first + + Button { + if selectedItem?.type == .article { + replyOpen = true + } + } label: { + Label( + "Reply to \(selectedItem?.articleUsername ?? "Post")", + systemImage: "arrowshape.turn.up.left") + } + .disabled(selectedItem == nil || selectedItem?.type != .article) + + } primaryAction: { items in + guard let clickedNews = items.first else { + return + } + + self.selection = clickedNews + if clickedNews.type == .bundle || clickedNews.type == .category + || clickedNews.children.count > 0 + { + clickedNews.expanded.toggle() + } + } + .onChange(of: selection) { + self.articleText = nil + if let article = selection, article.type == .article { + article.read = true + if let articleFlavor = article.articleFlavors?.first, + let articleID = article.articleID + { + Task { + if let articleText = try? await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { + self.articleText = articleText + } + } + if self.splitHidden.side != nil { + withAnimation(.easeOut(duration: 0.15)) { + self.splitHidden.side = nil + } + } + + } + } else { + if self.splitHidden.side != .bottom { + withAnimation(.easeOut(duration: 0.25)) { + self.splitHidden.side = .bottom + } + } + } + } + .onKeyPress(.rightArrow) { + if let s = selection, s.expandable { + s.expanded = true + return .handled + } + return .ignored + } + .onKeyPress(.leftArrow) { + if let s = selection, s.expandable { + s.expanded = false + return .handled + } + return .ignored + } + } + + private var loadingIndicator: some View { + VStack { + HStack { + ProgressView { + Text("Loading Newsgroups") + } + .controlSize(.regular) + } + } + .frame(maxWidth: .infinity) + } + + private var articleViewer: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if let selection = selection, selection.type == .article { + if let poster = selection.articleUsername, let postDate = selection.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() + + Text(selection.name).font(.title) + .textSelection(.enabled) + .padding(.bottom, 8) + .padding(.top, 16) + + if let newsText = self.articleText { + Markdown(newsText) + .markdownTheme(.basic) + .textSelection(.enabled) + .lineSpacing(6) + .padding(.top, 16) + } + } + else { + EmptyView() +// HStack(alignment: .center) { +// Spacer() +// HStack(alignment: .center, spacing: 8) { +// Text("Select a news article to read") +// .foregroundStyle(.tertiary) +// .font(.subheadline) +// } +// Spacer() +// } +// .padding() +// .padding(.top, 48) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.move(edge: .bottom)) + } +} + +#Preview { + NewsView() + .environment(HotlineState()) +} |