Report Compaction Progress and Improve UI

Add NdbCompactionProgress and Step with user-facing titles/details,
and a progress callback to Ndb.compact_if_needed to emit stage updates.
Refactor CompactionView to a single CompactionState and render linear
progress, step title/detail and percentage. Ensure UI updates happen on
the MainActor and surface errors/completion cleanly.

Changelog-None
Closes: https://github.com/damus-io/damus/issues/3730

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2026-04-27 10:09:59 -07:00
parent cae83e5399
commit d6654dff29
2 changed files with 272 additions and 64 deletions
+168 -60
View File
@@ -24,19 +24,45 @@ struct CompactionView: View {
let keypair: Keypair
let appDelegate: AppDelegate?
@State private var isCompacting: Bool = false
@State private var compactionComplete: Bool = false
@State private var compactionError: String? = nil
/// The current state of the startup compaction flow.
enum CompactionState {
case idle
case compacting(progress: Double, stepTitle: String, stepDetail: String)
case failed(error: String)
case complete
/// The initial visible state shown when compaction begins.
static var initialCompactingState: CompactionState {
return .compacting(
progress: 0.05,
stepTitle: NSLocalizedString("Preparing database compaction", comment: "Initial title shown during database compaction"),
stepDetail: NSLocalizedString("Checking the database and getting everything ready.", comment: "Initial detail shown during database compaction")
)
}
}
@State private var compactionState: CompactionState = .idle
@State private var hasStartedCompactionFlow: Bool = false
var body: some View {
Group {
if compactionComplete {
switch compactionState {
case .complete:
ContentView(keypair: keypair, appDelegate: appDelegate)
} else {
case .idle:
CompactionLoadingView(
isCompacting: isCompacting,
error: compactionError,
state: .compacting(
progress: 0.0,
stepTitle: NSLocalizedString("Preparing database compaction", comment: "Title shown before database compaction starts"),
stepDetail: NSLocalizedString("Checking whether database optimization is needed.", comment: "Detail shown before database compaction starts")
),
continueAfterError: continueAfterError
)
case .compacting, .failed:
CompactionLoadingView(
state: compactionState,
continueAfterError: continueAfterError
)
}
@@ -60,24 +86,32 @@ struct CompactionView: View {
let needsCompaction = UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key)
guard needsCompaction else {
compactionComplete = true
compactionState = .complete
return
}
isCompacting = true
compactionState = CompactionState.initialCompactingState
Task.detached(priority: .userInitiated) {
do {
try Ndb.compact_if_needed()
try Ndb.compact_if_needed(
progress: { progress in
Task { @MainActor in
compactionState = .compacting(
progress: progress.fractionCompleted,
stepTitle: progress.step.title,
stepDetail: progress.step.detail
)
}
}
)
await MainActor.run {
isCompacting = false
compactionComplete = true
compactionState = .complete
}
} catch {
await MainActor.run {
isCompacting = false
compactionError = error.localizedDescription
compactionState = .failed(error: error.localizedDescription)
}
}
}
@@ -85,7 +119,7 @@ struct CompactionView: View {
/// Continues to the main content after the user acknowledges a compaction error.
private func continueAfterError() {
compactionComplete = true
compactionState = .complete
}
}
@@ -94,86 +128,160 @@ struct CompactionView: View {
/// Shows a spinner and informative text to the user while the compaction process runs.
/// If compaction fails, it shows the error and requires explicit acknowledgement before continuing.
struct CompactionLoadingView: View {
let isCompacting: Bool
let error: String?
let state: CompactionView.CompactionState
let continueAfterError: () -> Void
/// A percentage label for the current compaction progress.
private var progressPercentageText: String? {
guard case .compacting(let progress, _, _) = state else { return nil }
let percentage = Int((progress * 100).rounded())
return String(
format: NSLocalizedString("%d%% complete", comment: "Accessibility and status label showing database compaction progress percentage"),
percentage
)
}
var body: some View {
ZStack {
Color(uiColor: .systemBackground)
.ignoresSafeArea()
LinearGradient(
colors: [
Color(uiColor: .systemBackground),
Color.accentColor.opacity(0.08)
],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 20) {
Image("icon")
.resizable()
.frame(width: 80, height: 80)
.cornerRadius(16)
if isCompacting {
ProgressView()
.scaleEffect(1.5)
.padding()
VStack(spacing: 28) {
VStack(spacing: 16) {
Image("icon")
.resizable()
.frame(width: 88, height: 88)
.cornerRadius(20)
.shadow(color: .black.opacity(0.08), radius: 12, y: 6)
Text("Optimizing Database", comment: "Title shown during database compaction")
.font(.headline)
.font(.title2.weight(.semibold))
.foregroundColor(.primary)
Text("This may take a moment…", comment: "Subtitle shown during database compaction")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
if let progressPercentageText {
Text(progressPercentageText)
.font(.caption.weight(.semibold))
.foregroundColor(.accentColor)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
Capsule()
.fill(Color.accentColor.opacity(0.12))
)
}
}
if let error {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 40))
switch state {
case .idle, .complete:
EmptyView()
case .compacting(let progress, let stepTitle, let stepDetail):
VStack(spacing: 20) {
ProgressView(value: progress, total: 1.0)
.progressViewStyle(.linear)
.tint(.accentColor)
.scaleEffect(x: 1, y: 1.4, anchor: .center)
VStack(alignment: .leading, spacing: 14) {
Text(stepTitle)
.font(.headline)
.foregroundColor(.primary)
Text(stepDetail)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
}
.padding(20)
.frame(maxWidth: 420, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color(uiColor: .secondarySystemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.primary.opacity(0.06), lineWidth: 1)
)
Label(
NSLocalizedString("This can take a few minutes. Please keep the app open.", comment: "Subtitle shown during long-running database compaction"),
systemImage: "clock"
)
.font(.footnote)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 420)
}
case .failed(let error):
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 42))
.foregroundColor(.orange)
Text("Compaction Warning", comment: "Title shown when database compaction encounters an error")
.font(.headline)
.foregroundColor(.primary)
VStack(spacing: 8) {
Text("Compaction Warning", comment: "Title shown when database compaction encounters an error")
.font(.headline)
.foregroundColor(.primary)
Text(error)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Text(error)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Text("The app can continue, but some storage may not have been freed.", comment: "Message shown when compaction fails but app can continue after acknowledgement")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Text("The app can continue, but some storage may not have been freed.", comment: "Message shown when compaction fails but app can continue after acknowledgement")
.font(.footnote)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
Button(action: continueAfterError) {
Text("Continue", comment: "Button title used to continue into the app after acknowledging a compaction error")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding(.top, 8)
.padding(.horizontal)
.padding(.top, 4)
}
.padding(20)
.frame(maxWidth: 420)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color(uiColor: .secondarySystemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.orange.opacity(0.2), lineWidth: 1)
)
}
}
.padding()
.padding(.horizontal, 24)
.padding(.vertical, 32)
}
}
}
#Preview {
CompactionLoadingView(
isCompacting: true,
error: nil,
state: CompactionView.CompactionState.compacting(
progress: 0.55,
stepTitle: "Creating compacted snapshot",
stepDetail: "Copying data into a smaller optimized database file. This is usually the longest step."
),
continueAfterError: {}
)
}
#Preview("With Error") {
CompactionLoadingView(
isCompacting: false,
error: "Failed to compact database",
state: CompactionView.CompactionState.failed(error: "Failed to compact database"),
continueAfterError: {}
)
}
}