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

158 lines
5.8 KiB
Swift

import SwiftUI
import OrgSocialKit
struct ThreadRoute: Hashable {
let postURL: String
let relayURL: URL
}
struct ThreadView: View {
@State private var viewModel: ThreadViewModel
let relayURL: URL
init(postURL: String, relayURL: URL) {
self.relayURL = relayURL
_viewModel = State(initialValue: ThreadViewModel(postURL: postURL, relayURL: relayURL))
}
var body: some View {
Group {
if viewModel.isLoading && viewModel.thread == nil {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewModel.errorMessage, viewModel.thread == nil {
ContentUnavailableView {
Label("Thread unavailable", systemImage: "bubble.left.and.bubble.right")
} description: {
Text(error)
} actions: {
Button("Retry") { Task { await viewModel.load() } }
.buttonStyle(.bordered)
}
} else if let thread = viewModel.thread {
threadContent(thread)
}
}
.navigationTitle("Thread")
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load() }
.navigationDestination(for: URL.self) { ProfileView(feedURL: $0) }
.navigationDestination(for: ThreadRoute.self) {
ThreadView(postURL: $0.postURL, relayURL: $0.relayURL)
}
}
@ViewBuilder
private func threadContent(_ thread: OrgSocialThread) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
// Parent chain (ancestors, oldest first)
ForEach(thread.parentChain, id: \.timestamp) { post in
ancestorRow(post)
threadConnector
}
// Focal post (highlighted)
focalPostRow(thread.focalPost)
if !thread.replies.isEmpty {
threadConnector
}
// Reply tree
ForEach(Array(thread.replies.enumerated()), id: \.element.post.timestamp) { index, node in
replyNodeRow(node, depth: 0)
if index < thread.replies.count - 1 {
Divider().padding(.leading, 72)
}
}
}
.padding(.vertical, 8)
}
}
private func ancestorRow(_ post: OrgSocialPost) -> some View {
PostRowView(post: post, relayURL: relayURL, showReplyCount: false)
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
private var threadConnector: some View {
Rectangle()
.fill(Color.secondary.opacity(0.3))
.frame(width: 2, height: 20)
.padding(.leading, 27)
}
private func focalPostRow(_ post: OrgSocialPost) -> some View {
VStack(alignment: .leading, spacing: 8) {
PostRowView(post: post, relayURL: relayURL, showReplyCount: false)
if let moods = viewModel.thread?.moods, !moods.isEmpty {
reactionChips(moods)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.accentColor.opacity(0.05))
}
private func replyNodeRow(_ node: OrgSocialThreadNode, depth: Int) -> AnyView {
AnyView(
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 0) {
if depth > 0 {
Rectangle()
.fill(Color.secondary.opacity(0.25))
.frame(width: 2)
.padding(.leading, CGFloat(16 + (depth - 1) * 32))
.padding(.trailing, 10)
}
VStack(alignment: .leading, spacing: 6) {
PostRowView(post: node.post, relayURL: relayURL, showReplyCount: false, threadRoute: replyRoute(for: node))
if !node.moods.isEmpty {
reactionChips(node.moods)
}
}
.padding(.horizontal, depth == 0 ? 16 : 0)
.padding(.trailing, depth > 0 ? 16 : 0)
.padding(.vertical, 10)
}
if !node.children.isEmpty {
threadConnector
.padding(.leading, CGFloat(depth) * 32)
}
ForEach(Array(node.children.enumerated()), id: \.element.post.timestamp) { index, child in
replyNodeRow(child, depth: depth + 1)
if index < node.children.count - 1 {
Divider().padding(.leading, CGFloat(72 + (depth + 1) * 32))
}
}
}
)
}
private func reactionChips(_ moods: [OrgSocialMood]) -> some View {
HStack(spacing: 6) {
ForEach(moods, id: \.emoji) { mood in
HStack(spacing: 3) {
Text(EmojiShortcode.resolve(mood.emoji)).font(.caption)
Text("\(mood.posts.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 7).padding(.vertical, 3)
.background(Color.secondary.opacity(0.08), in: Capsule())
}
}
}
private func replyRoute(for node: OrgSocialThreadNode) -> ThreadRoute? {
guard !node.children.isEmpty,
let feedURL = node.post.feedURL else { return nil }
let postURL = "\(feedURL.absoluteString)#\(node.post.timestamp)"
return ThreadRoute(postURL: postURL, relayURL: relayURL)
}
}