From cd54bc2880a270ca24590a6ffe6f7acc96888f71 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 10 Mar 2026 15:13:33 -0700 Subject: [PATCH] Add up/edit to chats --- CHANGELOG.md | 5 + src/app/components/Chat.svelte | 187 ++++++++++++------ src/app/components/ChatCompose.svelte | 30 ++- src/app/components/ChatComposeEdit.svelte | 21 ++ src/app/components/ChatMessage.svelte | 11 +- src/app/components/ChatMessageMenu.svelte | 9 +- .../components/ChatMessageMenuMobile.svelte | 15 +- src/app/components/KeyRecoveryConfirm.svelte | 2 +- src/app/components/LogInOTPConfirm.svelte | 2 +- src/app/components/PasswordReset.svelte | 2 +- src/app/core/state.ts | 57 ++++-- src/routes/settings/profile/+page.svelte | 2 +- src/routes/spaces/[relay]/[h]/+page.svelte | 10 +- src/routes/spaces/[relay]/chat/+page.svelte | 10 +- 14 files changed, 258 insertions(+), 105 deletions(-) create mode 100644 src/app/components/ChatComposeEdit.svelte diff --git a/CHANGELOG.md b/CHANGELOG.md index 248948ca..330b59cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +# Current + +* Enable email/password login +* Add up/edit to direct messages + # 1.6.5 * Attempt to fix permission grant for notifications diff --git a/src/app/components/Chat.svelte b/src/app/components/Chat.svelte index 98d233fe..283b6198 100644 --- a/src/app/components/Chat.svelte +++ b/src/app/components/Chat.svelte @@ -2,9 +2,11 @@ import type {Snippet} from "svelte" import {onMount} from "svelte" import { + ago, int, ms, partition, + ifLet, spec, nthEq, nthNe, @@ -46,11 +48,12 @@ import ChatMembers from "@app/components/ChatMembers.svelte" import ChatMessage from "@app/components/ChatMessage.svelte" import ChatCompose from "@app/components/ChatCompose.svelte" + import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte" import ChatComposeParent from "@app/components/ChatComposeParent.svelte" import ThunkToast from "@app/components/ThunkToast.svelte" import {userSettingsValues, PLATFORM_NAME, deriveChat} from "@app/core/state" import {pushModal} from "@app/util/modal" - import {prependParent} from "@app/core/commands" + import {makeDelete, prependParent} from "@app/core/commands" import {pushToast} from "@app/util/toast" type Props = { @@ -78,73 +81,115 @@ parent = undefined } - const onSubmit = async (params: EventContent) => { - const ptags = remove($pubkey!, pubkeys).map(tagPubkey) - - // Remove p tags since they result in forking the conversation - params.tags = params.tags.filter(nthNe(0, "p")) - - // Add our reply quote to content - params = prependParent(parent, params) - - const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags) - const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta) - const templates: EventTemplate[] = [] - const buffer = [] - - const addTemplate = (kind: number, content: string, tags: string[][]) => { - content = content.trim() - - if (content) { - templates.push(makeEvent(kind, {content, tags: [...tags, ...ptags]})) - } - } - - for (const p of parse(params)) { - const imeta = isLink(p) - ? imetas.find(tags => tags.find(spec(["url", p.value.url.toString()]))) - : undefined - - if (isLink(p) && imeta) { - addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags) - addTemplate( - DIRECT_MESSAGE_FILE, - p.value.url.toString(), - imeta.slice(1).filter(nthNe(0, "url")), - ) - } else { - buffer.push(p.raw) - } - } - - addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags) - - // Split the message into multiple pieces so that we can use kind 15 to send images per nip 17 - // Sleep 1 second between each one to make sure timestamps are distinct - const thunks = await Promise.all( - Array.from(enumerate(templates)).map(([i, event]) => - sendWrapped({ - event, - recipients: pubkeys, - delay: $userSettingsValues.send_delay + ms(i), - }), - ), - ) - - pushToast({ - timeout: 30_000, - children: { - component: ThunkToast, - props: {thunk: mergeThunks(thunks)}, - }, - }) - - clearParent() + const clearEventToEdit = () => { + eventToEdit = undefined } + const onSubmit = async (params: EventContent) => { + try { + const ptags = remove($pubkey!, pubkeys).map(tagPubkey) + + // Remove p tags since they result in forking the conversation + params.tags = params.tags.filter(nthNe(0, "p")) + + // Add our reply quote to content + params = prependParent(parent, params) + + if (eventToEdit) { + if (eventToEdit.content === params.content) { + return + } + + await sendWrapped({ + event: makeDelete({event: eventToEdit, protect: false}), + recipients: pubkeys, + }) + } + + const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags) + const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta) + const templates: EventTemplate[] = [] + const buffer = [] + + const addTemplate = (kind: number, content: string, tags: string[][]) => { + content = content.trim() + + if (content) { + templates.push( + makeEvent(kind, { + content, + tags: [...tags, ...ptags], + created_at: eventToEdit?.created_at, + }), + ) + } + } + + for (const p of parse(params)) { + const imeta = isLink(p) + ? imetas.find(tags => tags.find(spec(["url", p.value.url.toString()]))) + : undefined + + if (isLink(p) && imeta) { + addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags) + addTemplate( + DIRECT_MESSAGE_FILE, + p.value.url.toString(), + imeta.slice(1).filter(nthNe(0, "url")), + ) + } else { + buffer.push(p.raw) + } + } + + addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags) + + // Split the message into multiple pieces so that we can use kind 15 to send images per nip 17 + // Sleep 1 second between each one to make sure timestamps are distinct + const thunks = await Promise.all( + Array.from(enumerate(templates)).map(([i, event]) => + sendWrapped({ + event, + recipients: pubkeys, + delay: $userSettingsValues.send_delay + ms(i), + }), + ), + ) + + pushToast({ + timeout: 30_000, + children: { + component: ThunkToast, + props: {thunk: mergeThunks(thunks)}, + }, + }) + } finally { + clearParent() + clearEventToEdit() + } + } + + const onEscape = () => { + clearParent() + clearEventToEdit() + } + + const canEditEvent = (event: TrustedEvent) => + event.pubkey === $pubkey && + event.kind === DIRECT_MESSAGE && + event.created_at >= ago(500, MINUTE) + + const onEditEvent = (event: TrustedEvent) => { + clearParent() + eventToEdit = event + } + + const onEditPrevious = () => ifLet($chat?.messages.toReversed().find(canEditEvent), onEditEvent) + let loading = $state(true) let compose: ChatCompose | undefined = $state() let parent: TrustedEvent | undefined = $state() + let eventToEdit: TrustedEvent | undefined = $state() let chatCompose: HTMLElement | undefined = $state() let dynamicPadding: HTMLElement | undefined = $state() @@ -285,7 +330,9 @@ event={$state.snapshot(value as TrustedEvent)} {pubkeys} {showPubkey} - {replyTo} /> + {replyTo} + canEdit={canEditEvent} + onEdit={onEditEvent} /> {/if} {/each}

