From c710a15879dd3ff91b10056a56f888bb53076e90 Mon Sep 17 00:00:00 2001
From: Jon Staab
Date: Fri, 25 Sep 2026 16:19:07 -0700
Subject: [PATCH] Merge the room and DM composers into Compose and share their
reply, edit and share state through ComposeArea
---
e2e/specs/articles-threads.spec.ts | 2 +-
e2e/specs/composer.spec.ts | 2 +-
src/app/components/Chat.svelte | 115 +++--------
src/app/components/ChatCompose.svelte | 189 ------------------
.../{RoomCompose.svelte => Compose.svelte} | 109 +++++-----
src/app/components/ComposeArea.svelte | 122 +++++++++++
src/app/components/RoomChat.svelte | 120 ++---------
src/app/share.ts | 4 +-
8 files changed, 233 insertions(+), 430 deletions(-)
delete mode 100644 src/app/components/ChatCompose.svelte
rename src/app/components/{RoomCompose.svelte => Compose.svelte} (71%)
create mode 100644 src/app/components/ComposeArea.svelte
diff --git a/e2e/specs/articles-threads.spec.ts b/e2e/specs/articles-threads.spec.ts
index 9618b164..66dc46c3 100644
--- a/e2e/specs/articles-threads.spec.ts
+++ b/e2e/specs/articles-threads.spec.ts
@@ -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")
diff --git a/e2e/specs/composer.spec.ts b/e2e/specs/composer.spec.ts
index 764ff22b..3ad0fb3d 100644
--- a/e2e/specs/composer.spec.ts
+++ b/e2e/specs/composer.spec.ts
@@ -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']")
diff --git a/src/app/components/Chat.svelte b/src/app/components/Chat.svelte
index 5eb8b285..5b6f9dc7 100644
--- a/src/app/components/Chat.svelte
+++ b/src/app/components/Chat.svelte
@@ -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(`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 = $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 @@
-
-
- {#if parent}
-
- {/if}
- {#if eventToEdit}
-
- {/if}
-
- {#key initialValues}
-
- {/key}
+
+
diff --git a/src/app/components/ChatCompose.svelte b/src/app/components/ChatCompose.svelte
deleted file mode 100644
index 97af9a03..00000000
--- a/src/app/components/ChatCompose.svelte
+++ /dev/null
@@ -1,189 +0,0 @@
-
-
-
diff --git a/src/app/components/RoomCompose.svelte b/src/app/components/Compose.svelte
similarity index 71%
rename from src/app/components/RoomCompose.svelte
rename to src/app/components/Compose.svelte
index bbdaf654..cdd855ba 100644
--- a/src/app/components/RoomCompose.svelte
+++ b/src/app/components/Compose.svelte
@@ -1,5 +1,7 @@
-
+{#if commandTarget}
+
+{/if}