Merge the room and DM composers into Compose and share their reply, edit and share state through ComposeArea

This commit is contained in:
Jon Staab 2026-09-25 16:19:07 -07:00
parent 964a1bb6fa
commit c710a15879
8 changed files with 233 additions and 430 deletions

View file

@ -25,7 +25,7 @@ type Seeded = {readonly id: string; readonly event: SignedEvent}
const composerForm = (page: Page) => const composerForm = (page: Page) =>
page.locator("form").filter({has: page.locator(".note-editor")}) page.locator("form").filter({has: page.locator(".note-editor")})
// RoomCompose's join is the upload button and then the compose menu. // A room composer's join is the upload button and then the compose menu.
const openComposeMenu = (page: Page) => const openComposeMenu = (page: Page) =>
page page
.locator("form") .locator("form")

View file

@ -24,7 +24,7 @@ import {
const suggestions = (page: Page) => page.locator(".tiptap-suggestions__item") const suggestions = (page: Page) => page.locator(".tiptap-suggestions__item")
// RoomCompose leads with a two button group: upload, then the create menu. // A room composer leads with a two button group: upload, then the create menu.
const roomUploadButton = (page: Page) => page.locator(".room__compose-inner .join-item").first() const roomUploadButton = (page: Page) => page.locator(".room__compose-inner .join-item").first()
const chatUploadButton = (page: Page) => page.locator("button[data-tip='Add an image']") const chatUploadButton = (page: Page) => page.locator("button[data-tip='Add an image']")

View file

@ -6,7 +6,6 @@
int, int,
ms, ms,
partition, partition,
ifLet,
spec, spec,
nthEq, nthEq,
nthNe, nthNe,
@ -16,8 +15,7 @@
enumerate, enumerate,
formatTimestampAsDate, formatTimestampAsDate,
} from "@welshman/lib" } from "@welshman/lib"
import type {Maybe} from "@welshman/lib" import type {TrustedEvent, EventTemplate} from "@welshman/util"
import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util"
import {makeEvent, userInbox, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE} from "@welshman/util" import {makeEvent, userInbox, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE} from "@welshman/util"
import {parse, isLink} from "@welshman/content" import {parse, isLink} from "@welshman/content"
import {MessagingRelayLists, Thunks} from "@welshman/app" import {MessagingRelayLists, Thunks} from "@welshman/app"
@ -36,18 +34,14 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMembers from "@app/components/ChatMembers.svelte" import ChatMembers from "@app/components/ChatMembers.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte" import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChatCompose.svelte" import ComposeArea, {type ComposeValues} from "@app/components/ComposeArea.svelte"
import ComposeEdit from "@app/components/ComposeEdit.svelte"
import ComposeParent from "@app/components/ComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte" import ThunkToast from "@app/components/ThunkToast.svelte"
import {app, deletes, router, user, wraps} from "@app/core" import {app, deletes, router, user, wraps} from "@app/core"
import {loadSendDelay} from "@app/settings" import {loadSendDelay} from "@app/settings"
import {deriveChat, makeChatId} from "@app/chats" import {deriveChat, makeChatId} from "@app/chats"
import {makeFeedContext} from "@app/feeds" import {makeFeedContext} from "@app/feeds"
import {navigate, pushModal} from "@app/modal" import {navigate, pushModal} from "@app/modal"
import {DraftKey, type Draft} from "@app/drafts"
import {prependParent} from "@app/rooms" import {prependParent} from "@app/rooms"
import {pendingShare, type Share} from "@app/share"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
type Props = { type Props = {
@ -62,7 +56,6 @@
onDestroy(context.cleanup) onDestroy(context.cleanup)
const chat = deriveChat(chatId) const chat = deriveChat(chatId)
const draftKey = new DraftKey<Draft>(`dm:${chatId}`)
const others = remove($user.pubkey, pubkeys) const others = remove($user.pubkey, pubkeys)
const messagingRelayLists = $app.use(MessagingRelayLists).index.$ const messagingRelayLists = $app.use(MessagingRelayLists).index.$
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayLists.has(pk))) const missingRelayLists = $derived(others.filter(pk => !$messagingRelayLists.has(pk)))
@ -74,35 +67,19 @@
const back = () => navigate("/chat") const back = () => navigate("/chat")
const replyTo = (event: TrustedEvent) => { const replyTo = (event: TrustedEvent) => composeArea?.reply(event)
parent = event
compose?.focus()
}
const clearParent = () => { const onSubmit = async ({parent, edit, ...values}: ComposeValues) => {
parent = undefined
}
const clearEventToEdit = () => {
eventToEdit = undefined
}
const onSubmit = async (params: EventContent) => {
const ptags = others.map(pk => ["p", pk]) const ptags = others.map(pk => ["p", pk])
// Remove p tags since they result in forking the conversation // p tags would fork the conversation, so drop them before quoting the parent
params.tags = params.tags.filter(nthNe(0, "p")) const params = await prependParent(parent, {
content: values.content,
tags: values.tags.filter(nthNe(0, "p")),
})
// Add our reply quote to content if (edit) {
params = await prependParent(parent, params) const command = await $deletes.deleteEvent(edit)
if (eventToEdit) {
// An edit that changes nothing is just a dismissal
if (eventToEdit.content === params.content) {
return clearEventToEdit()
}
const command = await $deletes.deleteEvent(eventToEdit)
await $wraps.publish({event: command.event, recipients: pubkeys, pow: 16}) await $wraps.publish({event: command.event, recipients: pubkeys, pow: 16})
} }
@ -120,7 +97,7 @@
makeEvent(kind, { makeEvent(kind, {
content, content,
tags: [...tags, ...ptags], tags: [...tags, ...ptags],
created_at: eventToEdit?.created_at, created_at: edit?.created_at,
}), }),
) )
} }
@ -155,10 +132,6 @@
), ),
) )
// Publishing reads each recipient's messaging relays first, and a failed read throws before any thunk.
clearParent()
clearEventToEdit()
pushToast({ pushToast({
timeout: 30_000, timeout: 30_000,
children: { children: {
@ -168,46 +141,15 @@
}) })
} }
const onEscape = () => {
clearParent()
clearEventToEdit()
}
const canEditEvent = (event: TrustedEvent) => const canEditEvent = (event: TrustedEvent) =>
event.pubkey === $user.pubkey && event.pubkey === $user.pubkey &&
event.kind === DIRECT_MESSAGE && event.kind === DIRECT_MESSAGE &&
event.created_at >= ago(500, MINUTE) event.created_at >= ago(500, MINUTE)
const onEditEvent = (event: TrustedEvent) => { const onEditEvent = (event: TrustedEvent) => composeArea?.edit(event)
clearParent()
eventToEdit = event
}
const onEditPrevious = () => ifLet($chat?.messages.find(canEditEvent), onEditEvent)
let loading = $state(true) let loading = $state(true)
let compose: ChatCompose | undefined = $state() let composeArea: ComposeArea | undefined = $state()
let parent: TrustedEvent | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
let share: Maybe<Share> = $state()
// Sharing into the conversation already on screen doesn't re-create this component.
$effect(() => {
if ($pendingShare) {
share = $pendingShare
pendingShare.set(undefined)
}
})
const initialValues = $derived.by((): Share | undefined => {
if (eventToEdit) {
return {type: "text", value: eventToEdit.content}
}
if (share) {
return share
}
})
const elements = $derived.by(() => { const elements = $derived.by(() => {
const elements = [] const elements = []
@ -335,25 +277,14 @@
</p> </p>
</PageContent> </PageContent>
<div class="room__compose bg-surface"> <div class="room__compose bg-surface px-2">
<div class="flex flex-col gap-px"> <ComposeArea
{#if parent}
<ComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if eventToEdit}
<ComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key initialValues}
<ChatCompose
bind:this={compose}
{onSubmit} {onSubmit}
{onEscape} encryptFiles
{onEditPrevious} draftKey="dm:{chatId}"
{initialValues} messages={$chat?.messages || []}
dictationKey={draftKey.key} canEdit={canEditEvent}
draftKey={eventToEdit ? undefined : draftKey} disabled={Boolean(missingRelayLists.length)}
disabled={Boolean(missingRelayLists.length)} /> bind:this={composeArea} />
{/key}
</div> </div>
</div> </div>

View file

@ -1,189 +0,0 @@
<script lang="ts">
import {onDestroy, onMount} from "svelte"
import {writable} from "svelte/store"
import cx from "classnames"
import type {MaybeAsync} from "@welshman/lib"
import type {EventContent} from "@welshman/util"
import {escapeHtml, isMobile, preventDefault} from "@lib/html"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import Plane from "@assets/icons/plane-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import DictationButton from "@app/components/DictationButton.svelte"
import EditorEmojiButton from "@app/components/EditorEmojiButton.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {getDictation} from "@app/dictation"
import {type DraftKey, type Draft} from "@app/drafts"
import {pushToast} from "@app/toast"
import type {Share} from "@app/share"
type Props = {
disabled?: boolean
dictationKey: string
draftKey?: DraftKey<Draft>
onEscape?: () => void
onEditPrevious?: () => void
onSubmit: (event: EventContent) => MaybeAsync<void>
initialValues?: Share
}
const {
initialValues,
disabled = false,
dictationKey,
draftKey,
onEscape,
onEditPrevious,
onSubmit,
}: Props = $props()
const autofocus = !isMobile && !disabled
const uploading = writable(false)
const editorClass = $derived(
cx("chat-editor grow overflow-hidden", {
"pointer-events-none opacity-50": disabled,
}),
)
export const focus = () => editor.then(ed => ed.chain().focus().run())
export const canEnterEditPrevious = () =>
editor.then(ed => ed.getText({blockSeparator: "\n"}) === "")
const handleKeyDown = async (event: KeyboardEvent) => {
if (event.key === "Escape") {
onEscape?.()
}
if (event.key === "ArrowUp" && (await canEnterEditPrevious())) {
onEditPrevious?.()
}
}
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
// Tiptap parses a string handed to insertContent as html, so an angle bracket would eat the sentence.
const insertTranscript = async (text: string) => {
const ed = await editor
ed.chain().focus().insertContent(escapeHtml(text)).run()
}
const attachVoiceNote = async (audio: File) => {
const ed = await editor
ed.chain()
.addFile(audio, ed.state.selection.from + 1)
.run()
}
const submit = async () => {
if ($uploading || disabled) {
return
}
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = ed.storage.nostr.getEditorTags()
if (!content) {
return
}
try {
await onSubmit({content, tags})
} catch (error) {
console.error("Failed to send message", error)
return pushToast({theme: "error", message: "Failed to send your message."})
}
draftKey?.clear()
ed.chain().clearContent(true).run()
}
let content = $state(
initialValues?.type === "text" ? initialValues.value : (draftKey?.get()?.content ?? ""),
)
let dictating = $state(Boolean(getDictation(dictationKey)))
const onChange = (json: object) => {
content = json
}
const empty = writable(true)
const editor = makeEditor({
content,
empty,
submit,
uploading,
onChange,
aggressive: true,
encryptFiles: true,
})
$effect(() => {
if ($empty) {
draftKey?.clear()
} else {
draftKey?.set({content})
}
})
onMount(async () => {
const ed = await editor
ed.view.dom.addEventListener("keydown", handleKeyDown)
if (initialValues?.type === "file") {
ed.chain()
.addFile(initialValues.value, ed.state.selection.from + 1)
.run()
}
})
onDestroy(async () => {
const ed = await editor
ed?.view?.dom.removeEventListener("keydown", handleKeyDown)
})
</script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<Button
data-tip="Add an image"
class="button button-neutral button-square tip tip-right h-10 w-10 min-w-10 rounded-2xl transition-colors"
disabled={$uploading || disabled}
onclick={uploadFiles}>
{#if $uploading}
<Spinner size="xs" />
{:else}
<Icon icon={GallerySend} />
{/if}
</Button>
<EditorEmojiButton
{editor}
disabled={$uploading || disabled}
class="button button-neutral button-square tip tip-right h-10 w-10 min-w-10 rounded-2xl transition-colors" />
<div class={editorClass} aria-disabled={disabled}>
<EditorContent {autofocus} {editor} />
</div>
{#if dictating || ($empty && !disabled)}
<DictationButton
key={dictationKey}
bind:dictating
onTranscript={insertTranscript}
onVoiceNote={attachVoiceNote} />
{:else}
<Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
class="button button-primary button-circle tip tip-left h-10 w-10 min-w-10"
disabled={$uploading || disabled}
onclick={submit}>
<Icon icon={Plane} />
</Button>
{/if}
</form>

View file

@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onDestroy, onMount} from "svelte"
import {writable} from "svelte/store" import {writable} from "svelte/store"
import cx from "classnames"
import type {Maybe, MaybeAsync} from "@welshman/lib" import type {Maybe, MaybeAsync} from "@welshman/lib"
import {MESSAGE} from "@welshman/util" import {MESSAGE} from "@welshman/util"
import type {EventContent} from "@welshman/util" import type {EventContent} from "@welshman/util"
@ -23,35 +25,52 @@
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {DraftKey, type Draft} from "@app/drafts" import {DraftKey, type Draft} from "@app/drafts"
import type {Share} from "@app/share" import type {Share} from "@app/share"
import {onDestroy, onMount} from "svelte"
type Props = { type Props = {
url?: string url?: string
h?: string h?: string
draftKey: string
encryptFiles?: boolean
disabled?: boolean
initialValues?: Share
onEscape?: () => void onEscape?: () => void
onEditPrevious?: () => void onEditPrevious?: () => void
onSubmit: (event: EventContent) => MaybeAsync<void> onSubmit: (event: EventContent) => MaybeAsync<void>
initialValues?: Share
} }
const {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props() const {
url,
h,
draftKey,
encryptFiles = false,
disabled = false,
initialValues,
onEscape,
onEditPrevious,
onSubmit,
}: Props = $props()
const key = `room:${url ?? ""}:${h ?? ""}` const draft = initialValues ? undefined : new DraftKey<Draft>(draftKey)
const draftKey = (url || h) && !initialValues ? new DraftKey<Draft>(key) : undefined const autofocus = !isMobile && !disabled
const autofocus = !isMobile
const uploading = writable(false) const uploading = writable(false)
const text = writable("") const text = writable("")
const commandTarget = { const commandTarget = url
url, ? {url, kind: MESSAGE, pubkey: $app.user?.pubkey, tags: h ? [["h", h]] : []}
kind: MESSAGE, : undefined
pubkey: $app.user?.pubkey,
tags: h ? [["h", h]] : [], const buttonClass = url
} ? "join-item h-10 w-10 min-w-10 button button-neutral"
: "button button-neutral button-square tip tip-right h-10 w-10 min-w-10 rounded-2xl transition-colors"
const editorClass = $derived(
cx("chat-editor grow overflow-hidden", {
"pointer-events-none opacity-50": disabled,
}),
)
export const focus = () => editor.then(ed => ed.chain().focus().run()) export const focus = () => editor.then(ed => ed.chain().focus().run())
@ -96,12 +115,12 @@
.run() .run()
} }
const showPopover = () => tippy?.show() const showMenu = () => tippy?.show()
const hidePopover = () => tippy?.hide() const hideMenu = () => tippy?.hide()
const submit = async () => { const submit = async () => {
if ($uploading || sending) { if ($uploading || disabled || sending) {
return return
} }
@ -121,16 +140,16 @@
sending = false sending = false
} }
draftKey?.clear() draft?.clear()
ed.chain().clearContent(true).run() ed.chain().clearContent(true).run()
} }
let sending = false let sending = false
let tippy: Maybe<TippyController> = $state() let tippy: Maybe<TippyController> = $state()
let content = $state( let content = $state(
initialValues?.type === "text" ? initialValues.value : (draftKey?.get()?.content ?? ""), initialValues?.type === "text" ? initialValues.value : (draft?.get()?.content ?? ""),
) )
let dictating = $state(Boolean(getDictation(key))) let dictating = $state(Boolean(getDictation(draftKey)))
const onChange = (json: object) => { const onChange = (json: object) => {
content = json content = json
@ -147,14 +166,15 @@
uploading, uploading,
onChange, onChange,
commandTarget, commandTarget,
encryptFiles,
aggressive: true, aggressive: true,
}) })
$effect(() => { $effect(() => {
if ($empty) { if ($empty) {
draftKey?.clear() draft?.clear()
} else { } else {
draftKey?.set({content}) draft?.set({content})
} }
}) })
@ -175,12 +195,15 @@
}) })
</script> </script>
{#if commandTarget}
<CommandArgBar target={commandTarget} content={$text} insert={insertCommandToken} /> <CommandArgBar target={commandTarget} content={$text} insert={insertCommandToken} />
{/if}
<form class="relative flex gap-2 py-2" onsubmit={preventDefault(submit)}> <form class="relative flex gap-2 py-2" onsubmit={preventDefault(submit)}>
<div class="join"> <div class={cx({join: url, "flex gap-2": !url})}>
<Button <Button
class="join-item h-10 w-10 min-w-10 button button-neutral" data-tip="Add an image"
disabled={$uploading} class={buttonClass}
disabled={$uploading || disabled}
onclick={uploadFiles}> onclick={uploadFiles}>
{#if $uploading} {#if $uploading}
<Spinner size="xs" /> <Spinner size="xs" />
@ -188,29 +211,25 @@
<Icon icon={GallerySend} /> <Icon icon={GallerySend} />
{/if} {/if}
</Button> </Button>
<Button {#if url}
disabled={$uploading} <Button disabled={$uploading} onclick={showMenu} class={buttonClass}>
onclick={showPopover}
class="join-item h-10 w-10 min-w-10 button button-neutral">
<Tippy <Tippy
bind:controller={tippy} bind:controller={tippy}
component={ComposeMenu} component={ComposeMenu}
props={{url, h, onClick: hidePopover}} props={{url, h, onClick: hideMenu}}
params={{trigger: "manual", interactive: true}}> params={{trigger: "manual", interactive: true}}>
<Icon icon={WidgetAdd} /> <Icon icon={WidgetAdd} />
</Tippy> </Tippy>
</Button> </Button>
<EditorEmojiButton {/if}
{editor} <EditorEmojiButton {editor} disabled={$uploading || disabled} class={buttonClass} />
disabled={$uploading}
class="join-item h-10 w-10 min-w-10 button button-neutral" />
</div> </div>
<div class="chat-editor grow overflow-hidden"> <div class={editorClass} aria-disabled={disabled}>
<EditorContent {autofocus} {editor} /> <EditorContent {autofocus} {editor} />
</div> </div>
{#if dictating || $empty} {#if dictating || ($empty && !disabled)}
<DictationButton <DictationButton
{key} key={draftKey}
bind:dictating bind:dictating
onTranscript={insertTranscript} onTranscript={insertTranscript}
onVoiceNote={attachVoiceNote} /> onVoiceNote={attachVoiceNote} />
@ -218,7 +237,7 @@
<Button <Button
data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send" data-tip="{window.navigator.platform.includes('Mac') ? 'cmd' : 'ctrl'}+enter to send"
class="button button-primary button-circle flex justify-center items-center tip tip-left h-10 w-10 min-w-10" class="button button-primary button-circle flex justify-center items-center tip tip-left h-10 w-10 min-w-10"
disabled={$uploading} disabled={$uploading || disabled}
onclick={submit}> onclick={submit}>
<Icon icon={Plane} /> <Icon icon={Plane} />
</Button> </Button>

View file

@ -0,0 +1,122 @@
<script module lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util"
export type ComposeValues = EventContent & {
parent?: TrustedEvent
share?: TrustedEvent
edit?: TrustedEvent
}
</script>
<script lang="ts">
import type {ComponentProps} from "svelte"
import {page} from "$app/stores"
import {ifLet} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import Compose from "@app/components/Compose.svelte"
import ComposeEdit from "@app/components/ComposeEdit.svelte"
import ComposeParent from "@app/components/ComposeParent.svelte"
import {pendingShare, type Share} from "@app/share"
type Props = Pick<
ComponentProps<typeof Compose>,
"url" | "h" | "draftKey" | "encryptFiles" | "disabled"
> & {
messages: TrustedEvent[]
canEdit: (event: TrustedEvent) => boolean
onSubmit: (values: ComposeValues) => Promise<void>
}
const {messages, canEdit, onSubmit, ...props}: Props = $props()
export const reply = (event: TrustedEvent) => {
parent = event
compose?.focus()
}
export const edit = (event: TrustedEvent) => {
clearParent()
clearShare()
eventToEdit = event
}
const clearParent = () => {
parent = undefined
}
const clearShare = () => {
share = undefined
}
const clearEventToEdit = () => {
eventToEdit = undefined
}
const onEscape = () => {
clearParent()
clearShare()
clearEventToEdit()
}
const onEditPrevious = () => ifLet(messages.find(canEdit), edit)
const submit = async ({content, tags}: EventContent) => {
// An edit that changes nothing is just a dismissal
if (eventToEdit?.content === content) {
clearEventToEdit()
} else if (content || sharedEvent) {
await onSubmit({content, tags, parent, share: sharedEvent, edit: eventToEdit})
// Only once it's sent, so a failure leaves the reply or edit in place to try again.
clearParent()
clearShare()
clearEventToEdit()
}
}
let compose: Compose | undefined = $state()
let parent: Maybe<TrustedEvent> = $state()
let share: Maybe<Share> = $state()
let eventToEdit: Maybe<TrustedEvent> = $state()
const sharedEvent = $derived(share?.type === "event" ? share.value : undefined)
const initialValues = $derived.by((): Maybe<Share> => {
if (eventToEdit) {
return {type: "text", value: eventToEdit.content}
}
if (share) {
return share
}
})
// Only the conversation a share was sent to takes it, including the one already on screen.
$effect(() => {
if ($pendingShare?.path === $page.url.pathname) {
share = $pendingShare.share
pendingShare.set(undefined)
}
})
</script>
<div class="flex flex-col gap-px">
{#if parent}
<ComposeParent url={props.url} event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if sharedEvent}
<ComposeParent url={props.url} event={sharedEvent} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<ComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key initialValues}
<Compose
{...props}
{initialValues}
{onEscape}
{onEditPrevious}
onSubmit={submit}
bind:this={compose} />
{/key}

View file

@ -6,9 +6,9 @@
import type {Readable} from "svelte/store" import type {Readable} from "svelte/store"
import {debounce} from "throttle-debounce" import {debounce} from "throttle-debounce"
import cx from "classnames" import cx from "classnames"
import {now, ifLet, ago, spec, MINUTE} from "@welshman/lib" import {now, ago, spec, MINUTE} from "@welshman/lib"
import type {Maybe} from "@welshman/lib" import type {Maybe} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {stamp, MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util" import {stamp, MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util"
import {Message} from "@welshman/domain" import {Message} from "@welshman/domain"
import {MembershipStatus} from "@welshman/app" import {MembershipStatus} from "@welshman/app"
@ -24,9 +24,7 @@
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import VirtualList from "@lib/components/VirtualList.svelte" import VirtualList from "@lib/components/VirtualList.svelte"
import type {VirtualListController} from "@lib/components/VirtualList.svelte" import type {VirtualListController} from "@lib/components/VirtualList.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte" import ComposeArea, {type ComposeValues} from "@app/components/ComposeArea.svelte"
import ComposeEdit from "@app/components/ComposeEdit.svelte"
import ComposeParent from "@app/components/ComposeParent.svelte"
import RoomItem from "@app/components/RoomItem.svelte" import RoomItem from "@app/components/RoomItem.svelte"
import RoomItemAddMember from "@app/components/RoomItemAddMember.svelte" import RoomItemAddMember from "@app/components/RoomItemAddMember.svelte"
import RoomPinnedMessages from "@app/components/RoomPinnedMessages.svelte" import RoomPinnedMessages from "@app/components/RoomPinnedMessages.svelte"
@ -49,7 +47,6 @@
import {pageLoading} from "@app/loading" import {pageLoading} from "@app/loading"
import {checked, deferredRoomPath, setChecked} from "@app/notifications" import {checked, deferredRoomPath, setChecked} from "@app/notifications"
import {getPermalinkTarget, highlightedEvent, makeRoomPath} from "@app/routes" import {getPermalinkTarget, highlightedEvent, makeRoomPath} from "@app/routes"
import {pendingShare, type Share} from "@app/share"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
type Props = { type Props = {
@ -153,38 +150,14 @@
} }
} }
const replyTo = (event: TrustedEvent) => { const replyTo = (event: TrustedEvent) => composeArea?.reply(event)
parent = event
compose?.focus()
}
const clearParent = () => {
parent = undefined
}
const clearShare = () => {
share = undefined
}
const clearEventToEdit = () => {
eventToEdit = undefined
}
const onSubmit = async ({content, tags}: EventContent) => {
if (!content && !sharedEvent) {
return
}
if (eventToEdit) {
// Don't do anything if message hasn't changed
if (eventToEdit.content === content) {
return clearEventToEdit()
}
const onSubmit = async ({content, tags, parent, share, edit}: ComposeValues) => {
if (edit) {
const protect = await shouldProtect const protect = await shouldProtect
// Delete the previous message, to be republished below with the same timestamp // Delete the previous message, to be republished below with the same timestamp
const command = await $deletes.deleteEvent($state.snapshot(eventToEdit), w => const command = await $deletes.deleteEvent($state.snapshot(edit), w =>
w.setProtected(protect), w.setProtected(protect),
) )
@ -192,8 +165,8 @@
} }
// A share is a quote rather than a reply, so setParent prepends the reply's own reference ahead of it. // A share is a quote rather than a reply, so setParent prepends the reply's own reference ahead of it.
if (sharedEvent) { if (share) {
;({content, tags} = await prependParent(sharedEvent, {content, tags}, url)) ;({content, tags} = await prependParent(share, {content, tags}, url))
} }
const eventWriter = await toSpace( const eventWriter = await toSpace(
@ -212,7 +185,7 @@
const thunk = $thunks.publish({ const thunk = $thunks.publish({
relays: [url], relays: [url],
event: stamp(await eventWriter.renderTemplate(), eventToEdit?.created_at), event: stamp(await eventWriter.renderTemplate(), edit?.created_at),
delay, delay,
}) })
@ -225,11 +198,6 @@
}, },
}) })
} }
// Only once it's sent, so a failure leaves the reply or edit in place to try again.
clearParent()
clearShare()
clearEventToEdit()
} }
const getElementKey = (element: {id: string}) => element.id const getElementKey = (element: {id: string}) => element.id
@ -382,8 +350,6 @@
let virtualList: Maybe<VirtualListController> = $state() let virtualList: Maybe<VirtualListController> = $state()
let older: Maybe<ReturnType<typeof makeScrollLoader>> = $state() let older: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
let newer: Maybe<ReturnType<typeof makeScrollLoader>> = $state() let newer: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
let share: Maybe<Share> = $state()
let parent: TrustedEvent | undefined = $state()
let element: HTMLElement | undefined = $state() let element: HTMLElement | undefined = $state()
let lastVisibleAt = now() let lastVisibleAt = now()
let newMessagesAfter = $state($checked[$page.url.pathname]) let newMessagesAfter = $state($checked[$page.url.pathname])
@ -393,8 +359,7 @@
let scrolledUp = $state(false) let scrolledUp = $state(false)
let cleanup: () => void let cleanup: () => void
let events: Readable<TrustedEvent[]> = $state(readable([])) let events: Readable<TrustedEvent[]> = $state(readable([]))
let compose: RoomCompose | undefined = $state() let composeArea: ComposeArea | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
// A link into history renders the newest messages first and only then scrolls. // A link into history renders the newest messages first and only then scrolls.
const awaitingJump = $derived(!isNaN(at) && !jumpSettled) const awaitingJump = $derived(!isNaN(at) && !jumpSettled)
@ -408,26 +373,6 @@
return () => pageLoading.set(false) return () => pageLoading.set(false)
}) })
// Sharing into the room already on screen doesn't re-create this component.
$effect(() => {
if ($pendingShare) {
share = $pendingShare
pendingShare.set(undefined)
}
})
const sharedEvent = $derived(share?.type === "event" ? share.value : undefined)
const initialValues = $derived.by((): Share | undefined => {
if (eventToEdit) {
return {type: "text", value: eventToEdit.content}
}
if (share) {
return share
}
})
const clearIsUserScrolling = debounce(150, () => { const clearIsUserScrolling = debounce(150, () => {
isUserScrolling = false isUserScrolling = false
}) })
@ -519,22 +464,10 @@
} }
} }
const onEscape = () => {
clearParent()
clearShare()
eventToEdit = undefined
}
const canEditEvent = (event: TrustedEvent) => const canEditEvent = (event: TrustedEvent) =>
event.pubkey === $user.pubkey && event.created_at >= ago(5, MINUTE) event.pubkey === $user.pubkey && event.created_at >= ago(5, MINUTE)
const onEditEvent = (event: TrustedEvent) => { const onEditEvent = (event: TrustedEvent) => composeArea?.edit(event)
clearParent()
clearShare()
eventToEdit = event
}
const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent)
onMount(() => { onMount(() => {
// Defer rendering until navigation finishes // Defer rendering until navigation finishes
@ -711,27 +644,14 @@
{@render membershipButton("Ask to Join")} {@render membershipButton("Ask to Join")}
</div> </div>
{:else} {:else}
<div class="flex flex-col gap-px"> <ComposeArea
{#if parent}
<ComposeParent {url} event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if sharedEvent}
<ComposeParent {url} event={sharedEvent} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<ComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key initialValues}
<RoomCompose
{url} {url}
{h} {h}
{onSubmit} {onSubmit}
{onEscape} draftKey="room:{url}:{h ?? ''}"
{onEditPrevious} messages={$events.toReversed()}
{initialValues} canEdit={canEditEvent}
bind:this={compose} /> bind:this={composeArea} />
{/key}
{/if} {/if}
</div> </div>
{#if h && isVoiceRoom && !voiceConnectedHere} {#if h && isVoiceRoom && !voiceConnectedHere}

View file

@ -16,7 +16,7 @@ export type Share =
| {type: "text"; value: string} | {type: "text"; value: string}
| {type: "file"; value: File} | {type: "file"; value: File}
export const pendingShare = writable<Maybe<Share>>(undefined) export const pendingShare = writable<Maybe<{path: string; share: Share}>>(undefined)
// Set pendingShare after navigating so the current view doesn't pop it // Set pendingShare after navigating so the current view doesn't pop it
export const shareTo = async ( export const shareTo = async (
@ -26,7 +26,7 @@ export const shareTo = async (
) => { ) => {
await navigate(path, options) await navigate(path, options)
pendingShare.set(share) pendingShare.set({path, share})
} }
export const shareEvent = (url: string, noun: string, event: TrustedEvent) => { export const shareEvent = (url: string, noun: string, event: TrustedEvent) => {