import LChatCore import SwiftUI // πŸ€– The Mac's Settings window (⌘,): the native feed's theme (the web's // palettes) and your per-server display name β€” encrypted under the instance // key, exactly the web's publishMyLabel. Profile photo and more later. struct MacSettingsView: View { @EnvironmentObject var app: MacAppState @AppStorage("nativeTheme") private var themeName = "classic" @AppStorage("formatAsYouType") private var formatAsYouType = true @AppStorage(ChatTextSize.key) private var textSize = ChatTextSize.defaultSize @State private var name = "" @State private var saving = false @State private var note: String? @State private var selectedInstanceID: String? @FocusState private var nameFocused: Bool @State private var away = false @State private var savedAway = false // last server-confirmed value β€” makes onChange idempotent (load, revert) @State private var awayLoaded = false // gate the toggle until /api/me answers @State private var awayNote: String? // 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? @State private var hasPaidLevels = false // this space sells (or I'm subscribed to) an access level @State private var showMembership = false private var instance: SavedInstance? { app.instances.first { $0.id == selectedInstanceID } ?? app.instances.first } private var api: LChatAPI? { guard let inst = instance, let base = inst.baseURL else { return nil } return LChatAPI(baseURL: base, token: inst.deviceToken) } @StateObject private var homeSignIn = MacHomeSignIn() var body: some View { Form { // Profile first β€” who you are, then your account (the sign-in // state + Sign Out rows), one combined section (Paul, 2026-07-15). Section("Profile") { Picker("Space", selection: $selectedInstanceID) { ForEach(app.instances) { inst in Text(inst.displayName).tag(Optional(inst.id)) } } // No Save button: Return or leaving the field commits an edit // (self-gated on actual changes). TextField("Display name", text: $name) .focused($nameFocused) .onSubmit(save) .onChange(of: nameFocused) { _, focused in if !focused { save() } } if saving || note != nil { HStack { if saving { ProgressView().controlSize(.small) } if let note { Text(note).font(.footnote).foregroundStyle(.secondary) } } } // Presence: pin your dot to away (activity won't flip it back). // Applies immediately, per server, synced across your devices. Toggle("Set yourself as away", isOn: $away) .toggleStyle(.checkbox) .disabled(!awayLoaded) .onChange(of: away) { _, next in setAway(next) } if let awayNote { Text(awayNote).font(.footnote).foregroundStyle(.secondary) } // DM privacy (#578): your DM door, per server β€” saved on change. 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) } Text("Existing chats keep working. Admins and helper bots can always reach you.") .font(.footnote).foregroundStyle(.secondary) if let dmPrivacyNote { Text(dmPrivacyNote).font(.footnote).foregroundStyle(.secondary) } HomeSignInSection(model: homeSignIn) } Section("Theme") { Picker("Chat theme", selection: $themeName) { ForEach(ChatTheme.presets, id: \.id) { preset in Text(preset.title).tag(preset.id) } } .pickerStyle(.radioGroup) } Section { // The iMessage Aβ€”β€”A slider: seven stops over the same ladder // as the web slider and the View menu's ⌘+/βŒ˜βˆ’. HStack(spacing: 10) { Text("A").font(.system(size: 11)).foregroundStyle(.secondary) Slider(value: textSizeIndex, in: 0...Double(ChatTextSize.stops.count - 1), step: 1) Text("A").font(.system(size: 19)).foregroundStyle(.secondary) } .accessibilityLabel("Text size") } header: { Text("Text size") } Section { // Checkboxes, not switches: the grouped form style renders // Toggle as an iOS-style switch, but a Mac Settings window // conventionally uses checkboxes β€” and the checkbox style // makes the whole label clickable (Paul, 2026-07-15). Toggle("Convert common Markdown formatting as you type", isOn: $formatAsYouType) .toggleStyle(.checkbox) } header: { Text("Composer") } footer: { // verbatim: the hint TEACHES the syntax β€” the markers must show. Text(verbatim: "e.g. **bold**, *italic* (or _italic_) and ~strikethrough~") } if hasPaidLevels { Section("Membership") { Button("Manage membership…") { showMembership = true } Text("Subscribe to a paid access level, or change and cancel your plan.") .font(.footnote).foregroundStyle(.secondary) } } } .formStyle(.grouped) .sheet(isPresented: $showMembership) { if let inst = instance { NavigationStack { MembershipView(store: ThreadStore(instance: inst)) } .frame(minWidth: 460, minHeight: 460) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showMembership = false } } } } } // No frame here β€” the Settings window's single fixed size lives on the // TabView (LChatMacApp), so every tab shares it. .onAppear { selectedInstanceID = instance?.id name = instance?.screenName ?? "" loadAway() loadDMPrivacy() loadPaidLevels() } .onChange(of: selectedInstanceID) { _, _ in name = instance?.screenName ?? "" note = nil loadAway() loadDMPrivacy() loadPaidLevels() } } // Only surface Membership where paid access levels exist (or I'm already // subscribed) β€” no dead entry on spaces that don't sell access. private func loadPaidLevels() { hasPaidLevels = false Task { let tiers = await api?.tiers() ?? [] hasPaidLevels = tiers.contains { $0.sellable || $0.subscribed } } } private func loadAway() { awayLoaded = false awayNote = nil Task { if let current = await api?.away() { savedAway = current away = current awayLoaded = true } else { awayNote = "Couldn't load your status." } } } private func setAway(_ next: Bool) { guard awayLoaded, next != savedAway else { return } // skip programmatic sets (load, revert) awayNote = nil Task { if await api?.setAway(next) == true { savedAway = next } else { away = savedAway // didn't stick β€” put the switch back, say so inline awayNote = "Couldn't update β€” try again." } } } private func loadDMPrivacy() { dmPrivacyLoaded = false dmPrivacyNote = nil Task { if let current = await api?.dmPrivacy() { // 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." } } } private func setDMPrivacy(_ next: String) { guard dmPrivacyLoaded, next != savedDMPrivacy else { return } // skip programmatic sets dmPrivacyNote = nil Task { if await api?.setDMPrivacy(next) == true { savedDMPrivacy = next } else { dmPrivacy = savedDMPrivacy // didn't stick β€” put it back, say so inline dmPrivacyNote = "Couldn't save β€” try again." } } } // The slider moves by stop index; the pref stores the point size itself // (snap() forgives any stored off-ladder value). private var textSizeIndex: Binding { Binding( get: { Double(ChatTextSize.stops.firstIndex(of: ChatTextSize.snap(textSize)) ?? 5) }, set: { textSize = ChatTextSize.stops[Int($0.rounded())] } ) } private func save() { guard let inst = instance else { return } let trimmed = name.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, trimmed != inst.screenName, !saving else { return } saving = true note = nil Task { let store = ThreadStore(instance: inst) await store.refresh() let outcome = await store.setScreenName(trimmed) if outcome != .failed { app.noteIdentity(origin: inst.origin, token: inst.deviceToken, name: trimmed, userPub: inst.userPub, userPriv: inst.userPriv) } 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." } } } }