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// 🤖 "Media, Links & Docs" on the Mac — the per-thread inventory of shared
5// photos/videos, links and files (spec: docs/superpowers/specs/
6// 2026-08-05-media-links-docs-design.md § Native; iOS twin
7// MediaLinksDocsSheet.swift, web twin mediaview.js). Same shared extraction
8// (ThreadMedia over MessageStore) and the same auto-run per-thread history
9// walk. It lives in its own RESIZABLE window (MediaWindows) rather than a
10// sheet — see that file for why. Viewing honours the Mac's deliberate
11// anti-QuickLook stance (Lightbox.swift: decrypted bytes never touch disk
12// uninvited): photos open an in-window viewer with ‹ › / arrow-key
13// collection navigation, all in memory; a video tile shows its poster there
14// (playing rides "Show in chat", where the feed's in-memory player lives); a
15// doc tap saves to ~/Downloads — the feed's own file behaviour.
16struct MediaLinksDocsSheet: View {
17 let instance: SavedInstance
18 let thread: ChatThread
19 @ObservedObject var store: ThreadStore
20 let model: ChatFeedModel
21 let onJump: (Int64) -> Void
22
23 @Environment(\.openURL) private var openURL
24 @State private var tabs = ThreadMedia.Tabs()
25 @State private var loaded = false
26 @State private var tab = "media"
27 @StateObject private var backfill = HistoryBackfill()
28 // What's IN each photo (Vision, on device) — search matches "dog" or the
29 // text on a whiteboard. Indexes the thread's stills on open, resumably.
30 @StateObject private var imageIndex = ImageSearchIndex()
31 // The in-sheet photo viewer's position in the media tab (nil = closed).
32 @State private var viewerIndex: Int?
33 @State private var query = ""
34 // Unfurl titles the Links tab has fetched so far, so search matches a
35 // link by title too (a title only exists once its row was on screen —
36 // search never triggers fetches of its own).
37 @State private var linkTitles: [URL: String] = [:]
38 @State private var savingID: String? // one save at a time (blob id)
39
40 private var hiddenUids: Set<String> { store.mutedPeople.union(store.blockedPeople) }
41
42 // Moderation applies always; the query narrows the open tab (and the
43 // viewer's collection with it, so ‹ › walk the search results).
44 private func visible(_ entries: [ThreadMedia.Entry]) -> [ThreadMedia.Entry] {
45 // `_ = imageIndex.tick` isn't needed: the @StateObject's @Published
46 // tick re-renders the body, which re-runs this filter.
47 ThreadMedia.filtered(ThreadMedia.filtered(entries, hiddenUids: hiddenUids),
48 query: query, titleFor: { linkTitles[$0] },
49 imageTextFor: { imageIndex.textFor($0) })
50 }
51
52 private var mediaEntries: [ThreadMedia.Entry] { visible(tabs.media) }
53
54 var body: some View {
55 VStack(spacing: 0) {
56 Picker("Kind", selection: $tab) {
57 Text(segLabel("Media", mediaEntries.count)).tag("media")
58 Text(segLabel("Links", ThreadMedia.filtered(tabs.links, hiddenUids: hiddenUids).count)).tag("links")
59 Text(segLabel("Docs", ThreadMedia.filtered(tabs.docs, hiddenUids: hiddenUids).count)).tag("docs")
60 }
61 .pickerStyle(.segmented)
62 .labelsHidden()
63 .padding(.horizontal, 12)
64 .padding(.top, 10)
65 // Search within the open tab — sender, URL/title, filename
66 // (ThreadMedia.filtered(query:)); the sidebar search's shape.
67 HStack(spacing: 6) {
68 Image(systemName: "magnifyingglass")
69 .font(.system(size: 11))
70 .foregroundStyle(.tertiary)
71 TextField("Search", text: $query)
72 .textFieldStyle(.plain)
73 .font(.system(size: 13))
74 .accessibilityLabel("Search media, links and docs")
75 if !query.isEmpty {
76 Button {
77 query = ""
78 } label: {
79 Image(systemName: "xmark.circle.fill")
80 .foregroundStyle(.tertiary)
81 }
82 .buttonStyle(.plain)
83 .accessibilityLabel("Clear search")
84 }
85 }
86 .padding(.horizontal, 8)
87 .padding(.vertical, 5)
88 .background(RoundedRectangle(cornerRadius: 7, style: .continuous)
89 .fill(.primary.opacity(0.06)))
90 .padding(.horizontal, 12)
91 .padding(.top, 8)
92 if backfill.running {
93 progressRow(backfill.progress ?? "Loading earlier messages…") { backfill.cancel() }
94 }
95 if imageIndex.running {
96 progressRow(imageIndex.progress ?? "Indexing photos…") { imageIndex.cancel() }
97 }
98 content
99 .padding(.top, 6)
100 }
101 // The window (MediaWindows) owns the size; the content just fills it,
102 // with a floor so the grid never collapses.
103 .frame(minWidth: 480, minHeight: 400)
104 .overlay {
105 if let i = viewerIndex, mediaEntries.indices.contains(i) {
106 MediaViewerOverlay(entries: mediaEntries, index: i,
107 source: { viewerSource($0) },
108 onStep: { viewerIndex = $0 },
109 onSave: { if let att = mediaEntries[i].att { saveToDownloads(att) } },
110 onClose: { viewerIndex = nil })
111 }
112 }
113 .task(id: backfill.pageTick) { await reload() }
114 .onAppear { startWalkIfIncomplete() }
115 .onDisappear {
116 backfill.cancel()
117 imageIndex.cancel()
118 }
119 }
120
121 private func segLabel(_ label: String, _ n: Int) -> String {
122 n > 0 ? "\(label) · \(n.formatted())" : label
123 }
124
125 // Inline, occupying layout (never a floating toast): a walk's narration
126 // plus its Stop — the history walk and the photo indexer share the shape.
127 private func progressRow(_ text: String, stop: @escaping () -> Void) -> some View {
128 HStack(spacing: 8) {
129 // The spinner is the "still working" signal: a count that sits on
130 // "0 of 5" while a slow photo decrypts otherwise reads as stuck.
131 ProgressView()
132 .controlSize(.small)
133 .scaleEffect(0.7)
134 .frame(width: 12, height: 12)
135 Text(text)
136 .font(.system(size: 12))
137 .foregroundStyle(.secondary)
138 Spacer(minLength: 0)
139 Button("Stop", action: stop)
140 .buttonStyle(.plain)
141 .font(.system(size: 12, weight: .semibold))
142 }
143 .padding(.horizontal, 12)
144 .padding(.top, 6)
145 .accessibilityElement(children: .combine)
146 }
147
148 // Save a photo/video/file to ~/Downloads and reveal it — the Mac feed's
149 // own file behaviour (a deliberate save, never a silent temp file).
150 private func saveToDownloads(_ att: Attachment) {
151 guard savingID == nil else { return }
152 savingID = att.id
153 Task {
154 defer { savingID = nil }
155 guard let data = await model.blobData(att) else { return }
156 let name = AttachmentDownload.safeFilename(name: att.name, mime: att.type)
157 let dir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
158 ?? FileManager.default.temporaryDirectory
159 let dest = uniqueDestination(in: dir, filename: name)
160 guard (try? data.write(to: dest, options: .atomic)) != nil else { return }
161 NSWorkspace.shared.activateFileViewerSelecting([dest])
162 }
163 }
164
165 // Never overwrite: "name 2.ext", the Finder rule (NativeFeedView's twin).
166 private func uniqueDestination(in dir: URL, filename: String) -> URL {
167 let fm = FileManager.default
168 let name = filename.isEmpty ? "download" : filename
169 var candidate = dir.appendingPathComponent(name)
170 guard fm.fileExists(atPath: candidate.path) else { return candidate }
171 let ext = (name as NSString).pathExtension
172 let base = (name as NSString).deletingPathExtension
173 var n = 2
174 repeat {
175 let next = ext.isEmpty ? "\(base) \(n)" : "\(base) \(n).\(ext)"
176 candidate = dir.appendingPathComponent(next)
177 n += 1
178 } while fm.fileExists(atPath: candidate.path)
179 return candidate
180 }
181
182 @ViewBuilder private var content: some View {
183 let all = tab == "media" ? tabs.media : tab == "links" ? tabs.links : tabs.docs
184 let entries = visible(all)
185 if entries.isEmpty {
186 Spacer()
187 if !loaded {
188 ProgressView()
189 } else if !ThreadMedia.filtered(all, hiddenUids: hiddenUids).isEmpty {
190 Text("Nothing matches.")
191 .foregroundStyle(.secondary)
192 } else {
193 Text(tab == "media" ? "No photos or videos shared yet."
194 : tab == "links" ? "No links shared yet." : "No files shared yet.")
195 .foregroundStyle(.secondary)
196 }
197 Spacer()
198 } else {
199 ScrollView {
200 LazyVStack(alignment: .leading, spacing: 6) {
201 ForEach(ThreadMedia.bucketByMonth(entries)) { bucket in
202 Text(bucket.label)
203 .font(.system(size: 11))
204 .foregroundStyle(.secondary)
205 .padding(.horizontal, 14)
206 .padding(.top, 8)
207 if tab == "media" {
208 mediaGrid(bucket.items, all: entries)
209 } else if tab == "links" {
210 ForEach(bucket.items) { e in linkRow(e) }
211 } else {
212 ForEach(bucket.items) { e in docRow(e) }
213 }
214 }
215 }
216 .padding(.bottom, 12)
217 }
218 }
219 }
220
221 private func mediaGrid(_ items: [ThreadMedia.Entry], all: [ThreadMedia.Entry]) -> some View {
222 LazyVGrid(columns: [GridItem(.adaptive(minimum: 118), spacing: 2)], spacing: 2) {
223 ForEach(items) { e in
224 if let att = e.att {
225 MediaGridTile(att: att, source: tileSource(att))
226 .onTapGesture {
227 viewerIndex = all.firstIndex { $0.id == e.id } ?? 0
228 }
229 .accessibilityLabel("\(att.isVideo ? "Video" : "Photo") from \(e.name.isEmpty ? "someone" : e.name), \(relTime(e.ts / 1000))")
230 .accessibilityAddTraits(.isButton)
231 .contextMenu {
232 Button("Save to Downloads…") { saveToDownloads(att) }
233 Button("Show in Chat") { onJump(e.sid) }
234 }
235 }
236 }
237 }
238 .padding(.horizontal, 2)
239 }
240
241 // Video tiles (and the viewer) show the poster frame — whole videos never
242 // decrypt for a thumbnail.
243 private func tileSource(_ att: Attachment) -> AvatarSource? {
244 guard var source = model.blobSource(att) else { return nil }
245 if att.isVideo, let poster = att.poster { source.blobID = poster }
246 return source
247 }
248
249 private func viewerSource(_ e: ThreadMedia.Entry) -> AvatarSource? {
250 e.att.flatMap { tileSource($0) }
251 }
252
253 private func linkRow(_ e: ThreadMedia.Entry) -> some View {
254 LinkUnfurlRow(e: e, unfurl: { await model.unfurl($0) },
255 peek: { model.cachedUnfurl($0) },
256 open: { openURL($0) },
257 onTitle: { url, title in linkTitles[url] = title })
258 .contextMenu {
259 if let url = e.url {
260 Button("Open in Browser") { openURL(url) }
261 Button("Copy Link") {
262 NSPasteboard.general.clearContents()
263 NSPasteboard.general.setString(url.absoluteString, forType: .string)
264 }
265 }
266 Button("Show in Chat") { onJump(e.sid) }
267 }
268 }
269
270 private func docRow(_ e: ThreadMedia.Entry) -> some View {
271 Button { if let att = e.att { saveToDownloads(att) } } label: {
272 HStack(spacing: 10) {
273 Text(fileIcon(e.att?.name))
274 .font(.system(size: 18))
275 VStack(alignment: .leading, spacing: 1) {
276 Text(e.att?.name ?? "file")
277 .font(.system(size: 13, weight: .medium))
278 .lineLimit(1)
279 Text(docSubline(e))
280 .font(.system(size: 11))
281 .foregroundStyle(.secondary)
282 .lineLimit(1)
283 }
284 Spacer(minLength: 0)
285 // The save is a fetch + decrypt; show it's running rather
286 // than leaving the row inert until Finder pops.
287 if savingID == e.att?.id {
288 ProgressView()
289 .controlSize(.small)
290 .scaleEffect(0.7)
291 .frame(width: 12, height: 12)
292 }
293 }
294 .padding(.horizontal, 14)
295 .padding(.vertical, 5)
296 .contentShape(Rectangle())
297 }
298 .buttonStyle(.plain)
299 .modifier(RowHover())
300 .contextMenu {
301 if let att = e.att {
302 Button("Save to Downloads…") { saveToDownloads(att) }
303 }
304 Button("Show in Chat") { onJump(e.sid) }
305 }
306 }
307
308 private func docSubline(_ e: ThreadMedia.Entry) -> String {
309 var parts: [String] = []
310 if let size = e.att?.size, size > 0 { parts.append(formatBytes(size)) }
311 parts.append("\(e.name.isEmpty ? "Someone" : e.name) · \(relTime(e.ts / 1000))")
312 return parts.joined(separator: " · ")
313 }
314
315 // First load sweeps the whole store; each backfill page then folds ONLY
316 // the newly-filed (strictly older) rows — the web overlay's incremental
317 // onPage fold, not a full re-extract per tick.
318 @State private var oldestSID: Int64 = 0
319 private func reload() async {
320 if oldestSID == 0 {
321 let rows = await MessageStore.shared.allMessages(instance: instance.id,
322 thread: thread.id)
323 tabs = ThreadMedia.extractAll(rows)
324 oldestSID = rows.last?.id ?? 0 // newest-first, so last = oldest
325 } else {
326 let older = await MessageStore.shared.page(instance: instance.id,
327 thread: thread.id,
328 before: oldestSID, limit: 50_000)
329 guard let first = older.first else { loaded = true; return }
330 // Strictly older rows: appending keeps each tab's newest-first order.
331 let ex = ThreadMedia.extractAll(older)
332 tabs.media.append(contentsOf: ex.media)
333 tabs.links.append(contentsOf: ex.links)
334 tabs.docs.append(contentsOf: ex.docs)
335 oldestSID = first.id // page() returns ascending, so first = oldest
336 }
337 loaded = true
338 // Every photo the thread now holds gets looked at once (on device).
339 imageIndex.ensure(instance: instance.id, entries: tabs.media,
340 load: { await model.blobData($0) })
341 }
342
343 private func startWalkIfIncomplete() {
344 Task {
345 let mark = await MessageStore.shared.backfillMark(instance: instance.id,
346 thread: thread.id)
347 guard !mark.complete else { return }
348 backfill.start(instance: instance, threadID: thread.id,
349 threadKind: thread.kind, threadKeyB64: thread.threadKeyB64)
350 }
351 }
352}
353
354// One grid tile: BlurHash ghost immediately, decrypted thumbnail when loaded.
355// LazyVGrid materialises only visible tiles, so decryption stays proportional
356// to what's viewed.
357private struct MediaGridTile: View {
358 let att: Attachment
359 let source: AvatarSource?
360
361 @State private var image: NSImage?
362 @State private var blur: NSImage?
363
364 var body: some View {
365 GeometryReader { geo in
366 ZStack(alignment: .bottomTrailing) {
367 Rectangle().fill(Color(nsColor: .quaternarySystemFill))
368 if let shown = image ?? blur {
369 Image(nsImage: shown)
370 .resizable()
371 .aspectRatio(contentMode: .fill)
372 .frame(width: geo.size.width, height: geo.size.height)
373 .clipped()
374 }
375 if att.isVideo, let dur = att.dur, dur > 0 {
376 Text(formatDuration(dur))
377 .font(.system(size: 10, weight: .semibold))
378 .foregroundStyle(.white)
379 .padding(.horizontal, 5)
380 .padding(.vertical, 1)
381 .background(.black.opacity(0.65), in: Capsule())
382 .padding(4)
383 }
384 }
385 }
386 .aspectRatio(1, contentMode: .fit)
387 .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
388 .task(id: att.id) {
389 if image == nil, let bh = att.bh, blur == nil,
390 let cg = await Task.detached(operation: { Blurhash.decode(bh) }).value {
391 blur = NSImage(cgImage: cg, size: .zero)
392 }
393 guard image == nil, let source else { return }
394 image = await BlobImageLoader.shared.image(source, maxPixel: 320)
395 }
396 }
397}
398
399private struct LinkUnfurlRow: View {
400 let e: ThreadMedia.Entry
401 let unfurl: (URL) async -> LinkPreview?
402 let peek: (URL) -> LinkPreview??
403 let open: (URL) -> Void
404 let onTitle: (URL, String) -> Void // feeds the sheet's search-by-title memo
405
406 @State private var title: String?
407
408 var body: some View {
409 Button { if let url = e.url { open(url) } } label: {
410 VStack(alignment: .leading, spacing: 1) {
411 if let title, !title.isEmpty {
412 Text(title)
413 .font(.system(size: 13, weight: .semibold))
414 .lineLimit(2)
415 }
416 Text(e.url?.absoluteString ?? "")
417 .font(.system(size: 11))
418 .foregroundStyle(.secondary)
419 .lineLimit(1)
420 Text("\(e.name.isEmpty ? "Someone" : e.name) · \(relTime(e.ts / 1000))")
421 .font(.system(size: 11))
422 .foregroundStyle(.tertiary)
423 }
424 .frame(maxWidth: .infinity, alignment: .leading)
425 .padding(.horizontal, 14)
426 .padding(.vertical, 5)
427 .contentShape(Rectangle())
428 }
429 .buttonStyle(.plain)
430 .modifier(RowHover())
431 .onHover { $0 ? NSCursor.pointingHand.push() : NSCursor.pop() }
432 .task(id: e.id) {
433 guard let url = e.url else { return }
434 if case .some(let hit) = peek(url) { title = hit?.title } else { title = await unfurl(url)?.title }
435 if let title, !title.isEmpty { onTitle(url, title) }
436 }
437 }
438}
439
440// Rows highlight under the pointer — the sidebar hover idiom
441// (NativeChatView's `.primary.opacity(0.08)` wash), inset a touch so the
442// wash doesn't kiss the sheet's edges.
443private struct RowHover: ViewModifier {
444 @State private var hovering = false
445 func body(content: Content) -> some View {
446 content
447 .background(RoundedRectangle(cornerRadius: 8, style: .continuous)
448 .fill(.primary.opacity(hovering ? 0.07 : 0))
449 .padding(.horizontal, 6))
450 .onHover { hovering = $0 }
451 }
452}
453
454// 🤖 The in-window collection viewer: the current photo (or a video's poster)
455// over a scrim, ‹ › buttons + ←/→ arrow keys stepping through the whole tab,
456// clamped at the ends (no wrap — the web lightbox's stepIndex rule). All in
457// memory via BlobImageLoader; Escape (or a scrim click) closes the viewer
458// without closing the window.
459//
460// Pinch to zoom, drag to pan, double-click to toggle — iOS gets all three
461// from Quick Look, so the Mac hand-rolls them to keep the two in step (the
462// feed's own lightbox deliberately doesn't zoom; here it's the whole point,
463// since this is where you go to read the text in a screenshot). Zoom resets
464// when you step to another photo, so every arrival starts fitted.
465private struct MediaViewerOverlay: View {
466 let entries: [ThreadMedia.Entry]
467 let index: Int
468 let source: (ThreadMedia.Entry) -> AvatarSource?
469 let onStep: (Int) -> Void
470 let onSave: () -> Void
471 let onClose: () -> Void
472
473 @State private var image: NSImage?
474 // Live gesture values plus what they settle to, so a second pinch picks
475 // up where the last left off (the feed lightbox's steady/live split).
476 @State private var scale: CGFloat = 1
477 @State private var steadyScale: CGFloat = 1
478 @State private var offset: CGSize = .zero
479 @State private var steadyOffset: CGSize = .zero
480
481 private let maxScale: CGFloat = 8
482
483 var body: some View {
484 ZStack {
485 Color.black.opacity(0.82)
486 .onTapGesture { onClose() }
487 if let image {
488 // Fills the (resizable) window, so a screenshot full of small
489 // text is readable by growing the window — then zooming in.
490 Image(nsImage: image)
491 .resizable()
492 .interpolation(.high)
493 .aspectRatio(contentMode: .fit)
494 .scaleEffect(scale)
495 .offset(offset)
496 .gesture(magnify.simultaneously(with: pan))
497 .onTapGesture(count: 2) { toggleZoom() }
498 .padding(.horizontal, 12)
499 .padding(.top, 44)
500 .padding(.bottom, 12)
501 .clipped() // a zoomed photo stays inside the viewer
502 .accessibilityLabel("Photo \((index + 1).formatted()) of \(entries.count.formatted())")
503 } else {
504 ProgressView()
505 .controlSize(.large)
506 .tint(.white)
507 }
508 HStack {
509 stepButton("chevron.left", "Previous photo", enabled: index > 0) {
510 onStep(index - 1)
511 }
512 Spacer()
513 stepButton("chevron.right", "Next photo", enabled: index < entries.count - 1) {
514 onStep(index + 1)
515 }
516 }
517 .padding(.horizontal, 8)
518 VStack {
519 HStack(spacing: 10) {
520 Spacer()
521 Button(action: onSave) {
522 Label("Save", systemImage: "arrow.down.circle")
523 .font(.system(size: 12, weight: .semibold))
524 .foregroundStyle(.white)
525 .padding(.horizontal, 10)
526 .padding(.vertical, 5)
527 .background(.black.opacity(0.45), in: Capsule())
528 }
529 .buttonStyle(.plain)
530 .keyboardShortcut("s", modifiers: .command)
531 .accessibilityLabel("Save photo to Downloads")
532 .arrowCursor()
533 Button {
534 onClose()
535 } label: {
536 Image(systemName: "xmark.circle.fill")
537 .font(.system(size: 20))
538 .foregroundStyle(.white.opacity(0.8))
539 }
540 .buttonStyle(.plain)
541 .keyboardShortcut(.cancelAction)
542 .accessibilityLabel("Close viewer")
543 .arrowCursor()
544 }
545 Spacer()
546 }
547 .padding(10)
548 // Hidden arrow-key handlers — ← / → step the collection.
549 Button("") { if index > 0 { onStep(index - 1) } }
550 .keyboardShortcut(.leftArrow, modifiers: [])
551 .opacity(0).frame(width: 0, height: 0)
552 Button("") { if index < entries.count - 1 { onStep(index + 1) } }
553 .keyboardShortcut(.rightArrow, modifiers: [])
554 .opacity(0).frame(width: 0, height: 0)
555 }
556 .task(id: entries[index].id) {
557 image = nil
558 resetZoom()
559 guard let src = source(entries[index]) else { return }
560 image = await BlobImageLoader.shared.image(src, maxPixel: nil)
561 }
562 }
563
564 // Trackpad pinch. Clamped at 1 (fitted) so a photo can't shrink away.
565 private var magnify: some Gesture {
566 MagnifyGesture()
567 .onChanged { value in
568 scale = min(max(steadyScale * value.magnification, 1), maxScale)
569 }
570 .onEnded { _ in
571 steadyScale = scale
572 if scale <= 1 { resetPan() }
573 }
574 }
575
576 // Drag pans, but only while zoomed — at 1× a drag would just slide the
577 // fitted photo off its own scrim.
578 private var pan: some Gesture {
579 DragGesture()
580 .onChanged { value in
581 guard scale > 1 else { return }
582 offset = CGSize(width: steadyOffset.width + value.translation.width,
583 height: steadyOffset.height + value.translation.height)
584 }
585 .onEnded { _ in steadyOffset = offset }
586 }
587
588 private func toggleZoom() {
589 withAnimation(.easeOut(duration: 0.18)) {
590 if scale > 1 {
591 resetZoom()
592 } else {
593 scale = 2.5
594 steadyScale = 2.5
595 }
596 }
597 }
598
599 private func resetZoom() {
600 scale = 1
601 steadyScale = 1
602 resetPan()
603 }
604
605 private func resetPan() {
606 offset = .zero
607 steadyOffset = .zero
608 }
609
610 private func stepButton(_ symbol: String, _ label: String, enabled: Bool,
611 action: @escaping () -> Void) -> some View {
612 Button(action: action) {
613 Image(systemName: symbol)
614 .font(.system(size: 17, weight: .semibold))
615 .foregroundStyle(.white)
616 .frame(width: 34, height: 34)
617 .background(.black.opacity(0.45), in: Circle())
618 }
619 .buttonStyle(.plain)
620 .arrowCursor()
621 .accessibilityLabel(label)
622 .opacity(enabled ? 1 : 0)
623 .disabled(!enabled)
624 }
625}
626