| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | |
| 4 | // π€ Per-server settings (the cog beside search): display name β encrypted |
| 5 | // under the instance key, exactly the web's publishMyLabel β and the native |
| 6 | // feed's theme (the web's palettes, transcribed in ChatTheme). A proper |
| 7 | // profile panel (photo etc.) comes later. |
| 8 | struct ServerSettingsSheet: View { |
| 9 | let store: ThreadStore |
| 10 | @Environment(\.dismiss) private var dismiss |
| 11 | @AppStorage("nativeTheme") private var themeName = "classic" |
| 12 | @AppStorage("formatAsYouType") private var formatAsYouType = true |
| 13 | // Perf recording consent (app-wide, like the theme): Debug builds default |
| 14 | // on, Release waits for this switch β see Perf.swift. |
| 15 | @AppStorage(Perf.consentKey) private var perfDiagnostics = Perf.defaultEnabled |
| 16 | @State private var perfSummary: String? |
| 17 | @State private var perfReportURL: URL? |
| 18 | |
| 19 | @State private var name = "" |
| 20 | @State private var saving = false |
| 21 | @State private var note: String? |
| 22 | @State private var away = false |
| 23 | @State private var savedAway = false // last server-confirmed value β makes onChange idempotent (initial load, failure revert) |
| 24 | @State private var awayLoaded = false // gate the toggle until /api/me answers |
| 25 | @State private var awayNote: String? |
| 26 | @State private var hasPaidLevels = false // this space sells (or I'm subscribed to) an access level |
| 27 | // DM privacy (#578): who may START a direct message with you. Same |
| 28 | // load/save discipline as Away β gated until /api/me answers, saved on |
| 29 | // change, reverted with an inline note if the save doesn't stick. |
| 30 | @State private var dmPrivacy = "everyone" |
| 31 | @State private var savedDMPrivacy = "everyone" |
| 32 | @State private var dmPrivacyLoaded = false |
| 33 | @State private var dmPrivacyNote: String? |
| 34 | |
| 35 | var body: some View { |
| 36 | NavigationStack { |
| 37 | Form { |
| 38 | Section("Display name") { |
| 39 | TextField("Your name", text: $name) |
| 40 | .onSubmit { if dirty { save() } } |
| 41 | if let note { Text(note).font(.footnote).foregroundStyle(.secondary) } |
| 42 | } |
| 43 | Section { |
| 44 | Toggle("Set yourself as away", isOn: $away) |
| 45 | .disabled(!awayLoaded) |
| 46 | .onChange(of: away) { _, next in setAway(next) } |
| 47 | if let awayNote { |
| 48 | Text(awayNote).font(.footnote).foregroundStyle(.secondary) |
| 49 | } |
| 50 | } header: { |
| 51 | Text("Presence") |
| 52 | } footer: { |
| 53 | Text("Your dot stays yellow until you switch this off β being active won't flip you back to online.") |
| 54 | } |
| 55 | Section { |
| 56 | Picker("Who can message you directly", selection: $dmPrivacy) { |
| 57 | Text("Everyone in this space").tag("everyone") |
| 58 | Text("No one").tag("nobody") |
| 59 | } |
| 60 | .disabled(!dmPrivacyLoaded) |
| 61 | .onChange(of: dmPrivacy) { _, next in setDMPrivacy(next) } |
| 62 | if let dmPrivacyNote { |
| 63 | Text(dmPrivacyNote).font(.footnote).foregroundStyle(.secondary) |
| 64 | } |
| 65 | } header: { |
| 66 | Text("Direct messages") |
| 67 | } footer: { |
| 68 | Text("Existing chats keep working. Admins and helper bots can always reach you.") |
| 69 | } |
| 70 | if hasPaidLevels { |
| 71 | Section { |
| 72 | NavigationLink { |
| 73 | MembershipView(store: store) |
| 74 | } label: { |
| 75 | Label("Membership", systemImage: "creditcard") |
| 76 | } |
| 77 | } footer: { |
| 78 | Text("Subscribe to a paid access level, or change and cancel your plan.") |
| 79 | } |
| 80 | } |
| 81 | Section("Theme") { |
| 82 | Picker("Theme", selection: $themeName) { |
| 83 | ForEach(ChatTheme.presets, id: \.id) { preset in |
| 84 | Text(preset.title).tag(preset.id) |
| 85 | } |
| 86 | } |
| 87 | .pickerStyle(.inline) |
| 88 | .labelsHidden() |
| 89 | } |
| 90 | Section { |
| 91 | Toggle("Convert common Markdown formatting as you type", isOn: $formatAsYouType) |
| 92 | } header: { |
| 93 | Text("Composer") |
| 94 | } footer: { |
| 95 | // verbatim: the hint TEACHES the syntax β the markers must show. |
| 96 | Text(verbatim: "e.g. **bold**, *italic* (or _italic_) and ~strikethrough~") |
| 97 | } |
| 98 | Section { |
| 99 | Toggle("Record performance diagnostics", isOn: $perfDiagnostics) |
| 100 | .onChange(of: perfDiagnostics) { _, next in |
| 101 | Perf.setRecording(next) |
| 102 | if !next { perfReportURL = nil } |
| 103 | } |
| 104 | if perfDiagnostics { |
| 105 | if let url = perfReportURL { |
| 106 | ShareLink(item: url) { |
| 107 | Label("Share performance report", systemImage: "square.and.arrow.up") |
| 108 | } |
| 109 | } else { |
| 110 | // Assembled on demand β the report concatenates the |
| 111 | // ledger + MetricKit payloads, so build it when asked. |
| 112 | Button("Prepare performance report") { preparePerfReport() } |
| 113 | } |
| 114 | } |
| 115 | } header: { |
| 116 | Text("Performance") |
| 117 | } footer: { |
| 118 | Text(perfFooter) |
| 119 | } |
| 120 | Section { |
| 121 | Text("Notifications are managed in Settings βΈ Notifications βΈ Eleven.") |
| 122 | .font(.footnote) |
| 123 | .foregroundStyle(.secondary) |
| 124 | } |
| 125 | } |
| 126 | .navigationTitle(store.instance.hostLabel) |
| 127 | .navigationBarTitleDisplayMode(.inline) |
| 128 | .toolbar { |
| 129 | // "Done" becomes "Save" the moment the name is edited β no |
| 130 | // separate button to hunt for; Save writes then closes. |
| 131 | ToolbarItem(placement: .confirmationAction) { |
| 132 | Button { |
| 133 | if dirty { save(thenDismiss: true) } else { dismiss() } |
| 134 | } label: { |
| 135 | if saving { ProgressView() } else { Text(dirty ? "Save" : "Done") } |
| 136 | } |
| 137 | .disabled(saving || (dirty && name.trimmingCharacters(in: .whitespaces).isEmpty)) |
| 138 | } |
| 139 | } |
| 140 | } |
| 141 | .onAppear { name = store.instance.screenName } |
| 142 | .task { |
| 143 | if let current = await store.loadAway() { |
| 144 | savedAway = current |
| 145 | away = current |
| 146 | awayLoaded = true |
| 147 | } else { |
| 148 | awayNote = "Couldn't load your status." |
| 149 | } |
| 150 | } |
| 151 | .task { |
| 152 | if let current = await store.loadDMPrivacy() { |
| 153 | // The server stores "everyone" as "" β normalise for the tags. |
| 154 | let v = current == "nobody" ? "nobody" : "everyone" |
| 155 | savedDMPrivacy = v |
| 156 | dmPrivacy = v |
| 157 | dmPrivacyLoaded = true |
| 158 | } else { |
| 159 | dmPrivacyNote = "Couldn't load this setting." |
| 160 | } |
| 161 | } |
| 162 | .task { |
| 163 | // Only surface Membership where paid access levels exist (or the |
| 164 | // member already has a subscription) β no dead entry otherwise. |
| 165 | let tiers = await store.membershipTiers() |
| 166 | hasPaidLevels = tiers.contains { $0.sellable || $0.subscribed } |
| 167 | } |
| 168 | .task(id: perfDiagnostics) { |
| 169 | guard perfDiagnostics else { return } |
| 170 | let events = await Task.detached { Perf.recordedEvents() }.value |
| 171 | perfSummary = PerfEvent.summary(events) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | private var perfFooter: String { |
| 176 | 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." |
| 177 | if let perfSummary { text += "\nRecorded so far: \(perfSummary)." } |
| 178 | return text |
| 179 | } |
| 180 | |
| 181 | private func preparePerfReport() { |
| 182 | Task { |
| 183 | perfReportURL = await Task.detached { Perf.assembleExport() }.value |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | private func setAway(_ next: Bool) { |
| 188 | guard awayLoaded, next != savedAway else { return } // skip programmatic sets (load, revert) |
| 189 | awayNote = nil |
| 190 | Task { |
| 191 | if await store.setAway(next) { |
| 192 | savedAway = next |
| 193 | } else { |
| 194 | away = savedAway // didn't stick β put the switch back, say so inline |
| 195 | awayNote = "Couldn't update β try again." |
| 196 | } |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | private func setDMPrivacy(_ next: String) { |
| 201 | guard dmPrivacyLoaded, next != savedDMPrivacy else { return } // skip programmatic sets |
| 202 | dmPrivacyNote = nil |
| 203 | Task { |
| 204 | if await store.setDMPrivacy(next) { |
| 205 | savedDMPrivacy = next |
| 206 | } else { |
| 207 | dmPrivacy = savedDMPrivacy // didn't stick β put it back, say so inline |
| 208 | dmPrivacyNote = "Couldn't save β try again." |
| 209 | } |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | private var dirty: Bool { |
| 214 | let trimmed = name.trimmingCharacters(in: .whitespaces) |
| 215 | return !trimmed.isEmpty && trimmed != store.instance.screenName |
| 216 | } |
| 217 | |
| 218 | private func save(thenDismiss: Bool = false) { |
| 219 | let trimmed = name.trimmingCharacters(in: .whitespaces) |
| 220 | guard !trimmed.isEmpty else { return } |
| 221 | saving = true |
| 222 | Task { |
| 223 | let outcome = await store.setScreenName(trimmed) |
| 224 | if outcome != .failed { |
| 225 | // Outgoing payloads carry the SavedInstance's name β persist it. |
| 226 | var instances = InstancesStore.load() |
| 227 | if let i = instances.firstIndex(where: { $0.id == store.instance.id }) { |
| 228 | instances[i].screenName = trimmed |
| 229 | InstancesStore.save(instances) |
| 230 | } |
| 231 | } |
| 232 | saving = false |
| 233 | switch outcome { |
| 234 | case .published: note = "Saved β everyone sees β\(trimmed)β now." |
| 235 | // Keyless (a fresh join whose key grant is still in flight): the |
| 236 | // name is parked and publishes itself the moment the key arrives. |
| 237 | case .queued: note = "Saved β β\(trimmed)β will show for everyone shortly." |
| 238 | case .failed: note = "Couldn't save. Try again." |
| 239 | } |
| 240 | if outcome != .failed, thenDismiss { dismiss() } |
| 241 | } |
| 242 | } |
| 243 | } |
| 244 | |