eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

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