Files
plspace/src/components/blog/EmojiReactions.svelte
T

132 lines
3.6 KiB
Svelte

<script lang="ts">
import type { EmojiReaction, Status } from '$lib/api/types'
import { useAppServices } from '$lib/app-services'
import { safeCustomEmojiUrl } from '$lib/util/html'
interface Props {
status: Status
onupdate?: (status: Status) => void
}
let { status, onupdate }: Props = $props()
const { endpoints, session } = useAppServices()
const reactions = $derived(
(status.pleroma?.emoji_reactions ?? []).filter(
(reaction) => reaction.name.trim().length > 0 && reaction.count > 0,
),
)
let busyEmoji = $state<string | null>(null)
let error = $state<string | null>(null)
function customLabel(reaction: EmojiReaction): string {
return `:${reaction.name}:`
}
function accessibleLabel(reaction: EmojiReaction): string {
const action = reaction.me ? 'Remove' : 'Add'
const emoji = reaction.url ? customLabel(reaction) : reaction.name
const countLabel = reaction.count === 1 ? '1 reaction' : `${reaction.count} reactions`
return `${action} ${emoji} reaction, ${countLabel}`
}
function withReactions(base: Status, next: EmojiReaction[]): Status {
return {
...base,
pleroma: {
...(base.pleroma ?? {}),
emoji_reactions: next,
},
}
}
function optimisticReactions(
source: EmojiReaction[],
selected: EmojiReaction,
on: boolean,
): EmojiReaction[] {
return source
.map((reaction) => {
if (reaction.name !== selected.name) return reaction
return {
...reaction,
count: Math.max(0, reaction.count + (on ? 1 : -1)),
me: on,
}
})
.filter((reaction) => reaction.count > 0)
}
async function toggle(reaction: EmojiReaction): Promise<void> {
if (!session.signedIn || busyEmoji) return
const before = status
const on = !reaction.me
busyEmoji = reaction.name
error = null
onupdate?.(
withReactions(
before,
optimisticReactions(before.pleroma?.emoji_reactions ?? [], reaction, on),
),
)
try {
const updated = await endpoints.setEmojiReaction(
session.api,
status.id,
reaction.name,
on,
)
onupdate?.(updated)
} catch (cause) {
onupdate?.(before)
error =
cause instanceof Error
? cause.message
: 'Could not save that emoji reaction.'
} finally {
busyEmoji = null
}
}
</script>
{#if reactions.length > 0}
<div class="emoji-reaction-bar" aria-label="Emoji reactions">
{#each reactions as reaction (reaction.url ?? reaction.name)}
{@const imageUrl = reaction.url ? safeCustomEmojiUrl(reaction.url) : null}
<button
type="button"
class="emoji-reaction"
class:emoji-reaction--mine={reaction.me}
data-custom={imageUrl ? 'true' : 'false'}
aria-label={accessibleLabel(reaction)}
aria-pressed={reaction.me ? 'true' : 'false'}
title={session.signedIn
? accessibleLabel(reaction)
: 'Sign in to react with this emoji'}
disabled={!session.signedIn || busyEmoji !== null}
onclick={() => void toggle(reaction)}
>
{#if imageUrl}
<img
class="emoji-reaction-image"
src={imageUrl}
alt={customLabel(reaction)}
loading="lazy"
decoding="async"
/>
{:else}
<span class="emoji-reaction-glyph" aria-hidden="true">{reaction.name}</span>
{/if}
<span class="emoji-reaction-count">{reaction.count}</span>
</button>
{/each}
</div>
{/if}
{#if error}
<p class="error-note emoji-reaction-error" role="alert">{error}</p>
{/if}