Files
andros d4af4b675e Resolve every emoji shortcode the relay sends
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.
2026-04-29 16:32:25 +01:00

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