This commit fixes an issue where the post view would scroll to the text cursor at seemingly random times. This was done by detaching the save view and its logic, so that we have 3 components: 1. The `PostView` 2. An auto-save view model (which is an Observable object) 3. A separate SwiftUI view for the auto-save indicator The auto-save view model is shared between the `PostView` and the new indicator view to ensure proper signaling and communication across views. However, this view model is only observed by the indicator view, ensuring it updates its own view, while not causing any re-renders on the rest of the `PostView`. This refactor had the side-effect of making the auto-save logic and view more reusable. It is now a separate collection of elements that can be used anywhere else. Beyond the scroll issue, this commit also prevents empty drafts from being saved, by introducing the new save state called `.nothingToSave` Note: No changelog item is needed because the new drafts feature was never publicly released. Changelog-None Closes: https://github.com/damus-io/damus/issues/2826 Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
137 lines
5.4 KiB
Swift
137 lines
5.4 KiB
Swift
//
|
||
// AutoSaveIndicatorView.swift
|
||
// damus
|
||
//
|
||
// Created by Daniel D’Aquino on 2025-02-12.
|
||
//
|
||
import SwiftUI
|
||
|
||
/// A small indicator view to indicate whether an item has been saved or not.
|
||
///
|
||
/// This view uses and observes an `AutoSaveViewModel`.
|
||
struct AutoSaveIndicatorView: View {
|
||
@ObservedObject var saveViewModel: AutoSaveViewModel
|
||
|
||
var body: some View {
|
||
HStack {
|
||
switch saveViewModel.savedState {
|
||
case .needsSaving, .nothingToSave:
|
||
EmptyView()
|
||
.accessibilityHidden(true) // Probably no need to show this to users with visual impairment, might be too noisy.
|
||
case .saving:
|
||
ProgressView()
|
||
.accessibilityHidden(true) // Probably no need to show this to users with visual impairment, might be too noisy.
|
||
case .saved:
|
||
Image(systemName: "checkmark")
|
||
.accessibilityHidden(true)
|
||
Text("Saved", comment: "Small label indicating that the user's draft has been saved to storage.")
|
||
.accessibilityLabel(NSLocalizedString("Your draft has been saved to storage.", comment: "Accessibility label indicating that a user's post draft has been saved, meant to be read by screen reading technology."))
|
||
.font(.caption)
|
||
}
|
||
}
|
||
.padding(6)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
|
||
extension AutoSaveIndicatorView {
|
||
/// A simple data structure to model the saving state of an item that can be auto-saved every few seconds.
|
||
enum SaveState: Equatable {
|
||
/// There is nothing to save (e.g. A new empty item was just created, an item was just loaded)
|
||
case nothingToSave
|
||
/// The item has been modified and needs saving.
|
||
/// Saving should occur in N seconds.
|
||
case needsSaving(secondsRemaining: Int)
|
||
/// A saving operation is in progress.
|
||
case saving
|
||
/// The item has been saved to disk.
|
||
case saved
|
||
}
|
||
|
||
/// Models an auto-save mechanism, which automatically saves an item after N seconds.
|
||
///
|
||
/// # Implementation notes
|
||
///
|
||
/// - This runs on the main actor because running this on other actors causes issues with published properties.
|
||
/// - Running on one actor helps ensure thread safety.
|
||
@MainActor
|
||
class AutoSaveViewModel: ObservableObject {
|
||
/// The delay between the time something is marked as needing to save, and the actual saving operation.
|
||
///
|
||
/// Should be low enough that the user does not lose significant progress, and should be high enough to avoid unnecessary disk writes and jittery, stress-inducing behavior
|
||
let saveDelay: Int
|
||
/// The current state of this model
|
||
@Published private(set) var savedState: SaveState
|
||
/// A timer which counts down the time to save the item
|
||
private var timer: Timer?
|
||
/// The function that performs the actual save operation
|
||
var save: () async -> Void
|
||
|
||
|
||
// MARK: Init/de-init
|
||
|
||
/// Initializes a new auto-save model
|
||
/// - Parameters:
|
||
/// - save: The function that performs the save operation
|
||
/// - initialState: Optional initial state
|
||
/// - saveDelay: The time delay between the item is marked as needing to be saved, and the actual save operation — denoted in seconds.
|
||
init(save: @escaping () async -> Void, initialState: SaveState = .nothingToSave, saveDelay: Int = 3) {
|
||
self.saveDelay = saveDelay
|
||
self.savedState = initialState
|
||
self.save = save
|
||
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: { timer in
|
||
Task { await self.tick() } // Task { await ... } ensures the function is properly run on the main actor and avoids thread-safety issues
|
||
})
|
||
self.timer = timer
|
||
}
|
||
|
||
deinit {
|
||
if let timer = self.timer {
|
||
timer.isValid ? timer.invalidate() : ()
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: Internal logic
|
||
|
||
/// Runs internal countdown-to-save logic
|
||
private func tick() async {
|
||
switch self.savedState {
|
||
case .needsSaving(secondsRemaining: let secondsRemaining):
|
||
if secondsRemaining <= 0 {
|
||
self.savedState = .saving
|
||
await save()
|
||
self.savedState = .saved
|
||
}
|
||
else {
|
||
self.savedState = .needsSaving(secondsRemaining: secondsRemaining - 1)
|
||
}
|
||
case .saving, .saved, .nothingToSave:
|
||
break
|
||
}
|
||
}
|
||
|
||
|
||
// MARK: External interface
|
||
|
||
/// Marks item as needing to be saved.
|
||
/// Call this whenever your item is modified.
|
||
func needsSaving() {
|
||
self.savedState = .needsSaving(secondsRemaining: self.saveDelay)
|
||
}
|
||
|
||
/// Marks item as saved.
|
||
/// Call this when you know the item is already saved (e.g. when loading a saved item from memory).
|
||
func markSaved() {
|
||
self.savedState = .saved
|
||
}
|
||
|
||
/// Tells the auto-save logic that there is nothing to be saved.
|
||
/// Call this when there is nothing to be saved (e.g. when opening a new empty item).
|
||
func markNothingToSave() {
|
||
self.savedState = .nothingToSave
|
||
}
|
||
}
|
||
}
|