Files
andros 78ba61034f Fix theme colors across all screens; fix Discover title on iOS 26
- 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.
2026-05-24 21:10:04 +02:00

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