eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import 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.
7struct 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