Merge Fix unwanted draft auto-scrolls
Daniel D’Aquino (1):
Fix unwanted auto-scrolls related to draft saving mechanism
This commit is contained in:
@@ -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 */,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
136
damus/Views/Posting/AutoSaveIndicatorView.swift
Normal file
136
damus/Views/Posting/AutoSaveIndicatorView.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user