8e01a953dc
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.
296 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|