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