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