Files
andros 7170a34a5d Hide fenced code blocks behind Read more; bump 1.4 (1)
Code blocks (#+BEGIN_SRC/QUOTE/EXAMPLE) are now gated by the same
isTruncated flag as the inline body text, so they do not appear
outside the collapsible when a post is truncated.
2026-05-22 08:36:05 +02:00

895 lines
39 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
var truncate: Bool = true
@AppStorage("postTruncationLimit") private var truncationLimit = 500
@State private var isExpanded = false
@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
@State private var showReportSheet = false
/// 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
let plainText = String(content.inline.characters)
let isTruncated = truncate && !isExpanded && plainText.count > truncationLimit
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 displayedInline = isTruncated
? Self.truncateInline(content.inline, limit: truncationLimit)
: content.inline
let hasThread = post.replyTo != nil || (showReplyCount && (replyCount ?? 0) > 0)
if hasThread, let route = postThreadRoute {
NavigationLink(value: route) {
Text(displayedInline)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
}
.buttonStyle(.plain)
.environment(\.openURL, mentionOpenURL)
} else {
Text(displayedInline)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
.environment(\.openURL, mentionOpenURL)
}
if isTruncated {
Button { isExpanded = true } label: {
HStack(spacing: 4) {
Text("Read more")
Image(systemName: "chevron.down")
.font(.caption2.weight(.semibold))
}
.font(.caption.weight(.semibold))
.foregroundStyle(Color.accentColor)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.accentColor.opacity(0.1), in: Capsule())
}
.buttonStyle(.plain)
.accessibilityLabel("Read full post")
}
}
// Fenced blocks (src, quote, example) hidden while the post is collapsed.
if !isTruncated {
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")
}
// Report button: visible on every post except your own.
// Required by App Store guideline 1.2 (UGC); see
// App/Views/Common/ReportPostSheet.swift.
if !isOwnPost {
Button { showReportSheet = true } label: {
actionIcon("flag")
}
.buttonStyle(.plain)
.accessibilityLabel("Report")
}
// 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)
.onAppear { isExpanded = false }
.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)
}
.sheet(isPresented: $showReportSheet) {
ReportPostSheet(post: post)
}
.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) {
if post.replyTo != nil {
Image(systemName: "arrowshape.turn.up.left")
.font(.caption2)
.foregroundStyle(.secondary)
.accessibilityLabel("Reply")
}
// 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 static func truncateInline(_ full: AttributedString, limit: Int) -> AttributedString {
let plain = String(full.characters)
guard plain.count > limit else { return full }
var pos = plain.index(plain.startIndex, offsetBy: limit)
// Walk back to a word boundary so we don't cut mid-word.
var scan = pos
while scan > plain.startIndex {
let prev = plain.index(before: scan)
if plain[prev].isWhitespace || plain[prev].isNewline { break }
scan = prev
}
if scan > plain.startIndex { pos = scan }
let charCount = plain.distance(from: plain.startIndex, to: pos)
let endIdx = full.index(full.startIndex, offsetByCharacters: charCount)
var result = AttributedString(full[full.startIndex..<endIdx])
result += AttributedString("")
return result
}
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)
}
private func blockView(_ block: OrgBlock) -> some View {
CodeBlockView(block: block)
}
}
// 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)
}
}