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 Avatar from '../common/Avatar.svelte'
|
||||||
import MfmContent from '../common/MfmContent.svelte'
|
import MfmContent from '../common/MfmContent.svelte'
|
||||||
import Attachments from './Attachments.svelte'
|
import Attachments from './Attachments.svelte'
|
||||||
|
import EmojiReactions from './EmojiReactions.svelte'
|
||||||
import PollView from './PollView.svelte'
|
import PollView from './PollView.svelte'
|
||||||
import PreviewCardView from './PreviewCardView.svelte'
|
import PreviewCardView from './PreviewCardView.svelte'
|
||||||
import YouTubeEmbeds from './YouTubeEmbeds.svelte'
|
import YouTubeEmbeds from './YouTubeEmbeds.svelte'
|
||||||
@@ -266,5 +267,10 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<EmojiReactions
|
||||||
|
status={entry}
|
||||||
|
onupdate={(updated) => onupdate?.(rewrap(status, updated))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fireEvent, render } from '@testing-library/svelte'
|
import { fireEvent, render, waitFor } from '@testing-library/svelte'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { APP_SERVICES } from '$lib/app-services'
|
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'
|
import BlogEntry from './BlogEntry.svelte'
|
||||||
|
|
||||||
function renderEntry(overrides: Parameters<typeof status>[0]) {
|
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}
|
||||||
@@ -3,6 +3,7 @@ import { ApiClient } from './client'
|
|||||||
import {
|
import {
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
postStatus,
|
postStatus,
|
||||||
|
setEmojiReaction,
|
||||||
updateProfileFields,
|
updateProfileFields,
|
||||||
updatePublicProfile,
|
updatePublicProfile,
|
||||||
votePoll,
|
votePoll,
|
||||||
@@ -152,6 +153,34 @@ describe('poll endpoints', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('emoji reaction endpoints', () => {
|
||||||
|
it.each([
|
||||||
|
[true, 'PUT'],
|
||||||
|
[false, 'DELETE'],
|
||||||
|
] as const)('uses the Pleroma/Akkoma reaction endpoint (on=%s)', async (on, method) => {
|
||||||
|
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||||
|
expect(init?.method).toBe(method)
|
||||||
|
return new Response(JSON.stringify({ id: 'status/one' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
await setEmojiReaction(
|
||||||
|
new ApiClient('example.test', 'token'),
|
||||||
|
'status/one',
|
||||||
|
'party_blob@remote.example',
|
||||||
|
on,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'https://example.test/api/v1/pleroma/statuses/status%2Fone/reactions/party_blob%40remote.example',
|
||||||
|
expect.any(Object),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('fetchNotifications', () => {
|
describe('fetchNotifications', () => {
|
||||||
it('defensively applies requested types when a server ignores the filter', async () => {
|
it('defensively applies requested types when a server ignores the filter', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
|
|||||||
@@ -314,6 +314,26 @@ export function reblogStatus(api: ApiClient, id: string, on: boolean): Promise<S
|
|||||||
return api.post<Status>(`/api/v1/statuses/${encodeURIComponent(id)}/${action}`)
|
return api.post<Status>(`/api/v1/statuses/${encodeURIComponent(id)}/${action}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or remove one of the reactions already present on a status.
|
||||||
|
*
|
||||||
|
* Pleroma and Akkoma share this endpoint. `emoji` can be a Unicode emoji, a
|
||||||
|
* local custom shortcode, or the server-qualified `shortcode@host` returned in
|
||||||
|
* `pleroma.emoji_reactions`.
|
||||||
|
*/
|
||||||
|
export async function setEmojiReaction(
|
||||||
|
api: ApiClient,
|
||||||
|
id: string,
|
||||||
|
emoji: string,
|
||||||
|
on: boolean,
|
||||||
|
): Promise<Status> {
|
||||||
|
const path =
|
||||||
|
`/api/v1/pleroma/statuses/${encodeURIComponent(id)}` +
|
||||||
|
`/reactions/${encodeURIComponent(emoji)}`
|
||||||
|
const { data } = await api.raw<Status>(path, { method: on ? 'PUT' : 'DELETE' })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export function votePoll(api: ApiClient, id: string, choices: number[]): Promise<Poll> {
|
export function votePoll(api: ApiClient, id: string, choices: number[]): Promise<Poll> {
|
||||||
return api.post<Poll>(`/api/v1/polls/${encodeURIComponent(id)}/votes`, { choices })
|
return api.post<Poll>(`/api/v1/polls/${encodeURIComponent(id)}/votes`, { choices })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,21 @@ export interface Poll {
|
|||||||
own_votes?: number[]
|
own_votes?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pleroma/Akkoma's per-status emoji reaction summary. */
|
||||||
|
export interface EmojiReaction {
|
||||||
|
/** Unicode emoji, local custom shortcode, or `shortcode@remote.host`. */
|
||||||
|
name: string
|
||||||
|
count: number
|
||||||
|
/** Whether the authenticated viewer contributed to this count. */
|
||||||
|
me: boolean
|
||||||
|
/** Present for custom emoji reactions. */
|
||||||
|
url?: string | null
|
||||||
|
/** Included by some Pleroma/Akkoma status renderers. */
|
||||||
|
account_ids?: string[]
|
||||||
|
/** Included by the expanded reactions endpoint, but usually absent on timelines. */
|
||||||
|
accounts?: Account[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface Status {
|
export interface Status {
|
||||||
id: string
|
id: string
|
||||||
uri: string
|
uri: string
|
||||||
@@ -205,6 +220,8 @@ export interface Status {
|
|||||||
conversation_id?: number
|
conversation_id?: number
|
||||||
content?: Record<string, string>
|
content?: Record<string, string>
|
||||||
spoiler_text?: Record<string, string>
|
spoiler_text?: Record<string, string>
|
||||||
|
/** Pleroma/Akkoma emoji reactions, including custom emoji URLs. */
|
||||||
|
emoji_reactions?: EmojiReaction[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -361,6 +361,76 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Existing Pleroma/Akkoma reactions. The whole bar is absent when empty. */
|
||||||
|
.emoji-reaction-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-reaction {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 34px;
|
||||||
|
min-height: 25px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border: 1px solid var(--ms-table-border);
|
||||||
|
border-radius: 0;
|
||||||
|
color: var(--ms-page-fg);
|
||||||
|
background: var(--ms-page-bg);
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-reaction:hover:not(:disabled) {
|
||||||
|
border-color: var(--ms-link);
|
||||||
|
background: var(--ms-highlight-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-reaction[aria-pressed='true'] {
|
||||||
|
border-color: var(--ms-link);
|
||||||
|
color: var(--ms-link);
|
||||||
|
background: var(--ms-table-label-bg);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-reaction:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-reaction-image {
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-reaction-glyph {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-reaction-count {
|
||||||
|
min-width: 1ch;
|
||||||
|
color: var(--ms-muted-fg);
|
||||||
|
font-size: var(--ms-font-size-small);
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-reaction[aria-pressed='true'] .emoji-reaction-count {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-reaction-error {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------- comments */
|
/* --------------------------------------------------------------- comments */
|
||||||
|
|
||||||
.comment-list {
|
.comment-list {
|
||||||
|
|||||||
Reference in New Issue
Block a user