Merge Fix unwanted draft auto-scrolls

Daniel D’Aquino (1):
      Fix unwanted auto-scrolls related to draft saving mechanism
This commit is contained in:
William Casarin
2025-02-12 15:44:30 -08:00
3 changed files with 155 additions and 53 deletions

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