cd0f055ffb
Type `@` in the post body and a dropdown of the user's follows appears filtered by what is typed after the `@`. Tapping a candidate inserts `@nick ` into the editor and records `nick -> feedURL` in a mentionMap. On publish / save the `@nick` tokens are rewritten to the spec's `[[org-social:URL][nick]]` form before the post is serialised. Edit sheet rounds-trips: on open the existing post body is decoded so the user sees `@nick` instead of the raw Org link, and on save it is re-encoded. Source of candidates: - Follows (always; from the already-cached FollowCoordinator feed). - All relay users (when the `showAllRelayFeeds` setting is on); nicks derived from the feed URL path since fetching every profile would be too expensive, and the exact serialised URL is always correct. Design notes: - SwiftUI TextEditor exposes no caret, so detection is a trailing-token heuristic (`@[A-Za-z0-9_.-]*$`). Mid-text mentions don't trigger the dropdown — acceptable since the dropdown is purely a convenience and manually-typed `[[org-social:URL][nick]]` still works. - Rewrite is whole-word (`(?<!\w)@nick(?![\w.-])`), so email addresses and `@foo.bar` fragments that are not mapped are left alone. Longer nicks are replaced before shorter ones to avoid partial overlap. - mentionOnlyWithoutMentions warning in Compose now accepts both the mentionMap and manually-typed `[[org-social:...]]` links as evidence of mentions.
204 lines
8.6 KiB
Swift
204 lines
8.6 KiB
Swift
import SwiftUI
|
|
import OrgSocialKit
|
|
import Observation
|
|
|
|
struct PostEditResult {
|
|
let newText: String
|
|
let newTimestamp: String? // nil if unchanged
|
|
let lang: String?
|
|
let tags: String?
|
|
let mood: String?
|
|
let visibility: NewPostOptions.Visibility
|
|
}
|
|
|
|
/// Form state for the edit sheet, held as a single @Observable so that SwiftUI
|
|
/// does NOT reset individual @State properties when the parent re-renders mid-edit.
|
|
/// (Doing `_visibility = State(initialValue:)` in init proved unreliable — taps on
|
|
/// the Visibility/Mood confirmation dialogs appeared to dismiss without updating
|
|
/// the row because a parent re-render re-ran init and clobbered the mutation.)
|
|
@Observable @MainActor
|
|
final class EditFormModel {
|
|
var text: String
|
|
/// `nick -> feed URL` for any `@nick` in `text`. Pre-populated from
|
|
/// existing `[[org-social:URL][nick]]` links in the post on open, and
|
|
/// grows as the user picks new mentions from autocomplete. On save the
|
|
/// inverse transform produces the spec's serialised form.
|
|
var mentionMap: [String: String]
|
|
var lang: String
|
|
var tags: String
|
|
var mood: String
|
|
var visibility: NewPostOptions.Visibility
|
|
var isScheduled: Bool
|
|
var scheduledDate: Date
|
|
|
|
init(post: OrgSocialPost) {
|
|
let decoded = decodeMentions(in: post.text)
|
|
self.text = decoded.body
|
|
self.mentionMap = decoded.map
|
|
self.lang = post.lang ?? ""
|
|
self.tags = post.tags.joined(separator: " ")
|
|
self.mood = post.mood ?? ""
|
|
self.visibility = post.visibility == "mention" ? .mention : .public
|
|
self.isScheduled = post.date > Date()
|
|
// Default the date picker to "now + 1 hour" for never-scheduled posts
|
|
// so the user gets a sensible starting point when toggling Schedule on.
|
|
self.scheduledDate = post.date > Date() ? post.date : Date().addingTimeInterval(3600)
|
|
}
|
|
}
|
|
|
|
struct EditPostView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
let post: OrgSocialPost
|
|
var viewModel: PostActionsViewModel
|
|
var onSaved: ((PostEditResult) -> Void)? = nil
|
|
|
|
@State private var form: EditFormModel
|
|
@FocusState private var focused: Bool
|
|
|
|
init(post: OrgSocialPost, viewModel: PostActionsViewModel, onSaved: ((PostEditResult) -> Void)? = nil) {
|
|
self.post = post
|
|
self.viewModel = viewModel
|
|
self.onSaved = onSaved
|
|
// @State with a class reference: SwiftUI persists the first instance across
|
|
// re-inits. Subsequent EditPostView(...) calls with a new EditFormModel are
|
|
// ignored; the original instance keeps its state until the sheet closes.
|
|
_form = State(initialValue: EditFormModel(post: post))
|
|
}
|
|
|
|
var body: some View {
|
|
@Bindable var form = form
|
|
return NavigationStack {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
MentionAutocompleteField(
|
|
text: $form.text,
|
|
mentionMap: $form.mentionMap,
|
|
candidates: MentionDirectory.shared.candidates,
|
|
placeholder: "Edit your post…",
|
|
minHeight: 160,
|
|
focusBinding: $focused,
|
|
focusedValue: true
|
|
)
|
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 16)
|
|
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "clock")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Text(post.timestamp)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 8)
|
|
|
|
VStack(spacing: 0) {
|
|
PostOptionScheduleSection(isScheduled: $form.isScheduled, scheduledDate: $form.scheduledDate)
|
|
Divider().padding(.leading, 52)
|
|
PostOptionVisibilityRow(visibility: $form.visibility)
|
|
Divider().padding(.leading, 52)
|
|
PostOptionTextFieldRow(icon: "globe", label: "Language", placeholder: "en", text: $form.lang)
|
|
Divider().padding(.leading, 52)
|
|
PostOptionMoodRow(mood: $form.mood)
|
|
Divider().padding(.leading, 52)
|
|
PostOptionTextFieldRow(icon: "number", label: "Tags", placeholder: "", text: $form.tags)
|
|
}
|
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 16)
|
|
|
|
if viewModel.isEditing {
|
|
HStack {
|
|
Spacer()
|
|
ProgressView("Saving…").padding()
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
if let error = viewModel.errorMessage {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.red)
|
|
Text(error)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.red)
|
|
}
|
|
.padding()
|
|
.background(Color.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 12)
|
|
}
|
|
}
|
|
.padding(.bottom, 32)
|
|
}
|
|
.scrollDismissesKeyboard(.immediately)
|
|
.navigationTitle("Edit Post")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .keyboard) {
|
|
HStack {
|
|
Spacer()
|
|
Button("Done") { focused = false }
|
|
}
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Save") { save() }
|
|
.fontWeight(.semibold)
|
|
.disabled(form.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isEditing)
|
|
}
|
|
}
|
|
.task { await MentionDirectory.shared.refresh() }
|
|
}
|
|
}
|
|
|
|
private func save() {
|
|
Task {
|
|
let trimmedText = form.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let encodedText = encodeMentions(in: trimmedText, using: form.mentionMap)
|
|
let trimmedLang = form.lang.trimmingCharacters(in: .whitespaces)
|
|
let trimmedTags = form.tags.trimmingCharacters(in: .whitespaces)
|
|
let trimmedMood = form.mood.trimmingCharacters(in: .whitespaces)
|
|
|
|
// Schedule edits emit a new timestamp when:
|
|
// - Schedule toggle is on AND the chosen date differs from the original, OR
|
|
// - Schedule toggle was turned off on a previously-scheduled post (retarget to "now").
|
|
let wasScheduled = post.date > Date()
|
|
let newTimestamp: String?
|
|
if form.isScheduled, form.scheduledDate != post.date {
|
|
newTimestamp = PostWriter().generateTimestamp(for: form.scheduledDate)
|
|
} else if wasScheduled, !form.isScheduled {
|
|
newTimestamp = PostWriter().generateTimestamp(for: Date())
|
|
} else {
|
|
newTimestamp = nil
|
|
}
|
|
|
|
await viewModel.editPost(
|
|
post,
|
|
newText: encodedText,
|
|
newTimestamp: newTimestamp,
|
|
lang: trimmedLang.isEmpty ? nil : trimmedLang,
|
|
tags: trimmedTags.isEmpty ? nil : trimmedTags,
|
|
mood: trimmedMood.isEmpty ? nil : trimmedMood,
|
|
visibility: form.visibility
|
|
)
|
|
|
|
if viewModel.errorMessage == nil {
|
|
onSaved?(PostEditResult(
|
|
newText: encodedText,
|
|
newTimestamp: newTimestamp,
|
|
lang: trimmedLang.isEmpty ? nil : trimmedLang,
|
|
tags: trimmedTags.isEmpty ? nil : trimmedTags,
|
|
mood: trimmedMood.isEmpty ? nil : trimmedMood,
|
|
visibility: form.visibility
|
|
))
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|