commit ec9a89ee4d388f4c706db47d2b73c298f1e268cc
parent 4741c2a3e8d34e72b99d8475399fda18242aceeb
Author: William Casarin <jb55@jb55.com>
Date: Wed, 12 Feb 2025 15:44:30 -0800
Merge Fix unwanted draft auto-scrolls
Daniel D’Aquino (1):
Fix unwanted auto-scrolls related to draft saving mechanism
Diffstat:
3 files changed, 155 insertions(+), 53 deletions(-)
diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj
@@ -1045,6 +1045,9 @@
D703D7B62C67118200A400EA /* String+extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B9472A9AD44700DC3548 /* String+extension.swift */; };
D703D7B72C67118F00A400EA /* StringUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8CC6CB2A2CFEF900940F5F /* StringUtil.swift */; };
D703D7B82C6711A000A400EA /* NativeObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B9462A9AD44700DC3548 /* NativeObject.swift */; };
+ D706C5AF2D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706C5AE2D5D31B20027C627 /* AutoSaveIndicatorView.swift */; };
+ D706C5B02D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706C5AE2D5D31B20027C627 /* AutoSaveIndicatorView.swift */; };
+ D706C5B12D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D706C5AE2D5D31B20027C627 /* AutoSaveIndicatorView.swift */; };
D70A3B172B02DCE5008BD568 /* NotificationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A3B162B02DCE5008BD568 /* NotificationFormatter.swift */; };
D70D90982CDED61800CD0534 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = D70D90972CDED61800CD0534 /* CodeScanner */; };
D70D909C2CDED7B200CD0534 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = D70D909B2CDED7B200CD0534 /* CodeScanner */; };
@@ -2413,6 +2416,7 @@
D703D7222C66E47100A400EA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D703D7262C66E47100A400EA /* highlighter action extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "highlighter action extension.entitlements"; sourceTree = "<group>"; };
D703D72A2C66F29500A400EA /* getSelection.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = getSelection.js; sourceTree = "<group>"; };
+ D706C5AE2D5D31B20027C627 /* AutoSaveIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoSaveIndicatorView.swift; sourceTree = "<group>"; };
D70A3B162B02DCE5008BD568 /* NotificationFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFormatter.swift; sourceTree = "<group>"; };
D7100C552B76F8E600C59298 /* PurpleViewPrimitives.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleViewPrimitives.swift; sourceTree = "<group>"; };
D7100C572B76FC8400C59298 /* MarketingContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketingContentView.swift; sourceTree = "<group>"; };
@@ -3762,6 +3766,7 @@
4CF0ABF42985CD4200D66079 /* Posting */ = {
isa = PBXGroup;
children = (
+ D706C5AE2D5D31B20027C627 /* AutoSaveIndicatorView.swift */,
4CF0ABF52985CD5500D66079 /* UserSearch.swift */,
);
path = Posting;
@@ -4697,6 +4702,7 @@
4CE1399429F0669900AC6A0B /* BigButton.swift in Sources */,
D7EFBA372CC322F300F45588 /* DamusVideoControlsView.swift in Sources */,
7C60CAEF298471A1009C80D6 /* CoreSVG.swift in Sources */,
+ D706C5AF2D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */,
6439E014296790CF0020672B /* ProfilePicImageView.swift in Sources */,
4CE6DF1627F8DEBF00C66700 /* RelayConnection.swift in Sources */,
4C1253682A76D2470004F4B8 /* MuteNotify.swift in Sources */,
@@ -4970,6 +4976,7 @@
82D6FAE62CD99F7900C925F4 /* LocalNotificationNotify.swift in Sources */,
82D6FAE72CD99F7900C925F4 /* LoginNotify.swift in Sources */,
82D6FAE82CD99F7900C925F4 /* LogoutNotify.swift in Sources */,
+ D706C5B12D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */,
82D6FAE92CD99F7900C925F4 /* NewMutesNotify.swift in Sources */,
82D6FAEA2CD99F7900C925F4 /* NewUnmutesNotify.swift in Sources */,
82D6FAEB2CD99F7900C925F4 /* Notify.swift in Sources */,
@@ -5798,6 +5805,7 @@
D703D7A22C670E1A00A400EA /* list.c in Sources */,
D703D7A42C670E3C00A400EA /* midl.c in Sources */,
D7DB1FE02D5A78CE00CF06DA /* NIP44.swift in Sources */,
+ D706C5B02D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */,
D703D7982C670DF200A400EA /* utf8.c in Sources */,
D703D78B2C670C9500A400EA /* MakeZapRequest.swift in Sources */,
D703D7862C670C6500A400EA /* NewUnmutesNotify.swift in Sources */,
diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift
@@ -71,9 +71,12 @@ struct PostView: View {
@State var focusWordAttributes: (String?, NSRange?) = (nil, nil)
@State var newCursorIndex: Int?
@State var textHeight: CGFloat? = nil
- @State var saved_state: SaveState = .needs_saving()
- /// A timer that helps us add a delay between when changes occur and when they are saved persistently (to avoid too many disk writes and a jittery save indicator)
- let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
+ /// Manages the auto-save logic for drafts.
+ ///
+ /// ## Implementation notes
+ ///
+ /// - This intentionally does _not_ use `@ObservedObject` or `@StateObject` because observing changes causes unwanted automatic scrolling to the text cursor on each save state update.
+ var autoSaveModel: AutoSaveIndicatorView.AutoSaveViewModel
@State var preUploadedMedia: [PreUploadedMedia] = []
@@ -89,16 +92,6 @@ struct PostView: View {
let placeholder_messages: [String]
let initial_text_suffix: String?
- enum SaveState: Equatable {
- /// The draft has been modified and needs saving.
- /// Saving should occur in N seconds
- case needs_saving(seconds_remaining: Int = 3)
- /// A saving operation is in progress
- case saving
- /// The draft has been saved to disk.
- case saved
- }
-
init(
action: PostAction,
damus_state: DamusState,
@@ -111,6 +104,7 @@ struct PostView: View {
self.prompt_view = prompt_view
self.placeholder_messages = placeholder_messages ?? [POST_PLACEHOLDER]
self.initial_text_suffix = initial_text_suffix
+ self.autoSaveModel = AutoSaveIndicatorView.AutoSaveViewModel(save: { damus_state.drafts.save(damus_state: damus_state) })
}
@Environment(\.dismiss) var dismiss
@@ -183,33 +177,12 @@ struct PostView: View {
})
}
- var save_state_indicator: some View {
- HStack {
- switch saved_state {
- case .needs_saving:
- EmptyView()
- .accessibilityHidden(true) // Probably no need to show this to visually impaired users, might be too noisy
- case .saving:
- ProgressView()
- .accessibilityHidden(true) // Probably no need to show this to visually impaired users, 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 only for visually impaired users"))
- .font(.caption)
- }
- }
- .padding(6)
- .foregroundStyle(.secondary)
- }
-
var AttachmentBar: some View {
HStack(alignment: .center, spacing: 15) {
ImageButton
CameraButton
Spacer()
- self.save_state_indicator
+ AutoSaveIndicatorView(saveViewModel: self.autoSaveModel)
}
.disabled(uploading_disabled)
}
@@ -263,13 +236,13 @@ struct PostView: View {
guard let draft = load_draft_for_post(drafts: self.damus_state.drafts, action: self.action) else {
self.post = NSMutableAttributedString("")
self.uploadedMedias = []
- self.saved_state = .needs_saving()
+ self.autoSaveModel.markNothingToSave() // We should not save empty drafts.
return false
}
self.uploadedMedias = draft.media
self.post = draft.content
- self.saved_state = .saved
+ self.autoSaveModel.markSaved() // The draft we just loaded is saved to memory. Mark it as such.
return true
}
@@ -287,7 +260,7 @@ struct PostView: View {
let artifacts = DraftArtifacts(content: post, media: uploadedMedias, references: references, id: UUID().uuidString)
set_draft_for_post(drafts: damus_state.drafts, action: action, artifacts: artifacts)
}
- self.saved_state = .needs_saving()
+ self.autoSaveModel.needsSaving()
}
var TextEntry: some View {
@@ -602,21 +575,6 @@ struct PostView: View {
preUploadedMedia.removeAll()
}
}
- .onReceive(timer) { time in
- switch self.saved_state {
- case .needs_saving(seconds_remaining: let seconds_remaining):
- if seconds_remaining <= 0 {
- self.saved_state = .saving
- damus_state.drafts.save(damus_state: damus_state)
- self.saved_state = .saved
- }
- else {
- self.saved_state = .needs_saving(seconds_remaining: seconds_remaining - 1)
- }
- case .saving, .saved:
- break
- }
- }
}
}
diff --git a/damus/Views/Posting/AutoSaveIndicatorView.swift b/damus/Views/Posting/AutoSaveIndicatorView.swift
@@ -0,0 +1,136 @@
+//
+// 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
+ }
+ }
+}