feat: persist composer drafts in memory across navigation

This commit is contained in:
2026-04-04 16:52:22 +05:45
parent 70e5172f1b
commit c84cdc076d
12 changed files with 257 additions and 55 deletions
+39 -14
View File
@@ -20,24 +20,33 @@
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {PROTECTED} from "@app/core/state" import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {canEnforceNip70} from "@app/core/commands" import {canEnforceNip70} from "@app/core/commands"
type CalendarValues = {
d?: string
title: string
content: unknown
location: string
start: number | undefined
end: number | undefined
}
type Props = { type Props = {
url: string url: string
h?: string h?: string
header: Snippet header: Snippet
initialValues?: { initialValues?: CalendarValues
d: string
title: string
content: string
location: string
start: number
end: number
}
} }
const {url, h, header, initialValues}: Props = $props() let {url, h, header, initialValues}: Props = $props()
const draftKey = new DraftKey<CalendarValues>(`calendar:${url}:${h ?? ""}`)
const draft = draftKey.get()
if (!initialValues) {
initialValues = draft
}
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
@@ -95,17 +104,33 @@
pushToast({message: "Your event has been saved!"}) pushToast({message: "Your event has been saved!"})
publishThunk({event, relays: [url]}) publishThunk({event, relays: [url]})
draftKey.clear()
history.back() history.back()
} }
const content = initialValues?.content || "" let editorContent = $state<unknown>(initialValues?.content ?? "")
const editor = makeEditor({url, submit, uploading, content})
let title = $state(initialValues?.title || "") const onChange = (json: unknown) => {
let location = $state(initialValues?.location || "") editorContent = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
content: initialValues?.content ?? "",
})
let title = $state(initialValues?.title ?? "")
let location = $state(initialValues?.location ?? "")
let start: number | undefined = $state(initialValues?.start) let start: number | undefined = $state(initialValues?.start)
let end: number | undefined = $state(initialValues?.end) let end: number | undefined = $state(initialValues?.end)
let endDirty = Boolean(initialValues?.end) let endDirty = $state(Boolean(initialValues?.end))
$effect(() => {
draftKey.set({d: initialValues?.d, title, location, start, end, content: editorContent})
})
$effect(() => { $effect(() => {
if (!endDirty && start) { if (!endDirty && start) {
+3
View File
@@ -55,6 +55,7 @@
import ThunkToast from "@app/components/ThunkToast.svelte" import ThunkToast from "@app/components/ThunkToast.svelte"
import {userSettingsValues, deriveChat} from "@app/core/state" import {userSettingsValues, deriveChat} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {DraftKey} from "@app/util/drafts"
import {makeDelete, prependParent} from "@app/core/commands" import {makeDelete, prependParent} from "@app/core/commands"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
@@ -66,6 +67,7 @@
const {pubkeys, info}: Props = $props() const {pubkeys, info}: Props = $props()
const chat = deriveChat(pubkeys) const chat = deriveChat(pubkeys)
const draftKey = new DraftKey<{content?: unknown}>(`dm:${$chat?.id}`)
const others = remove($pubkey!, pubkeys) const others = remove($pubkey!, pubkeys)
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk))) const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
@@ -337,6 +339,7 @@
{onEscape} {onEscape}
{onEditPrevious} {onEditPrevious}
content={eventToEdit?.content} content={eventToEdit?.content}
draftKey={eventToEdit ? undefined : draftKey}
disabled={Boolean(missingRelayLists.length)} /> disabled={Boolean(missingRelayLists.length)} />
{/key} {/key}
</div> </div>
+12 -2
View File
@@ -10,16 +10,20 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {type DraftKey} from "@app/util/drafts"
type Props = { type Props = {
content?: string content?: string
disabled?: boolean disabled?: boolean
draftKey?: DraftKey<{content?: unknown}>
onEscape?: () => void onEscape?: () => void
onEditPrevious?: () => void onEditPrevious?: () => void
onSubmit: (event: EventContent) => void onSubmit: (event: EventContent) => void
} }
const {content, disabled = false, onEscape, onEditPrevious, onSubmit}: Props = $props() const {content, disabled = false, draftKey, onEscape, onEditPrevious, onSubmit}: Props = $props()
const draft = draftKey?.get()
const autofocus = !isMobile && !disabled const autofocus = !isMobile && !disabled
@@ -59,14 +63,20 @@
onSubmit({content, tags}) onSubmit({content, tags})
draftKey?.clear()
ed.chain().clearContent().run() ed.chain().clearContent().run()
} }
const onChange = (json: unknown) => {
draftKey?.set({content: json})
}
const editor = makeEditor({ const editor = makeEditor({
content, content: content ?? (draft?.content as string | object | undefined),
autofocus, autofocus,
submit, submit,
uploading, uploading,
onChange,
aggressive: true, aggressive: true,
encryptFiles: true, encryptFiles: true,
}) })
+42 -10
View File
@@ -20,6 +20,7 @@
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state" import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70, uploadFile} from "@app/core/commands" import {canEnforceNip70, uploadFile} from "@app/core/commands"
type Props = { type Props = {
@@ -32,13 +33,31 @@
content?: string content?: string
price?: number price?: number
currency?: string currency?: string
images?: string[] images?: (string | File)[]
status?: string status?: string
topics?: string[] topics?: string[]
} }
} }
const {url, h, header, initialValues}: Props = $props() let {url, h, header, initialValues}: Props = $props()
type ClassifiedDraft = {
editorContent?: unknown
d?: string
title?: string
content?: string
price?: number
currency?: string
images?: (string | File)[]
status?: string
topics?: string[]
}
const draftKey = new DraftKey<ClassifiedDraft>(`classified:${url}:${h ?? ""}`)
const draft = draftKey.get()
if (!initialValues) {
initialValues = draft
}
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
@@ -110,22 +129,35 @@
event: makeEvent(CLASSIFIED, {content, tags}), event: makeEvent(CLASSIFIED, {content, tags}),
}) })
draftKey.clear()
history.back() history.back()
} finally { } finally {
loading = false loading = false
} }
} }
const content = initialValues?.content || "" const initialContent = initialValues?.content || ""
const editor = makeEditor({url, submit, content}) const onChange = (json: unknown) => {
draftKey.set({editorContent: json, title, price, currency, topics, status, images})
}
const editor = makeEditor({
url,
submit,
onChange,
content: draft?.editorContent ?? initialContent,
})
let loading = $state(false) let loading = $state(false)
let title = $state(initialValues?.title || "") let title = $state(initialValues?.title ?? "")
let status = $state(initialValues?.status || "active") let status = $state(initialValues?.status ?? "active")
let price = $state(Number(initialValues?.price || 0)) let price = $state(Number(initialValues?.price ?? 0))
let currency = $state(initialValues?.currency || "SAT") let currency = $state(initialValues?.currency ?? "SAT")
let images = $state<(string | File)[]>(initialValues?.images || []) let images = $state<(string | File)[]>(initialValues?.images ?? [])
let topics = $state(uniq(removeUndefined((initialValues?.topics || []).map(normalizeTopic)))) let topics = $state(uniq(removeUndefined((initialValues?.topics ?? []).map(normalizeTopic))))
$effect(() => {
draftKey.set({...draftKey.get(), title, price, currency, topics, status, images})
})
</script> </script>
<Modal tag="form" onsubmit={preventDefault(submit)}> <Modal tag="form" onsubmit={preventDefault(submit)}>
+15 -1
View File
@@ -10,10 +10,14 @@
import {publishComment, canEnforceNip70} from "@app/core/commands" import {publishComment, canEnforceNip70} from "@app/core/commands"
import {PROTECTED} from "@app/core/state" import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
const {url, event, onClose, onSubmit} = $props() const {url, event, onClose, onSubmit} = $props()
const draftKey = new DraftKey<{content?: unknown}>(`reply:${event.id}`)
const draft = draftKey.get()
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
const uploading = writable(false) const uploading = writable(false)
@@ -38,10 +42,20 @@
}) })
} }
draftKey.clear()
onSubmit(publishComment({event, content, tags, relays: [url]})) onSubmit(publishComment({event, content, tags, relays: [url]}))
} }
const editor = makeEditor({url, submit, uploading, autofocus: !isMobile}) const onChange = (json: unknown) => draftKey.set({content: json})
const editor = makeEditor({
url,
submit,
uploading,
autofocus: !isMobile,
content: draft?.content as string | object | undefined,
onChange,
})
let form: HTMLElement let form: HTMLElement
let spacer: HTMLElement let spacer: HTMLElement
+35 -4
View File
@@ -20,14 +20,31 @@
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state" import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70} from "@app/core/commands" import {canEnforceNip70} from "@app/core/commands"
type Props = { type Props = {
url: string url: string
h?: string h?: string
initialValues?: {
content?: string
amount?: number
}
} }
const {url, h}: Props = $props() let {url, h, initialValues}: Props = $props()
type GoalDraft = {
editorContent?: unknown
content?: string
amount?: number
}
const draftKey = new DraftKey<GoalDraft>(`goal:${url}:${h ?? ""}`)
const draft = draftKey.get()
if (!initialValues) {
initialValues = draft
}
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
@@ -77,13 +94,27 @@
event: makeEvent(ZAP_GOAL, {content, tags}), event: makeEvent(ZAP_GOAL, {content, tags}),
}) })
draftKey.clear()
history.back() history.back()
} }
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"}) const onChange = (json: unknown) => draftKey.update({editorContent: json})
let content = $state("") const editor = makeEditor({
let amount = $state(1000) url,
submit,
uploading,
onChange,
placeholder: "What's on your mind?",
content: draft?.editorContent as string | object | undefined,
})
let content = $state(initialValues?.content ?? "")
let amount = $state(initialValues?.amount ?? 1000)
$effect(() => {
draftKey.update({content, amount})
})
</script> </script>
<Modal tag="form" onsubmit={preventDefault(submit)}> <Modal tag="form" onsubmit={preventDefault(submit)}>
+30 -12
View File
@@ -22,8 +22,21 @@
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state" import {PROTECTED} from "@app/core/state"
import {canEnforceNip70} from "@app/core/commands" import {canEnforceNip70} from "@app/core/commands"
import {DraftKey} from "@app/util/drafts"
import type {PollType} from "@app/util/polls" import type {PollType} from "@app/util/polls"
type DraftOption = {
id: string
value: string
}
type PollDraft = {
title?: string
pollType?: PollType
endsAt?: number
options?: DraftOption[]
}
type Props = { type Props = {
url: string url: string
h?: string h?: string
@@ -31,12 +44,10 @@
const {url, h}: Props = $props() const {url, h}: Props = $props()
const shouldProtect = canEnforceNip70(url) const draftKey = new DraftKey<PollDraft>(`poll:${url}:${h ?? ""}`)
const draft = draftKey.get()
type DraftOption = { const shouldProtect = canEnforceNip70(url)
id: string
value: string
}
const back = () => history.back() const back = () => history.back()
@@ -129,17 +140,24 @@
event: makeEvent(Poll, {content: title.trim(), tags}), event: makeEvent(Poll, {content: title.trim(), tags}),
}) })
draftKey.clear()
history.back() history.back()
} }
let title = $state("") let title = $state(draft?.title ?? "")
let pollType = $state<PollType>("singlechoice") let pollType = $state<PollType>(draft?.pollType ?? "singlechoice")
let endsAt = $state<number | undefined>() let endsAt = $state<number | undefined>(draft?.endsAt)
let options = $state<DraftOption[]>([ let options = $state<DraftOption[]>(
{id: randomId(), value: "Yes"}, draft?.options ?? [
{id: randomId(), value: "No"}, {id: randomId(), value: "Yes"},
]) {id: randomId(), value: "No"},
],
)
let draggedOptionId = $state<string | undefined>() let draggedOptionId = $state<string | undefined>()
$effect(() => {
draftKey.set({title, pollType, endsAt, options})
})
</script> </script>
<Modal tag="form" onsubmit={preventDefault(submit)}> <Modal tag="form" onsubmit={preventDefault(submit)}>
+20 -1
View File
@@ -12,6 +12,7 @@
import ComposeMenu from "@app/components/ComposeMenu.svelte" import ComposeMenu from "@app/components/ComposeMenu.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {onDestroy, onMount} from "svelte" import {onDestroy, onMount} from "svelte"
type Props = { type Props = {
@@ -25,6 +26,11 @@
const {url, h, content, onEscape, onEditPrevious, onSubmit}: Props = $props() const {url, h, content, onEscape, onEditPrevious, onSubmit}: Props = $props()
type ComposeDraft = {content?: unknown}
const draftKey = url || h ? new DraftKey<ComposeDraft>(`room:${url ?? ""}:${h ?? ""}`) : undefined
const draft = draftKey?.get()
const autofocus = !isMobile const autofocus = !isMobile
const uploading = writable(false) const uploading = writable(false)
@@ -61,10 +67,23 @@
onSubmit({content, tags}) onSubmit({content, tags})
draftKey?.clear()
ed.chain().clearContent().run() ed.chain().clearContent().run()
} }
const editor = makeEditor({url, content, autofocus, submit, uploading, aggressive: true}) const onChange = (json: unknown) => {
draftKey?.set({content: json})
}
const editor = makeEditor({
url,
content: content ?? (draft?.content as string | object | undefined),
autofocus,
submit,
uploading,
onChange,
aggressive: true,
})
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
+20 -2
View File
@@ -18,6 +18,7 @@
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {PROTECTED} from "@app/core/state" import {PROTECTED} from "@app/core/state"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/util/drafts"
import {canEnforceNip70} from "@app/core/commands" import {canEnforceNip70} from "@app/core/commands"
type Props = { type Props = {
@@ -27,6 +28,9 @@
const {url, h}: Props = $props() const {url, h}: Props = $props()
const draftKey = new DraftKey<{content?: unknown; title?: string}>(`thread:${url}:${h ?? ""}`)
const draft = draftKey.get()
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
const uploading = writable(false) const uploading = writable(false)
@@ -70,12 +74,26 @@
event: makeEvent(THREAD, {content, tags}), event: makeEvent(THREAD, {content, tags}),
}) })
draftKey.clear()
history.back() history.back()
} }
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"}) const onChange = (json: unknown) => draftKey.update({content: json})
let title: string = $state("") const editor = makeEditor({
url,
submit,
uploading,
onChange,
placeholder: "What's on your mind?",
content: draft?.content as string | object | undefined,
})
let title: string = $state(draft?.title ?? "")
$effect(() => {
draftKey.update({title})
})
</script> </script>
<Modal tag="form" onsubmit={preventDefault(submit)}> <Modal tag="form" onsubmit={preventDefault(submit)}>
+9 -5
View File
@@ -11,13 +11,17 @@
let element: HTMLElement let element: HTMLElement
onMount(() => { onMount(() => {
editor.then(({options}) => { editor.then(ed => {
if (options.element) { if (ed.options.element) {
element?.append(options.element) element?.append(ed.options.element)
} }
if (options.autofocus) { if ((ed as any)._shouldAutofocus) {
;(element?.querySelector("[contenteditable]") as HTMLElement)?.focus() const hasContent = ed.getText().trim().length > 0
requestAnimationFrame(() => {
ed.commands.focus(hasContent ? "end" : "start")
})
} }
}) })
}) })
+11 -4
View File
@@ -28,6 +28,7 @@ export const makeEditor = async ({
autofocus = false, autofocus = false,
charCount, charCount,
content = "", content = "",
onChange,
placeholder = "", placeholder = "",
url, url,
submit, submit,
@@ -38,7 +39,8 @@ export const makeEditor = async ({
aggressive?: boolean aggressive?: boolean
autofocus?: boolean autofocus?: boolean
charCount?: Writable<number> charCount?: Writable<number>
content?: string content?: string | object
onChange?: (json: unknown) => void
placeholder?: string placeholder?: string
url?: string url?: string
submit: () => void submit: () => void
@@ -82,9 +84,9 @@ export const makeEditor = async ({
}, },
) )
return new Editor({ const ed = new Editor({
content: escapeHtml(content), content: typeof content === "string" ? escapeHtml(content) : content,
autofocus, autofocus: false,
editorProps, editorProps,
element: document.createElement("div"), element: document.createElement("div"),
extensions: [ extensions: [
@@ -142,6 +144,11 @@ export const makeEditor = async ({
onUpdate({editor}) { onUpdate({editor}) {
wordCount?.set(editor.storage.wordCount.words) wordCount?.set(editor.storage.wordCount.words)
charCount?.set(editor.storage.wordCount.chars) charCount?.set(editor.storage.wordCount.chars)
onChange?.(editor.getJSON())
}, },
}) })
;(ed as any)._shouldAutofocus = autofocus
return ed
} }
+21
View File
@@ -0,0 +1,21 @@
const store = new Map<string, unknown>()
export class DraftKey<T> {
constructor(private key: string) {}
get(): T | undefined {
return store.get(this.key) as T | undefined
}
set(value: T): void {
store.set(this.key, value)
}
update(value: Partial<T>): void {
this.set({...this.get(), ...value} as T)
}
clear(): void {
store.delete(this.key)
}
}