diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 5a614828..6bb9c181 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -1224,6 +1224,7 @@ D7100C5A2B76FD5100C59298 /* LogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C592B76FD5100C59298 /* LogoView.swift */; }; D7100C5C2B77016700C59298 /* IAPProductStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C5B2B77016700C59298 /* IAPProductStateView.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 */; }; D71527FF2E0A3D6900C893D6 /* 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 = ""; }; D7100C5B2B77016700C59298 /* IAPProductStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPProductStateView.swift; sourceTree = ""; }; D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleStoreKitManager.swift; sourceTree = ""; }; + D7100CB52EEA3E20008D94B7 /* AutoSaveViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoSaveViewModelTests.swift; sourceTree = ""; }; D71527F32E0A2DC900C893D6 /* follow-packs.jsonl */ = {isa = PBXFileReference; lastKnownFileType = text; path = "follow-packs.jsonl"; sourceTree = ""; }; D71527FE2E0A3D5F00C893D6 /* InterestList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterestList.swift; sourceTree = ""; }; D71AC4CB2BA8E3480076268E /* VisibilityTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibilityTracker.swift; sourceTree = ""; }; @@ -3810,6 +3812,7 @@ 4CE6DEF627F7A08200C66700 /* damusTests */ = { isa = PBXGroup; children = ( + D7100CB52EEA3E20008D94B7 /* AutoSaveViewModelTests.swift */, D7EBF8BC2E5946F9004EAE29 /* NostrNetworkManagerTests */, D7DB1FED2D5AC50F00CF06DA /* NIP44v2EncryptionTests.swift */, D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */, @@ -6286,6 +6289,7 @@ D7A0D8752D1FE67900DCBE59 /* EditPictureControlTests.swift in Sources */, 4C90BD1C283AC38E008EE7EF /* Bech32Tests.swift in Sources */, 50A50A8D29A09E1C00C01BE7 /* RequestTests.swift in Sources */, + D7100CB62EEA3E20008D94B7 /* AutoSaveViewModelTests.swift in Sources */, 4CE6DEF827F7A08200C66700 /* damusTests.swift in Sources */, D753CEAA2BE9DE04001C3A5D /* MutingTests.swift in Sources */, 3A3040F329A91366008A0F29 /* ProfileViewTests.swift in Sources */, diff --git a/damus/Features/Posting/Views/AutoSaveIndicatorView.swift b/damus/Features/Posting/Views/AutoSaveIndicatorView.swift index a4fca502..cc097efa 100644 --- a/damus/Features/Posting/Views/AutoSaveIndicatorView.swift +++ b/damus/Features/Posting/Views/AutoSaveIndicatorView.swift @@ -51,6 +51,13 @@ extension AutoSaveIndicatorView { /// 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 /// /// - 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. /// Call this whenever your item is modified. 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) } diff --git a/damusTests/AutoSaveViewModelTests.swift b/damusTests/AutoSaveViewModelTests.swift new file mode 100644 index 00000000..d25b4a17 --- /dev/null +++ b/damusTests/AutoSaveViewModelTests.swift @@ -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") + } +}