import LChatCore import SwiftUI // 🤖 Per-server settings (the cog beside search): display name — encrypted // under the instance key, exactly the web's publishMyLabel — and the native // feed's theme (the web's palettes, transcribed in ChatTheme). A proper // profile panel (photo etc.) comes later. struct ServerSettingsSheet: View { let store: ThreadStore @Environment(\.dismiss) private var dismiss @AppStorage("nativeTheme") private var themeName = "classic" @AppStorage("formatAsYouType") private var formatAsYouType = true // Perf recording consent (app-wide, like the theme): Debug builds default // on, Release waits for this switch — see Perf.swift. @AppStorage(Perf.consentKey) private var perfDiagnostics = Perf.defaultEnabled @State private var perfSummary: String? @State private var perfReportURL: URL? @State private var name = "" @State private var saving = false @State private var note: String? @State private var away = false @State private var savedAway = false // last server-confirmed value — makes onChange idempotent (initial load, failure revert) @State private var awayLoaded = false // gate the toggle until /api/me answers @State private var awayNote: String? @State private var hasPaidLevels = false // this space sells (or I'm subscribed to) an access level // DM privacy (#578): who may START a direct message with you. Same // load/save discipline as Away — gated until /api/me answers, saved on // change, reverted with an inline note if the save doesn't stick. @State private var dmPrivacy = "everyone" @State private var savedDMPrivacy = "everyone" @State private var dmPrivacyLoaded = false @State private var dmPrivacyNote: String? var body: some View { NavigationStack { Form { Section("Display name") { TextField("Your name", text: $name) .onSubmit { if dirty { save() } } if let note { Text(note).font(.footnote).foregroundStyle(.secondary) } } Section { Toggle("Set yourself as away", isOn: $away) .disabled(!awayLoaded) .onChange(of: away) { _, next in setAway(next) } if let awayNote { Text(awayNote).font(.footnote).foregroundStyle(.secondary) } } header: { Text("Presence") } footer: { Text("Your dot stays yellow until you switch this off — being active won't flip you back to online.") } Section { Picker("Who can message you directly", selection: $dmPrivacy) { Text("Everyone in this space").tag("everyone") Text("No one").tag("nobody") } .disabled(!dmPrivacyLoaded) .onChange(of: dmPrivacy) { _, next in setDMPrivacy(next) } if let dmPrivacyNote { Text(dmPrivacyNote).font(.footnote).foregroundStyle(.secondary) } } header: { Text("Direct messages") } footer: { Text("Existing chats keep working. Admins and helper bots can always reach you.") } if hasPaidLevels { Section { NavigationLink { MembershipView(store: store) } label: { Label("Membership", systemImage: "creditcard") } } footer: { Text("Subscribe to a paid access level, or change and cancel your plan.") } } Section("Theme") { Picker("Theme", selection: $themeName) { ForEach(ChatTheme.presets, id: \.id) { preset in Text(preset.title).tag(preset.id) } } .pickerStyle(.inline) .labelsHidden() } Section { Toggle("Convert common Markdown formatting as you type", isOn: $formatAsYouType) } header: { Text("Composer") } footer: { // verbatim: the hint TEACHES the syntax — the markers must show. Text(verbatim: "e.g. **bold**, *italic* (or _italic_) and ~strikethrough~") } Section { Toggle("Record performance diagnostics", isOn: $perfDiagnostics) .onChange(of: perfDiagnostics) { _, next in Perf.setRecording(next) if !next { perfReportURL = nil } } if perfDiagnostics { if let url = perfReportURL { ShareLink(item: url) { Label("Share performance report", systemImage: "square.and.arrow.up") } } else { // Assembled on demand — the report concatenates the // ledger + MetricKit payloads, so build it when asked. Button("Prepare performance report") { preparePerfReport() } } } } header: { Text("Performance") } footer: { Text(perfFooter) } Section { Text("Notifications are managed in Settings ▸ Notifications ▸ Eleven.") .font(.footnote) .foregroundStyle(.secondary) } } .navigationTitle(store.instance.hostLabel) .navigationBarTitleDisplayMode(.inline) .toolbar { // "Done" becomes "Save" the moment the name is edited — no // separate button to hunt for; Save writes then closes. ToolbarItem(placement: .confirmationAction) { Button { if dirty { save(thenDismiss: true) } else { dismiss() } } label: { if saving { ProgressView() } else { Text(dirty ? "Save" : "Done") } } .disabled(saving || (dirty && name.trimmingCharacters(in: .whitespaces).isEmpty)) } } } .onAppear { name = store.instance.screenName } .task { if let current = await store.loadAway() { savedAway = current away = current awayLoaded = true } else { awayNote = "Couldn't load your status." } } .task { if let current = await store.loadDMPrivacy() { // The server stores "everyone" as "" — normalise for the tags. let v = current == "nobody" ? "nobody" : "everyone" savedDMPrivacy = v dmPrivacy = v dmPrivacyLoaded = true } else { dmPrivacyNote = "Couldn't load this setting." } } .task { // Only surface Membership where paid access levels exist (or the // member already has a subscription) — no dead entry otherwise. let tiers = await store.membershipTiers() hasPaidLevels = tiers.contains { $0.sellable || $0.subscribed } } .task(id: perfDiagnostics) { guard perfDiagnostics else { return } let events = await Task.detached { Perf.recordedEvents() }.value perfSummary = PerfEvent.summary(events) } } private var perfFooter: String { var text = "Keeps a private log on this device of how long things take — starting the app, opening chats, moments it freezes. It never includes what's in your messages, and it never leaves this device unless you share the report yourself." if let perfSummary { text += "\nRecorded so far: \(perfSummary)." } return text } private func preparePerfReport() { Task { perfReportURL = await Task.detached { Perf.assembleExport() }.value } } private func setAway(_ next: Bool) { guard awayLoaded, next != savedAway else { return } // skip programmatic sets (load, revert) awayNote = nil Task { if await store.setAway(next) { savedAway = next } else { away = savedAway // didn't stick — put the switch back, say so inline awayNote = "Couldn't update — try again." } } } private func setDMPrivacy(_ next: String) { guard dmPrivacyLoaded, next != savedDMPrivacy else { return } // skip programmatic sets dmPrivacyNote = nil Task { if await store.setDMPrivacy(next) { savedDMPrivacy = next } else { dmPrivacy = savedDMPrivacy // didn't stick — put it back, say so inline dmPrivacyNote = "Couldn't save — try again." } } } private var dirty: Bool { let trimmed = name.trimmingCharacters(in: .whitespaces) return !trimmed.isEmpty && trimmed != store.instance.screenName } private func save(thenDismiss: Bool = false) { let trimmed = name.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return } saving = true Task { let outcome = await store.setScreenName(trimmed) if outcome != .failed { // Outgoing payloads carry the SavedInstance's name — persist it. var instances = InstancesStore.load() if let i = instances.firstIndex(where: { $0.id == store.instance.id }) { instances[i].screenName = trimmed InstancesStore.save(instances) } } saving = false switch outcome { case .published: note = "Saved — everyone sees “\(trimmed)” now." // Keyless (a fresh join whose key grant is still in flight): the // name is parked and publishes itself the moment the key arrives. case .queued: note = "Saved — “\(trimmed)” will show for everyone shortly." case .failed: note = "Couldn't save. Try again." } if outcome != .failed, thenDismiss { dismiss() } } } }