d2ab6ea377
New files: AppTheme (palette + env key), ThemeManager (Observable singleton, UserDefaults persistence). Themes: Default, Emacs, Dracula, One Dark, Monokai, Material. Each controls background, accent, text, code block colors and a highlight.js theme for syntax highlighting. RootView applies preferredColorScheme, tint and appTheme env to the root group. SettingsView applies the same modifiers to its NavigationStack so the modal sheet is themed immediately (sheets run in a separate UIWindow and don't inherit preferredColorScheme from the parent hierarchy). Theme selection in Settings uses onTapGesture instead of buttonStyle(.plain) inside Form, which was silently intercepting the button action. CodeBlockView, TimelineView, NotificationsView all read from appTheme env.
254 lines
9.3 KiB
Swift
254 lines
9.3 KiB
Swift
import SwiftUI
|
|
import OrgSocialKit
|
|
|
|
struct NotificationsView: View {
|
|
var viewModel: NotificationsViewModel
|
|
@State private var replyingTo: String? = nil
|
|
@Environment(\.appTheme) private var theme
|
|
|
|
init(viewModel: NotificationsViewModel) {
|
|
self.viewModel = viewModel
|
|
}
|
|
@State private var blockList = BlockList.shared
|
|
@State private var navPath = NavigationPath()
|
|
private var pushRouter = PushRouter.shared
|
|
|
|
var body: some View {
|
|
NavigationStack(path: $navPath) {
|
|
Group {
|
|
if viewModel.isLoading && viewModel.notifications.isEmpty {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let error = viewModel.errorMessage, viewModel.notifications.isEmpty {
|
|
ContentUnavailableView {
|
|
Label("No Notifications", systemImage: "bell.slash")
|
|
} description: {
|
|
Text(error)
|
|
} actions: {
|
|
Button("Retry") { Task { await viewModel.load() } }
|
|
.buttonStyle(.bordered)
|
|
}
|
|
} else if viewModel.notifications.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Notifications",
|
|
systemImage: "bell",
|
|
description: Text("Mentions, replies, reactions and boosts will appear here.")
|
|
)
|
|
} else {
|
|
list
|
|
}
|
|
}
|
|
.navigationTitle("Notifications")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.task {
|
|
await viewModel.load()
|
|
viewModel.markAllRead()
|
|
}
|
|
.onAppear { viewModel.markAllRead() }
|
|
.navigationDestination(for: URL.self) { ProfileView(feedURL: $0) }
|
|
.navigationDestination(for: ThreadRoute.self) {
|
|
ThreadView(postURL: $0.postURL, relayURL: $0.relayURL)
|
|
}
|
|
.onChange(of: pushRouter.pendingRoute) { _, route in
|
|
guard let route else { return }
|
|
navPath = NavigationPath()
|
|
navPath.append(route)
|
|
pushRouter.pendingRoute = nil
|
|
}
|
|
.sheet(isPresented: Binding(
|
|
get: { replyingTo != nil },
|
|
set: { if !$0 { replyingTo = nil } }
|
|
)) {
|
|
ComposeView(replyTo: replyingTo)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func threadRoute(for notification: OrgSocialNotification) -> ThreadRoute? {
|
|
let targetURL = notification.parent ?? (notification.kind == .mention ? notification.post : nil)
|
|
guard let targetURL else { return nil }
|
|
return ThreadRoute(postURL: targetURL, relayURL: viewModel.relayURL)
|
|
}
|
|
|
|
private var list: some View {
|
|
// Render-time block filter: blocking from a profile must remove
|
|
// notifications from the blocked author instantly without waiting
|
|
// for the next /notifications/ fetch.
|
|
let visible = viewModel.notifications.filter { notif in
|
|
guard let url = notif.authorFeedURL else { return true }
|
|
return !blockList.isBlocked(url)
|
|
}
|
|
return VStack(spacing: 0) {
|
|
summaryBar
|
|
Divider()
|
|
List(visible) { notification in
|
|
let route = threadRoute(for: notification)
|
|
Button {
|
|
if let route { navPath.append(route) }
|
|
} label: {
|
|
NotificationRowView(
|
|
notification: notification,
|
|
authorNick: notification.authorFeedURL.flatMap { viewModel.authorNicks[$0] },
|
|
onReply: { parent in replyingTo = parent },
|
|
onProfileTap: { url in navPath.append(url) }
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.listRowSeparator(.visible)
|
|
.listRowSeparatorTint(Color.secondary.opacity(0.2))
|
|
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
|
|
.listRowBackground(theme.background)
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.background(theme.background)
|
|
.refreshable { await viewModel.load() }
|
|
}
|
|
}
|
|
|
|
private var summaryBar: some View {
|
|
let counts = viewModel.kindCounts
|
|
return HStack(spacing: 20) {
|
|
ForEach(Array(counts.sorted(by: { $0.key.rawValue < $1.key.rawValue })), id: \.key.rawValue) { kind, count in
|
|
VStack(spacing: 1) {
|
|
Text("\(count)").font(.subheadline.weight(.bold))
|
|
Text(kind.label).font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
|
|
struct NotificationRowView: View {
|
|
let notification: OrgSocialNotification
|
|
var authorNick: String? = nil
|
|
var onReply: ((String) -> Void)? = nil
|
|
var onProfileTap: ((URL) -> Void)? = nil
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
iconView
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(authorLabel)
|
|
.font(.subheadline.weight(.semibold))
|
|
.lineLimit(1)
|
|
Text(actionLabel)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
if let parent = notification.parent {
|
|
Text(postID(from: parent))
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
if let feedURL = notification.authorFeedURL {
|
|
Button {
|
|
onProfileTap?(feedURL)
|
|
} label: {
|
|
Image(systemName: "person.circle")
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
// Reply chip — only for notifications that target a specific post.
|
|
let replyTarget = notification.parent ?? (notification.kind == .mention ? notification.post : nil)
|
|
if let replyTarget {
|
|
Button {
|
|
onReply?(replyTarget)
|
|
} label: {
|
|
actionChip("Reply", icon: "arrowshape.turn.up.left")
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var iconView: some View {
|
|
ZStack {
|
|
Circle()
|
|
.fill(iconColor.opacity(0.15))
|
|
.frame(width: 36, height: 36)
|
|
Text(iconSymbol)
|
|
.font(.system(size: 16))
|
|
}
|
|
}
|
|
|
|
private var iconSymbol: String {
|
|
switch notification.kind {
|
|
case .mention: return "💬"
|
|
case .reaction: return EmojiShortcode.resolve(notification.emoji ?? "❤️")
|
|
case .reply: return "↩️"
|
|
case .boost: return "🔁"
|
|
case .unknown: return "🔔"
|
|
}
|
|
}
|
|
|
|
private var iconColor: Color {
|
|
switch notification.kind {
|
|
case .mention: return .blue
|
|
case .reaction: return .pink
|
|
case .reply: return .green
|
|
case .boost: return .orange
|
|
case .unknown: return .secondary
|
|
}
|
|
}
|
|
|
|
private var actionLabel: String {
|
|
switch notification.kind {
|
|
case .mention: return "mentioned you"
|
|
case .reaction: return "reacted \(EmojiShortcode.resolve(notification.emoji ?? "")) to your post"
|
|
case .reply: return "replied to your post"
|
|
case .boost: return "boosted your post"
|
|
case .unknown: return "interacted with your post"
|
|
}
|
|
}
|
|
|
|
private var authorLabel: String {
|
|
if let nick = authorNick, !nick.isEmpty { return "@\(nick)" }
|
|
guard let url = notification.authorFeedURL else { return "Someone" }
|
|
return url.host ?? url.absoluteString
|
|
}
|
|
|
|
private func postID(from postURL: String) -> String {
|
|
postURL.components(separatedBy: "#").last ?? postURL
|
|
}
|
|
|
|
private func actionChip(_ label: String, icon: String) -> some View {
|
|
Label(label, systemImage: icon)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(Color.accentColor)
|
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
.background(Color.accentColor.opacity(0.08), in: Capsule())
|
|
}
|
|
}
|
|
|
|
extension OrgSocialNotification.Kind {
|
|
var label: String {
|
|
switch self {
|
|
case .mention: return "Mentions"
|
|
case .reaction: return "Reactions"
|
|
case .reply: return "Replies"
|
|
case .boost: return "Boosts"
|
|
case .unknown: return "Other"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension NotificationsViewModel {
|
|
var kindCounts: [OrgSocialNotification.Kind: Int] {
|
|
var counts: [OrgSocialNotification.Kind: Int] = [:]
|
|
for n in notifications {
|
|
counts[n.kind, default: 0] += 1
|
|
}
|
|
return counts
|
|
}
|
|
}
|