Files
andros cd0f055ffb Mention autocomplete in Compose and Edit
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.
2026-04-24 10:46:26 +02:00

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