feat: persist composer drafts in memory across navigation #155

Merged
hodlbod merged 1 commits from userAdityaa/flotilla:branch-persist-drafts into dev 2026-04-07 12:06:30 +00:00
Collaborator

Description

Implement in-memory draft support for all compose areas (rooms, DMs, threads, goals, classifieds, event/comment forms). Calendar event date fields are intentionally not drafted.

Solution

  • Added a session-only in-memory draft store.
  • Updated all compose components to save/restore/clear drafts.
  • Caret restores to end of editor content on remount.

Fixes #150

How to Test

  1. Start composing in any area (room, DM, thread, goal, classified, event/comment form).
  2. Navigate away and return, draft content should restore.
  3. Caret should be at the end of restored content.
### Description Implement in-memory draft support for all compose areas (rooms, DMs, threads, goals, classifieds, event/comment forms). Calendar event date fields are intentionally not drafted. ### Solution - Added a session-only in-memory draft store. - Updated all compose components to save/restore/clear drafts. - Caret restores to end of editor content on remount. Fixes #150 ### How to Test 1. Start composing in any area (room, DM, thread, goal, classified, event/comment form). 2. Navigate away and return, draft content should restore. 3. Caret should be at the end of restored content.
hodlbod reviewed 2026-04-04 16:24:47 +00:00
hodlbod left a comment
Owner

Looks pretty good, I think if we can start each component by reconciling initialValues and draft, it will remove a lot of complexity. Also, go ahead and add this to the new polls form as well.

Looks pretty good, I think if we can start each component by reconciling initialValues and draft, it will remove a lot of complexity. Also, go ahead and add this to the new polls form as well.
@@ -40,2 +41,4 @@
const {url, h, header, initialValues}: Props = $props()
const draftKey = !initialValues ? `calendar:${url}:${h ?? ""}` : undefined
const draft = draftKey ? getDraft(draftKey) : {}
Owner

Let's actually do the opposite — if a user closes an edit form and re-opens it the current values should overwrite their changes. On the other hand, when creating a new event the draft doesn't conflict with anything and so it should stick around. Same thing for classifieds, goals, etc.

Let's actually do the opposite — if a user closes an edit form and re-opens it the current values should overwrite their changes. On the other hand, when creating a new event the draft doesn't conflict with anything and so it should stick around. Same thing for classifieds, goals, etc.
Owner

In fact, this will make things a lot simpler, since we can do something like if (!h) { initialValues = {...intialValues, ...getDraft(calendar:${url}:${h})} }, cleaning up later code significantly.

In fact, this will make things a lot simpler, since we can do something like `if (!h) { initialValues = {...intialValues, ...getDraft(`calendar:${url}:${h}`)} }`, cleaning up later code significantly.
hodlbod marked this conversation as resolved
@@ -95,17 +99,37 @@
pushToast({message: "Your event has been saved!"})
publishThunk({event, relays: [url]})
if (draftKey) clearDraft(draftKey)
Owner

Go ahead and accept undefined in clearDraft and move the conditional inside just to keep callers cleaner

Go ahead and accept undefined in `clearDraft` and move the conditional inside just to keep callers cleaner
hodlbod marked this conversation as resolved
@@ -65,6 +65,7 @@
const {pubkeys, info}: Props = $props()
const draftKey = `dm:${[...pubkeys].sort().join(":")}`
Owner

Use $chat.id from the line below this one

Use `$chat.id` from the line below this one
hodlbod marked this conversation as resolved
@@ -22,1 +24,3 @@
const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props()
const {content, disabled = false, draftKey, onEscape, onEditPrevious, onSubmit}: Props = $props()
const draft = draftKey && !content ? getDraft(draftKey) : {}
Owner

Howabout removing the content conditional and changing line 75 to content ?? draft.content? Just makes things a little simpler.

Howabout removing the content conditional and changing line 75 to `content ?? draft.content`? Just makes things a little simpler.
hodlbod marked this conversation as resolved
Author
Collaborator

Completed all the refactoring changes.

Quick summary

Added draft/initialValues reconciliation pattern to CalendarEventForm and ClassifiedForm so drafts only persist for new items, not edits. Updated draftKey logic in Chat to use $chat.id, and simplified content conditionals in ChatCompose/RoomCompose.

Also, go ahead and add this to the new polls form as well.

Sorry, could you clarify what you mean by “new polls” here?

Completed all the refactoring changes. **Quick summary** Added draft/initialValues reconciliation pattern to `CalendarEventForm` and `ClassifiedForm` so drafts only persist for new items, not edits. Updated draftKey logic in Chat to use `$chat.id`, and simplified content conditionals in ChatCompose/RoomCompose. > Also, go ahead and add this to the new polls form as well. Sorry, could you clarify what you mean by “new polls” here?
Owner

Sorry, could you clarify what you mean by “new polls” here?

Sure, there's a new component PollCreate.svelte that could use the same treatment.

> Sorry, could you clarify what you mean by “new polls” here? Sure, there's a new component `PollCreate.svelte` that could use the same treatment.
Author
Collaborator

Sorry, could you clarify what you mean by “new polls” here?

