| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | |
| 4 | // π€ The Mac's Settings window (β,): the native feed's theme (the web's |
| 5 | // palettes) and your per-server display name β encrypted under the instance |
| 6 | // key, exactly the web's publishMyLabel. Profile photo and more later. |
| 7 | struct MacSettingsView: View { |
| 8 | @EnvironmentObject var app: MacAppState |
| 9 | @AppStorage("nativeTheme") private var themeName = "classic" |
| 10 | @AppStorage("formatAsYouType") private var formatAsYouType = true |
| 11 | @AppStorage(ChatTextSize.key) private var textSize = ChatTextSize.defaultSize |
| 12 | |
| 13 | @State private var name = "" |
| 14 | @State private var saving = false |
| 15 | @State private var note: String? |
| 16 | @State private var selectedInstanceID: String? |
| 17 | @FocusState private var nameFocused: Bool |
| 18 | @State private var away = false |
| 19 | @State private var savedAway = false // last server-confirmed value β makes onChange idempotent (load, revert) |
| 20 | @State private var awayLoaded = false // gate the toggle until /api/me answers |
| 21 | @State private var awayNote: String? |
| 22 | // DM privacy (#578): who may START a direct message with you. Same |
| 23 | // load/save discipline as Away β gated until /api/me answers, saved on |
| 24 | // change, reverted with an inline note if the save doesn't stick. |
| 25 | @State private var dmPrivacy = "everyone" |
| 26 | @State private var savedDMPrivacy = "everyone" |
| 27 | @State private var dmPrivacyLoaded = false |
| 28 | @State private var dmPrivacyNote: String? |
| 29 | @State private var hasPaidLevels = false // this space sells (or I'm subscribed to) an access level |
| 30 | @State private var showMembership = false |
| 31 | |
| 32 | private var instance: SavedInstance? { |
| 33 | app.instances.first { $0.id == selectedInstanceID } ?? app.instances.first |
| 34 | } |
| 35 | |
| 36 | private var api: LChatAPI? { |
| 37 | guard let inst = instance, let base = inst.baseURL else { return nil } |
| 38 | return LChatAPI(baseURL: base, token: inst.deviceToken) |
| 39 | } |
| 40 | |
| 41 | @StateObject private var homeSignIn = MacHomeSignIn() |
| 42 | |
| 43 | var body: some View { |
| 44 | Form { |
| 45 | // Profile first β who you are, then your account (the sign-in |
| 46 | // state + Sign Out rows), one combined section (Paul, 2026-07-15). |
| 47 | Section("Profile") { |
| 48 | Picker("Space", selection: $selectedInstanceID) { |
| 49 | ForEach(app.instances) { inst in |
| 50 | Text(inst.displayName).tag(Optional(inst.id)) |
| 51 | } |
| 52 | } |
| 53 | // No Save button: Return or leaving the field commits an edit |
| 54 | // (self-gated on actual changes). |
| 55 | TextField("Display name", text: $name) |
| 56 | .focused($nameFocused) |
| 57 | .onSubmit(save) |
| 58 | .onChange(of: nameFocused) { _, focused in |
| 59 | if !focused { save() } |
| 60 | } |
| 61 | if saving || note != nil { |
| 62 | HStack { |
| 63 | if saving { ProgressView().controlSize(.small) } |
| 64 | if let note { |
| 65 | Text(note).font(.footnote).foregroundStyle(.secondary) |
| 66 | } |
| 67 | } |
| 68 | } |
| 69 | // Presence: pin your dot to away (activity won't flip it back). |
| 70 | // Applies immediately, per server, synced across your devices. |
| 71 | Toggle("Set yourself as away", isOn: $away) |
| 72 | .toggleStyle(.checkbox) |
| 73 | .disabled(!awayLoaded) |
| 74 | .onChange(of: away) { _, next in setAway(next) } |
| 75 | if let awayNote { |
| 76 | Text(awayNote).font(.footnote).foregroundStyle(.secondary) |
| 77 | } |
| 78 | // DM privacy (#578): your DM door, per server β saved on change. |
| 79 | Picker("Who can message you directly", selection: $dmPrivacy) { |
| 80 | Text("Everyone in this space").tag("everyone") |
| 81 | Text("No one").tag("nobody") |
| 82 | } |
| 83 | .disabled(!dmPrivacyLoaded) |
| 84 | .onChange(of: dmPrivacy) { _, next in setDMPrivacy(next) } |
| 85 | Text("Existing chats keep working. Admins and helper bots can always reach you.") |
| 86 | .font(.footnote).foregroundStyle(.secondary) |
| 87 | if let dmPrivacyNote { |
| 88 | Text(dmPrivacyNote).font(.footnote).foregroundStyle(.secondary) |
| 89 | } |
| 90 | HomeSignInSection(model: homeSignIn) |
| 91 | } |
| 92 | Section("Theme") { |
| 93 | Picker("Chat theme", selection: $themeName) { |
| 94 | ForEach(ChatTheme.presets, id: \.id) { preset in |
| 95 | Text(preset.title).tag(preset.id) |
| 96 | } |
| 97 | } |
| 98 | .pickerStyle(.radioGroup) |
| 99 | } |
| 100 | Section { |
| 101 | // The iMessage AββA slider: seven stops over the same ladder |
| 102 | // as the web slider and the View menu's β+/ββ. |
| 103 | HStack(spacing: 10) { |
| 104 | Text("A").font(.system(size: 11)).foregroundStyle(.secondary) |
| 105 | Slider(value: textSizeIndex, |
| 106 | in: 0...Double(ChatTextSize.stops.count - 1), step: 1) |
| 107 | Text("A").font(.system(size: 19)).foregroundStyle(.secondary) |
| 108 | } |
| 109 | .accessibilityLabel("Text size") |
| 110 | } header: { |
| 111 | Text("Text size") |
| 112 | } |
| 113 | Section { |
| 114 | // Checkboxes, not switches: the grouped form style renders |
| 115 | // Toggle as an iOS-style switch, but a Mac Settings window |
| 116 | // conventionally uses checkboxes β and the checkbox style |
| 117 | // makes the whole label clickable (Paul, 2026-07-15). |
| 118 | Toggle("Convert common Markdown formatting as you type", isOn: $formatAsYouType) |
| 119 | .toggleStyle(.checkbox) |
| 120 | } header: { |
| 121 | Text("Composer") |
| 122 | } footer: { |
| 123 | // verbatim: the hint TEACHES the syntax β the markers must show. |
| 124 | Text(verbatim: "e.g. **bold**, *italic* (or _italic_) and ~strikethrough~") |
| 125 | } |
| 126 | if hasPaidLevels { |
| 127 | Section("Membership") { |
| 128 | Button("Manage membershipβ¦") { showMembership = true } |
| 129 | Text("Subscribe to a paid access level, or change and cancel your plan.") |
| 130 | .font(.footnote).foregroundStyle(.secondary) |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | .formStyle(.grouped) |
| 135 | .sheet(isPresented: $showMembership) { |
| 136 | if let inst = instance { |
| 137 | NavigationStack { MembershipView(store: ThreadStore(instance: inst)) } |
| 138 | .frame(minWidth: 460, minHeight: 460) |
| 139 | .toolbar { |
| 140 | ToolbarItem(placement: .confirmationAction) { |
| 141 | Button("Done") { showMembership = false } |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | } |
| 146 | // No frame here β the Settings window's single fixed size lives on the |
| 147 | // TabView (LChatMacApp), so every tab shares it. |
| 148 | .onAppear { |
| 149 | selectedInstanceID = instance?.id |
| 150 | name = instance?.screenName ?? "" |
| 151 | loadAway() |
| 152 | loadDMPrivacy() |
| 153 | loadPaidLevels() |
| 154 | } |
| 155 | .onChange(of: selectedInstanceID) { _, _ in |
| 156 | name = instance?.screenName ?? "" |
| 157 | note = nil |
| 158 | loadAway() |
| 159 | loadDMPrivacy() |
| 160 | loadPaidLevels() |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | // Only surface Membership where paid access levels exist (or I'm already |
| 165 | // subscribed) β no dead entry on spaces that don't sell access. |
| 166 | private func loadPaidLevels() { |
| 167 | hasPaidLevels = false |
| 168 | Task { |
| 169 | let tiers = await api?.tiers() ?? [] |
| 170 | hasPaidLevels = tiers.contains { $0.sellable || $0.subscribed } |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | private func loadAway() { |
| 175 | awayLoaded = false |
| 176 | awayNote = nil |
| 177 | Task { |
| 178 | if let current = await api?.away() { |
| 179 | savedAway = current |
| 180 | away = current |
| 181 | awayLoaded = true |
| 182 | } else { |
| 183 | awayNote = "Couldn't load your status." |
| 184 | } |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | private func setAway(_ next: Bool) { |
| 189 | guard awayLoaded, next != savedAway else { return } // skip programmatic sets (load, revert) |
| 190 | awayNote = nil |
| 191 | Task { |
| 192 | if await api?.setAway(next) == true { |
| 193 | savedAway = next |
| 194 | } else { |
| 195 | away = savedAway // didn't stick β put the switch back, say so inline |
| 196 | awayNote = "Couldn't update β try again." |
| 197 | } |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | private func loadDMPrivacy() { |
| 202 | dmPrivacyLoaded = false |
| 203 | dmPrivacyNote = nil |
| 204 | Task { |
| 205 | if let current = await api?.dmPrivacy() { |
| 206 | // The server stores "everyone" as "" β normalise for the tags. |
| 207 | let v = current == "nobody" ? "nobody" : "everyone" |
| 208 | savedDMPrivacy = v |
| 209 | dmPrivacy = v |
| 210 | dmPrivacyLoaded = true |
| 211 | } else { |
| 212 | dmPrivacyNote = "Couldn't load this setting." |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | private func setDMPrivacy(_ next: String) { |
| 218 | guard dmPrivacyLoaded, next != savedDMPrivacy else { return } // skip programmatic sets |
| 219 | dmPrivacyNote = nil |
| 220 | Task { |
| 221 | if await api?.setDMPrivacy(next) == true { |
| 222 | savedDMPrivacy = next |
| 223 | } else { |
| 224 | dmPrivacy = savedDMPrivacy // didn't stick β put it back, say so inline |
| 225 | dmPrivacyNote = "Couldn't save β try again." |
| 226 | } |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | // The slider moves by stop index; the pref stores the point size itself |
| 231 | // (snap() forgives any stored off-ladder value). |
| 232 | private var textSizeIndex: Binding<Double> { |
| 233 | Binding( |
| 234 | get: { Double(ChatTextSize.stops.firstIndex(of: ChatTextSize.snap(textSize)) ?? 5) }, |
| 235 | set: { textSize = ChatTextSize.stops[Int($0.rounded())] } |
| 236 | ) |
| 237 | } |
| 238 | |
| 239 | private func save() { |
| 240 | guard let inst = instance else { return } |
| 241 | let trimmed = name.trimmingCharacters(in: .whitespaces) |
| 242 | guard !trimmed.isEmpty, trimmed != inst.screenName, !saving else { return } |
| 243 | saving = true |
| 244 | note = nil |
| 245 | Task { |
| 246 | let store = ThreadStore(instance: inst) |
| 247 | await store.refresh() |
| 248 | let outcome = await store.setScreenName(trimmed) |
| 249 | if outcome != .failed { |
| 250 | app.noteIdentity(origin: inst.origin, token: inst.deviceToken, |
| 251 | name: trimmed, userPub: inst.userPub, userPriv: inst.userPriv) |
| 252 | } |
| 253 | saving = false |
| 254 | switch outcome { |
| 255 | case .published: note = "Saved β everyone sees β\(trimmed)β now." |
| 256 | // Keyless (a fresh join whose key grant is still in flight): the |
| 257 | // name is parked and publishes itself the moment the key arrives. |
| 258 | case .queued: note = "Saved β β\(trimmed)β will show for everyone shortly." |
| 259 | case .failed: note = "Couldn't save. Try again." |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | |