Avoid reflow by showing chat thunk status in a toast

This commit is contained in:
Jon Staab
2025-08-19 14:03:04 -07:00
parent 4f6c08f8a2
commit cde03ec0fe
26 changed files with 268 additions and 124 deletions
@@ -22,15 +22,17 @@
showActivity?: boolean
} = $props()
const shouldProtect = canEnforceNip70(url)
const path = makeCalendarPath(url, event.id)
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await canEnforceNip70(url)})
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await canEnforceNip70(url)})
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
+3 -1
View File
@@ -33,6 +33,8 @@
const {url, header, initialValues}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
const back = () => history.back()
@@ -75,7 +77,7 @@
...ed.storage.nostr.getEditorTags(),
]
if (await canEnforceNip70(url)) {
if (await shouldProtect) {
tags.push(PROTECTED)
}
+5 -4
View File
@@ -8,7 +8,7 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ThunkFailure from "@app/components/ThunkFailure.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ChannelMessageZapButton from "@app/components/ChannelMessageZapButton.svelte"
@@ -30,6 +30,7 @@
const {url, event, replyTo = undefined, showPubkey = false, inert = false}: Props = $props()
const thunk = $thunks[event.id]
const shouldProtect = canEnforceNip70(url)
const today = formatTimestampAsDate(now())
const profile = deriveProfile(event.pubkey, [url])
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
@@ -42,10 +43,10 @@
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await canEnforceNip70(url)})
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await canEnforceNip70(url)})
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<TapTarget
@@ -79,7 +80,7 @@
<div class="text-sm">
<Content minimalQuote {event} {url} />
{#if thunk}
<ThunkStatus {thunk} class="mt-2" />
<ThunkFailure showToastOnRetry {thunk} class="mt-2" />
{/if}
</div>
</div>
@@ -6,12 +6,14 @@
const {url, event} = $props()
const shouldProtect = canEnforceNip70(url)
const onEmoji = async (emoji: NativeEmoji) =>
publishReaction({
event,
relays: [url],
content: emoji.unicode,
protect: await canEnforceNip70(url),
protect: await shouldProtect,
})
</script>
@@ -20,13 +20,15 @@
const {url, event, reply}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const onEmoji = (async (event: TrustedEvent, url: string, emoji: NativeEmoji) => {
history.back()
publishReaction({
event,
relays: [url],
content: emoji.unicode,
protect: await canEnforceNip70(url),
protect: await shouldProtect,
})
}).bind(undefined, event, url)
+16 -1
View File
@@ -26,9 +26,11 @@
pubkey,
tagPubkey,
sendWrapped,
mergeThunks,
loadInboxRelaySelections,
inboxRelaySelectionsByPubkey,
} from "@welshman/app"
import type {AbstractThunk} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte"
@@ -44,6 +46,7 @@
import ChatMessage from "@app/components/ChatMessage.svelte"
import ChatCompose from "@app/components/ChatCompose.svelte"
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import {
INDEXER_RELAYS,
userSettingValues,
@@ -53,6 +56,7 @@
} from "@app/state"
import {pushModal} from "@app/modal"
import {prependParent} from "@app/commands"
import {pushToast} from "@app/toast"
type Props = {
id: string
@@ -121,12 +125,23 @@
// 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: AbstractThunk[] = []
for (let i = 0; i < templates.length; i++) {
const template = templates[i]
await sendWrapped({pubkeys, template, delay: $userSettingValues.send_delay + ms(i)})
thunks.push(
await sendWrapped({pubkeys, template, delay: $userSettingValues.send_delay + ms(i)}),
)
}
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk: mergeThunks(thunks)},
},
})
clearParent()
}
+2 -2
View File
@@ -11,7 +11,7 @@
import Avatar from "@lib/components/Avatar.svelte"
import Content from "@app/components/Content.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ThunkFailure from "@app/components/ThunkFailure.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
@@ -59,7 +59,7 @@
</script>
{#if thunk}
<ThunkStatus {thunk} class="mt-1" />
<ThunkFailure showToastOnRetry {thunk} class="mt-1" />
{/if}
<div
data-event={event.id}
+4 -2
View File
@@ -15,13 +15,15 @@
const {url, event, showActivity = false}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const path = makeThreadPath(url, event.id)
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await canEnforceNip70(url)})
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await canEnforceNip70(url)})
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
+3 -1
View File
@@ -22,6 +22,8 @@
const {url, noun, event, hideZap, customActions}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const showPopover = () => popover?.show()
const hidePopover = () => popover?.hide()
@@ -31,7 +33,7 @@
event,
content: emoji.unicode,
relays: [url],
protect: await canEnforceNip70(url),
protect: await shouldProtect,
})
let popover: Instance | undefined = $state()
+3 -1
View File
@@ -11,8 +11,10 @@
const {url, event}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const confirm = async () => {
await publishDelete({event, relays: [url], protect: await canEnforceNip70(url)})
await publishDelete({event, relays: [url], protect: await shouldProtect})
clearModals()
}
+3 -1
View File
@@ -14,6 +14,8 @@
const {url, event, onClose, onSubmit} = $props()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
@@ -25,7 +27,7 @@
const content = ed.getText({blockSeparator: "\n"}).trim()
const tags = ed.storage.nostr.getEditorTags()
if (await canEnforceNip70(url)) {
if (await shouldProtect) {
tags.push(PROTECTED)
}
+3 -1
View File
@@ -10,6 +10,8 @@
const {url, event} = $props()
const shouldProtect = canEnforceNip70(url)
const reports = deriveEvents(repository, {
filters: [{kinds: [REPORT], "#e": [event.id]}],
})
@@ -17,7 +19,7 @@
const back = () => history.back()
const deleteReport = async (report: TrustedEvent) => {
publishDelete({event: report, relays: [url], protect: await canEnforceNip70(url)})
publishDelete({event: report, relays: [url], protect: await shouldProtect})
if ($reports.length === 0) {
history.back()
+4 -2
View File
@@ -15,13 +15,15 @@
const {url, event, showActivity = false}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const path = makeGoalPath(url, event.id)
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await canEnforceNip70(url)})
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await canEnforceNip70(url)})
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
+3 -1
View File
@@ -17,6 +17,8 @@
const {url} = $props()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
const back = () => history.back()
@@ -50,7 +52,7 @@
["relays", url],
]
if (await canEnforceNip70(url)) {
if (await shouldProtect) {
tags.push(PROTECTED)
}
+5 -3
View File
@@ -10,18 +10,20 @@
const {url, event} = $props()
const shouldProtect = canEnforceNip70(url)
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await canEnforceNip70(url)})
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await canEnforceNip70(url)})
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
const onEmoji = async (emoji: NativeEmoji) =>
publishReaction({
event,
content: emoji.unicode,
relays: [url],
protect: await canEnforceNip70(url),
protect: await shouldProtect,
})
</script>
+4 -2
View File
@@ -15,13 +15,15 @@
const {url, event, showActivity = false}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const path = makeThreadPath(url, event.id)
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await canEnforceNip70(url)})
publishDelete({relays: [url], event, protect: await shouldProtect})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await canEnforceNip70(url)})
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
+3 -1
View File
@@ -16,6 +16,8 @@
const {url} = $props()
const shouldProtect = canEnforceNip70(url)
const uploading = writable(false)
const back = () => history.back()
@@ -44,7 +46,7 @@
const tags = [...ed.storage.nostr.getEditorTags(), ["title", title]]
if (await canEnforceNip70(url)) {
if (await shouldProtect) {
tags.push(PROTECTED)
}
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts">
import {stopPropagation} from "svelte/legacy"
import {noop} from "@welshman/lib"
import {
MergedThunk,
publishThunk,
isMergedThunk,
thunkIsComplete,
getFailedThunkUrls,
} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import ThunkStatusDetail from "@app/components/ThunkStatusDetail.svelte"
import {pushToast} from "@app/toast"
interface Props {
thunk: Thunk | MergedThunk
showToastOnRetry?: boolean
class?: string
}
let {thunk, showToastOnRetry, ...restProps}: Props = $props()
const retry = () => {
thunk = isMergedThunk(thunk)
? new MergedThunk(thunk.thunks.map(t => publishThunk(t.options)))
: publishThunk(thunk.options)
if (showToastOnRetry) {
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
},
})
}
}
const failedUrls = $derived(getFailedThunkUrls($thunk))
const showFailure = $derived(thunkIsComplete($thunk) && failedUrls.length > 0)
</script>
{#if showFailure}
{@const url = failedUrls[0]}
{@const status = $thunk.status[url]}
{@const message = $thunk.details[url]}
<button
class="flex w-full justify-end px-1 text-xs {restProps.class}"
onclick={stopPropagation(noop)}>
<Tippy
class="flex items-center"
component={ThunkStatusDetail}
props={{url, message, status, retry}}
params={{interactive: true}}>
{#snippet children()}
<span class="flex cursor-pointer items-center gap-1 text-error">
<Icon icon="danger" size={3} />
<span>Failed to send!</span>
</span>
{/snippet}
</Tippy>
</button>
{/if}
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import {stopPropagation} from "svelte/legacy"
import {PublishStatus} from "@welshman/net"
import type {AbstractThunk} from "@welshman/app"
import {abortThunk, thunkHasStatus} from "@welshman/app"
interface Props {
thunk: AbstractThunk
class?: string
}
const {thunk, ...restProps}: Props = $props()
const abort = () => abortThunk(thunk)
const isSending = $derived(thunkHasStatus(PublishStatus.Sending, $thunk))
</script>
<div class="flex w-full justify-end px-1 text-xs {restProps.class}">
<span class="flex items-center gap-1">
<span class="loading loading-spinner mx-1 h-3 w-3 translate-y-px"></span>
<span class="opacity-50">Sending...</span>
<button
type="button"
class="underline transition-all"
class:link={isSending}
class:opacity-25={!isSending}
onclick={stopPropagation(abort)}>
Cancel
</button>
</span>
</div>
+8 -81
View File
@@ -1,95 +1,22 @@
<script lang="ts">
import {stopPropagation} from "svelte/legacy"
import {nth, noop} from "@welshman/lib"
import {PublishStatus} from "@welshman/net"
import {
MergedThunk,
publishThunk,
isMergedThunk,
thunkIsComplete,
thunkHasStatus,
} from "@welshman/app"
import {MergedThunk, thunkIsComplete, getFailedThunkUrls} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import ThunkStatusDetail from "@app/components/ThunkStatusDetail.svelte"
import {userSettingValues} from "@app/state"
import ThunkFailure from "@app/components/ThunkFailure.svelte"
import ThunkPending from "@app/components/ThunkPending.svelte"
interface Props {
thunk: Thunk | MergedThunk
class?: string
}
let {thunk, ...restProps}: Props = $props()
const {thunk, ...restProps}: Props = $props()
const abort = () => thunk.controller.abort()
const retry = () => {
thunk = isMergedThunk(thunk)
? new MergedThunk(thunk.thunks.map(t => publishThunk(t.options)))
: publishThunk(thunk.options)
}
const statuses = $derived(Object.entries($thunk.status))
const isSending = $derived(thunkHasStatus($thunk, PublishStatus.Sending))
const canCancel = $derived(isSending && $userSettingValues.send_delay > 0)
const failedUrls = $derived(
statuses
.filter(([_, status]) => [PublishStatus.Failure, PublishStatus.Timeout].includes(status))
.map(nth(0)),
)
const showFailure = $derived(thunkIsComplete($thunk) && failedUrls.length > 0)
let isPending = $state(thunkHasStatus($thunk, PublishStatus.Pending))
const showPending = $derived(canCancel || isPending)
// Delay updating isPending so users can see that the message is sent
$effect(() => {
isPending = isPending || thunkHasStatus($thunk, PublishStatus.Pending)
if (!thunkHasStatus($thunk, PublishStatus.Pending)) {
setTimeout(() => {
isPending = false
}, 2000)
}
})
const showFailure = $derived(thunkIsComplete($thunk) && getFailedThunkUrls($thunk).length > 0)
const showPending = $derived(!thunkIsComplete($thunk))
</script>
{#if showFailure}
{@const url = failedUrls[0]}
{@const status = $thunk.status[url]}
{@const message = $thunk.details[url]}
<button
class="flex w-full justify-end px-1 text-xs {restProps.class}"
onclick={stopPropagation(noop)}>
<Tippy
class="flex items-center"
component={ThunkStatusDetail}
props={{url, message, status, retry}}
params={{interactive: true}}>
{#snippet children()}
<span class="flex cursor-pointer items-center gap-1 text-error">
<Icon icon="danger" size={3} />
<span>Failed to send!</span>
</span>
{/snippet}
</Tippy>
</button>
<ThunkFailure class={restProps.class} {thunk} />
{:else if showPending}
<div class="flex w-full justify-end px-1 text-xs {restProps.class}">
<span class="flex items-center gap-1">
<span class="loading loading-spinner mx-1 h-3 w-3 translate-y-px"></span>
<span class="opacity-50">Sending...</span>
<button
type="button"
class="underline transition-all"
class:link={canCancel}
class:opacity-25={!canCancel}
onclick={stopPropagation(abort)}>
Cancel
</button>
</span>
</div>
<ThunkPending class={restProps.class} {thunk} />
{/if}
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import type {AbstractThunk} from "@welshman/app"
import {thunkHasStatus, thunkIsComplete} from "@welshman/app"
import {PublishStatus} from "@welshman/net"
import ThunkPending from "@app/components/ThunkPending.svelte"
import type {Toast} from "@app/toast"
import {popToast} from "@app/toast"
type Props = {
toast: Toast
thunk: AbstractThunk
}
const {toast, ...props}: Props = $props()
const id = toast.id
const thunk = props.thunk
const {Aborted, Timeout, Failure} = PublishStatus
const isFailure = $derived(thunkHasStatus([Aborted, Timeout, Failure], $thunk))
const isComplete = $derived(thunkIsComplete($thunk))
$effect(() => {
if (isFailure) {
popToast(id)
}
})
$effect(() => {
if (isComplete) {
setTimeout(() => popToast(id), 2000)
}
})
</script>
{#if !isComplete}
<ThunkPending {thunk} />
{:else if !isFailure}
<p class="text-xs opacity-75">Message sent!</p>
{/if}
+11 -6
View File
@@ -21,12 +21,17 @@
class:bg-base-100={theme === "info"}
class:text-base-content={theme === "info"}
class:alert-error={theme === "error"}>
<p class="welshman-content-error">
{@html renderAsHtml(parse({content: $toast.message}))}
{#if $toast.action}
<Button class="cursor-pointer underline" onclick={onActionClick}>
{$toast.action.message}
</Button>
<p class:welshman-content-error={theme === "error"}>
{#if $toast.message}
{@html renderAsHtml(parse({content: $toast.message}))}
{#if $toast.action}
<Button class="cursor-pointer underline" onclick={onActionClick}>
{$toast.action.message}
</Button>
{/if}
{:else if $toast.children}
{@const {component: Component, props} = $toast?.children || {}}
<Component toast={$toast} {...props} />
{/if}
</p>
<Button class="flex items-center opacity-75" onclick={() => popToast($toast.id)}>
+2 -2
View File
@@ -86,7 +86,7 @@ import {
userFollows,
ensurePlaintext,
thunks,
walkThunks,
flattenThunks,
signer,
makeOutboxLoader,
appContext,
@@ -260,7 +260,7 @@ export const getUrlsForEvent = derived([trackerStore, thunks], ([$tracker, $thun
const getThunksByEventId = memoize(() => {
const thunksByEventId = new Map<string, Thunk[]>()
for (const thunk of walkThunks(Object.values($thunks))) {
for (const thunk of flattenThunks(Object.values($thunks))) {
pushToMapKey(thunksByEventId, thunk.event.id, thunk)
}
+6 -1
View File
@@ -1,11 +1,16 @@
import type {Component} from "svelte"
import {writable} from "svelte/store"
import {randomId} from "@welshman/lib"
import {copyToClipboard} from "@lib/html"
export type ToastParams = {
message: string
message?: string
timeout?: number
theme?: "error"
children?: {
component: Component<any>
props: Record<string, any>
}
action?: {
message: string
onclick: () => void
+12 -2
View File
@@ -23,6 +23,7 @@
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import ChannelMessage from "@app/components/ChannelMessage.svelte"
@@ -57,6 +58,7 @@
const channel = deriveChannel(url, room)
const filter = {kinds: [MESSAGE], "#h": [room]}
const isFavorite = $derived($userRoomsByUrl.get(url)?.has(room))
const shouldProtect = canEnforceNip70(url)
const membershipStatus = deriveUserMembershipStatus(url, room)
const addFavorite = () => addRoomMembership(url, room)
@@ -109,7 +111,7 @@
const onSubmit = async ({content, tags}: EventContent) => {
tags.push(["h", room])
if (await canEnforceNip70(url)) {
if (await shouldProtect) {
tags.push(PROTECTED)
}
@@ -123,12 +125,20 @@
template = prependParent(parent, template)
}
publishThunk({
const thunk = publishThunk({
relays: [url],
event: makeEvent(MESSAGE, template),
delay: $userSettingValues.send_delay,
})
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
},
})
clearParent()
clearShare()
}
+21 -5
View File
@@ -14,14 +14,21 @@
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ChannelMessage from "@app/components/ChannelMessage.svelte"
import ChannelCompose from "@app/components/ChannelCompose.svelte"
import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte"
import {userSettingValues, decodeRelay, getEventsForUrl} from "@app/state"
import {setChecked, checked} from "@app/notifications"
import {
userSettingValues,
decodeRelay,
getEventsForUrl,
PROTECTED,
REACTION_KINDS,
} from "@app/state"
import {prependParent, canEnforceNip70} from "@app/commands"
import {PROTECTED, REACTION_KINDS} from "@app/state"
import {setChecked, checked} from "@app/notifications"
import {pushToast} from "@app/toast"
import {makeFeed} from "@app/requests"
import {popKey} from "@app/implicit"
@@ -29,6 +36,7 @@
const lastChecked = $checked[$page.url.pathname]
const url = decodeRelay($page.params.relay)
const filter = {kinds: [MESSAGE]}
const shouldProtect = canEnforceNip70(url)
const replyTo = (event: TrustedEvent) => {
parent = event
@@ -44,7 +52,7 @@
}
const onSubmit = async ({content, tags}: EventContent) => {
if (await canEnforceNip70(url)) {
if (await shouldProtect) {
tags.push(PROTECTED)
}
@@ -58,12 +66,20 @@
template = prependParent(parent, template)
}
publishThunk({
const thunk = publishThunk({
relays: [url],
event: makeEvent(MESSAGE, template),
delay: $userSettingValues.send_delay,
})
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
},
})
clearParent()
clearShare()
}