aboutsummaryrefslogtreecommitdiff
path: root/Hotline/macOS/News/NewsView.swift
diff options
context:
space:
mode:
authorRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
committerRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
commit213710bf5bd6413c747bf126db50816ef5de5a6e (patch)
tree912f8cf87955a08077c92fea8ad934f50b7ab975 /Hotline/macOS/News/NewsView.swift
parentf466b21dc02f78c984ba6748e703f6780a7a0db4 (diff)
parent6a95b53616a4abfa306ddce43151cf4fefbd20ed (diff)
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'Hotline/macOS/News/NewsView.swift')
-rw-r--r--Hotline/macOS/News/NewsView.swift326
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())
+}