78ba61034f
- 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.
257 lines
9.4 KiB
Swift
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
|
|
}
|
|
}
|