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) =>
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) =>
page
.locator("form")

View file

@ -24,7 +24,7 @@ import {
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 chatUploadButton = (page: Page) => page.locator("button[data-tip='Add an image']")

View file

@ -6,7 +6,6 @@
int,
ms,
partition,
ifLet,
spec,
nthEq,
nthNe,
@ -16,8 +15,7 @@
enumerate,
formatTimestampAsDate,
} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util"
import type {TrustedEvent, EventTemplate} from "@welshman/util"
import {makeEvent, userInbox, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE} from "@welshman/util"
import {parse, isLink} from "@welshman/content"
import {MessagingRelayLists, Thunks} from "@welshman/app"
@ -36,18 +34,14 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMembers from "@app/components/ChatMembers.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChatCompose.svelte"
import ComposeEdit from "@app/components/ComposeEdit.svelte"
import ComposeParent from "@app/components/ComposeParent.svelte"
import ComposeArea, {type ComposeValues} from "@app/components/ComposeArea.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import {app, deletes, router, user, wraps} from "@app/core"
import {loadSendDelay} from "@app/settings"
import {deriveChat, makeChatId} from "@app/chats"
import {makeFeedContext} from "@app/feeds"
import {navigate, pushModal} from "@app/modal"
import {DraftKey, type Draft} from "@app/drafts"
import {prependParent} from "@app/rooms"
import {pendingShare, type Share} from "@app/share"
import {pushToast} from "@app/toast"
type Props = {
@ -62,7 +56,6 @@
onDestroy(context.cleanup)
const chat = deriveChat(chatId)
const draftKey = new DraftKey<Draft>(`dm:${chatId}`)
const others = remove($user.pubkey, pubkeys)
const messagingRelayLists = $app.use(MessagingRelayLists).index.$
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayLists.has(pk)))
@ -74,35 +67,19 @@
const back = () => navigate("/chat")
const replyTo = (event: TrustedEvent) => {
parent = event
compose?.focus()
}
const replyTo = (event: TrustedEvent) => composeArea?.reply(event)
const clearParent = () => {
parent = undefined
}
const clearEventToEdit = () => {
eventToEdit = undefined
}
const onSubmit = async (params: EventContent) => {
const onSubmit = async ({parent, edit, ...values}: ComposeValues) => {
const ptags = others.map(pk => ["p", pk])
// Remove p tags since they result in forking the conversation
params.tags = params.tags.filter(nthNe(0, "p"))
// p tags would fork the conversation, so drop them before quoting the parent
const params = await prependParent(parent, {
content: values.content,
tags: values.tags.filter(nthNe(0, "p")),
})
// Add our reply quote to content
params = await prependParent(parent, params)
if (eventToEdit) {
// An edit that changes nothing is just a dismissal
if (eventToEdit.content === params.content) {
return clearEventToEdit()
}
const command = await $deletes.deleteEvent(eventToEdit)
if (edit) {
const command = await $deletes.deleteEvent(edit)
await $wraps.publish({event: command.event, recipients: pubkeys, pow: 16})
}
@ -120,7 +97,7 @@
makeEvent(kind, {
content,
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({
timeout: 30_000,
children: {
@ -168,46 +141,15 @@
})
}
const onEscape = () => {
clearParent()
clearEventToEdit()
}
const canEditEvent = (event: TrustedEvent) =>
event.pubkey === $user.pubkey &&
event.kind === DIRECT_MESSAGE &&
event.created_at >= ago(500, MINUTE)
const onEditEvent = (event: TrustedEvent) => {
clearParent()
eventToEdit = event
}
const onEditPrevious = () => ifLet($chat?.messages.find(canEditEvent), onEditEvent)
const onEditEvent = (event: TrustedEvent) => composeArea?.edit(event)
let loading = $state(true)
let compose: ChatCompose | 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
}
})
let composeArea: ComposeArea | undefined = $state()
const elements = $derived.by(() => {
const elements = []
@ -335,25 +277,14 @@
</p>
</PageContent>
<div class="room__compose bg-surface">
<div class="flex flex-col gap-px">
{#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}
{onEscape}
{onEditPrevious}
{initialValues}
dictationKey={draftKey.key}
draftKey={eventToEdit ? undefined : draftKey}
disabled={Boolean(missingRelayLists.length)} />
{/key}
<div class="room__compose bg-surface px-2">
<ComposeArea
{onSubmit}
encryptFiles
draftKey="dm:{chatId}"
messages={$chat?.messages || []}
canEdit={canEditEvent}
disabled={Boolean(missingRelayLists.length)}
bind:this={composeArea} />
</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">
import {onDestroy, onMount} from "svelte"
import {writable} from "svelte/store"
import cx from "classnames"
import type {Maybe, MaybeAsync} from "@welshman/lib"
import {MESSAGE} from "@welshman/util"
import type {EventContent} from "@welshman/util"
@ -23,35 +25,52 @@
import {pushToast} from "@app/toast"
import {DraftKey, type Draft} from "@app/drafts"
import type {Share} from "@app/share"
import {onDestroy, onMount} from "svelte"
type Props = {
url?: string
h?: string
draftKey: string
encryptFiles?: boolean
disabled?: boolean
initialValues?: Share
onEscape?: () => void
onEditPrevious?: () => 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
const autofocus = !isMobile && !disabled
const uploading = writable(false)
const text = writable("")
const commandTarget = {
url,
kind: MESSAGE,
pubkey: $app.user?.pubkey,
tags: h ? [["h", h]] : [],
}
const commandTarget = url
? {url, kind: MESSAGE, pubkey: $app.user?.pubkey, tags: h ? [["h", h]] : []}
: undefined
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())
@ -96,12 +115,12 @@
.run()
}
const showPopover = () => tippy?.show()
const showMenu = () => tippy?.show()
const hidePopover = () => tippy?.hide()
const hideMenu = () => tippy?.hide()
const submit = async () => {
if ($uploading || sending) {
if ($uploading || disabled || sending) {
return
}
@ -121,16 +140,16 @@
sending = false
}
draftKey?.clear()
draft?.clear()
ed.chain().clearContent(true).run()
}
let sending = false
let tippy: Maybe<TippyController> = $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) => {
content = json
@ -147,14 +166,15 @@
uploading,
onChange,
commandTarget,
encryptFiles,
aggressive: true,
})
$effect(() => {
if ($empty) {
draftKey?.clear()
draft?.clear()
} else {
draftKey?.set({content})
draft?.set({content})
}
})
@ -175,12 +195,15 @@
})
</script>
<CommandArgBar target={commandTarget} content={$text} insert={insertCommandToken} />
{#if commandTarget}
<CommandArgBar target={commandTarget} content={$text} insert={insertCommandToken} />
{/if}
<form class="relative flex gap-2 py-2" onsubmit={preventDefault(submit)}>
<div class="join">
<div class={cx({join: url, "flex gap-2": !url})}>
<Button
class="join-item h-10 w-10 min-w-10 button button-neutral"
disabled={$uploading}
data-tip="Add an image"
class={buttonClass}
disabled={$uploading || disabled}
onclick={uploadFiles}>
{#if $uploading}
<Spinner size="xs" />
@ -188,29 +211,25 @@
<Icon icon={GallerySend} />
{/if}
</Button>
<Button
disabled={$uploading}
onclick={showPopover}
class="join-item h-10 w-10 min-w-10 button button-neutral">
<Tippy
bind:controller={tippy}
component={ComposeMenu}
props={{url, h, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}>
<Icon icon={WidgetAdd} />
</Tippy>
</Button>
<EditorEmojiButton
{editor}
disabled={$uploading}
class="join-item h-10 w-10 min-w-10 button button-neutral" />
{#if url}
<Button disabled={$uploading} onclick={showMenu} class={buttonClass}>
<Tippy
bind:controller={tippy}
component={ComposeMenu}
props={{url, h, onClick: hideMenu}}
params={{trigger: "manual", interactive: true}}>
<Icon icon={WidgetAdd} />
</Tippy>
</Button>
{/if}
<EditorEmojiButton {editor} disabled={$uploading || disabled} class={buttonClass} />
</div>
<div class="chat-editor grow overflow-hidden">
<div class={editorClass} aria-disabled={disabled}>
<EditorContent {autofocus} {editor} />
</div>
{#if dictating || $empty}
{#if dictating || ($empty && !disabled)}
<DictationButton
{key}
key={draftKey}
bind:dictating
onTranscript={insertTranscript}
onVoiceNote={attachVoiceNote} />
@ -218,7 +237,7 @@
<Button
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"
disabled={$uploading}
disabled={$uploading || disabled}
onclick={submit}>
<Icon icon={Plane} />
</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 {debounce} from "throttle-debounce"
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 {TrustedEvent, EventContent} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {stamp, MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util"
import {Message} from "@welshman/domain"
import {MembershipStatus} from "@welshman/app"
@ -24,9 +24,7 @@
import Spinner from "@lib/components/Spinner.svelte"
import VirtualList from "@lib/components/VirtualList.svelte"
import type {VirtualListController} from "@lib/components/VirtualList.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte"
import ComposeEdit from "@app/components/ComposeEdit.svelte"
import ComposeParent from "@app/components/ComposeParent.svelte"
import ComposeArea, {type ComposeValues} from "@app/components/ComposeArea.svelte"
import RoomItem from "@app/components/RoomItem.svelte"
import RoomItemAddMember from "@app/components/RoomItemAddMember.svelte"
import RoomPinnedMessages from "@app/components/RoomPinnedMessages.svelte"
@ -49,7 +47,6 @@
import {pageLoading} from "@app/loading"
import {checked, deferredRoomPath, setChecked} from "@app/notifications"
import {getPermalinkTarget, highlightedEvent, makeRoomPath} from "@app/routes"
import {pendingShare, type Share} from "@app/share"
import {pushToast} from "@app/toast"
type Props = {
@ -153,38 +150,14 @@
}
}
const replyTo = (event: TrustedEvent) => {
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 replyTo = (event: TrustedEvent) => composeArea?.reply(event)
const onSubmit = async ({content, tags, parent, share, edit}: ComposeValues) => {
if (edit) {
const protect = await shouldProtect
// 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),
)
@ -192,8 +165,8 @@
}
// A share is a quote rather than a reply, so setParent prepends the reply's own reference ahead of it.
if (sharedEvent) {
;({content, tags} = await prependParent(sharedEvent, {content, tags}, url))
if (share) {
;({content, tags} = await prependParent(share, {content, tags}, url))
}
const eventWriter = await toSpace(
@ -212,7 +185,7 @@
const thunk = $thunks.publish({
relays: [url],
event: stamp(await eventWriter.renderTemplate(), eventToEdit?.created_at),
event: stamp(await eventWriter.renderTemplate(), edit?.created_at),
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
@ -382,8 +350,6 @@
let virtualList: Maybe<VirtualListController> = $state()
let older: 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 lastVisibleAt = now()
let newMessagesAfter = $state($checked[$page.url.pathname])
@ -393,8 +359,7 @@
let scrolledUp = $state(false)
let cleanup: () => void
let events: Readable<TrustedEvent[]> = $state(readable([]))
let compose: RoomCompose | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
let composeArea: ComposeArea | undefined = $state()
// A link into history renders the newest messages first and only then scrolls.
const awaitingJump = $derived(!isNaN(at) && !jumpSettled)
@ -408,26 +373,6 @@
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, () => {
isUserScrolling = false
})
@ -519,22 +464,10 @@
}
}
const onEscape = () => {
clearParent()
clearShare()
eventToEdit = undefined
}
const canEditEvent = (event: TrustedEvent) =>
event.pubkey === $user.pubkey && event.created_at >= ago(5, MINUTE)
const onEditEvent = (event: TrustedEvent) => {
clearParent()
clearShare()
eventToEdit = event
}
const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent)
const onEditEvent = (event: TrustedEvent) => composeArea?.edit(event)
onMount(() => {
// Defer rendering until navigation finishes
@ -711,27 +644,14 @@
{@render membershipButton("Ask to Join")}
</div>
{:else}
<div class="flex flex-col gap-px">
{#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}
{h}
{onSubmit}
{onEscape}
{onEditPrevious}
{initialValues}
bind:this={compose} />
{/key}
<ComposeArea
{url}
{h}
{onSubmit}
draftKey="room:{url}:{h ?? ''}"
messages={$events.toReversed()}
canEdit={canEditEvent}
bind:this={composeArea} />
{/if}
</div>
{#if h && isVoiceRoom && !voiceConnectedHere}

View file

@ -16,7 +16,7 @@ export type Share =
| {type: "text"; value: string}
| {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
export const shareTo = async (
@ -26,7 +26,7 @@ export const shareTo = async (
) => {
await navigate(path, options)
pendingShare.set(share)
pendingShare.set({path, share})
}
export const shareEvent = (url: string, noun: string, event: TrustedEvent) => {