Fix unwanted auto-scrolls related to draft saving mechanism

This commit fixes an issue where the post view would scroll to the text
cursor at seemingly random times.

This was done by detaching the save view and its logic, so that we have
3 components:
1. The `PostView`
2. An auto-save view model (which is an Observable object)
3. A separate SwiftUI view for the auto-save indicator

The auto-save view model is shared between the `PostView` and the new
indicator view to ensure proper signaling and communication across
views.

However, this view model is only observed by the indicator view,
ensuring it updates its own view, while not causing any re-renders on
the rest of the `PostView`.

This refactor had the side-effect of making the auto-save logic and view
more reusable. It is now a separate collection of elements that can be
used anywhere else.

Beyond the scroll issue, this commit also prevents empty drafts from
being saved, by introducing the new save state called `.nothingToSave`

Note: No changelog item is needed because the new drafts feature was never
publicly released.

Changelog-None
Closes: https://github.com/damus-io/damus/issues/2826
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2025-02-12 11:11:47 -08:00
parent 0c5da08a42
commit 0111c5e2dc
3 changed files with 155 additions and 53 deletions

View File

@@ -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 */,

View File

@@ -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
}
}
}
}

View File

@@ -0,0 +1,136 @@
//
// AutoSaveIndicatorView.swift
// damus
//
// Created by Daniel DAquino 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
}
}
}