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.
849 lines
37 KiB
Swift
849 lines
37 KiB
Swift
import SwiftUI
|
|
import OrgSocialKit
|
|
|
|
private let reactionEmojis = ["❤️", "👍", "😂", "🎉", "😮", "😢", "🤔", "🙏", "🔥", "😍"]
|
|
|
|
struct PostRowView: View {
|
|
let post: OrgSocialPost
|
|
var relayURL: URL?
|
|
var showReplyCount: Bool = true
|
|
var threadRoute: ThreadRoute?
|
|
var onDelete: (() -> Void)? = nil
|
|
var onEdit: ((PostEditResult) -> Void)? = nil
|
|
|
|
@State private var replyCount: Int? = nil
|
|
@State private var boostCount: Int = 0
|
|
@State private var reactions: [OrgSocialMood] = []
|
|
@State private var showReplyCompose = false
|
|
@State private var showReactionPicker = false
|
|
@State private var showDeleteConfirm = false
|
|
@State private var showPinConfirm = false
|
|
@State private var showEdit = false
|
|
@State private var showReactorsFor: OrgSocialMood? = nil
|
|
/// Set when the user taps an inline `@nick` link; triggers navigation
|
|
/// to the mentioned user's profile via `.navigationDestination`.
|
|
@State private var pendingMentionURL: URL? = nil
|
|
@State private var pollVotes: [OrgSocialPollVote] = []
|
|
@State private var pollVoteViewModel = PollVoteViewModel()
|
|
@State private var reactionViewModel = ReactionViewModel()
|
|
@State private var boostViewModel = BoostViewModel()
|
|
@State private var actionsViewModel = PostActionsViewModel()
|
|
|
|
var body: some View {
|
|
let content = post.renderedBody
|
|
let isBodyEmpty = content.inline.characters.isEmpty
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
authorHeader
|
|
|
|
// Post type badges
|
|
HStack(spacing: 8) {
|
|
if post.include != nil {
|
|
postBadge("Boost", icon: "arrow.2.squarepath")
|
|
}
|
|
if let mood = post.mood, !mood.isEmpty, post.replyTo != nil, isBodyEmpty {
|
|
// Reaction-type post: show inline as "reacted [emoji]"
|
|
postBadge("Reacted \(EmojiShortcode.resolve(mood))", icon: "face.smiling")
|
|
}
|
|
if post.visibility == "mention" {
|
|
postBadge("Mention only", icon: "lock")
|
|
}
|
|
if post.migration != nil {
|
|
postBadge("Migration", icon: "arrow.right.circle")
|
|
}
|
|
}.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
// Body text — tapping opens the thread when one exists; otherwise selection is enabled.
|
|
// Inline `@nick` mentions are attributed as `.link` pointing at
|
|
// the feed URL; the `mentionOpenURL` action below intercepts
|
|
// the tap to navigate to the mentioned profile within the
|
|
// current NavigationStack instead of opening the URL in Safari.
|
|
if !isBodyEmpty {
|
|
let hasThread = post.replyTo != nil || (showReplyCount && (replyCount ?? 0) > 0)
|
|
if hasThread, let route = postThreadRoute {
|
|
NavigationLink(value: route) {
|
|
Text(content.inline)
|
|
.font(.body)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.foregroundStyle(.primary)
|
|
.multilineTextAlignment(.leading)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.environment(\.openURL, mentionOpenURL)
|
|
} else {
|
|
Text(content.inline)
|
|
.font(.body)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.textSelection(.enabled)
|
|
.environment(\.openURL, mentionOpenURL)
|
|
}
|
|
}
|
|
|
|
// Fenced blocks (src, quote, example)
|
|
ForEach(Array(content.blocks.enumerated()), id: \.offset) { _, block in
|
|
blockView(block)
|
|
}
|
|
|
|
// Inline images
|
|
if !content.imageURLs.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(content.imageURLs, id: \.absoluteString) { url in
|
|
AsyncImage(url: url) { phase in
|
|
switch phase {
|
|
case .success(let image):
|
|
image.resizable().scaledToFill()
|
|
.frame(width: 220, height: 160)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
case .failure:
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(Color.secondary.opacity(0.1))
|
|
.frame(width: 220, height: 160)
|
|
.overlay(Image(systemName: "photo").foregroundStyle(.secondary))
|
|
default:
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(Color.secondary.opacity(0.1))
|
|
.frame(width: 220, height: 160)
|
|
.overlay(ProgressView())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mention chips
|
|
if !content.mentions.isEmpty {
|
|
HStack(spacing: 6) {
|
|
ForEach(content.mentions, id: \.feedURL) { mention in
|
|
NavigationLink(value: mention.feedURL) {
|
|
Text("@\(mention.nick)")
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(Color.accentColor)
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(Color.accentColor.opacity(0.1), in: Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tags
|
|
if !post.tags.isEmpty {
|
|
HStack(spacing: 6) {
|
|
ForEach(post.tags, id: \.self) { tag in
|
|
Text("#\(tag)")
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Poll display
|
|
if post.pollEnd != nil {
|
|
pollView
|
|
}
|
|
|
|
// Reaction chips (lazy-loaded)
|
|
if !reactions.isEmpty {
|
|
reactionChips
|
|
}
|
|
|
|
// Action bar
|
|
let selfPostURL = post.feedURL.map { "\($0.absoluteString)#\(post.timestamp)" }
|
|
|
|
HStack(spacing: 20) {
|
|
// View thread: shown for replies always, and for roots when they have replies.
|
|
// Navigates with the CURRENT post URL as focal — ThreadView resolves parentChain
|
|
// and children itself, so no prefetch of a root URL is needed.
|
|
if let route = postThreadRoute,
|
|
post.replyTo != nil || (showReplyCount && (replyCount ?? 0) > 0) {
|
|
NavigationLink(value: route) {
|
|
HStack(spacing: 4) {
|
|
actionIcon("bubble.left.and.bubble.right")
|
|
if showReplyCount, let count = replyCount, count > 0 {
|
|
Text("\(count)").font(.caption.weight(.medium)).foregroundStyle(Color.accentColor)
|
|
}
|
|
}
|
|
}.buttonStyle(.plain)
|
|
}
|
|
|
|
// Reply button
|
|
if selfPostURL != nil {
|
|
Button { showReplyCompose = true } label: {
|
|
actionIcon("arrowshape.turn.up.left")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Reply")
|
|
}
|
|
|
|
// Reaction button
|
|
if let postURL = selfPostURL {
|
|
reactionButton(for: postURL)
|
|
}
|
|
|
|
// Boost button + count
|
|
if let postURL = selfPostURL, post.include == nil {
|
|
HStack(spacing: 4) {
|
|
boostButton(for: postURL)
|
|
if boostCount > 0 {
|
|
Text("\(boostCount)").font(.caption.weight(.medium)).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Share button: shareable preview URL via the configured
|
|
// preview service. Hidden when the user disables the
|
|
// toggle in Settings -> Sharing.
|
|
if let postURL = selfPostURL,
|
|
previewSharingEnabled,
|
|
let previewURL = previewShareURL(for: postURL) {
|
|
ShareLink(item: previewURL) {
|
|
actionIcon("square.and.arrow.up")
|
|
}
|
|
.accessibilityLabel("Share")
|
|
}
|
|
|
|
// Own-post actions: inline icons (Edit / Pin / Delete) so
|
|
// each is a single tap. Long-press context menus had
|
|
// unreliable activation timing on real devices.
|
|
if isOwnPost {
|
|
Spacer(minLength: 0)
|
|
Button { showEdit = true } label: {
|
|
actionIcon("pencil")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Edit")
|
|
|
|
Button {
|
|
showPinConfirm = true
|
|
} label: {
|
|
actionIcon("pin")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Pin to profile")
|
|
|
|
Button(role: .destructive) {
|
|
showDeleteConfirm = true
|
|
} label: {
|
|
actionIcon("trash")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Delete")
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
.navigationDestination(isPresented: Binding(
|
|
get: { pendingMentionURL != nil },
|
|
set: { if !$0 { pendingMentionURL = nil } }
|
|
)) {
|
|
if let url = pendingMentionURL {
|
|
ProfileView(feedURL: url)
|
|
}
|
|
}
|
|
.task(id: post.timestamp) { await fetchInteractionData() }
|
|
.sheet(isPresented: $showReplyCompose) {
|
|
ComposeView(replyTo: post.feedURL.map { "\($0.absoluteString)#\(post.timestamp)" },
|
|
replyToNick: post.authorNick)
|
|
}
|
|
.sheet(isPresented: $showEdit) {
|
|
EditPostView(post: post, viewModel: actionsViewModel, onSaved: { result in
|
|
onEdit?(result)
|
|
})
|
|
}
|
|
.sheet(item: Binding(
|
|
get: { showReactorsFor.map { IdentifiedMood(mood: $0) } },
|
|
set: { showReactorsFor = $0?.mood }
|
|
)) { item in
|
|
ReactorsSheet(mood: item.mood)
|
|
}
|
|
.confirmationDialog("Pin this post to your profile?", isPresented: $showPinConfirm) {
|
|
Button("Pin") {
|
|
Task { await actionsViewModel.pinPost(post) }
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("It will replace any previously pinned post.")
|
|
}
|
|
.confirmationDialog("Delete this post?", isPresented: $showDeleteConfirm) {
|
|
Button("Delete", role: .destructive) {
|
|
// Optimistic UI: remove the post immediately so there is a single
|
|
// synchronous mutation. The network upload runs in the background.
|
|
onDelete?()
|
|
Task { await actionsViewModel.deletePost(post) }
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("This will remove the post from your feed. This cannot be undone.")
|
|
}
|
|
.alert("Error", isPresented: Binding(
|
|
get: { actionsViewModel.errorMessage != nil },
|
|
set: { if !$0 { actionsViewModel.errorMessage = nil } }
|
|
)) {
|
|
Button("OK") { actionsViewModel.errorMessage = nil }
|
|
} message: {
|
|
Text(actionsViewModel.errorMessage ?? "")
|
|
}
|
|
// Fire-and-forget interactions (boost / react / vote) previously
|
|
// wrote failures into their view-model's errorMessage and nothing
|
|
// surfaced them — silent failures were the hardest class of bug
|
|
// to diagnose. Route them through the same alert pattern.
|
|
.alert("Couldn't boost", isPresented: Binding(
|
|
get: { boostViewModel.errorMessage != nil },
|
|
set: { if !$0 { boostViewModel.errorMessage = nil } }
|
|
)) {
|
|
Button("OK") { boostViewModel.errorMessage = nil }
|
|
} message: {
|
|
Text(boostViewModel.errorMessage ?? "")
|
|
}
|
|
.alert("Couldn't react", isPresented: Binding(
|
|
get: { reactionViewModel.errorMessage != nil },
|
|
set: { if !$0 { reactionViewModel.errorMessage = nil } }
|
|
)) {
|
|
Button("OK") { reactionViewModel.errorMessage = nil }
|
|
} message: {
|
|
Text(reactionViewModel.errorMessage ?? "")
|
|
}
|
|
.alert("Couldn't vote", isPresented: Binding(
|
|
get: { pollVoteViewModel.errorMessage != nil },
|
|
set: { if !$0 { pollVoteViewModel.errorMessage = nil } }
|
|
)) {
|
|
Button("OK") { pollVoteViewModel.errorMessage = nil }
|
|
} message: {
|
|
Text(pollVoteViewModel.errorMessage ?? "")
|
|
}
|
|
}
|
|
|
|
private var isOwnPost: Bool {
|
|
guard let feedURL = post.feedURL,
|
|
let own = UserDefaults.standard.string(forKey: "publicFeedURL") else { return false }
|
|
return feedURL.absoluteString == own
|
|
}
|
|
|
|
// MARK: - Poll view
|
|
|
|
private var pollView: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
if let pollEnd = post.pollEnd {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "chart.bar.xaxis").font(.caption)
|
|
Text(pollEnd > Date() ? "Ends \(pollEnd, style: .relative)" : "Poll closed")
|
|
.font(.caption)
|
|
if pollVoteViewModel.votedOption != nil {
|
|
Spacer()
|
|
Label("Voted", systemImage: "checkmark.circle.fill")
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
// Parse checkbox lines from post text.
|
|
let options = post.text.components(separatedBy: "\n")
|
|
.filter { $0.hasPrefix("- [ ]") || $0.hasPrefix("- [X]") || $0.hasPrefix("- [x]") }
|
|
.map { $0.dropFirst(5).trimmingCharacters(in: .whitespaces) }
|
|
|
|
let postURL = post.feedURL.map { "\($0.absoluteString)#\(post.timestamp)" }
|
|
let isOpen = post.pollEnd.map { $0 > Date() } == true
|
|
let hasVoted = pollVoteViewModel.votedOption != nil
|
|
let totalVotes = pollVotes.reduce(0) { $0 + $1.voteCount }
|
|
|
|
ForEach(options, id: \.self) { optionText in
|
|
pollOptionRow(
|
|
optionText: optionText,
|
|
postURL: postURL,
|
|
isOpen: isOpen,
|
|
hasVoted: hasVoted,
|
|
totalVotes: totalVotes
|
|
)
|
|
}
|
|
|
|
if hasVoted, isOpen, let postURL {
|
|
Button(role: .destructive) {
|
|
Task { await pollVoteViewModel.unvote(from: postURL) }
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "xmark.circle")
|
|
Text("Remove my vote")
|
|
}
|
|
.font(.caption.weight(.medium))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.red)
|
|
.disabled(pollVoteViewModel.isPosting)
|
|
}
|
|
|
|
if let error = pollVoteViewModel.errorMessage {
|
|
Text(error).font(.caption2).foregroundStyle(.red)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func pollOptionRow(
|
|
optionText: String,
|
|
postURL: String?,
|
|
isOpen: Bool,
|
|
hasVoted: Bool,
|
|
totalVotes: Int
|
|
) -> some View {
|
|
let matchingVote = pollVotes.first { $0.option.lowercased() == optionText.lowercased() }
|
|
let count = matchingVote?.voteCount ?? 0
|
|
let fraction: Double = totalVotes > 0 ? Double(count) / Double(totalVotes) : 0
|
|
let isMyChoice = pollVoteViewModel.votedOption?.lowercased() == optionText.lowercased()
|
|
let canVote = isOpen && !hasVoted && postURL != nil && !pollVoteViewModel.isPosting
|
|
|
|
// When the user can still vote, the whole row becomes a prominent
|
|
// tappable button. Once voted / closed, it collapses to a read-only
|
|
// progress bar with counts and a checkmark on the chosen option.
|
|
let inner = VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 8) {
|
|
if isMyChoice {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
Text(optionText)
|
|
.font(.subheadline.weight(isMyChoice ? .semibold : .regular))
|
|
.foregroundStyle(canVote ? Color.accentColor : .primary)
|
|
Spacer()
|
|
if !pollVotes.isEmpty {
|
|
Text("\(count)")
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if !pollVotes.isEmpty {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Capsule().fill(Color.secondary.opacity(0.15))
|
|
Capsule().fill(isMyChoice ? Color.accentColor.opacity(0.55) : Color.accentColor.opacity(0.25))
|
|
.frame(width: geo.size.width * fraction)
|
|
}
|
|
}
|
|
.frame(height: 6)
|
|
}
|
|
if let voters = matchingVote?.voterURLs, !voters.isEmpty {
|
|
// Hostnames of the voters, derived from the voter post URL
|
|
// (`https://host/social.org#ts`). Skips profile fetches so the
|
|
// poll card stays lightweight; taps could later open a sheet.
|
|
let hosts = voters.compactMap { v -> String? in
|
|
let feed = v.split(separator: "#", maxSplits: 1).first.map(String.init) ?? v
|
|
return URL(string: feed)?.host
|
|
}
|
|
Text(hosts.joined(separator: " · "))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(canVote ? Color.accentColor.opacity(0.08) : Color.clear)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.strokeBorder(canVote ? Color.accentColor.opacity(0.4) : Color.clear,
|
|
lineWidth: canVote ? 1 : 0)
|
|
)
|
|
|
|
if canVote, let postURL {
|
|
Button {
|
|
Task { await pollVoteViewModel.vote(for: optionText, on: postURL) }
|
|
} label: {
|
|
inner.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Vote for \(optionText)")
|
|
} else {
|
|
inner
|
|
}
|
|
}
|
|
|
|
// MARK: - Boost button
|
|
|
|
private func boostButton(for postURL: String) -> some View {
|
|
Group {
|
|
if boostViewModel.isPosting {
|
|
ProgressView().scaleEffect(0.7)
|
|
} else {
|
|
Button {
|
|
Task {
|
|
if boostViewModel.boosted {
|
|
await boostViewModel.unboost(postURL: postURL)
|
|
} else {
|
|
await boostViewModel.boost(postURL: postURL)
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: "arrow.2.squarepath")
|
|
.font(.subheadline)
|
|
.foregroundStyle(boostViewModel.boosted ? Color.accentColor : .secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(boostViewModel.boosted ? "Remove boost" : "Boost")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Reaction chips
|
|
|
|
private var reactionChips: some View {
|
|
HStack(spacing: 6) {
|
|
ForEach(reactions, id: \.emoji) { mood in
|
|
Button {
|
|
showReactorsFor = mood
|
|
} label: {
|
|
HStack(spacing: 3) {
|
|
// Other clients (org-social.el / Mastodon-style)
|
|
// sometimes write the reaction as a `:shortcode:`
|
|
// string. Resolve to Unicode for display.
|
|
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())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Reaction picker button
|
|
|
|
private func reactionButton(for postURL: String) -> some View {
|
|
Button {
|
|
if reactionViewModel.postedEmoji != nil {
|
|
// Reacted already: tap removes the reaction post from own feed.
|
|
// Optimistically clear the matching chip so the UI feels instant.
|
|
let removedEmoji = reactionViewModel.postedEmoji
|
|
if let emoji = removedEmoji, let idx = reactions.firstIndex(where: { $0.emoji == emoji }) {
|
|
let existing = reactions[idx]
|
|
let remaining = existing.posts.filter { $0 != postURL }
|
|
if remaining.isEmpty {
|
|
reactions.remove(at: idx)
|
|
} else {
|
|
reactions[idx] = OrgSocialMood(emoji: emoji, posts: remaining)
|
|
}
|
|
}
|
|
Task { await reactionViewModel.unreact(from: postURL) }
|
|
} else {
|
|
showReactionPicker = true
|
|
}
|
|
} label: {
|
|
if reactionViewModel.isPosting {
|
|
ProgressView().scaleEffect(0.7)
|
|
} else if let emoji = reactionViewModel.postedEmoji {
|
|
Text(emoji).font(.subheadline)
|
|
} else {
|
|
actionIcon("face.smiling")
|
|
}
|
|
}
|
|
.accessibilityLabel(reactionViewModel.postedEmoji != nil ? "Remove reaction" : "React")
|
|
.buttonStyle(.plain)
|
|
.disabled(reactionViewModel.isPosting)
|
|
.popover(isPresented: $showReactionPicker) {
|
|
emojiPickerPopover(for: postURL)
|
|
}
|
|
}
|
|
|
|
private func emojiPickerPopover(for postURL: String) -> some View {
|
|
VStack(spacing: 8) {
|
|
if let error = reactionViewModel.errorMessage {
|
|
Text(error).font(.caption).foregroundStyle(.red).padding(.horizontal)
|
|
}
|
|
LazyVGrid(columns: Array(repeating: GridItem(.fixed(44)), count: 5), spacing: 8) {
|
|
ForEach(reactionEmojis, id: \.self) { emoji in
|
|
Button {
|
|
showReactionPicker = false
|
|
Task {
|
|
await reactionViewModel.react(emoji: emoji, to: postURL)
|
|
if reactionViewModel.postedEmoji != nil {
|
|
let newMood = OrgSocialMood(emoji: emoji, posts: [postURL])
|
|
if let idx = reactions.firstIndex(where: { $0.emoji == emoji }) {
|
|
let existing = reactions[idx]
|
|
reactions[idx] = OrgSocialMood(emoji: emoji, posts: existing.posts + [postURL])
|
|
} else {
|
|
reactions.append(newMood)
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
Text(emoji).font(.title2)
|
|
.frame(width: 44, height: 44)
|
|
.background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.presentationCompactAdaptation(.popover)
|
|
}
|
|
|
|
/// Handler for tapping an inline `@nick` mention. If the URL looks like
|
|
/// an Org Social feed URL, we route to ProfileView via the ambient
|
|
/// NavigationStack's `.navigationDestination(for: URL.self)`. Any other
|
|
/// URL (e.g. regular `[[https://...]]` body link) falls through to
|
|
/// Safari via `.systemAction`.
|
|
private var mentionOpenURL: OpenURLAction {
|
|
OpenURLAction { url in
|
|
if Self.looksLikeFeedURL(url) {
|
|
pendingMentionURL = url
|
|
return .handled
|
|
}
|
|
return .systemAction
|
|
}
|
|
}
|
|
|
|
/// Whether the per-post Share button should be rendered, controlled by
|
|
/// the Settings toggle (default on, persisted in UserDefaults).
|
|
private var previewSharingEnabled: Bool {
|
|
UserDefaults.standard.object(forKey: "previewServiceEnabled") as? Bool ?? true
|
|
}
|
|
|
|
/// Builds the shareable preview URL for `postURL` against the
|
|
/// configured preview service. The post URL has to be percent-encoded,
|
|
/// including the `#`, `:` and `+` of the timestamp, because the preview
|
|
/// service receives the whole thing as a single query value.
|
|
private func previewShareURL(for postURL: String) -> URL? {
|
|
var allowed = CharacterSet.urlQueryAllowed
|
|
allowed.remove(charactersIn: "+:#")
|
|
guard let encoded = postURL.addingPercentEncoding(withAllowedCharacters: allowed) else {
|
|
return nil
|
|
}
|
|
let raw = (UserDefaults.standard.string(forKey: "previewServiceURL") ?? "https://preview.org-social.org/")
|
|
.trimmingCharacters(in: .whitespaces)
|
|
// Tolerate trailing slash both ways.
|
|
let base = raw.hasSuffix("/") ? String(raw.dropLast()) : raw
|
|
return URL(string: "\(base)/?post=\(encoded)")
|
|
}
|
|
|
|
private static func looksLikeFeedURL(_ url: URL) -> Bool {
|
|
guard url.scheme?.hasPrefix("http") == true else { return false }
|
|
// Org Social feed URLs always end in `social.org` (per spec examples)
|
|
// — not perfect but good enough to separate mentions from other links.
|
|
return url.path.hasSuffix("social.org")
|
|
}
|
|
|
|
// MARK: - Data helpers
|
|
|
|
private var postThreadRoute: ThreadRoute? {
|
|
guard let relay = relayURL ?? defaultRelayURL,
|
|
let feedURL = post.feedURL else { return nil }
|
|
let postURL = "\(feedURL.absoluteString)#\(post.timestamp)"
|
|
return ThreadRoute(postURL: postURL, relayURL: relay)
|
|
}
|
|
|
|
private var defaultRelayURL: URL? {
|
|
let stored = UserDefaults.standard.string(forKey: "relayURL") ?? ""
|
|
return URL(string: stored)
|
|
}
|
|
|
|
private func fetchInteractionData() async {
|
|
// Relay interactions (/replies/, /interactions/) only work when the relay is enabled.
|
|
let useRelay = UserDefaults.standard.object(forKey: "useRelay") as? Bool ?? true
|
|
guard useRelay else { return }
|
|
guard let relay = relayURL ?? defaultRelayURL,
|
|
let feedURL = post.feedURL else { return }
|
|
let postURL = "\(feedURL.absoluteString)#\(post.timestamp)"
|
|
let client = ThreadClient()
|
|
let relayClient = RelayClient()
|
|
|
|
// Detect whether I've already reacted so the button toggles to "remove"
|
|
// mode on fresh launches (postedEmoji is otherwise only set within the
|
|
// session where the reaction was added).
|
|
await reactionViewModel.loadExistingReaction(for: postURL)
|
|
|
|
// Same idea for polls — if I voted in a past session, the UI should
|
|
// hide vote buttons and offer "Remove my vote" instead.
|
|
if post.pollEnd != nil {
|
|
await pollVoteViewModel.loadExistingVote(for: postURL)
|
|
}
|
|
|
|
// And for boosts — so the icon shows accent-coloured on relaunch if
|
|
// the viewer had already boosted this post.
|
|
await boostViewModel.loadExistingBoost(for: postURL)
|
|
|
|
await withTaskGroup(of: Void.self) { group in
|
|
// /interactions/ returns reply count, boost count + booster URLs,
|
|
// and the reaction map in a single request — supplies everything
|
|
// the row needs (reply count badge, boost count badge, reaction
|
|
// chips, and the list of actors used to cross-check "did I
|
|
// already boost/react to this?" against the relay).
|
|
group.addTask {
|
|
guard let interactions = try? await client.fetchInteractions(for: postURL, from: relay) else { return }
|
|
let ownFeedPrefix = (UserDefaults.standard.string(forKey: "publicFeedURL") ?? "") + "#"
|
|
let viewerBoosted = interactions.boostURLs.contains { $0.hasPrefix(ownFeedPrefix) }
|
|
await MainActor.run {
|
|
self.replyCount = interactions.replyCount
|
|
self.boostCount = interactions.boostCount
|
|
if !interactions.reactions.isEmpty { self.reactions = interactions.reactions }
|
|
// Bidirectional reconciliation between the relay (slow,
|
|
// authoritative across clients/devices) and the local
|
|
// cache (fast, can go stale when a boost is removed
|
|
// from another client). The relay indexes every ~60s so
|
|
// a freshly-written boost may not be in the relay yet —
|
|
// we only treat "relay says NOT boosted" as a truth
|
|
// signal once the cached timestamp is old enough that
|
|
// the relay has had time to notice.
|
|
let relayLagGrace: TimeInterval = 120
|
|
if viewerBoosted {
|
|
if !self.boostViewModel.boosted { self.boostViewModel.boosted = true }
|
|
} else if self.boostViewModel.boosted,
|
|
let cachedTS = OwnInteractionCache.shared.boostTimestamp(for: postURL),
|
|
let cachedDate = PostWriter.parseTimestamp(cachedTS),
|
|
Date().timeIntervalSince(cachedDate) > relayLagGrace {
|
|
self.boostViewModel.boosted = false
|
|
self.boostViewModel.boostedTimestamp = nil
|
|
OwnInteractionCache.shared.removeBoost(postURL: postURL)
|
|
}
|
|
}
|
|
}
|
|
if post.pollEnd != nil {
|
|
group.addTask {
|
|
let votes = try? await relayClient.fetchPollVotes(for: postURL, from: relay)
|
|
await MainActor.run {
|
|
self.pollVotes = votes ?? []
|
|
// Cross-check: if the relay lists my feed as a voter,
|
|
// hide the vote buttons even if the local cache was
|
|
// cleared or the vote came from another client.
|
|
if self.pollVoteViewModel.votedOption == nil {
|
|
let ownFeedPrefix = (UserDefaults.standard.string(forKey: "publicFeedURL") ?? "") + "#"
|
|
for v in (votes ?? []) where v.voterURLs.contains(where: { $0.hasPrefix(ownFeedPrefix) }) {
|
|
self.pollVoteViewModel.votedOption = v.option
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Subviews
|
|
|
|
@ViewBuilder
|
|
private var authorHeader: some View {
|
|
HStack(spacing: 10) {
|
|
Group {
|
|
if let authorURL = post.authorURL {
|
|
NavigationLink(value: authorURL) { authorBlock }.buttonStyle(.plain)
|
|
} else {
|
|
authorBlock
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
HStack(spacing: 6) {
|
|
// Mood emoji shown next to the language badge for posts that also have body text
|
|
// or aren't pure reaction-type posts (which render the inline "Reacted X" badge).
|
|
if let mood = post.mood, !mood.isEmpty,
|
|
!(post.replyTo != nil && post.renderedBody.inline.characters.isEmpty) {
|
|
Text(EmojiShortcode.resolve(mood)).font(.subheadline)
|
|
}
|
|
if let lang = post.lang {
|
|
Text(lang.uppercased())
|
|
.font(.caption2.weight(.medium)).foregroundStyle(.secondary)
|
|
.padding(.horizontal, 5).padding(.vertical, 2)
|
|
.background(.secondary.opacity(0.12), in: Capsule())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var authorBlock: some View {
|
|
HStack(spacing: 10) {
|
|
AvatarView(url: post.authorAvatar, nick: post.authorNick)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(authorName).font(.subheadline.weight(.semibold)).lineLimit(1)
|
|
Text(post.date, style: .relative).font(.caption).foregroundStyle(.secondary)
|
|
if let client = post.client {
|
|
Text("via \(client)").font(.caption2).foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var authorName: String {
|
|
if let nick = post.authorNick { return nick }
|
|
if let host = post.authorURL?.host { return host }
|
|
return "Unknown"
|
|
}
|
|
|
|
private func actionLabel(_ label: String, icon: String) -> some View {
|
|
Label(label, systemImage: icon)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
|
|
private func actionIcon(_ systemName: String) -> some View {
|
|
Image(systemName: systemName)
|
|
.font(.subheadline)
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
|
|
private func postBadge(_ label: String, icon: String) -> some View {
|
|
Label(label, systemImage: icon)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func blockView(_ block: OrgBlock) -> some View {
|
|
let background: Color = {
|
|
switch block.kind {
|
|
case .src: return Color.secondary.opacity(0.08)
|
|
case .quote: return Color.accentColor.opacity(0.06)
|
|
case .example: return Color.secondary.opacity(0.06)
|
|
}
|
|
}()
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
if case .src(let lang) = block.kind, let lang, !lang.isEmpty {
|
|
Text(lang)
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Text(block.content)
|
|
.font(.system(.callout, design: block.kind == .quote ? .default : .monospaced))
|
|
.foregroundStyle(block.kind == .quote ? Color.primary : Color.primary.opacity(0.85))
|
|
.textSelection(.enabled)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(10)
|
|
.background(background, in: RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
}
|
|
|
|
// MARK: - Org Mode content parsing
|
|
|
|
extension OrgSocialPost {
|
|
/// Renders the post body via the shared Kit renderer: inline
|
|
/// AttributedString plus side-car mentions, images, and fenced blocks.
|
|
///
|
|
/// For poll posts, the `- [ ] Option` lines are stripped so the body
|
|
/// only shows the question. The poll card renders the options with
|
|
/// vote counts separately.
|
|
var renderedBody: RenderedBody { OrgBodyRenderer.render(displayText) }
|
|
|
|
private var displayText: String {
|
|
guard pollEnd != nil else { return text }
|
|
let stripped = text
|
|
.components(separatedBy: "\n")
|
|
.filter { line in
|
|
line.range(of: #"^\s*-\s*\[[ xX-]\]"#, options: .regularExpression) == nil
|
|
}
|
|
.joined(separator: "\n")
|
|
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
}
|