Allow editing previous messages in channel chat

This commit is contained in:
Matthew Remmel
2025-10-15 10:00:23 -04:00
committed by hodlbod
parent 7334cd26f8
commit 5b22d6ac01
5 changed files with 159 additions and 14 deletions

View File

@@ -8,13 +8,16 @@
import Button from "@lib/components/Button.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {onDestroy, onMount} from "svelte"
type Props = {
url?: string
content?: string
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
}
const {onSubmit, url}: Props = $props()
const {content, onEditPrevious, onSubmit, url}: Props = $props()
const autofocus = !isMobile
@@ -22,6 +25,15 @@
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 === "ArrowUp" && (await canEnterEditPrevious())) {
onEditPrevious?.()
}
}
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const submit = async () => {
@@ -38,7 +50,17 @@
ed.chain().clearContent().run()
}
const editor = makeEditor({url, autofocus, submit, uploading, aggressive: true})
const editor = makeEditor({url, autofocus, content, submit, uploading, aggressive: true})
onMount(async () => {
const ed = await editor
ed.view.dom.addEventListener("keydown", handleKeyDown)
})
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)}>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import {slide} from "@lib/transition"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
const {
clear,
}: {
clear: () => void
} = $props()
</script>
<div
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
transition:slide>
<p class="text-primary">Editing message</p>
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
<Icon icon={CloseCircle} />
</Button>
</div>

View File

