d4af4b675e
Replace the hand-curated 70-entry shortcode map with the full emojibase catalog (5884 entries unioned over github + iamcal + emojibase, the canonical dataset GitHub, Slack, Discord and Mastodon all derive from) so any reaction users send through the wider Org Social ecosystem now renders as the right emoji. Two relay quirks that still slipped through the original :KEY: scanner: - Some clients send moods as bare tokens (`happy`, `heartbeat`) with no delimiters. The resolver now treats a colon-free input as a candidate shortcode key when the whole string matches the map; multi-word body text is unaffected because it never matches. - Other clients use dashes instead of underscores (`christmas-tree` vs `christmas_tree`). Lookups now fall back to the underscore form. Apply the resolver in the two places it was still missing: NotificationsView (reaction icon + action label) and ThreadView (chips), plus the inline mood badge next to the language tag in PostRowView. The test suite grows from 5 to 8 cases covering bare-token, dash-form and free-text inputs.
222 lines
7.8 KiB
Swift
222 lines
7.8 KiB
Swift
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
|
|
}
|
|
}
|