Sure, there's a new component PollCreate.svelte that could use the same treatment.

Looks like my local branch was behind my fork. I’ve synced it now and will push the changes shortly.

> > Sorry, could you clarify what you mean by “new polls” here? > > Sure, there's a new component `PollCreate.svelte` that could use the same treatment. Looks like my local branch was behind my fork. I’ve synced it now and will push the changes shortly.
Author
Collaborator

Sorry, could you clarify what you mean by “new polls” here?

Sure, there's a new component PollCreate.svelte that could use the same treatment.

Looks like my local branch was behind my fork. I’ve synced it now and will push the changes shortly.

Pushed the changes including PollCreate.svelte. It’s now ready for another round of review.

> > > Sorry, could you clarify what you mean by “new polls” here? > > > > Sure, there's a new component `PollCreate.svelte` that could use the same treatment. > > Looks like my local branch was behind my fork. I’ve synced it now and will push the changes shortly. > Pushed the changes including `PollCreate.svelte`. It’s now ready for another round of review.
hodlbod requested changes 2026-04-06 18:11:51 +00:00
@@ -41,0 +44,4 @@
const draft = getDraft(draftKey)
if (!initialValues) {
initialValues = draft as unknown as typeof initialValues
}
Owner

In order to get rid of this coercion and ensure that get/set are dealing with the same data, maybe add a DraftKey utility:

export class DraftKey<T> {
  get(): T
  set(value: T): void
}

This will also clean up a lot of the stuff in other components like let content = $state((draft.content as string | undefined) ?? ""), which would simply become let content = $state(draft?.content ?? "")

In order to get rid of this coercion and ensure that get/set are dealing with the same data, maybe add a `DraftKey` utility: ```typescript export class DraftKey<T> { get(): T set(value: T): void } ``` This will also clean up a lot of the stuff in other components like `let content = $state((draft.content as string | undefined) ?? "")`, which would simply become `let content = $state(draft?.content ?? "")`
hodlbod marked this conversation as resolved
@@ -109,0 +129,4 @@
title,
location,
})
})
Owner

This is missing start/end

This is missing start/end
hodlbod marked this conversation as resolved
@@ -129,0 +139,4 @@
let topics = $state(uniq(removeUndefined((initialValues?.topics ?? []).map(normalizeTopic))))
$effect(() => {
setDraft(draftKey, {...getDraft(draftKey), title, price, currency, topics, status})
Owner

This is missing images

This is missing `images`
hodlbod marked this conversation as resolved
@@ -87,0 +100,4 @@
let amount = $state((draft.amount as number | undefined) ?? 1000)
$effect(() => {
setDraft(draftKey, {...getDraft(draftKey), content, amount})
Owner

destructuring shouldn't be necessary since we already set content/amount based on draft. For consistency, go ahead and add initialValues to props here too and follow the same fallback pattern.

destructuring shouldn't be necessary since we already set content/amount based on draft. For consistency, go ahead and add initialValues to props here too and follow the same fallback pattern.
hodlbod marked this conversation as resolved
Author
Collaborator

Apologies for missing the images and start/end points earlier. All the feedback has now been addressed.

Apologies for missing the `images` and `start/end` points earlier. All the feedback has now been addressed.
hodlbod requested changes 2026-04-06 19:58:15 +00:00
@@ -41,0 +48,4 @@
location?: string
start?: number
end?: number
}
Owner

This is redundant with Props, just use content in both cases correctly typed.

This is redundant with Props, just use `content` in both cases correctly typed.
hodlbod marked this conversation as resolved
@@ -101,2 +119,2 @@
const content = initialValues?.content || ""
const editor = makeEditor({url, submit, uploading, content})
const onChange = (json: unknown) => {
draftKey.update({editorContent: json})
Owner

Instead of update, let's just use set and make all the initialValues properties non-optional

Instead of update, let's just use set and make all the initialValues properties non-optional
hodlbod marked this conversation as resolved
@@ -103,0 +124,4 @@
submit,
uploading,
onChange,
content: draft?.editorContent ?? initialValues?.content ?? "",
Owner

This is redundant if we use content for both initialValues and draft.

This is redundant if we use `content` for both initialValues and draft.
hodlbod marked this conversation as resolved
@@ -34,0 +37,4 @@
pollType?: PollType
endsAt?: number
options?: DraftOption[]
}>(`poll:${url}:${h ?? ""}`)
Owner

Define the types at the top level so they're named

Define the types at the top level so they're named
hodlbod marked this conversation as resolved
Author
Collaborator

Hey @hodlbod, all the feedback has been addressed, and it's ready for review.

Hey @hodlbod, all the feedback has been addressed, and it's ready for review.
hodlbod added 1 commit 2026-04-07 12:06:08 +00:00
hodlbod force-pushed branch-persist-drafts from c84cdc076d to 1b1b1be34e 2026-04-07 12:06:08 +00:00 Compare
hodlbod merged commit 30c2a6ef79 into dev 2026-04-07 12:06:30 +00:00
hodlbod deleted branch branch-persist-drafts 2026-04-07 12:06:30 +00:00
Sign in to join this conversation.