mirror of
https://github.com/coracle-social/flotilla.git
synced 2025-12-11 11:27:03 +00:00
Allow editing previous messages in channel chat
This commit is contained in:
@@ -8,13 +8,16 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {onDestroy, onMount} from "svelte"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url?: string
|
url?: string
|
||||||
|
content?: string
|
||||||
|
onEditPrevious?: () => void
|
||||||
onSubmit: (event: EventContent) => void
|
onSubmit: (event: EventContent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const {onSubmit, url}: Props = $props()
|
const {content, onEditPrevious, onSubmit, url}: Props = $props()
|
||||||
|
|
||||||
const autofocus = !isMobile
|
const autofocus = !isMobile
|
||||||
|
|
||||||
@@ -22,6 +25,15 @@
|
|||||||
|
|
||||||
export const focus = () => editor.then(ed => ed.chain().focus().run())
|
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 uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
@@ -38,7 +50,17 @@
|
|||||||
ed.chain().clearContent().run()
|
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>
|
</script>
|
||||||
|
|
||||||
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
||||||
|
|||||||
21
src/app/components/ChannelComposeEdit.svelte
Normal file
21
src/app/components/ChannelComposeEdit.svelte
Normal 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>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
import TapTarget from "@lib/components/TapTarget.svelte"
|
import TapTarget from "@lib/components/TapTarget.svelte"
|
||||||
import Avatar from "@lib/components/Avatar.svelte"
|
import Avatar from "@lib/components/Avatar.svelte"
|
||||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
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 Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
@@ -26,9 +27,19 @@
|
|||||||
replyTo?: (event: TrustedEvent) => void
|
replyTo?: (event: TrustedEvent) => void
|
||||||
showPubkey?: boolean
|
showPubkey?: boolean
|
||||||
inert?: 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 thunk = $thunks[event.id]
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
@@ -38,8 +49,9 @@
|
|||||||
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
||||||
|
|
||||||
const reply = () => replyTo!(event)
|
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})
|
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
|
||||||
|
|
||||||
@@ -107,6 +119,11 @@
|
|||||||
<Icon icon={Reply} size={4} />
|
<Icon icon={Reply} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if edit}
|
||||||
|
<Button class="btn join-item btn-xs" onclick={edit}>
|
||||||
|
<Icon icon={Pen} size={4} />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
<ChannelMessageMenuButton {url} {event} />
|
<ChannelMessageMenuButton {url} {event} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
import {onMount, onDestroy} from "svelte"
|
import {onMount, onDestroy} from "svelte"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import type {Readable} from "svelte/store"
|
import type {Readable} from "svelte/store"
|
||||||
import {now, formatTimestampAsDate} from "@welshman/lib"
|
|
||||||
import type {MakeNonOptional} from "@welshman/lib"
|
import type {MakeNonOptional} from "@welshman/lib"
|
||||||
|
import {now, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
|
||||||
import {request} from "@welshman/net"
|
import {request} from "@welshman/net"
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
@@ -52,11 +52,13 @@
|
|||||||
canEnforceNip70,
|
canEnforceNip70,
|
||||||
removeRoomMembership,
|
removeRoomMembership,
|
||||||
prependParent,
|
prependParent,
|
||||||
|
publishDelete,
|
||||||
} from "@app/core/commands"
|
} from "@app/core/commands"
|
||||||
import {PROTECTED} from "@app/core/state"
|
import {PROTECTED} from "@app/core/state"
|
||||||
import {makeFeed} from "@app/core/requests"
|
import {makeFeed} from "@app/core/requests"
|
||||||
import {popKey} from "@lib/implicit"
|
import {popKey} from "@lib/implicit"
|
||||||
import {pushToast} from "@app/util/toast"
|
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 {room, relay} = $page.params as MakeNonOptional<typeof $page.params>
|
||||||
const mounted = now()
|
const mounted = now()
|
||||||
@@ -115,6 +117,10 @@
|
|||||||
share = undefined
|
share = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearEventToEdit = () => {
|
||||||
|
eventToEdit = undefined
|
||||||
|
}
|
||||||
|
|
||||||
const onSubmit = async ({content, tags}: EventContent) => {
|
const onSubmit = async ({content, tags}: EventContent) => {
|
||||||
tags.push(["h", room])
|
tags.push(["h", room])
|
||||||
|
|
||||||
@@ -122,7 +128,13 @@
|
|||||||
tags.push(PROTECTED)
|
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) {
|
if (share) {
|
||||||
template = prependParent(share, template)
|
template = prependParent(share, template)
|
||||||
@@ -150,6 +162,7 @@
|
|||||||
|
|
||||||
clearParent()
|
clearParent()
|
||||||
clearShare()
|
clearShare()
|
||||||
|
clearEventToEdit()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
@@ -188,6 +201,7 @@
|
|||||||
let cleanup: () => void
|
let cleanup: () => void
|
||||||
let events: Readable<TrustedEvent[]> = $state(readable([]))
|
let events: Readable<TrustedEvent[]> = $state(readable([]))
|
||||||
let compose: ChannelCompose | undefined = $state()
|
let compose: ChannelCompose | undefined = $state()
|
||||||
|
let eventToEdit: TrustedEvent | undefined = $state()
|
||||||
|
|
||||||
const elements = $derived.by(() => {
|
const elements = $derived.by(() => {
|
||||||
const elements = []
|
const elements = []
|
||||||
@@ -266,6 +280,23 @@
|
|||||||
cleanup = feed.cleanup
|
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(() => {
|
onMount(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
||||||
@@ -402,7 +433,9 @@
|
|||||||
{url}
|
{url}
|
||||||
{replyTo}
|
{replyTo}
|
||||||
event={$state.snapshot(value as TrustedEvent)}
|
event={$state.snapshot(value as TrustedEvent)}
|
||||||
{showPubkey} />
|
{showPubkey}
|
||||||
|
canEdit={canEditEvent}
|
||||||
|
onEdit={onEditEvent} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -446,8 +479,18 @@
|
|||||||
{#if share}
|
{#if share}
|
||||||
<ChannelComposeParent event={share} clear={clearShare} verb="Sharing" />
|
<ChannelComposeParent event={share} clear={clearShare} verb="Sharing" />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if eventToEdit}
|
||||||
|
<ChannelComposeEdit clear={clearEventToEdit} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<ChannelCompose bind:this={compose} {onSubmit} {url} />
|
{#key eventToEdit}
|
||||||
|
<ChannelCompose
|
||||||
|
bind:this={compose}
|
||||||
|
content={eventToEdit?.content}
|
||||||
|
{onSubmit}
|
||||||
|
{url}
|
||||||
|
{onEditPrevious} />
|
||||||
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import type {Readable} from "svelte/store"
|
import type {Readable} from "svelte/store"
|
||||||
import {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 type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {makeEvent, getTag, MESSAGE, DELETE} from "@welshman/util"
|
import {makeEvent, getTag, MESSAGE, DELETE} from "@welshman/util"
|
||||||
import {pubkey, publishThunk} from "@welshman/app"
|
import {pubkey, publishThunk} from "@welshman/app"
|
||||||
@@ -28,11 +28,12 @@
|
|||||||
PROTECTED,
|
PROTECTED,
|
||||||
REACTION_KINDS,
|
REACTION_KINDS,
|
||||||
} from "@app/core/state"
|
} 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 {setChecked, checked} from "@app/util/notifications"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
import {makeFeed} from "@app/core/requests"
|
import {makeFeed} from "@app/core/requests"
|
||||||
import {popKey} from "@lib/implicit"
|
import {popKey} from "@lib/implicit"
|
||||||
|
import ChannelComposeEdit from "@src/app/components/ChannelComposeEdit.svelte"
|
||||||
|
|
||||||
const mounted = now()
|
const mounted = now()
|
||||||
const lastChecked = $checked[$page.url.pathname]
|
const lastChecked = $checked[$page.url.pathname]
|
||||||
@@ -49,17 +50,27 @@
|
|||||||
parent = undefined
|
parent = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearEventToEdit = () => {
|
||||||
|
eventToEdit = undefined
|
||||||
|
}
|
||||||
|
|
||||||
const clearShare = () => {
|
const clearShare = () => {
|
||||||
share = undefined
|
share = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = async ({content, tags}: EventContent) => {
|
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) {
|
if (await shouldProtect) {
|
||||||
tags.push(PROTECTED)
|
tags.push(PROTECTED)
|
||||||
}
|
}
|
||||||
|
|
||||||
let template = {content, tags}
|
|
||||||
|
|
||||||
if (share) {
|
if (share) {
|
||||||
template = prependParent(share, template)
|
template = prependParent(share, template)
|
||||||
}
|
}
|
||||||
@@ -86,6 +97,7 @@
|
|||||||
|
|
||||||
clearParent()
|
clearParent()
|
||||||
clearShare()
|
clearShare()
|
||||||
|
clearEventToEdit()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
@@ -122,6 +134,7 @@
|
|||||||
let cleanup: () => void
|
let cleanup: () => void
|
||||||
let events: Readable<TrustedEvent[]> = $state(readable([]))
|
let events: Readable<TrustedEvent[]> = $state(readable([]))
|
||||||
let compose: ChannelCompose | undefined = $state()
|
let compose: ChannelCompose | undefined = $state()
|
||||||
|
let eventToEdit: TrustedEvent | undefined = $state()
|
||||||
|
|
||||||
const elements = $derived.by(() => {
|
const elements = $derived.by(() => {
|
||||||
const elements = []
|
const elements = []
|
||||||
@@ -184,6 +197,23 @@
|
|||||||
return elements
|
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(() => {
|
onMount(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
||||||
@@ -261,7 +291,9 @@
|
|||||||
{url}
|
{url}
|
||||||
{replyTo}
|
{replyTo}
|
||||||
event={$state.snapshot(value as TrustedEvent)}
|
event={$state.snapshot(value as TrustedEvent)}
|
||||||
{showPubkey} />
|
{showPubkey}
|
||||||
|
canEdit={canEditEvent}
|
||||||
|
onEdit={onEditEvent} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -282,8 +314,18 @@
|
|||||||
{#if share}
|
{#if share}
|
||||||
<ChannelComposeParent event={share} clear={clearShare} verb="Sharing" />
|
<ChannelComposeParent event={share} clear={clearShare} verb="Sharing" />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if eventToEdit}
|
||||||
|
<ChannelComposeEdit clear={clearEventToEdit} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<ChannelCompose bind:this={compose} {onSubmit} {url} />
|
{#key eventToEdit}
|
||||||
|
<ChannelCompose
|
||||||
|
bind:this={compose}
|
||||||
|
content={eventToEdit?.content}
|
||||||
|
{onSubmit}
|
||||||
|
{url}
|
||||||
|
{onEditPrevious} />
|
||||||
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showScrollButton}
|
{#if showScrollButton}
|
||||||
|
|||||||
Reference in New Issue
Block a user