mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
reacts and custom emoji reacts
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
import Avatar from '../common/Avatar.svelte'
|
||||
import MfmContent from '../common/MfmContent.svelte'
|
||||
import Attachments from './Attachments.svelte'
|
||||
import EmojiReactions from './EmojiReactions.svelte'
|
||||
import PollView from './PollView.svelte'
|
||||
import PreviewCardView from './PreviewCardView.svelte'
|
||||
import YouTubeEmbeds from './YouTubeEmbeds.svelte'
|
||||
@@ -266,5 +267,10 @@
|
||||
</button>
|
||||
{/if}
|
||||
</footer>
|
||||
|
||||
<EmojiReactions
|
||||
status={entry}
|
||||
onupdate={(updated) => onupdate?.(rewrap(status, updated))}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, render } from '@testing-library/svelte'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fireEvent, render, waitFor } from '@testing-library/svelte'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
import { status, testServices } from '$test/fixtures'
|
||||
import { account, session, status, testServices } from '$test/fixtures'
|
||||
import BlogEntry from './BlogEntry.svelte'
|
||||
|
||||
function renderEntry(overrides: Parameters<typeof status>[0]) {
|
||||
@@ -66,3 +66,113 @@ describe('BlogEntry MFM rendering', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('BlogEntry emoji reactions', () => {
|
||||
it('collapses the reaction bar when the entry has no reactions', () => {
|
||||
const view = renderEntry({ pleroma: { emoji_reactions: [] } })
|
||||
|
||||
expect(view.queryByLabelText('Emoji reactions')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows counts for Unicode and custom Pleroma/Akkoma reactions', () => {
|
||||
const view = renderEntry({
|
||||
pleroma: {
|
||||
emoji_reactions: [
|
||||
{ name: '🎉', count: 2, me: false },
|
||||
{
|
||||
name: 'party_blob@remote.example',
|
||||
count: 3,
|
||||
me: true,
|
||||
url: 'https://cdn.example/emoji/party_blob.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(view.getByLabelText('Emoji reactions')).toBeInTheDocument()
|
||||
expect(
|
||||
view.getByRole('button', { name: 'Add 🎉 reaction, 2 reactions' }),
|
||||
).toHaveTextContent('2')
|
||||
expect(
|
||||
view.getByRole('button', {
|
||||
name: 'Remove :party_blob@remote.example: reaction, 3 reactions',
|
||||
}),
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(view.getByAltText(':party_blob@remote.example:')).toHaveAttribute(
|
||||
'src',
|
||||
'https://cdn.example/emoji/party_blob.png',
|
||||
)
|
||||
})
|
||||
|
||||
it('optimistically adds an existing reaction and applies the returned status', async () => {
|
||||
const original = status({
|
||||
pleroma: { emoji_reactions: [{ name: '🎉', count: 2, me: false }] },
|
||||
})
|
||||
const confirmed = status({
|
||||
pleroma: { emoji_reactions: [{ name: '🎉', count: 3, me: true }] },
|
||||
})
|
||||
const onupdate = vi.fn()
|
||||
const setEmojiReaction = vi.fn().mockResolvedValue(confirmed)
|
||||
const services = testServices({
|
||||
session: session({ signedIn: true, me: account() }),
|
||||
endpoints: { setEmojiReaction },
|
||||
})
|
||||
const view = render(BlogEntry, {
|
||||
props: { status: original, onupdate },
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
await fireEvent.click(
|
||||
view.getByRole('button', { name: 'Add 🎉 reaction, 2 reactions' }),
|
||||
)
|
||||
|
||||
expect(setEmojiReaction).toHaveBeenCalledWith(
|
||||
services.session.api,
|
||||
original.id,
|
||||
'🎉',
|
||||
true,
|
||||
)
|
||||
expect(onupdate.mock.calls[0][0].pleroma?.emoji_reactions).toEqual([
|
||||
{ name: '🎉', count: 3, me: true },
|
||||
])
|
||||
await waitFor(() => expect(onupdate).toHaveBeenLastCalledWith(confirmed))
|
||||
})
|
||||
|
||||
it('removes the viewer’s existing custom reaction', async () => {
|
||||
const customReaction = {
|
||||
name: 'party_blob@remote.example',
|
||||
count: 1,
|
||||
me: true,
|
||||
url: 'https://cdn.example/emoji/party_blob.png',
|
||||
}
|
||||
const original = status({
|
||||
pleroma: { emoji_reactions: [customReaction] },
|
||||
})
|
||||
const confirmed = status({ pleroma: { emoji_reactions: [] } })
|
||||
const onupdate = vi.fn()
|
||||
const setEmojiReaction = vi.fn().mockResolvedValue(confirmed)
|
||||
const services = testServices({
|
||||
session: session({ signedIn: true, me: account() }),
|
||||
endpoints: { setEmojiReaction },
|
||||
})
|
||||
const view = render(BlogEntry, {
|
||||
props: { status: original, onupdate },
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
await fireEvent.click(
|
||||
view.getByRole('button', {
|
||||
name: 'Remove :party_blob@remote.example: reaction, 1 reaction',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(setEmojiReaction).toHaveBeenCalledWith(
|
||||
services.session.api,
|
||||
original.id,
|
||||
customReaction.name,
|
||||
false,
|
||||
)
|
||||
expect(onupdate.mock.calls[0][0].pleroma?.emoji_reactions).toEqual([])
|
||||
await waitFor(() => expect(onupdate).toHaveBeenLastCalledWith(confirmed))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import type { EmojiReaction, Status } from '$lib/api/types'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
|
||||
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)}
|
||||
<button
|
||||
type="button"
|
||||
class="emoji-reaction"
|
||||
class:emoji-reaction--mine={reaction.me}
|
||||
data-custom={reaction.url ? '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 reaction.url}
|
||||
<img
|
||||
class="emoji-reaction-image"
|
||||
src={reaction.url}
|
||||
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}
|
||||
Reference in New Issue
Block a user