Merge the room and DM composers into Compose and share their reply, edit and share state through ComposeArea
This commit is contained in:
parent
964a1bb6fa
commit
c710a15879
8 changed files with 233 additions and 430 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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']")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
122
src/app/components/ComposeArea.svelte
Normal file
122
src/app/components/ComposeArea.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue