import SwiftUI import OrgSocialKit struct NotificationsView: View { @State private var viewModel = NotificationsViewModel() @State private var replyingTo: String? = nil var body: some View { NavigationStack { 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() } .navigationDestination(for: URL.self) { ProfileView(feedURL: $0) } .navigationDestination(for: ThreadRoute.self) { ThreadView(postURL: $0.postURL, relayURL: $0.relayURL) } .sheet(isPresented: Binding( get: { replyingTo != nil }, set: { if !$0 { replyingTo = nil } } )) { ComposeView(replyTo: replyingTo) } } } private var list: some View { VStack(spacing: 0) { summaryBar Divider() List(viewModel.notifications) { notification in NotificationRowView( notification: notification, relayURL: viewModel.relayURL, onReply: { parent in replyingTo = parent } ) .listRowSeparator(.visible) .listRowSeparatorTint(Color.secondary.opacity(0.2)) .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) .listRowBackground(Color.clear) } .listStyle(.plain) .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 let relayURL: URL var onReply: ((String) -> 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 { NavigationLink(value: feedURL) { Image(systemName: "person.circle") .foregroundStyle(Color.accentColor) } .buttonStyle(.plain) } } // Action buttons if let targetURL = parentForThread { HStack(spacing: 12) { NavigationLink(value: ThreadRoute(postURL: targetURL, relayURL: relayURL)) { actionChip("View thread", icon: "arrow.up.message") } .buttonStyle(.plain) Button { onReply?(notification.parent ?? notification.post) } label: { actionChip("Reply", icon: "arrowshape.turn.up.left") } .buttonStyle(.plain) } } } } // Returns the post URL to navigate to for "View Thread" / "Reply" actions. // For replies/reactions: the parent (post that was interacted with). For mentions: the mention post itself. private var parentForThread: String? { notification.parent ?? (notification.kind == .mention ? notification.post : nil) } 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 { 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 } }