7170a34a5d
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.
895 lines
39 KiB
Swift
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)
|
|
}
|
|
}
|