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:
@@ -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 */,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
118
damusTests/AutoSaveViewModelTests.swift
Normal file
118
damusTests/AutoSaveViewModelTests.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user