@@ -6,6 +6,7 @@
import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
@@ -26,9 +27,19 @@
replyTo?: (event: TrustedEvent) => void
showPubkey?: boolean
inert?: boolean
canEdit: (event: TrustedEvent) => boolean
onEdit: (event: TrustedEvent) => void
}
const {url, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
const {
url,
event,
replyTo = undefined,
showPubkey = false,
inert = false,
canEdit,
onEdit,
}: Props = $props()
const thunk = $thunks[event.id]
const shouldProtect = canEnforceNip70(url)
@@ -38,8 +49,9 @@
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const reply = () => replyTo!(event)
const edit = canEdit(event) ? () => onEdit(event) : undefined
const onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply})
const onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply, edit})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
@@ -107,6 +119,11 @@
<Icon icon={Reply} size={4} />
</Button>
{/if}
{#if edit}
<Button class="btn join-item btn-xs" onclick={edit}>
<Icon icon={Pen} size={4} />
</Button>
{/if}
<ChannelMessageMenuButton {url} {event} />
</button>
{/if}

View File

@@ -4,8 +4,8 @@
import {onMount, onDestroy} from "svelte"
import {page} from "$app/stores"
import type {Readable} from "svelte/store"
import {now, formatTimestampAsDate} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import {now, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
import {request} from "@welshman/net"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {
@@ -52,11 +52,13 @@
canEnforceNip70,
removeRoomMembership,
prependParent,
publishDelete,
} from "@app/core/commands"
import {PROTECTED} from "@app/core/state"
import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit"
import {pushToast} from "@app/util/toast"
import ChannelComposeEdit from "@src/app/components/ChannelComposeEdit.svelte"
const {room, relay} = $page.params as MakeNonOptional<typeof $page.params>
const mounted = now()
@@ -115,6 +117,10 @@
share = undefined
}
const clearEventToEdit = () => {
eventToEdit = undefined
}
const onSubmit = async ({content, tags}: EventContent) => {
tags.push(["h", room])
@@ -122,7 +128,13 @@
tags.push(PROTECTED)
}
let template = {content, tags}
let template: EventContent & {created_at?: number} = {content, tags}
if (eventToEdit) {
// Delete previous message, to be republished with same timestamp
template.created_at = eventToEdit.created_at
publishDelete({relays: [url], event: eventToEdit, protect: await shouldProtect})
}
if (share) {
template = prependParent(share, template)
@@ -150,6 +162,7 @@
clearParent()
clearShare()
clearEventToEdit()
}
const onScroll = () => {
@@ -188,6 +201,7 @@
let cleanup: () => void
let events: Readable<TrustedEvent[]> = $state(readable([]))
let compose: ChannelCompose | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
const elements = $derived.by(() => {
const elements = []
@@ -266,6 +280,23 @@
cleanup = feed.cleanup
}
const canEditEvent = (event: TrustedEvent) =>
event.pubkey === $pubkey && event.created_at >= ago(5, MINUTE)
const onEditEvent = (event: TrustedEvent) => {
clearParent()
clearShare()
eventToEdit = event
}
const onEditPrevious = () => {
const prev = $events.find(e => e.pubkey === $pubkey)
if (prev && canEditEvent(prev)) {
onEditEvent(prev)
}
}
onMount(() => {
const controller = new AbortController()
@@ -402,7 +433,9 @@
{url}
{replyTo}
event={$state.snapshot(value as TrustedEvent)}
{showPubkey} />
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
</div>
{/if}
{/each}
@@ -446,8 +479,18 @@
{#if share}
<ChannelComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<ChannelComposeEdit clear={clearEventToEdit} />
{/if}
</div>
<ChannelCompose bind:this={compose} {onSubmit} {url} />
{#key eventToEdit}
<ChannelCompose
bind:this={compose}
content={eventToEdit?.content}
{onSubmit}
{url}
{onEditPrevious} />
{/key}
{/if}
</div>

View File

@@ -3,7 +3,7 @@
import {page} from "$app/stores"
import type {Readable} from "svelte/store"
import {readable} from "svelte/store"
import {now, formatTimestampAsDate} from "@welshman/lib"
import {now, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {makeEvent, getTag, MESSAGE, DELETE} from "@welshman/util"
import {pubkey, publishThunk} from "@welshman/app"
@@ -28,11 +28,12 @@
PROTECTED,
REACTION_KINDS,
} from "@app/core/state"
import {prependParent, canEnforceNip70} from "@app/core/commands"
import {prependParent, canEnforceNip70, publishDelete} from "@app/core/commands"
import {setChecked, checked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit"
import ChannelComposeEdit from "@src/app/components/ChannelComposeEdit.svelte"
const mounted = now()
const lastChecked = $checked[$page.url.pathname]
@@ -49,17 +50,27 @@
parent = undefined
}
const clearEventToEdit = () => {
eventToEdit = undefined
}
const clearShare = () => {
share = undefined
}
const onSubmit = async ({content, tags}: EventContent) => {
let template: EventContent & {created_at?: number} = {content, tags}
if (eventToEdit) {
// Delete previous message, to be republished with same timestamp
template.created_at = eventToEdit.created_at
publishDelete({relays: [url], event: eventToEdit, protect: await shouldProtect})
}
if (await shouldProtect) {
tags.push(PROTECTED)
}
let template = {content, tags}
if (share) {
template = prependParent(share, template)
}
@@ -86,6 +97,7 @@
clearParent()
clearShare()
clearEventToEdit()
}
const onScroll = () => {
@@ -122,6 +134,7 @@
let cleanup: () => void
let events: Readable<TrustedEvent[]> = $state(readable([]))
let compose: ChannelCompose | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
const elements = $derived.by(() => {
const elements = []
@@ -184,6 +197,23 @@
return elements
})
const canEditEvent = (event: TrustedEvent) =>
event.pubkey === $pubkey && event.created_at >= ago(5, MINUTE)
const onEditEvent = (event: TrustedEvent) => {
clearParent()
clearShare()
eventToEdit = event
}
const onEditPrevious = () => {
const prev = $events.find(e => e.pubkey === $pubkey)
if (prev && canEditEvent(prev)) {
onEditEvent(prev)
}
}
onMount(() => {
const controller = new AbortController()
@@ -261,7 +291,9 @@
{url}
{replyTo}
event={$state.snapshot(value as TrustedEvent)}
{showPubkey} />
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
</div>
{/if}
{/each}
@@ -282,8 +314,18 @@
{#if share}
<ChannelComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<ChannelComposeEdit clear={clearEventToEdit} />
{/if}
</div>
<ChannelCompose bind:this={compose} {onSubmit} {url} />
{#key eventToEdit}
<ChannelCompose
bind:this={compose}
content={eventToEdit?.content}
{onSubmit}
{url}
{onEditPrevious} />
{/key}
</div>
{#if showScrollButton}