damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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:
Mdamus.xcodeproj/project.pbxproj | 8++++++++
Mdamus/Views/PostView.swift | 64+++++++++++-----------------------------------------------------
Adamus/Views/Posting/AutoSaveIndicatorView.swift | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 + } + } +}