diff --git a/src/components/blog/BlogEntry.svelte b/src/components/blog/BlogEntry.svelte index 5c9fcb9..e55a236 100644 --- a/src/components/blog/BlogEntry.svelte +++ b/src/components/blog/BlogEntry.svelte @@ -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 @@ {/if} + + onupdate?.(rewrap(status, updated))} + /> diff --git a/src/components/blog/BlogEntry.test.ts b/src/components/blog/BlogEntry.test.ts index b0232fd..53eb798 100644 --- a/src/components/blog/BlogEntry.test.ts +++ b/src/components/blog/BlogEntry.test.ts @@ -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[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)) + }) +}) diff --git a/src/components/blog/EmojiReactions.svelte b/src/components/blog/EmojiReactions.svelte new file mode 100644 index 0000000..9f5fd4d --- /dev/null +++ b/src/components/blog/EmojiReactions.svelte @@ -0,0 +1,129 @@ + + +{#if reactions.length > 0} +
+ {#each reactions as reaction (reaction.url ?? reaction.name)} + + {/each} +
+{/if} + +{#if error} + +{/if} diff --git a/src/lib/api/endpoints.test.ts b/src/lib/api/endpoints.test.ts index eafcd82..85e73b7 100644 --- a/src/lib/api/endpoints.test.ts +++ b/src/lib/api/endpoints.test.ts @@ -3,6 +3,7 @@ import { ApiClient } from './client' import { fetchNotifications, postStatus, + setEmojiReaction, updateProfileFields, updatePublicProfile, 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', () => { it('defensively applies requested types when a server ignores the filter', async () => { vi.stubGlobal( diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts index 160acb8..49b0c4a 100644 --- a/src/lib/api/endpoints.ts +++ b/src/lib/api/endpoints.ts @@ -314,6 +314,26 @@ export function reblogStatus(api: ApiClient, id: string, on: boolean): Promise(`/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 { + const path = + `/api/v1/pleroma/statuses/${encodeURIComponent(id)}` + + `/reactions/${encodeURIComponent(emoji)}` + const { data } = await api.raw(path, { method: on ? 'PUT' : 'DELETE' }) + return data +} + export function votePoll(api: ApiClient, id: string, choices: number[]): Promise { return api.post(`/api/v1/polls/${encodeURIComponent(id)}/votes`, { choices }) } diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 0ac7809..6e08a8d 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -163,6 +163,21 @@ export interface Poll { 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 { id: string uri: string @@ -205,6 +220,8 @@ export interface Status { conversation_id?: number content?: Record spoiler_text?: Record + /** Pleroma/Akkoma emoji reactions, including custom emoji URLs. */ + emoji_reactions?: EmojiReaction[] } } diff --git a/src/styles/blog.css b/src/styles/blog.css index bac77a9..1e5bb3b 100644 --- a/src/styles/blog.css +++ b/src/styles/blog.css @@ -361,6 +361,76 @@ 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 */ .comment-list {