aboutsummaryrefslogtreecommitdiff
path: root/Hotline/macOS/News
diff options
context:
space:
mode:
Diffstat (limited to 'Hotline/macOS/News')
-rw-r--r--Hotline/macOS/News/NewsEditorView.swift153
-rw-r--r--Hotline/macOS/News/NewsItemView.swift152
-rw-r--r--Hotline/macOS/News/NewsView.swift297
3 files changed, 602 insertions, 0 deletions
diff --git a/Hotline/macOS/News/NewsEditorView.swift b/Hotline/macOS/News/NewsEditorView.swift
new file mode 100644
index 0000000..f69c846
--- /dev/null
+++ b/Hotline/macOS/News/NewsEditorView.swift
@@ -0,0 +1,153 @@
+import SwiftUI
+
+private enum FocusFields {
+ case title
+ case body
+}
+
+struct NewsEditorView: View {
+ @Environment(\.controlActiveState) private var controlActiveState
+ @Environment(\.colorScheme) private var colorScheme
+ @Environment(\.dismiss) private var dismiss
+ @Environment(Hotline.self) private var model: Hotline
+
+ let editorTitle: String
+ let isReply: Bool
+ let path: [String]
+ let parentID: UInt32
+
+ @State var title: 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 {
+ 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: "arrow.up.circle.fill")
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFit()
+ .foregroundColor((title.isEmpty || text.isEmpty) ? .secondary : .accentColor)
+ }
+ .buttonStyle(.plain)
+ .frame(width: 22, height: 22)
+ .help("Post News")
+ .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)
+ .border(Color.pink, width: 0)
+ .background(.tertiary.opacity(0.2))
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ .padding()
+ .focused($focusedField, equals: .title)
+
+ Divider()
+
+ BetterTextEditor(text: $text)
+ .betterEditorFont(NSFont.monospacedSystemFont(ofSize: 14.0, weight: .regular))
+ .betterEditorAutomaticSpellingCorrection(true)
+ .betterEditorTextInset(.init(width: 16, height: 18))
+ .background(Color(nsColor: .textBackgroundColor))
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .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()
+
+ 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)
+ .onAppear {
+ if !title.isEmpty {
+ focusedField = .body
+ }
+ else {
+ focusedField = .title
+ }
+ }
+ .onDisappear {
+ dismiss()
+ }
+ }
+}
diff --git a/Hotline/macOS/News/NewsItemView.swift b/Hotline/macOS/News/NewsItemView.swift
new file mode 100644
index 0000000..fc20e61
--- /dev/null
+++ b/Hotline/macOS/News/NewsItemView.swift
@@ -0,0 +1,152 @@
+import SwiftUI
+
+struct NewsItemView: View {
+ @Environment(Hotline.self) private var model: Hotline
+
+ var news: NewsInfo
+ let depth: Int
+
+ static var dateFormatter: DateFormatter = {
+ var dateFormatter = DateFormatter()
+ dateFormatter.dateStyle = .long
+ dateFormatter.timeStyle = .short
+ dateFormatter.timeZone = .gmt
+ return dateFormatter
+ }()
+
+ static var relativeDateFormatter: RelativeDateTimeFormatter = {
+ var formatter = RelativeDateTimeFormatter()
+ formatter.unitsStyle = .full
+ formatter.dateTimeStyle = .named
+ formatter.formattingContext = .listItem
+ return formatter
+ }()
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 0) {
+ if news.expandable {
+ Button {
+ news.expanded.toggle()
+ } label: {
+ Text(Image(systemName: news.expanded ? "chevron.down" : "chevron.right"))
+ .bold()
+ .font(.system(size: 10))
+ .opacity(0.5)
+ .frame(alignment: .center)
+ }
+ .buttonStyle(.plain)
+ .frame(width: 10)
+ .padding(.leading, 4)
+ .padding(.trailing, 8)
+ }
+ else {
+ Spacer()
+ .frame(width: 10)
+ .padding(.leading, 4)
+ .padding(.trailing, 8)
+ }
+
+ // Tree indent
+ Spacer()
+ .frame(width: (CGFloat(depth) * 22))
+
+ switch news.type {
+ case .category:
+ Image("News Category")
+ .resizable()
+ .frame(width: 16, height: 16, alignment: .center)
+ .padding(.trailing, 6)
+ case .bundle:
+ Image("News Bundle")
+ .resizable()
+ .frame(width: 16, height: 16, alignment: .center)
+ .padding(.trailing, 6)
+ case .article:
+ EmptyView()
+ }
+
+ Text(news.name)
+ .fontWeight((news.type == .bundle || news.type == .category || !news.read) ? .semibold : .regular)
+ .lineLimit(1)
+ .truncationMode(.tail)
+
+ if news.type == .article && news.articleUsername != nil {
+ Text(news.articleUsername!)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .padding(.leading, 8)
+ }
+
+ Spacer()
+
+ if news.type == .category && news.count > 0 {
+ Text("^[\(news.count) Post](inflect: true)")
+ .lineLimit(1)
+ .foregroundStyle(.secondary)
+ .padding(.trailing, 8)
+ }
+ else if news.type == .bundle && news.count > 0 {
+ Text("^[\(news.count) Category](inflect: true)")
+ .lineLimit(1)
+ .foregroundStyle(.secondary)
+ .padding(.trailing, 8)
+ }
+// if news.type == .bundle {
+// Text("\(news.count)")
+// .lineLimit(1)
+// .foregroundStyle(.tertiary)
+// .padding(.trailing, 8)
+
+// ZStack {
+// Text("^[\(news.count) \(news.type == .bundle ? "Category" : "Post")](inflect: true)")
+// Text("\(news.count)")
+// .foregroundStyle(.clear)
+//// .font(.caption)
+// .lineLimit(1)
+// .padding([.leading, .trailing], 8)
+// .padding([.top, .bottom], 2)
+// .background(.secondary)
+// .clipShape(Capsule())
+//
+// Text("\(news.count)")
+// .foregroundStyle(.white)
+//// .font(.caption)
+// .lineLimit(1)
+// .padding([.leading, .trailing], 8)
+// .padding([.top, .bottom], 2)
+// .blendMode(.destinationOut)
+// }
+// .drawingGroup(opaque: false)
+// }
+ else if news.type == .article && news.articleUsername != nil {
+ if let d = news.articleDate {
+ Text(NewsItemView.relativeDateFormatter.localizedString(for: d, relativeTo: Date.now))
+ .lineLimit(1)
+ .foregroundStyle(.secondary)
+ .padding(.trailing, 8)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .onChange(of: news.expanded) {
+ guard news.expanded, news.type == .bundle || news.type == .category else {
+ return
+ }
+
+ Task {
+ await model.getNewsList(at: news.path)
+ }
+ }
+
+ if news.expanded {
+ ForEach(news.children.reversed(), id: \.self) { childNews in
+ NewsItemView(news: childNews, depth: self.depth + 1).tag(childNews.id)
+ }
+ }
+ }
+}
+
+#Preview {
+ NewsItemView(news: NewsInfo(hotlineNewsArticle: HotlineNewsArticle(id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now, flavors: [("", 1)], path: ["Guest"])), depth: 0)
+ .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
+}
diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift
new file mode 100644
index 0000000..91bf2fe
--- /dev/null
+++ b/Hotline/macOS/News/NewsView.swift
@@ -0,0 +1,297 @@
+import SwiftUI
+import MarkdownUI
+import SplitView
+
+struct NewsView: View {
+ @Environment(Hotline.self) private var model: Hotline
+ @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(.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 {
+ VStack {
+ Text("No News")
+ .bold()
+ .foregroundStyle(.secondary)
+ .font(.title3)
+ Text("This server has news turned off.")
+ .foregroundStyle(.tertiary)
+ .font(.system(size: 13))
+ }
+ .padding()
+ }
+ else {
+ NavigationStack {
+ VSplit(
+ top: {
+ if !model.newsLoaded {
+ loadingIndicator
+ }
+ else if model.news.isEmpty {
+ ZStack(alignment: .center) {
+ Text("No News")
+ .font(.title)
+ .multilineTextAlignment(.center)
+ .foregroundStyle(.secondary)
+ .padding()
+ }
+ .frame(maxWidth: .infinity)
+ }
+ else {
+ 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
+ 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(placement: .primaryAction) {
+ Button {
+ if selection?.type == .category || selection?.type == .article {
+ editorOpen = true
+ }
+ } 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 {
+ loading = true
+ if let selectionPath = selection?.path {
+ Task {
+ await model.getNewsList(at: selectionPath)
+ loading = false
+ }
+ }
+ else {
+ Task {
+ await model.getNewsList()
+ loading = false
+ }
+ }
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ .help("Reload News")
+ .disabled(loading)
+ }
+ }
+ }
+
+ 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 = 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
+ }
+ }
+
+ var loadingIndicator: some View {
+ VStack {
+ HStack {
+ ProgressView {
+ Text("Loading Newsgroups")
+ }
+ .controlSize(.regular)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ 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 {
+ HStack(alignment: .center) {
+ Spacer()
+ HStack(alignment: .center, spacing: 8) {
+// Image(systemName: "doc.append")
+// .resizable()
+// .scaledToFit()
+// .foregroundStyle(.tertiary)
+// .frame(width: 16, height: 16)
+ Text("Select a news post to read")
+ .foregroundStyle(.tertiary)
+ .font(.system(size: 13))
+ }
+ 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(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
+}