krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v4.5.0: DomainDig/WatchlistView.swift · raw

  1import SwiftUI
  2
  3struct WatchlistView: View {
  4    @Environment(\.appDensity) private var appDensity
  5    @Bindable var viewModel: DomainViewModel
  6    @Environment(\.dismiss) private var dismiss
  7    @State private var purchaseService = PurchaseService.shared
  8    @State private var showWorkflowAddSheet = false
  9    @State private var showAddDomainSheet = false
 10    @State private var newTrackedDomain = ""
 11    @State private var addDomainError: String?
 12    @FocusState private var isAddDomainFieldFocused: Bool
 13
 14    private var pinnedDomains: [TrackedDomain] {
 15        viewModel.filteredTrackedDomains.filter(\.isPinned)
 16    }
 17
 18    private var otherDomains: [TrackedDomain] {
 19        viewModel.filteredTrackedDomains.filter { !$0.isPinned }
 20    }
 21
 22    var body: some View {
 23        let _ = purchaseService.currentTier
 24
 25        List {
 26            if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
 27                Section("Refresh Progress") {
 28                    VStack(alignment: .leading, spacing: 8) {
 29                        ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1)))
 30                            .tint(.cyan)
 31                        HStack {
 32                            Text(viewModel.batchProgressLabel)
 33                                .font(appDensity.font(.caption))
 34                                .foregroundStyle(.secondary)
 35                            Spacer()
 36                            if viewModel.batchLookupRunning {
 37                                Button("Cancel") {
 38                                    viewModel.cancelBatchLookup()
 39                                }
 40                                .buttonStyle(.bordered)
 41                                .font(appDensity.font(.caption2))
 42                            }
 43                        }
 44
 45                        ForEach(viewModel.batchResults.prefix(5)) { result in
 46                            BatchResultRowView(result: result)
 47                        }
 48                    }
 49                    .padding(.vertical, 4)
 50                }
 51                .listRowBackground(Color(.systemGray6).opacity(0.5))
 52            }
 53
 54            if viewModel.filteredTrackedDomains.isEmpty {
 55                Section {
 56                    EmptyStateCardView(
 57                        title: "No Tracked Domains",
 58                        message: "Track important domains locally so you can refresh them quickly and see status changes at a glance.",
 59                        suggestion: "Run an inspection and use the Track action on a domain you care about.",
 60                        systemImage: "eye",
 61                        showsCardBackground: false
 62                    )
 63                }
 64                .listRowBackground(Color(.systemGray6).opacity(0.5))
 65            } else {
 66                if let limitMessage = FeatureAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) {
 67                    Section {
 68                        Text(limitMessage)
 69                            .font(appDensity.font(.caption))
 70                            .foregroundStyle(.secondary)
 71                    }
 72                    .listRowBackground(Color(.systemGray6).opacity(0.5))
 73                }
 74
 75                if !pinnedDomains.isEmpty {
 76                    trackedSection(title: "Pinned", domains: pinnedDomains)
 77                }
 78
 79                if !otherDomains.isEmpty {
 80                    trackedSection(title: pinnedDomains.isEmpty ? "Tracked Domains" : "Others", domains: otherDomains)
 81                }
 82            }
 83        }
 84        .animation(.easeInOut(duration: 0.2), value: viewModel.filteredTrackedDomains.map(\.id))
 85        .scrollContentBackground(.hidden)
 86        .background(Color.black)
 87        .navigationTitle("Watchlist")
 88        .searchable(text: $viewModel.watchlistSearchText, prompt: "Search tracked domains")
 89        .toolbar {
 90            ToolbarItemGroup(placement: .topBarTrailing) {
 91                Button {
 92                    addDomainError = nil
 93                    newTrackedDomain = ""
 94                    showAddDomainSheet = true
 95                } label: {
 96                    Image(systemName: "plus")
 97                }
 98
 99                if !viewModel.filteredTrackedDomains.isEmpty {
100                    Menu {
101                        Picker("Filter", selection: $viewModel.watchlistFilter) {
102                            ForEach(WatchlistFilterOption.allCases) { option in
103                                Text(option.title).tag(option)
104                            }
105                        }
106
107                        Picker("Sort", selection: $viewModel.watchlistSortOption) {
108                            ForEach(WatchlistSortOption.allCases) { option in
109                                Text(option.title).tag(option)
110                            }
111                        }
112
113                        Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") {
114                            AppHaptics.refresh()
115                            viewModel.refreshAllTrackedDomains()
116                        }
117                        .disabled(viewModel.batchLookupRunning)
118
119                        Button("Add to Workflow") {
120                            showWorkflowAddSheet = true
121                        }
122
123                        Button("Export TXT") {
124                            shareTrackedDomains(format: .text)
125                        }
126
127                        if FeatureAccessService.hasAccess(to: .advancedExports) {
128                            Button("Export CSV") {
129                                shareTrackedDomains(format: .csv)
130                            }
131
132                            Button("Export JSON") {
133                                shareTrackedDomains(format: .json)
134                            }
135                        } else {
136                            Button("CSV Export • Available in Pro") {}
137                                .disabled(true)
138                            Button("JSON Export • Available in Pro") {}
139                                .disabled(true)
140                        }
141                    } label: {
142                        Image(systemName: "line.3.horizontal.decrease.circle")
143                    }
144
145                    EditButton()
146                }
147            }
148        }
149        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
150            dismiss()
151        }
152        .sheet(item: batchSummaryBinding) { summary in
153            BatchSweepSummaryView(viewModel: viewModel, summary: summary)
154        }
155        .sheet(isPresented: $showAddDomainSheet) {
156            NavigationStack {
157                Form {
158                    Section("Domain") {
159                        TextField("example.com", text: $newTrackedDomain)
160                            .textInputAutocapitalization(.never)
161                            .autocorrectionDisabled()
162                            .keyboardType(.URL)
163                            .textContentType(.URL)
164                            .focused($isAddDomainFieldFocused)
165                            .onSubmit(addTrackedDomain)
166                    }
167
168                    Section {
169                        Text("Adds the domain directly to your watchlist so monitoring can run without a prior inspection.")
170                            .font(appDensity.font(.caption))
171                            .foregroundStyle(.secondary)
172                    }
173
174                    if let addDomainError {
175                        Section {
176                            Text(addDomainError)
177                                .font(appDensity.font(.caption))
178                                .foregroundStyle(.red)
179                        }
180                    }
181                }
182                .navigationTitle("Add Domain")
183                .toolbar {
184                    ToolbarItem(placement: .cancellationAction) {
185                        Button("Cancel") {
186                            showAddDomainSheet = false
187                        }
188                    }
189
190                    ToolbarItem(placement: .confirmationAction) {
191                        Button("Add", action: addTrackedDomain)
192                    }
193                }
194                .onAppear {
195                    DispatchQueue.main.async {
196                        isAddDomainFieldFocused = true
197                    }
198                }
199            }
200        }
201        .sheet(isPresented: $showWorkflowAddSheet) {
202            WorkflowBulkAddSheet(
203                viewModel: viewModel,
204                title: "Add Watchlist Domains",
205                availableDomains: viewModel.filteredTrackedDomains.map(\.domain)
206            )
207        }
208        .preferredColorScheme(.dark)
209    }
210
211    @ViewBuilder
212    private func trackedSection(title: String, domains: [TrackedDomain]) -> some View {
213        Section(title) {
214            ForEach(domains) { trackedDomain in
215                trackedDomainRow(trackedDomain)
216            }
217        }
218    }
219
220    private func trackedDomainRow(_ trackedDomain: TrackedDomain) -> some View {
221        NavigationLink {
222            TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
223        } label: {
224            WatchlistRowView(
225                trackedDomain: trackedDomain,
226                isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id
227            )
228        }
229        .buttonStyle(.plain)
230        .swipeActions(edge: .leading, allowsFullSwipe: false) {
231            Button {
232                AppHaptics.refresh()
233                viewModel.refreshTrackedDomain(trackedDomain)
234            } label: {
235                Label("Refresh", systemImage: "arrow.clockwise")
236            }
237            .tint(.cyan)
238
239            Button {
240                viewModel.togglePinned(for: trackedDomain)
241            } label: {
242                Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
243            }
244            .tint(.yellow)
245        }
246        .swipeActions(edge: .trailing, allowsFullSwipe: false) {
247            Button {
248                AppHaptics.refresh()
249                viewModel.refreshTrackedDomain(trackedDomain)
250            } label: {
251                Label("Refresh", systemImage: "arrow.clockwise")
252            }
253            .tint(.cyan)
254
255            if viewModel.canDelete(trackedDomain) {
256                Button(role: .destructive) {
257                    viewModel.deleteTrackedDomain(trackedDomain)
258                } label: {
259                    Label("Delete", systemImage: "trash")
260                }
261            }
262        }
263        .contextMenu {
264            Button {
265                AppHaptics.refresh()
266                viewModel.refreshTrackedDomain(trackedDomain)
267            } label: {
268                Label("Refresh", systemImage: "arrow.clockwise")
269            }
270
271            Button {
272                dismiss()
273                viewModel.rerunInspection(for: trackedDomain)
274            } label: {
275                Label("Open Inspection", systemImage: "magnifyingglass")
276            }
277
278            Button {
279                viewModel.togglePinned(for: trackedDomain)
280            } label: {
281                Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
282            }
283            .disabled(!viewModel.canEdit(trackedDomain))
284
285            Button {
286                // The system sharing UI manages participants and permissions.
287            } label: {
288                Label(trackedDomain.collaboration?.isShared == true ? "Shared" : "Private", systemImage: "person.2")
289            }
290            .disabled(true)
291
292            if viewModel.canDelete(trackedDomain) {
293                Button(role: .destructive) {
294                    viewModel.deleteTrackedDomain(trackedDomain)
295                } label: {
296                    Label("Delete", systemImage: "trash")
297                }
298            }
299        }
300        .listRowBackground(Color(.systemGray6).opacity(0.5))
301    }
302
303    private var batchSummaryBinding: Binding<BatchSweepSummary?> {
304        Binding(
305            get: { viewModel.latestBatchSweepSummary },
306            set: { viewModel.latestBatchSweepSummary = $0 }
307        )
308    }
309
310    private func deleteFilteredTrackedDomains(at offsets: IndexSet) {
311        let domains = offsets.map { viewModel.filteredTrackedDomains[$0] }
312        domains.forEach(viewModel.deleteTrackedDomain)
313    }
314
315    private func shareTrackedDomains(format: DomainExportFormat) {
316        let formatter = DateFormatter()
317        formatter.dateFormat = "yyyyMMdd_HHmmss"
318        let timestamp = formatter.string(from: Date())
319        let filename = "\(timestamp)_domaindig_watchlist.\(format.fileExtension)"
320        let data: Data
321
322        switch format {
323        case .text:
324            data = Data(viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains).utf8)
325        case .csv:
326            data = Data(viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains).utf8)
327        case .json:
328            data = viewModel.exportTrackedDomainsJSONData(domains: viewModel.filteredTrackedDomains) ?? Data("[]".utf8)
329        }
330
331        ExportPresenter.share(filename: filename, data: data)
332    }
333
334    private func addTrackedDomain() {
335        let draft = newTrackedDomain.trimmingCharacters(in: .whitespacesAndNewlines)
336        guard !draft.isEmpty else {
337            addDomainError = "Enter a domain to add."
338            return
339        }
340
341        if viewModel.trackDomain(domain: draft, availabilityStatus: nil) {
342            AppHaptics.track()
343            isAddDomainFieldFocused = false
344            addDomainError = nil
345            newTrackedDomain = ""
346            showAddDomainSheet = false
347        } else if viewModel.upgradePrompt == nil {
348            addDomainError = "Enter a valid domain like example.com."
349        }
350    }
351}
352
353struct WatchlistRowView: View {
354    @Environment(\.appDensity) private var appDensity
355    let trackedDomain: TrackedDomain
356    let isRefreshing: Bool
357
358    var body: some View {
359        VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
360            HStack(alignment: .firstTextBaseline, spacing: 8) {
361                if trackedDomain.isPinned {
362                    Image(systemName: "pin.fill")
363                        .font(.caption2)
364                        .foregroundStyle(.yellow)
365                }
366                Text(trackedDomain.domain)
367                    .font(appDensity.font(.callout))
368                    .foregroundStyle(.primary)
369                    .lineLimit(2)
370                    .multilineTextAlignment(.leading)
371                Spacer(minLength: 8)
372                statusBadge
373            }
374
375            Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
376                .font(appDensity.font(.caption2))
377                .foregroundStyle(.secondary)
378
379            if let collaboration = trackedDomain.collaboration, collaboration.isShared {
380                Text("\(collaboration.ownership.title)\(collaboration.permission.title)")
381                    .font(appDensity.font(.caption2))
382                    .foregroundStyle(.secondary)
383            }
384
385            HStack(spacing: 8) {
386                Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off")
387                if let lastMonitoredAt = trackedDomain.lastMonitoredAt {
388                    Text("Checked \(lastMonitoredAt.formatted(date: .omitted, time: .shortened))")
389                }
390                if let lastAlertAt = trackedDomain.lastAlertAt {
391                    Text("Alert \(lastAlertAt.formatted(date: .omitted, time: .shortened))")
392                }
393            }
394            .font(appDensity.font(.caption2))
395            .foregroundStyle(.secondary)
396
397            indicatorRow
398
399            if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
400                Text(note)
401                    .font(appDensity.font(.caption))
402                    .foregroundStyle(.secondary)
403                    .lineLimit(2)
404            } else if let summary = trackedDomain.lastChangeSummary {
405                Text(summary.message)
406                    .font(appDensity.font(.caption))
407                    .foregroundStyle(.secondary)
408                    .lineLimit(2)
409            }
410        }
411        .frame(maxWidth: .infinity, alignment: .leading)
412        .padding(.vertical, 4)
413    }
414
415    private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
416        switch status {
417        case .available:
418            return "Available"
419        case .registered:
420            return "Registered"
421        case .unknown, .none:
422            return "Unknown"
423        }
424    }
425
426    @ViewBuilder
427    private var statusBadge: some View {
428        if isRefreshing {
429            AppStatusBadgeView(model: .init(title: "Refreshing", systemImage: "arrow.clockwise", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.6)))
430        } else {
431            AppStatusBadgeView(model: AppStatusFactory.availability(trackedDomain.lastKnownAvailability))
432        }
433    }
434
435    @ViewBuilder
436    private var indicatorRow: some View {
437        HStack(spacing: 8) {
438            if trackedDomain.collaboration?.isShared == true {
439                AppStatusBadgeView(
440                    model: .init(
441                        title: "Shared",
442                        systemImage: "person.2.fill",
443                        foregroundColor: .cyan,
444                        backgroundColor: .cyan.opacity(0.16)
445                    )
446                )
447            }
448
449            AppStatusBadgeView(model: AppStatusFactory.change(trackedDomain.lastChangeSummary))
450
451            if trackedDomain.certificateWarningLevel != .none {
452                AppStatusBadgeView(model: certificateBadge)
453            }
454        }
455    }
456
457    private var certificateBadge: AppStatusBadgeModel {
458        let days = trackedDomain.certificateDaysRemaining.map { "\($0)d" } ?? "Soon"
459        switch trackedDomain.certificateWarningLevel {
460        case .critical:
461            return .init(title: "Invalid \(days)", systemImage: "xmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
462        case .warning:
463            return .init(title: "Expiring \(days)", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
464        case .none:
465            return .init(title: "Valid", systemImage: "lock.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
466        }
467    }
468}
469
470struct TrackedDomainDetailView: View {
471    @Bindable var viewModel: DomainViewModel
472    let trackedDomain: TrackedDomain
473    @Environment(\.dismiss) private var dismiss
474
475    @State private var noteDraft = ""
476    @State private var isEditingNote = false
477    @State private var showRerunOptions = false
478    @State private var shareEntity: ShareableEntity?
479    @State private var showingAuditTimeline = false
480    @State private var auditStartInFlight = false
481
482    private var liveTrackedDomain: TrackedDomain {
483        viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain
484    }
485
486    private var latestSnapshots: [HistoryEntry] {
487        viewModel.recentSnapshots(for: liveTrackedDomain)
488    }
489
490    private var latestDiffSections: [DomainDiffSection] {
491        viewModel.diffSectionsForLatestSnapshots(of: liveTrackedDomain)
492    }
493
494    var body: some View {
495        List {
496            Section {
497                WatchlistRowView(
498                    trackedDomain: liveTrackedDomain,
499                    isRefreshing: viewModel.refreshingTrackedDomainID == liveTrackedDomain.id
500                )
501            }
502            .listRowBackground(Color(.systemGray6).opacity(0.5))
503
504            Section {
505                Button {
506                    viewModel.refreshTrackedDomain(liveTrackedDomain)
507                } label: {
508                    Label("Manual Refresh", systemImage: "arrow.clockwise")
509                }
510
511                Button {
512                    showRerunOptions = true
513                } label: {
514                    Label("Re-run Inspection", systemImage: "magnifyingglass")
515                }
516
517                Button {
518                    Task {
519                        auditStartInFlight = true
520                        if await viewModel.startAudit(for: liveTrackedDomain.domain) != nil {
521                            showingAuditTimeline = true
522                        }
523                        auditStartInFlight = false
524                    }
525                } label: {
526                    Label(auditStartInFlight ? "Starting Audit…" : "Start Audit", systemImage: "checklist")
527                }
528                .disabled(auditStartInFlight)
529
530                if !viewModel.audits(for: liveTrackedDomain.domain).isEmpty {
531                    Button {
532                        showingAuditTimeline = true
533                    } label: {
534                        Label("View Audits", systemImage: "clock.badge.checkmark")
535                    }
536                }
537
538                Button {
539                    viewModel.togglePinned(for: liveTrackedDomain)
540                } label: {
541                    Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin")
542                }
543                .disabled(!viewModel.canEdit(liveTrackedDomain))
544
545                Button {
546                    viewModel.toggleMonitoring(for: liveTrackedDomain)
547                } label: {
548                    Label(
549                        liveTrackedDomain.monitoringEnabled ? "Disable Monitoring" : "Enable Monitoring",
550                        systemImage: liveTrackedDomain.monitoringEnabled ? "bell.slash" : "bell"
551                    )
552                }
553                .disabled(!viewModel.canEdit(liveTrackedDomain))
554
555                Button {
556                    noteDraft = liveTrackedDomain.note ?? ""
557                    isEditingNote = true
558                } label: {
559                    Label(liveTrackedDomain.note == nil ? "Add Note" : "Edit Note", systemImage: "note.text")
560                }
561                .disabled(!viewModel.canEdit(liveTrackedDomain))
562
563                Button {
564                    shareEntity = .trackedDomain(liveTrackedDomain.domain)
565                } label: {
566                    Label(liveTrackedDomain.collaboration?.isShared == true ? "Manage Share" : "Share Domain", systemImage: "person.2")
567                }
568            }
569            .listRowBackground(Color(.systemGray6).opacity(0.5))
570
571            Section("Monitoring Status") {
572                LabeledContent("State", value: viewModel.monitoringStatusLabel(for: liveTrackedDomain))
573                LabeledContent("Current Interval", value: viewModel.monitoringIntervalLabel(for: liveTrackedDomain))
574                LabeledContent(
575                    "Last Change",
576                    value: liveTrackedDomain.monitoringState.lastChangeDate?.formatted(date: .abbreviated, time: .shortened) ?? "None"
577                )
578                if !liveTrackedDomain.pendingMonitoringAlerts.isEmpty {
579                    LabeledContent("Queued Alerts", value: "\(liveTrackedDomain.pendingMonitoringAlerts.count)")
580                }
581            }
582            .listRowBackground(Color(.systemGray6).opacity(0.5))
583
584            if let summary = viewModel.latestChangeSummary(for: liveTrackedDomain) {
585                Section("Latest Change Summary") {
586                    DomainChangeSummaryView(summary: summary)
587                }
588                .listRowBackground(Color.clear)
589            }
590
591            if !latestDiffSections.isEmpty {
592                Section("Latest Diff") {
593                    DomainDiffView(
594                        title: "Latest Snapshot vs Previous",
595                        sections: latestDiffSections,
596                        contextNote: latestSnapshots.count >= 2
597                            ? DomainDiffService.comparisonContextNote(from: latestSnapshots[1].snapshot, to: latestSnapshots[0].snapshot)
598                            : nil,
599                        showsUnchanged: false,
600                        highlightedSectionID: nil
601                    )
602                }
603                .listRowBackground(Color.clear)
604            }
605
606            Section("Recent Snapshots") {
607                if latestSnapshots.isEmpty {
608                    Text("No snapshots yet")
609                        .font(.system(.caption, design: .monospaced))
610                        .foregroundStyle(.secondary)
611                } else {
612                    ForEach(latestSnapshots) { entry in
613                        NavigationLink {
614                            HistoryDetailView(viewModel: viewModel, entry: entry)
615                        } label: {
616                            VStack(alignment: .leading, spacing: 4) {
617                                Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened))
618                                    .font(.system(.caption, design: .monospaced))
619                                    .foregroundStyle(.primary)
620                                Text(entry.changeSummary?.hasChanges == true ? "Changed" : "Snapshot")
621                                    .font(.system(.caption2, design: .monospaced))
622                                    .foregroundStyle(.secondary)
623                            }
624                        }
625                    }
626                }
627            }
628            .listRowBackground(Color(.systemGray6).opacity(0.5))
629        }
630        .scrollContentBackground(.hidden)
631        .background(Color.black)
632        .navigationTitle(liveTrackedDomain.domain)
633        .preferredColorScheme(.dark)
634        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
635            dismiss()
636        }
637        .confirmationDialog("Re-run inspection", isPresented: $showRerunOptions) {
638            Button("Run with Current Settings") {
639                viewModel.rerunInspection(for: liveTrackedDomain, useSnapshotResolver: false)
640            }
641            if latestSnapshots.first != nil {
642                Button("Run with Snapshot Resolver") {
643                    viewModel.rerunInspection(for: liveTrackedDomain, useSnapshotResolver: true)
644                }
645            }
646            Button("Cancel", role: .cancel) {}
647        } message: {
648            Text(viewModel.resolverMismatchNote(for: liveTrackedDomain) ?? "Choose how to reproduce the most recent snapshot.")
649        }
650        .sheet(isPresented: $isEditingNote) {
651            NavigationStack {
652                Form {
653                    Section("Tracking Note") {
654                        TextField("Optional note", text: $noteDraft, axis: .vertical)
655                            .lineLimit(3...6)
656                    }
657                }
658                .navigationTitle("Edit Note")
659                .toolbar {
660                    ToolbarItem(placement: .cancellationAction) {
661                        Button("Cancel") {
662                            isEditingNote = false
663                        }
664                    }
665                    ToolbarItem(placement: .confirmationAction) {
666                        Button("Save") {
667                            viewModel.updateNote(noteDraft, for: liveTrackedDomain)
668                            isEditingNote = false
669                        }
670                    }
671                }
672            }
673        }
674        .sheet(item: $shareEntity) { entity in
675            CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain)
676        }
677        .sheet(isPresented: $showingAuditTimeline) {
678            NavigationStack {
679                AuditDomainTimelineView(viewModel: viewModel, domain: liveTrackedDomain.domain)
680            }
681        }
682    }
683}