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