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()) }