Files
andros 78ba61034f Fix theme colors across all screens; fix Discover title on iOS 26
- Remove .toolbarBackground(.visible) from all NavigationStack views:
  this modifier suppresses large title rendering in iOS 26.
- Discover: switch to always-present List + overlay pattern so SwiftUI
  never loses the scroll context during loading transitions.
- Discover, Notifications, Groups: use .inline title mode; the tab bar
  already identifies these screens, large titles are redundant.
- RootView: add .toolbarColorScheme propagation for nav bar foreground.
- Settings sheet: apply theme background and toolbar color to the Form.
2026-05-24 21:10:04 +02:00

257 lines
9.4 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
}
}
.background(theme.background)
.navigationTitle("Notifications")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(theme.secondaryBackground, for: .navigationBar)
.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
.background(theme.background)
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
}
}