Files
org-social-ios/App/Views/Notifications/NotificationsView.swift
andros d2ab6ea377 Add color theme system with 6 built-in themes; fix modal sheet theming
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.
2026-05-24 09:50:16 +02:00

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