@@ -305,6 +352,16 @@ {#if parent} {/if} + {#if eventToEdit} + + {/if} - + {#key eventToEdit} + + {/key} diff --git a/src/app/components/ChatCompose.svelte b/src/app/components/ChatCompose.svelte index f8726c8f..43f4a80f 100644 --- a/src/app/components/ChatCompose.svelte +++ b/src/app/components/ChatCompose.svelte @@ -1,4 +1,5 @@

diff --git a/src/app/components/ChatComposeEdit.svelte b/src/app/components/ChatComposeEdit.svelte new file mode 100644 index 00000000..a6f64a6b --- /dev/null +++ b/src/app/components/ChatComposeEdit.svelte @@ -0,0 +1,21 @@ + + +
+

Editing message

+ +
diff --git a/src/app/components/ChatMessage.svelte b/src/app/components/ChatMessage.svelte index 16f88dea..c11fd2a7 100644 --- a/src/app/components/ChatMessage.svelte +++ b/src/app/components/ChatMessage.svelte @@ -23,11 +23,13 @@ interface Props { event: TrustedEvent replyTo: (event: TrustedEvent) => void + canEdit?: (event: TrustedEvent) => boolean + onEdit?: (event: TrustedEvent) => void pubkeys: string[] showPubkey?: boolean } - const {event, replyTo, pubkeys, showPubkey = false}: Props = $props() + const {event, replyTo, canEdit, onEdit, pubkeys, showPubkey = false}: Props = $props() const isOwn = event.pubkey === $pubkey const profileDisplay = deriveProfileDisplay(event.pubkey) @@ -35,6 +37,7 @@ const [_, colorValue] = colors[hash(event.pubkey) % colors.length] const reply = () => replyTo(event) + const edit = canEdit?.(event) ? () => onEdit?.(event) : undefined const deleteReaction = (event: TrustedEvent) => sendWrapped({event: makeDelete({event, protect: false}), recipients: pubkeys}) @@ -44,7 +47,7 @@ const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey}) - const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply}) + const showMobileMenu = () => pushModal(ChatMessageMenuMobile, {event, pubkeys, reply, edit}) const togglePopover = () => { if (popoverIsVisible) { @@ -71,7 +74,7 @@ {#if showPubkey}
diff --git a/src/app/components/ChatMessageMenu.svelte b/src/app/components/ChatMessageMenu.svelte index 86fd4ebf..60691daa 100644 --- a/src/app/components/ChatMessageMenu.svelte +++ b/src/app/components/ChatMessageMenu.svelte @@ -4,12 +4,14 @@ import ChatMessageEmojiButton from "@app/components/ChatMessageEmojiButton.svelte" import EventInfo from "@app/components/EventInfo.svelte" import {pushModal} from "@app/util/modal" + import Pen from "@assets/icons/pen.svg?dataurl" import Reply from "@assets/icons/reply-2.svg?dataurl" import Code2 from "@assets/icons/code-2.svg?dataurl" - const {event, pubkeys, popover, replyTo} = $props() + const {event, pubkeys, popover, replyTo, edit} = $props() const reply = () => replyTo(event) + const onEdit = () => edit?.() const showInfo = () => { popover.hide() @@ -24,6 +26,11 @@ {/if} + {#if edit} + + {/if} diff --git a/src/app/components/ChatMessageMenuMobile.svelte b/src/app/components/ChatMessageMenuMobile.svelte index bb173aac..370ed41b 100644 --- a/src/app/components/ChatMessageMenuMobile.svelte +++ b/src/app/components/ChatMessageMenuMobile.svelte @@ -3,6 +3,7 @@ import type {TrustedEvent} from "@welshman/util" import {sendWrapped} from "@welshman/app" import SmileCircle from "@assets/icons/smile-circle.svg?dataurl" + import Pen from "@assets/icons/pen.svg?dataurl" import Reply from "@assets/icons/reply-2.svg?dataurl" import Copy from "@assets/icons/copy.svg?dataurl" import Code2 from "@assets/icons/code-2.svg?dataurl" @@ -20,9 +21,10 @@ pubkeys: string[] event: TrustedEvent reply: () => void + edit?: () => void } - const {event, pubkeys, reply}: Props = $props() + const {event, pubkeys, reply, edit}: Props = $props() const onEmoji = ((event: TrustedEvent, pubkeys: string[], emoji: NativeEmoji) => { history.back() @@ -39,6 +41,11 @@ reply() } + const sendEdit = () => { + history.back() + edit?.() + } + const copyText = () => { history.back() clip(event.content) @@ -62,6 +69,12 @@ Send Reply + {#if edit} + + {/if} diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index 29751946..fb07015b 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -5,7 +5,7 @@ import {goto} from "$app/navigation" import type {Readable} from "svelte/store" import {pubkey, publishThunk, waitForThunkError, joinRoom, leaveRoom} from "@welshman/app" - import {now, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib" + import {now, ifLet, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib" import type {MakeNonOptional} from "@welshman/lib" import type {TrustedEvent, EventContent} from "@welshman/util" import { @@ -336,13 +336,7 @@ eventToEdit = event } - const onEditPrevious = () => { - const prev = $events.toReversed().find(e => e.pubkey === $pubkey) - - if (prev && canEditEvent(prev)) { - onEditEvent(prev) - } - } + const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent) onMount(() => { const observer = new ResizeObserver(() => { diff --git a/src/routes/spaces/[relay]/chat/+page.svelte b/src/routes/spaces/[relay]/chat/+page.svelte index a3d265ac..41d61ac1 100644 --- a/src/routes/spaces/[relay]/chat/+page.svelte +++ b/src/routes/spaces/[relay]/chat/+page.svelte @@ -4,7 +4,7 @@ import {goto} from "$app/navigation" import type {Readable} from "svelte/store" import {readable} from "svelte/store" - import {now, int, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib" + import {now, int, ifLet, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib" import type {TrustedEvent, EventContent} from "@welshman/util" import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER} from "@welshman/util" import {pubkey, publishThunk} from "@welshman/app" @@ -272,13 +272,7 @@ eventToEdit = event } - const onEditPrevious = () => { - const prev = $events.toReversed().find(e => e.pubkey === $pubkey) - - if (prev && canEditEvent(prev)) { - onEditEvent(prev) - } - } + const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent) onMount(() => { const controller = new AbortController()