Files
andros 8e01a953dc Discard-draft dialog in Compose, search in Profile, TODO.org update
Compose now prompts Keep / Discard on Cancel when the top-level body
is non-empty. The default action keeps the draft (matching the silent
behaviour we had before), but users who want to start fresh can now do
so from inside the sheet without having to tap-and-hold the TextEditor
to select and delete.

Profile view becomes `.searchable()` with a case-insensitive substring
match on post body + tags. The filter runs locally on the parsed profile
so it's cheap even on large feeds.

TODO.org gains a "Post-1.0 enhancements" section summarising every
interaction-fidelity, timeline, compose, render, network and test
change landed during this review pass.
2026-04-24 12:05:59 +02:00

296 lines
13 KiB
Swift

import SwiftUI
import OrgSocialKit
struct ComposeView: View {
@Environment(\.dismiss) private var dismiss
@State private var viewModel: ComposeViewModel
@State private var showMentionWarning = false
@State private var showDiscardDraftDialog = false
@FocusState private var textFocused: Bool
private let onPosted: (() -> Void)?
init(replyTo: String? = nil, replyToNick: String? = nil, group: String? = nil, onPosted: (() -> Void)? = nil) {
_viewModel = State(initialValue: ComposeViewModel(replyTo: replyTo, replyToNick: replyToNick, group: group))
self.onPosted = onPosted
}
/// `:VISIBILITY: mention` is a UI hint telling clients to surface the post
/// only to the users referenced by `[[org-social:URL][nick]]` links in the
/// body (plus the author). Publishing mention-only with zero mention links
/// produces a post nobody sees almost always a mistake.
private var mentionOnlyWithoutMentions: Bool {
guard viewModel.visibility == .mention else { return false }
// Picker-selected mentions are tracked in mentionMap, pre-encoding.
// Manually-typed Org links are still accepted via the regex fallback.
if !viewModel.mentionMap.isEmpty { return false }
return viewModel.text.range(of: #"\[\[org-social:[^\]]+\]\[[^\]]+\]\]"#, options: .regularExpression) == nil
}
private var mentionCandidates: [MentionCandidate] {
MentionDirectory.shared.candidates
}
private func submit() {
Task {
await viewModel.post()
if viewModel.postedURL != nil {
try? await Task.sleep(for: .seconds(1))
onPosted?()
dismiss()
}
}
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
// Text editor with @-mention autocomplete
MentionAutocompleteField(
text: $viewModel.text,
mentionMap: $viewModel.mentionMap,
candidates: mentionCandidates,
placeholder: "What's on your mind?",
focusBinding: $textFocused,
focusedValue: true
)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.top, 16)
// Reply-to context
if let replyTo = viewModel.replyTo {
HStack(spacing: 8) {
Image(systemName: "arrowshape.turn.up.left.fill")
.font(.caption)
.foregroundStyle(.secondary)
if let nick = viewModel.replyToNick, !nick.isEmpty {
Text("@\(nick)")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text("·")
.font(.caption)
.foregroundStyle(.secondary)
}
Text(replyTo.components(separatedBy: "#").last ?? replyTo)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
.padding(.horizontal, 20)
.padding(.top, 10)
}
// Group context
if let group = viewModel.group {
HStack(spacing: 8) {
Image(systemName: "person.3.fill")
.font(.caption)
.foregroundStyle(.secondary)
Text(group.components(separatedBy: " ").first ?? group)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 20)
.padding(.top, 10)
}
// Poll options
if viewModel.isPoll {
pollOptionsSection
}
// Options
VStack(spacing: 0) {
PostOptionToggleRow(icon: "chart.bar.xaxis", label: "Poll", isOn: $viewModel.isPoll)
Divider().padding(.leading, 52)
if viewModel.isPoll {
HStack(spacing: 12) {
Image(systemName: "calendar.badge.clock")
.foregroundStyle(.secondary).frame(width: 24)
Text("Ends")
Spacer()
DatePicker("", selection: $viewModel.pollEndDate, in: Date()...)
.labelsHidden()
}
.padding(.horizontal, 16).padding(.vertical, 12)
Divider().padding(.leading, 52)
}
PostOptionScheduleSection(isScheduled: $viewModel.isScheduled, scheduledDate: $viewModel.scheduledDate)
Divider().padding(.leading, 52)
PostOptionVisibilityRow(visibility: $viewModel.visibility)
Divider().padding(.leading, 52)
PostOptionTextFieldRow(icon: "globe", label: "Language", placeholder: "en", text: $viewModel.lang)
Divider().padding(.leading, 52)
PostOptionMoodRow(mood: $viewModel.mood)
Divider().padding(.leading, 52)
PostOptionTextFieldRow(icon: "number", label: "Tags", placeholder: "", text: $viewModel.tags)
}
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.top, 16)
// Visibility hint
if viewModel.visibility == .mention {
HStack(spacing: 6) {
Image(systemName: "info.circle")
.font(.caption)
Text("Only users you mention with [[org-social:URL][nick]] will see this post.")
.font(.caption)
}
.foregroundStyle(.secondary)
.padding(.horizontal, 20)
.padding(.top, 8)
}
// No feed configured warning
if !viewModel.hasFeedConfigured {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Set your VFile Token in Settings before publishing.")
.font(.subheadline)
.foregroundStyle(.orange)
}
.padding()
.background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
.padding(.top, 12)
}
// Status messages
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)
}
if viewModel.postedURL != nil {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Published!")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.green)
}
.padding()
.background(Color.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
.padding(.top, 12)
}
}
.padding(.bottom, 32)
}
.navigationTitle(viewModel.replyTo != nil ? "Reply" : viewModel.group != nil ? "Group Post" : "New Post")
.navigationBarTitleDisplayMode(.inline)
.toolbar { composeToolbar }
.onAppear { textFocused = true }
.task { await MentionDirectory.shared.refresh() }
// Persist the in-progress draft on every keystroke (top-level
// posts only; the model decides) so the body survives an
// accidental Cancel or a backgrounded app.
.onChange(of: viewModel.text) { _, _ in viewModel.saveDraftIfTopLevel() }
.onDisappear { viewModel.saveDraftIfTopLevel() }
.confirmationDialog(
"Publish without mentions?",
isPresented: $showMentionWarning,
titleVisibility: .visible
) {
Button("Publish anyway") { submit() }
Button("Cancel", role: .cancel) {}
} message: {
Text("Mention-only posts are only shown to users linked with [[org-social:URL][nick]]. This post has no mention links, so no-one will see it.")
}
.confirmationDialog(
"Discard this draft?",
isPresented: $showDiscardDraftDialog,
titleVisibility: .visible
) {
Button("Keep draft") { dismiss() }
Button("Discard", role: .destructive) {
viewModel.text = ""
viewModel.clearDraft()
dismiss()
}
} message: {
Text("Keep the draft for later, or discard it now.")
}
}
}
private var pollOptionsSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Options").font(.caption.weight(.medium)).foregroundStyle(.secondary)
.padding(.horizontal, 20)
ForEach(viewModel.pollOptions.indices, id: \.self) { i in
HStack {
Image(systemName: "circle").foregroundStyle(.secondary)
TextField("Option \(i + 1)", text: $viewModel.pollOptions[i])
if viewModel.pollOptions.count > 2 {
Button { viewModel.pollOptions.remove(at: i) } label: {
Image(systemName: "minus.circle.fill").foregroundStyle(.red)
}
}
}
.padding(.horizontal, 16).padding(.vertical, 8)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
}
Button {
if viewModel.pollOptions.count < 8 { viewModel.pollOptions.append("") }
} label: {
Label("Add option", systemImage: "plus.circle")
.font(.subheadline)
}
.padding(.horizontal, 20).padding(.vertical, 4)
}
.padding(.top, 16)
}
private func handleCancel() {
// If the user has typed something, ask before closing so they can
// choose between keeping the draft for later or discarding it.
// Reply and group contexts skip the prompt because those drafts
// aren't persisted anyway.
let trimmed = viewModel.text.trimmingCharacters(in: .whitespacesAndNewlines)
if viewModel.replyTo == nil, viewModel.group == nil, !trimmed.isEmpty {
showDiscardDraftDialog = true
} else {
dismiss()
}
}
@ToolbarContentBuilder
private var composeToolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { handleCancel() }
}
ToolbarItem(placement: .confirmationAction) {
if viewModel.isPosting {
ProgressView()
} else {
Button("Post") {
if mentionOnlyWithoutMentions {
showMentionWarning = true
} else {
submit()
}
}
.fontWeight(.semibold)
.disabled(!viewModel.canPost)
}
}
}
}