Improve draft saving mechanism to start timer on first edit

Modified AutoSaveViewModel.needsSaving() to not reset the timer if already
counting down. This ensures the timer starts when the user begins typing and
continues counting even if they keep typing continuously, leading to auto-save
every few seconds instead of waiting for the user to stop typing.

Added automated tests for the new behavior.

Fixes the issue where drafts would only save after user stops typing,
potentially leading to data loss if the app is closed too quickly.

Closes: https://github.com/damus-io/damus/issues/3164
Changelog-Changed: Improved draft saving feature to prevent data loss if app closes too quickly
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2025-12-10 15:41:10 -08:00
parent 674d4683c3
commit 9eda7e5886
3 changed files with 136 additions and 0 deletions

View File

@@ -1224,6 +1224,7 @@
D7100C5A2B76FD5100C59298 /* LogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C592B76FD5100C59298 /* LogoView.swift */; }; D7100C5A2B76FD5100C59298 /* LogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C592B76FD5100C59298 /* LogoView.swift */; };
D7100C5C2B77016700C59298 /* IAPProductStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C5B2B77016700C59298 /* IAPProductStateView.swift */; }; D7100C5C2B77016700C59298 /* IAPProductStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C5B2B77016700C59298 /* IAPProductStateView.swift */; };
D7100C5E2B7709ED00C59298 /* PurpleStoreKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */; }; D7100C5E2B7709ED00C59298 /* PurpleStoreKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */; };
D7100CB62EEA3E20008D94B7 /* AutoSaveViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100CB52EEA3E20008D94B7 /* AutoSaveViewModelTests.swift */; };
D71527F42E0A2DCA00C893D6 /* follow-packs.jsonl in Resources */ = {isa = PBXBuildFile; fileRef = D71527F32E0A2DC900C893D6 /* follow-packs.jsonl */; }; D71527F42E0A2DCA00C893D6 /* follow-packs.jsonl in Resources */ = {isa = PBXBuildFile; fileRef = D71527F32E0A2DC900C893D6 /* follow-packs.jsonl */; };
D71527FF2E0A3D6900C893D6 /* InterestList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71527FE2E0A3D5F00C893D6 /* InterestList.swift */; }; D71527FF2E0A3D6900C893D6 /* InterestList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71527FE2E0A3D5F00C893D6 /* InterestList.swift */; };
D71528002E0A3D6900C893D6 /* InterestList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71527FE2E0A3D5F00C893D6 /* InterestList.swift */; }; D71528002E0A3D6900C893D6 /* InterestList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71527FE2E0A3D5F00C893D6 /* InterestList.swift */; };
@@ -2721,6 +2722,7 @@
D7100C592B76FD5100C59298 /* LogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoView.swift; sourceTree = "<group>"; }; D7100C592B76FD5100C59298 /* LogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoView.swift; sourceTree = "<group>"; };
D7100C5B2B77016700C59298 /* IAPProductStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPProductStateView.swift; sourceTree = "<group>"; }; D7100C5B2B77016700C59298 /* IAPProductStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPProductStateView.swift; sourceTree = "<group>"; };
D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleStoreKitManager.swift; sourceTree = "<group>"; }; D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleStoreKitManager.swift; sourceTree = "<group>"; };
D7100CB52EEA3E20008D94B7 /* AutoSaveViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoSaveViewModelTests.swift; sourceTree = "<group>"; };
D71527F32E0A2DC900C893D6 /* follow-packs.jsonl */ = {isa = PBXFileReference; lastKnownFileType = text; path = "follow-packs.jsonl"; sourceTree = "<group>"; }; D71527F32E0A2DC900C893D6 /* follow-packs.jsonl */ = {isa = PBXFileReference; lastKnownFileType = text; path = "follow-packs.jsonl"; sourceTree = "<group>"; };
D71527FE2E0A3D5F00C893D6 /* InterestList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterestList.swift; sourceTree = "<group>"; }; D71527FE2E0A3D5F00C893D6 /* InterestList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterestList.swift; sourceTree = "<group>"; };
D71AC4CB2BA8E3480076268E /* VisibilityTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibilityTracker.swift; sourceTree = "<group>"; }; D71AC4CB2BA8E3480076268E /* VisibilityTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibilityTracker.swift; sourceTree = "<group>"; };
@@ -3810,6 +3812,7 @@
4CE6DEF627F7A08200C66700 /* damusTests */ = { 4CE6DEF627F7A08200C66700 /* damusTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D7100CB52EEA3E20008D94B7 /* AutoSaveViewModelTests.swift */,
D7EBF8BC2E5946F9004EAE29 /* NostrNetworkManagerTests */, D7EBF8BC2E5946F9004EAE29 /* NostrNetworkManagerTests */,
D7DB1FED2D5AC50F00CF06DA /* NIP44v2EncryptionTests.swift */, D7DB1FED2D5AC50F00CF06DA /* NIP44v2EncryptionTests.swift */,
D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */, D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */,
@@ -6286,6 +6289,7 @@
D7A0D8752D1FE67900DCBE59 /* EditPictureControlTests.swift in Sources */, D7A0D8752D1FE67900DCBE59 /* EditPictureControlTests.swift in Sources */,
4C90BD1C283AC38E008EE7EF /* Bech32Tests.swift in Sources */, 4C90BD1C283AC38E008EE7EF /* Bech32Tests.swift in Sources */,
50A50A8D29A09E1C00C01BE7 /* RequestTests.swift in Sources */, 50A50A8D29A09E1C00C01BE7 /* RequestTests.swift in Sources */,
D7100CB62EEA3E20008D94B7 /* AutoSaveViewModelTests.swift in Sources */,
4CE6DEF827F7A08200C66700 /* damusTests.swift in Sources */, 4CE6DEF827F7A08200C66700 /* damusTests.swift in Sources */,
D753CEAA2BE9DE04001C3A5D /* MutingTests.swift in Sources */, D753CEAA2BE9DE04001C3A5D /* MutingTests.swift in Sources */,
3A3040F329A91366008A0F29 /* ProfileViewTests.swift in Sources */, 3A3040F329A91366008A0F29 /* ProfileViewTests.swift in Sources */,

View File

@@ -51,6 +51,13 @@ extension AutoSaveIndicatorView {
/// Models an auto-save mechanism, which automatically saves an item after N seconds. /// Models an auto-save mechanism, which automatically saves an item after N seconds.
/// ///
/// # Behavior
///
/// - The timer starts when the user begins typing (first call to `needsSaving()`)
/// - If the user keeps typing continuously, the timer continues counting down without resetting
/// - This ensures drafts are auto-saved every few seconds even during continuous typing
/// - After a save completes, the timer can start again on the next edit
///
/// # Implementation notes /// # Implementation notes
/// ///
/// - This runs on the main actor because running this on other actors causes issues with published properties. /// - This runs on the main actor because running this on other actors causes issues with published properties.
@@ -118,6 +125,13 @@ extension AutoSaveIndicatorView {
/// Marks item as needing to be saved. /// Marks item as needing to be saved.
/// Call this whenever your item is modified. /// Call this whenever your item is modified.
func needsSaving() { func needsSaving() {
// Only start the timer if we're not already counting down.
// This ensures the timer starts when the user first types, and continues
// counting down even if they keep typing, leading to auto-save every few seconds.
if case .needsSaving = self.savedState {
// Already counting down, don't reset the timer
return
}
self.savedState = .needsSaving(secondsRemaining: self.saveDelay) self.savedState = .needsSaving(secondsRemaining: self.saveDelay)
} }

View File

@@ -0,0 +1,118 @@
//
// AutoSaveViewModelTests.swift
// damusTests
//
// Created by Daniel D'Aquino on 2025-12-10.
//
import XCTest
@testable import damus
@MainActor
final class AutoSaveViewModelTests: XCTestCase {
func testTimerStartsOnFirstEdit() async throws {
// Given
var saveCount = 0
let viewModel = AutoSaveIndicatorView.AutoSaveViewModel(
save: { saveCount += 1 },
saveDelay: 2
)
// When - user starts typing
viewModel.needsSaving()
// Then - timer should be started
if case .needsSaving(let secondsRemaining) = viewModel.savedState {
XCTAssertEqual(secondsRemaining, 2)
} else {
XCTFail("Expected needsSaving state")
}
}
func testTimerDoesNotResetOnContinuousTyping() async throws {
// Given
var saveCount = 0
let viewModel = AutoSaveIndicatorView.AutoSaveViewModel(
save: { saveCount += 1 },
saveDelay: 3
)
// When - user starts typing
viewModel.needsSaving()
// Verify initial state
if case .needsSaving(let secondsRemaining) = viewModel.savedState {
XCTAssertEqual(secondsRemaining, 3)
} else {
XCTFail("Expected needsSaving state")
}
// Simulate timer countdown by waiting a bit
try await Task.sleep(nanoseconds: 1_500_000_000) // 1.5 seconds
// When - user continues typing (timer should be around 1-2 seconds now)
viewModel.needsSaving()
// Then - timer should NOT reset to 3 seconds
if case .needsSaving(let secondsRemaining) = viewModel.savedState {
XCTAssertLessThan(secondsRemaining, 3, "Timer should not reset on continuous typing")
} else {
XCTFail("Expected needsSaving state")
}
}
func testTimerRestartsAfterSave() async throws {
// Given
var saveCount = 0
let viewModel = AutoSaveIndicatorView.AutoSaveViewModel(
save: {
saveCount += 1
},
saveDelay: 1
)
// When - user starts typing
viewModel.needsSaving()
// Wait for save to complete
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
// Then - should have saved
XCTAssertEqual(saveCount, 1)
XCTAssertEqual(viewModel.savedState, .saved)
// When - user types again after save
viewModel.needsSaving()
// Then - timer should start again
if case .needsSaving(let secondsRemaining) = viewModel.savedState {
XCTAssertEqual(secondsRemaining, 1)
} else {
XCTFail("Expected needsSaving state after typing post-save")
}
}
func testAutoSaveEveryFewSecondsWithContinuousTyping() async throws {
// Given
var saveCount = 0
let viewModel = AutoSaveIndicatorView.AutoSaveViewModel(
save: {
saveCount += 1
},
saveDelay: 1
)
// When - user starts typing
viewModel.needsSaving()
// Simulate continuous typing every 0.5 seconds for 5 seconds
for _ in 0..<10 {
try await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
viewModel.needsSaving()
}
// Then - should have saved multiple times
XCTAssertGreaterThan(saveCount, 1, "Should auto-save multiple times with continuous typing")
}
}