78ba61034f
- Remove .toolbarBackground(.visible) from all NavigationStack views: this modifier suppresses large title rendering in iOS 26. - Discover: switch to always-present List + overlay pattern so SwiftUI never loses the scroll context during loading transitions. - Discover, Notifications, Groups: use .inline title mode; the tab bar already identifies these screens, large titles are redundant. - RootView: add .toolbarColorScheme propagation for nav bar foreground. - Settings sheet: apply theme background and toolbar color to the Form.
300 lines
13 KiB
Swift
300 lines
13 KiB
Swift
import SwiftUI
|
|
import OrgSocialKit
|
|
|
|
struct ComposeView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.appTheme) private var theme
|
|
@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(theme.secondaryBackground, 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(theme.secondaryBackground, 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)
|
|
}
|
|
.background(theme.background)
|
|
.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() }
|
|
.toolbarBackground(theme.secondaryBackground, for: .navigationBar)
|
|
.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.")
|
|
}
|
|
}
|
|
.preferredColorScheme(theme.colorScheme)
|
|
}
|
|
|
|
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(theme.secondaryBackground, 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)
|
|
}
|
|
}
|
|
}
|
|
}
|