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 @@ - - -
- - -
- -
- {#if dictating || ($empty && !disabled)} - - {:else} - - {/if} - 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}
-
+
- - + {#if url} + + {/if} +
-
+
- {#if dictating || $empty} + {#if dictating || ($empty && !disabled)} @@ -218,7 +237,7 @@ diff --git a/src/app/components/ComposeArea.svelte b/src/app/components/ComposeArea.svelte new file mode 100644 index 00000000..4456f64e --- /dev/null +++ b/src/app/components/ComposeArea.svelte @@ -0,0 +1,122 @@ + + + + +
+ {#if parent} + + {/if} + {#if sharedEvent} + + {/if} + {#if eventToEdit} + + {/if} +
+{#key initialValues} + +{/key} diff --git a/src/app/components/RoomChat.svelte b/src/app/components/RoomChat.svelte index fc0fb600..fc75ad9a 100644 --- a/src/app/components/RoomChat.svelte +++ b/src/app/components/RoomChat.svelte @@ -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 = $state() let older: Maybe> = $state() let newer: Maybe> = $state() - let share: Maybe = $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 = $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")}
{:else} -
- {#if parent} - - {/if} - {#if sharedEvent} - - {/if} - {#if eventToEdit} - - {/if} -
- {#key initialValues} - - {/key} + {/if}
{#if h && isVoiceRoom && !voiceConnectedHere} diff --git a/src/app/share.ts b/src/app/share.ts index 741a25db..7ef1d597 100644 --- a/src/app/share.ts +++ b/src/app/share.ts @@ -16,7 +16,7 @@ export type Share = | {type: "text"; value: string} | {type: "file"; value: File} -export const pendingShare = writable>(undefined) +export const pendingShare = writable>(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) => {