feat: show per-relay publish status on outgoing messages

This commit is contained in:
2026-06-01 15:36:38 +05:30
parent 1dd0270f4f
commit d2f2263676
9 changed files with 256 additions and 100 deletions
+1 -1
View File
@@ -280,7 +280,7 @@
</div>
</PageBar>
<PageContent class="flex flex-col-reverse gap-2 py-4">
<PageContent class="flex flex-col-reverse gap-2 py-4 md:pb-8!">
{#if missingRelayLists.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
+4 -4
View File
@@ -12,7 +12,7 @@
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import Content from "@app/components/Content.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkFailure from "@app/components/ThunkFailure.svelte"
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import ChatMessageMenuMobile from "@app/components/ChatMessageMenuMobile.svelte"
@@ -65,9 +65,6 @@
let popoverIsVisible = $state(false)
</script>
{#if thunk}
<ThunkFailure showToastOnRetry {thunk} class="mt-1" />
{/if}
<div
data-event={event.id}
class="group chat flex items-center justify-end gap-1 px-2"
@@ -125,6 +122,9 @@
<Content showEntire {event} />
</div>
</TapTarget>
{#if isOwn && thunk}
<ThunkStatus showToastOnRetry {thunk} class="mb-2 mr-2" />
{/if}
<div class="row-2 z-feature -mt-4 ml-4">
<ReactionSummary {event} {deleteReaction} {createReaction} noTooltip />
</div>
+3 -3
View File
@@ -26,7 +26,7 @@
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import ThunkFailure from "@app/components/ThunkFailure.svelte"
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
@@ -126,8 +126,8 @@
{/if}
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
<RoomItemContent {url} event={$innerEvent ?? event} />
{#if thunk}
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
{#if event.pubkey === $pubkey && thunk}
<ThunkStatus showToastOnRetry {thunk} class="mt-1 flex justify-end" />
{/if}
</div>
</div>
@@ -0,0 +1,128 @@
<script lang="ts">
import {stopPropagation} from "svelte/legacy"
import type {AbstractThunk} from "@welshman/app"
import {
flattenThunks,
getFailedThunkUrls,
getThunkUrlsWithStatus,
publishThunk,
} from "@welshman/app"
import {PublishStatus} from "@welshman/net"
import {displayRelayUrl} from "@welshman/util"
import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
import Close from "@assets/icons/close.svg?dataurl"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {addPeriod} from "@lib/util"
import ThunkToast from "@app/components/ThunkToast.svelte"
import {pushToast} from "@app/util/toast"
type DeliveryState = "delivered" | "partial" | "failed"
interface Props {
thunk: AbstractThunk
deliveryState: DeliveryState
hide: () => void
showToastOnRetry?: boolean
}
const {thunk, deliveryState, hide, showToastOnRetry}: Props = $props()
const successUrls = $derived(getThunkUrlsWithStatus(PublishStatus.Success, $thunk))
const failedUrls = $derived([
...getFailedThunkUrls($thunk),
...getThunkUrlsWithStatus(PublishStatus.Aborted, $thunk),
])
const total = $derived(successUrls.length + failedUrls.length)
const title = $derived(
deliveryState === "delivered"
? "Message delivered"
: deliveryState === "partial"
? `Partial delivery ${successUrls.length}/${total} relays`
: "Failed to deliver!",
)
const titleIconClass = $derived(
deliveryState === "delivered"
? "text-success"
: deliveryState === "partial"
? "text-warning"
: "text-error",
)
const titleIcon = $derived(deliveryState === "delivered" ? CheckCircle : Danger)
const relayMessage = (
url: string,
status: PublishStatus | undefined,
detail: string | undefined,
) => {
if (detail) {
return detail
}
if (status === PublishStatus.Timeout) {
return "request timed out"
}
return "no details received"
}
const retryRelay = (url: string) => {
for (const child of flattenThunks([thunk])) {
if (!child.options.relays.includes(url)) {
continue
}
const retried = publishThunk({...child.options, relays: [url]})
if (showToastOnRetry) {
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk: retried},
},
})
}
return
}
}
</script>
<div class="card2 bg-alt flex min-w-72 max-w-sm flex-col gap-3 px-4 py-3 shadow-lg">
<div class="flex items-center justify-between gap-2">
<span class="flex items-center gap-2 text-sm font-medium">
<Icon icon={titleIcon} class={titleIconClass} size={4} />
{title}
</span>
<button type="button" class="opacity-50 hover:opacity-100" onclick={hide}>
<Icon icon={Close} size={4} />
</button>
</div>
<div class="divider my-0"></div>
<div class="flex flex-col gap-3">
{#each successUrls as url (url)}
<div class="flex items-start gap-2 text-sm">
<Icon icon={CheckCircle} class="mt-0.5 shrink-0 text-success" size={4} />
<span>{displayRelayUrl(url)}</span>
</div>
{/each}
{#each failedUrls as url (url)}
{@const {detail, status} = $thunk.results[url] || {}}
<div class="grid grid-cols-[1rem_1fr_auto] items-start gap-x-3 gap-y-1 text-sm">
<Icon icon={Danger} class="mt-0.5 text-error" size={4} />
<div class="min-w-0">
<p class="break-all">{displayRelayUrl(url)}</p>
<p class="text-xs opacity-60">{addPeriod(relayMessage(url, status, detail))}</p>
</div>
<Button class="link shrink-0 px-1" onclick={stopPropagation(() => retryRelay(url))}>
Retry
</Button>
</div>
{/each}
</div>
</div>
+3 -47
View File
@@ -1,14 +1,6 @@
<script lang="ts">
import {stopPropagation} from "svelte/legacy"
import {noop} from "@welshman/lib"
import type {AbstractThunk} from "@welshman/app"
import {retryThunk, thunkIsComplete, getFailedThunkUrls} from "@welshman/app"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
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/util/toast"
import ThunkStatus from "@app/components/ThunkStatus.svelte"
interface Props {
thunk: AbstractThunk
@@ -16,43 +8,7 @@
class?: string
}
let {thunk, showToastOnRetry, ...restProps}: Props = $props()
const retry = () => {
thunk = retryThunk(thunk)
if (showToastOnRetry) {
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk},
},
})
}
}
const failedUrls = $derived(getFailedThunkUrls($thunk))
const showFailure = $derived(thunkIsComplete($thunk) && failedUrls.length > 0)
const props: Props = $props()
</script>
{#if showFailure}
{@const url = failedUrls[0]}
{@const {status, detail: message} = $thunk.results[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}
<ThunkStatus {...props} />
+115 -11
View File
@@ -1,22 +1,126 @@
<script lang="ts">
import type {Instance} from "tippy.js"
import {stopPropagation} from "svelte/legacy"
import {noop} from "@welshman/lib"
import type {AbstractThunk} from "@welshman/app"
import {thunkIsComplete, getFailedThunkUrls} from "@welshman/app"
import ThunkFailure from "@app/components/ThunkFailure.svelte"
import ThunkPending from "@app/components/ThunkPending.svelte"
import {
abortThunk,
getFailedThunkUrls,
getThunkUrlsWithStatus,
thunkHasStatus,
thunkIsComplete,
} from "@welshman/app"
import {PublishStatus} from "@welshman/net"
import Check from "@assets/icons/check.svg?dataurl"
import CheckRead from "@assets/icons/check-read.svg?dataurl"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import ThunkDeliveryBreakdown from "@app/components/ThunkDeliveryBreakdown.svelte"
type DeliveryState = "sending" | "delivered" | "partial" | "failed"
interface Props {
thunk: AbstractThunk
showToastOnRetry?: boolean
class?: string
}
const {thunk, ...restProps}: Props = $props()
const {thunk, showToastOnRetry, ...restProps}: Props = $props()
const showFailure = $derived(thunkIsComplete($thunk) && getFailedThunkUrls($thunk).length > 0)
const showPending = $derived(!thunkIsComplete($thunk))
const successUrls = $derived(getThunkUrlsWithStatus(PublishStatus.Success, $thunk))
const failedUrls = $derived([
...getFailedThunkUrls($thunk),
...getThunkUrlsWithStatus(PublishStatus.Aborted, $thunk),
])
const isComplete = $derived(thunkIsComplete($thunk))
const isSending = $derived(thunkHasStatus(PublishStatus.Sending, $thunk))
const deliveryState = $derived.by((): DeliveryState => {
if (!isComplete) {
return "sending"
}
if (successUrls.length === 0 && failedUrls.length > 0) {
return "failed"
}
if (failedUrls.length > 0) {
return "partial"
}
return "delivered"
})
const statusLabel = $derived(
deliveryState === "sending"
? "Sending..."
: deliveryState === "delivered"
? "Message delivered"
: deliveryState === "partial"
? "Partial delivery"
: "Failed to deliver!",
)
const statusIcon = $derived(
deliveryState === "delivered" ? CheckRead : deliveryState === "partial" ? Check : Danger,
)
const statusIconClass = $derived(
deliveryState === "delivered"
? "text-success"
: deliveryState === "partial"
? "text-warning"
: deliveryState === "failed"
? "text-error"
: "",
)
const showBreakdown = $derived(isComplete)
const breakdownState = $derived(
deliveryState === "delivered" || deliveryState === "partial" || deliveryState === "failed"
? deliveryState
: "delivered",
)
const abort = () => abortThunk(thunk)
let popover: Instance | undefined = $state()
const hideBreakdown = () => popover?.hide()
</script>
{#if showFailure}
<ThunkFailure class={restProps.class} {thunk} />
{:else if showPending}
<ThunkPending class={restProps.class} {thunk} />
{/if}
<div class="flex w-full justify-end px-1 text-xs {restProps.class}">
{#if deliveryState === "sending"}
<span class="flex items-center gap-1 opacity-50">
<span class="loading loading-spinner mx-1 h-3 w-3 translate-y-px"></span>
<span>{statusLabel}</span>
<button
type="button"
class="underline transition-all"
class:link={isSending}
class:opacity-25={!isSending}
class:pointer-events-none={!isSending}
onclick={stopPropagation(abort)}>
Cancel
</button>
</span>
{:else if showBreakdown}
<button type="button" class="flex items-center" onclick={stopPropagation(noop)}>
<Tippy
bind:popover
class="flex items-center"
component={ThunkDeliveryBreakdown}
props={{thunk, deliveryState: breakdownState, hide: hideBreakdown, showToastOnRetry}}
params={{interactive: true, maxWidth: "none"}}>
{#snippet children()}
<span class="flex cursor-pointer items-center gap-1 opacity-75">
<Icon icon={statusIcon} class={statusIconClass} size={3} />
<span>{statusLabel}</span>
</span>
{/snippet}
</Tippy>
</button>
{/if}
</div>
@@ -1,32 +0,0 @@
<script lang="ts">
import {PublishStatus} from "@welshman/net"
import {displayRelayUrl} from "@welshman/util"
import Button from "@lib/components/Button.svelte"
import {addPeriod} from "@lib/util"
interface Props {
url: string
status: string
message: string
retry: () => void
}
let {url, status, message = $bindable(), retry}: Props = $props()
$effect(() => {
if (!message && status === PublishStatus.Timeout) {
message = "request timed out"
}
if (!message) {
message = "no details recieved"
}
})
</script>
<div class="card2 bg-alt col-2 shadow-lg">
<p>
Failed to publish to {displayRelayUrl(url)}: {addPeriod(message)}
</p>
<Button class="link" onclick={retry}>Retry</Button>
</div>
+1 -1
View File
@@ -470,7 +470,7 @@
bind:element
onscroll={onScroll}
class={cx(
"flex-col-reverse pb-0! pt-4",
"flex-col-reverse pb-0! pt-4 md:pb-8!",
showMobileVideoPanel ? "hidden md:flex md:flex-col-reverse" : "flex",
pageContentHiddenDesktopVideoOnly && "md:hidden",
)}>
+1 -1
View File
@@ -318,7 +318,7 @@
{/snippet}
</SpaceBar>
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4 pb-0!">
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4 pb-0! md:pb-8!">
{#if loadingForward}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>