diff --git a/src/components/blog/Attachments.svelte b/src/components/blog/Attachments.svelte index 418abe4..14b5191 100644 --- a/src/components/blog/Attachments.svelte +++ b/src/components/blog/Attachments.svelte @@ -6,14 +6,16 @@ * when it's revealed, and the reveal is per-attachment because a single post * can mix flagged and unflagged media. */ - import type { MediaAttachment } from '$lib/api/types' + import type { CustomEmoji, MediaAttachment } from '$lib/api/types' + import EmojiText from '../common/EmojiText.svelte' interface Props { attachments: MediaAttachment[] + emojis?: CustomEmoji[] sensitive?: boolean } - let { attachments, sensitive = false }: Props = $props() + let { attachments, emojis, sensitive = false }: Props = $props() let revealed = $state>({}) @@ -81,7 +83,9 @@ {/if} {#if media.description} -
{media.description}
+
+ +
{/if} diff --git a/src/components/blog/BlogEntry.svelte b/src/components/blog/BlogEntry.svelte index e55a236..7a0a93f 100644 --- a/src/components/blog/BlogEntry.svelte +++ b/src/components/blog/BlogEntry.svelte @@ -17,6 +17,7 @@ import { isoDate, longDate, stampDate } from '$lib/util/time' import { extractYouTubeVideoIds } from '$lib/util/youtube' import Avatar from '../common/Avatar.svelte' + import EmojiText from '../common/EmojiText.svelte' import MfmContent from '../common/MfmContent.svelte' import Attachments from './Attachments.svelte' import EmojiReactions from './EmojiReactions.svelte' @@ -145,7 +146,10 @@ > {#if booster}

- {displayNameOf(booster)} reposted this + + + + reposted this

{/if} @@ -181,7 +185,9 @@
{#if entry.spoiler_text}
- {entry.spoiler_text} + + + {#if entry.media_attachments.length > 0} - + {/if}
@@ -203,7 +209,7 @@ lang={entry.language} /> {#if entry.media_attachments.length > 0} - + {/if} {/if} @@ -211,6 +217,7 @@ {#if entry.poll} onupdate?.(applyLocal(status, { poll }))} /> diff --git a/src/components/blog/EmojiReactions.svelte b/src/components/blog/EmojiReactions.svelte index 9f5fd4d..6d7dac6 100644 --- a/src/components/blog/EmojiReactions.svelte +++ b/src/components/blog/EmojiReactions.svelte @@ -1,6 +1,7 @@ + + +{@html rendered} diff --git a/src/components/common/EmojiText.test.ts b/src/components/common/EmojiText.test.ts new file mode 100644 index 0000000..2f0e182 --- /dev/null +++ b/src/components/common/EmojiText.test.ts @@ -0,0 +1,38 @@ +import { render } from '@testing-library/svelte' +import { describe, expect, it } from 'vitest' +import EmojiText from './EmojiText.svelte' + +const wideEmoji = { + shortcode: 'wide', + url: 'https://cdn.example/wide.png', + static_url: 'https://cdn.example/wide.png', + visible_in_picker: true, +} + +describe('EmojiText', () => { + it('escapes plain text and replaces declared custom emoji', () => { + const view = render(EmojiText, { + props: { + text: 'unsafe :wide:', + emojis: [wideEmoji], + }, + }) + + expect(view.getByText('unsafe')).toBeInTheDocument() + expect(view.queryByText('unsafe', { selector: 'b' })).not.toBeInTheDocument() + expect(view.getByAltText(':wide:')).toHaveAttribute('src', wideEmoji.url) + expect(view.getByAltText(':wide:')).toHaveClass('custom-emoji') + }) + + it('does not inject emoji images from unsafe URL schemes', () => { + const view = render(EmojiText, { + props: { + text: ':wide:', + emojis: [{ ...wideEmoji, url: 'data:image/svg+xml,' }], + }, + }) + + expect(view.queryByRole('img')).not.toBeInTheDocument() + expect(view.getByText(':wide:')).toBeInTheDocument() + }) +}) diff --git a/src/components/common/Module.svelte b/src/components/common/Module.svelte index 4780734..c0f2ced 100644 --- a/src/components/common/Module.svelte +++ b/src/components/common/Module.svelte @@ -6,11 +6,15 @@ * panel blue caption bar (left rail) * band peach caption bar (main column) * plain no chrome, just the heading - */ + */ import type { Snippet } from 'svelte' + import type { CustomEmoji } from '$lib/api/types' + import EmojiText from './EmojiText.svelte' interface Props { title?: string + /** Custom emoji available to user-derived titles. */ + titleEmojis?: CustomEmoji[] variant?: 'panel' | 'band' | 'plain' /** Right-aligned link in the caption bar, e.g. "[view all]". */ action?: Snippet @@ -23,6 +27,7 @@ let { title, + titleEmojis, variant = 'panel', action, flush = false, @@ -38,7 +43,7 @@
{#if title}

- {title} + {#if action} {@render action()} {/if} diff --git a/src/components/profile/ContactBox.svelte b/src/components/profile/ContactBox.svelte index 36f3bda..6a4087e 100644 --- a/src/components/profile/ContactBox.svelte +++ b/src/components/profile/ContactBox.svelte @@ -10,6 +10,7 @@ import { useAppServices } from '$lib/app-services' import { displayNameOf } from '$lib/util/profile' import Module from '../common/Module.svelte' + import EmojiText from '../common/EmojiText.svelte' interface Props { account: Account @@ -73,7 +74,7 @@ } - + {#if error} {/if} @@ -137,6 +138,8 @@ {#if relationship?.followed_by && !isSelf} -

{firstName} has you on their friends list.

+

+ has you on their friends list. +

{/if}
diff --git a/src/components/profile/DetailsTable.svelte b/src/components/profile/DetailsTable.svelte index 34d6d4e..575de47 100644 --- a/src/components/profile/DetailsTable.svelte +++ b/src/components/profile/DetailsTable.svelte @@ -7,23 +7,28 @@ * an unverified link that looks verified is a phishing surface. */ import type { ProfileField } from '$lib/util/profile' + import type { CustomEmoji } from '$lib/api/types' import Module from '../common/Module.svelte' + import EmojiText from '../common/EmojiText.svelte' interface Props { title: string fields: ProfileField[] + emojis?: CustomEmoji[] } - let { title, fields }: Props = $props() + let { title, fields, emojis }: Props = $props() {#if fields.length > 0} - + {#each fields as field, index (`${field.name}:${index}`)} - +
{field.name} + + {#if field.verified} diff --git a/src/components/profile/FriendSpace.svelte b/src/components/profile/FriendSpace.svelte index 226f33a..8ee1155 100644 --- a/src/components/profile/FriendSpace.svelte +++ b/src/components/profile/FriendSpace.svelte @@ -6,15 +6,17 @@ * accounts often return an empty list rather than an error, so the count and * the grid are allowed to disagree; the count is authoritative. */ - import type { Account } from '$lib/api/types' + import type { Account, CustomEmoji } from '$lib/api/types' import { displayNameOf, formatCount, profilePath } from '$lib/util/profile' import Module from '../common/Module.svelte' import Avatar from '../common/Avatar.svelte' + import EmojiText from '../common/EmojiText.svelte' interface Props { title: string /** The subject, used in "Tom has 527 friends." */ ownerName: string + ownerEmojis?: CustomEmoji[] friends: Account[] total: number viewAllHref: string @@ -29,6 +31,7 @@ let { title, ownerName, + ownerEmojis, friends, total, viewAllHref, @@ -39,7 +42,7 @@ }: Props = $props() - + {#snippet action()} [view all] {/snippet} @@ -47,10 +50,13 @@ {#if countHidden} -

{ownerName} keeps their friend count private.

+

+ keeps their friend count private. +

{:else}

- {ownerName} has {formatCount(total)} + has + {formatCount(total)} friend{total === 1 ? '' : 's'}.

{/if} @@ -66,7 +72,11 @@ {#each friends as friend (friend.id)}
  • - {displayNameOf(friend)} +
  • diff --git a/src/components/profile/InterestsTable.svelte b/src/components/profile/InterestsTable.svelte index c1bb45d..0bad826 100644 --- a/src/components/profile/InterestsTable.svelte +++ b/src/components/profile/InterestsTable.svelte @@ -6,18 +6,20 @@ * Mastodon account gets a compact box rather than six empty rows. */ import type { InterestEntry } from '$lib/util/profile' + import type { CustomEmoji } from '$lib/api/types' import Module from '../common/Module.svelte' interface Props { title: string interests: InterestEntry[] + emojis?: CustomEmoji[] } - let { title, interests }: Props = $props() + let { title, interests, emojis }: Props = $props() {#if interests.length > 0} - + {#each interests as entry, index (`${entry.row}:${index}`)} diff --git a/src/components/profile/PicStream.svelte b/src/components/profile/PicStream.svelte index 81dea01..16336fa 100644 --- a/src/components/profile/PicStream.svelte +++ b/src/components/profile/PicStream.svelte @@ -4,15 +4,17 @@ * stream. Videos/audio stay in the Blog; reposted pictures are not somebody's * own Pics. */ - import type { MediaAttachment, Status } from '$lib/api/types' + import type { CustomEmoji, MediaAttachment, Status } from '$lib/api/types' import type { Feed } from '$lib/stores/feed.svelte' import { toPlainText } from '$lib/util/html' import { stampDate } from '$lib/util/time' import Pager from '../common/Pager.svelte' + import EmojiText from '../common/EmojiText.svelte' interface Props { feed: Feed ownerName: string + ownerEmojis?: CustomEmoji[] } interface Picture { @@ -22,7 +24,7 @@ caption: string } - let { feed, ownerName }: Props = $props() + let { feed, ownerName, ownerEmojis }: Props = $props() let revealed = $state>({}) const pictures = $derived.by(() => @@ -46,7 +48,8 @@

    - Pictures from {ownerName}'s Blog Entries. Click a picture to view the full-size original. + Pictures from 's Blog Entries. Click a + picture to view the full-size original.

    {#if !feed.initialized && feed.loading} @@ -97,10 +100,17 @@
    {#if hidden} - {picture.status.spoiler_text || 'Sensitive picture'} + {:else if picture.caption} - {picture.caption} + {/if} Posted {stampDate(picture.status.created_at)} · view entry @@ -115,6 +125,6 @@ 0 ? 'That’s the whole picture stream.' : ''} /> diff --git a/src/components/profile/ProfileIdentity.svelte b/src/components/profile/ProfileIdentity.svelte index cdfcab7..e385c60 100644 --- a/src/components/profile/ProfileIdentity.svelte +++ b/src/components/profile/ProfileIdentity.svelte @@ -10,9 +10,9 @@ */ import type { ProfileView } from '$lib/util/profile' import { displayNameOf, fullHandle } from '$lib/util/profile' - import { renderDisplayName } from '$lib/util/html' import { relativeTime, shortDate, yearsSince } from '$lib/util/time' import { useAppServices } from '$lib/app-services' + import EmojiText from '../common/EmojiText.svelte' interface Props { profile: ProfileView @@ -22,7 +22,6 @@ const { session } = useAppServices() const account = $derived(profile.account) - const name = $derived(renderDisplayName(displayNameOf(account), account.emojis)) const handle = $derived(fullHandle(account, session.host)) const accountAge = $derived(profile.age ?? yearsSince(account.created_at)) const photo = $derived(account.avatar || account.avatar_static) @@ -41,12 +40,16 @@
    -

    {profile.headline}

    +

    + +

    {#if profile.gender}
    Gender
    -
    {profile.gender}
    +
    + +
    {/if} {#if accountAge !== null} @@ -61,7 +64,9 @@ {#if profile.location}
    Location
    -
    {profile.location}
    +
    + +
    {/if}
    Last active
    @@ -78,7 +83,8 @@ {#if profile.mood}

    - Mood: {profile.mood} + Mood: +

    {/if} diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 6e08a8d..a26c0a5 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -261,6 +261,9 @@ export interface Notification { created_at: string account: Account status?: Status | null + /** Pleroma/Akkoma emoji-reaction notification payload. */ + emoji?: string | null + emoji_url?: string | null } export interface Context { diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 0afe3df..06e5309 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -1,4 +1,4 @@ -import type { Notification } from './api/types' +import type { CustomEmoji, Notification } from './api/types' import { toPlainText } from './util/html' import { displayNameOf, profilePath } from './util/profile' @@ -53,6 +53,30 @@ export interface NotificationPresentation { href: string } +export interface NotificationEmoji { + text: string + emojis: CustomEmoji[] +} + +/** Turn Pleroma's separate reaction name/URL fields into EmojiText input. */ +export function emojiForNotification(notification: Notification): NotificationEmoji | null { + const text = notification.emoji?.trim() + if (!text) return null + const shortcode = text.match(/^:([^:]+):$/)?.[1] + if (!shortcode || !notification.emoji_url) return { text, emojis: [] } + return { + text, + emojis: [ + { + shortcode, + url: notification.emoji_url, + static_url: notification.emoji_url, + visible_in_picker: false, + }, + ], + } +} + const MESSAGE: Record = { mention: 'mentioned you in an entry', status: 'posted a new entry', diff --git a/src/lib/util/html.ts b/src/lib/util/html.ts index 45b9277..256f582 100644 --- a/src/lib/util/html.ts +++ b/src/lib/util/html.ts @@ -119,6 +119,15 @@ export function escapeHtml(value: string): string { * Runs on the *sanitized* string and only injects `` with a URL taken from * the emoji list, so it cannot reintroduce markup from the original content. */ +export function safeCustomEmojiUrl(value: string): string | null { + try { + const url = new URL(value, window.location.href) + return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null + } catch { + return null + } +} + function applyEmojis(html: string, emojis: CustomEmoji[] | undefined): string { if (!emojis?.length) return html const table = new Map(emojis.map((emoji) => [emoji.shortcode, emoji])) @@ -128,7 +137,9 @@ function applyEmojis(html: string, emojis: CustomEmoji[] | undefined): string { const replaced = text.replace(/:([a-zA-Z0-9_+-]+):/g, (whole, shortcode: string) => { const emoji = table.get(shortcode) if (!emoji) return whole - return `:${escapeHtml(
+      const url = safeCustomEmojiUrl(emoji.url)
+      if (!url) return whole
+      return `<img class=` }) @@ -213,7 +224,12 @@ export function toPlainText(source: string | null | undefined): string { return (container.textContent ?? '').replace(/\s+/g, ' ').trim() } -/** Emoji-substituted display name, safe for `{@html}`. */ +/** Emoji-substituted plain text, escaped and safe for `{@html}`. */ +export function renderEmojiText(text: string, emojis: CustomEmoji[] | undefined): string { + return applyEmojis(escapeHtml(text), emojis) +} + +/** Backward-compatible semantic name for existing display-name callers. */ export function renderDisplayName(name: string, emojis: CustomEmoji[] | undefined): string { - return applyEmojis(escapeHtml(name), emojis) + return renderEmojiText(name, emojis) } diff --git a/src/routes/Home.svelte b/src/routes/Home.svelte index 0f5931a..e7c07f0 100644 --- a/src/routes/Home.svelte +++ b/src/routes/Home.svelte @@ -15,12 +15,13 @@ instanceThumbnail, } from '$lib/api/endpoints' import { displayNameOf, fallbackMood, formatCount, profilePath } from '$lib/util/profile' - import { toPlainText } from '$lib/util/html' + import { escapeHtml, toPlainText } from '$lib/util/html' import { relativeTime, shortDate, stampDate } from '$lib/util/time' import { useTimelineRefresh } from '$lib/timeline-refresh' import { reconcileRefreshItems } from '$lib/stores/feed.svelte' import Module from '$components/common/Module.svelte' import Avatar from '$components/common/Avatar.svelte' + import EmojiText from '$components/common/EmojiText.svelte' import RichText from '$components/common/RichText.svelte' import MfmContent from '$components/common/MfmContent.svelte' import Composer from '$components/blog/Composer.svelte' @@ -217,7 +218,11 @@
    {:else}

    - {#if me}Hello, {displayNameOf(me).split(/\s+/)[0]}!{:else}{domain}{/if} + {#if me} + Hello, ! + {:else} + {domain} + {/if}

    {#if me}

    @@ -243,7 +248,9 @@

    - {displayNameOf(me)} + + +

    Profile views: {formatCount(me.statuses_count)} entries @@ -327,10 +334,10 @@

    - {displayNameOf(entry.account)} + ${entry.spoiler_text}

    ` : entry.content} + html={entry.spoiler_text ? `

    ${escapeHtml(entry.spoiler_text)}

    ` : entry.content} emojis={entry.emojis} mentions={entry.mentions} tags={entry.tags} @@ -376,12 +383,17 @@ {@const entry = status.reblog ?? status}
    @@ -407,7 +419,11 @@ {#each following as friend (friend.id)}
  • - {displayNameOf(friend)} +
  • {#each notifications.items as item (item.id)} + {@const reaction = emojiForNotification(item)}
    - {displayNameOf(entry.account)} + + + {stampDate(entry.created_at)} - {toPlainText(entry.spoiler_text || entry.content).slice(0, 90) || '(no text)'} +
    - {displayNameOf(account)} + + + wants to be your friend!
    {fullHandle(account, session.host)}
    @@ -205,6 +209,7 @@
    {stampDate(item.created_at)} @@ -214,14 +219,22 @@ - {displayNameOf(item.account)} + + + {VERB[item.type] ?? item.type} + {#if reaction} + with + {/if} {#if item.status}

    - {toPlainText(item.status.spoiler_text || item.status.content).slice(0, 140) || - '(no text)'} +

    {/if} diff --git a/src/routes/Profile.svelte b/src/routes/Profile.svelte index 8f1c40c..ae3e060 100644 --- a/src/routes/Profile.svelte +++ b/src/routes/Profile.svelte @@ -25,6 +25,7 @@ } from '$lib/util/profile' import { toPlainText } from '$lib/util/html' import Module from '$components/common/Module.svelte' + import EmojiText from '$components/common/EmojiText.svelte' import ProfileIdentity from '$components/profile/ProfileIdentity.svelte' import ContactBox from '$components/profile/ContactBox.svelte' import InterestsTable from '$components/profile/InterestsTable.svelte' @@ -204,7 +205,9 @@ {error}

    {:else if account && profile} -

    {displayNameOf(account)}

    +

    + +

    {#if account.moved}

    @@ -229,10 +232,18 @@

    - - + + - + @@ -275,6 +286,7 @@ {:else if view === 'blog'} - + {#snippet action()} [Back to Profile] {/snippet} @@ -309,12 +321,12 @@ /> {:else if view === 'pics'} - + {#snippet action()} [Back to Profile] {/snippet} - + {:else} - + {#snippet action()} [View Blog] {/snippet} @@ -337,7 +353,11 @@ {#each entries.items.slice(0, 6) as status (status.id)} {@const entry = status.reblog ?? status} {/each} @@ -348,7 +368,7 @@ {/if} - +

    About me:

    {#if profile.about}
    @@ -356,7 +376,9 @@ {@html profile.about}
    {:else} -

    {firstName} hasn't written an About me yet.

    +

    + hasn't written an About me yet. +

    {/if}

    Who I'd like to meet:

    @@ -375,6 +397,7 @@ { + it('renders account custom emoji in the public profile heading', async () => { + const profileAccount = account({ + display_name: ':smugkura: Kura :disconnecting: :kura_explode:', + emojis: [ + { + shortcode: 'smugkura', + url: 'https://cdn.example/smugkura.png', + static_url: 'https://cdn.example/smugkura.png', + visible_in_picker: true, + }, + { + shortcode: 'disconnecting', + url: 'https://cdn.example/disconnecting.png', + static_url: 'https://cdn.example/disconnecting.png', + visible_in_picker: true, + }, + { + shortcode: 'kura_explode', + url: 'https://cdn.example/kura_explode.png', + static_url: 'https://cdn.example/kura_explode.png', + visible_in_picker: true, + }, + ], + }) + const services = testServices({ + session: session(), + theme: theme(), + endpoints: { + lookupAccount: vi.fn().mockResolvedValue(profileAccount), + fetchAccountStatuses: vi.fn().mockResolvedValue({ items: [], links: {} }), + }, + }) + const view = render(Profile, { + props: { acct: 'alice' }, + context: new Map([[APP_SERVICES, services]]), + }) + + const headingEmoji = (await view.findAllByAltText(':smugkura:')).find((node) => + node.closest('h1.profile-name'), + ) + expect(headingEmoji).toBeDefined() + expect(view.getAllByAltText(':disconnecting:').length).toBeGreaterThan(0) + expect(view.getAllByAltText(':kura_explode:').length).toBeGreaterThan(0) + }) + it('does not apply profile CSS after it has unmounted', async () => { const lookup = deferred() const applyProfileCss = vi.fn() diff --git a/src/routes/Settings.svelte b/src/routes/Settings.svelte index 234e303..f0d2c11 100644 --- a/src/routes/Settings.svelte +++ b/src/routes/Settings.svelte @@ -13,6 +13,7 @@ import { instanceDomain } from '$lib/api/endpoints' import { displayNameOf, profilePath } from '$lib/util/profile' import Module from '$components/common/Module.svelte' + import EmojiText from '$components/common/EmojiText.svelte' import PublicProfileEditor from '$components/profile/PublicProfileEditor.svelte' import PublishedCssEditor from '$components/profile/PublishedCssEditor.svelte' @@ -97,7 +98,9 @@ {#if session.signedIn && session.me}

    Signed in as - {displayNameOf(session.me)} + + + on {domain}.

    diff --git a/src/routes/StatusPage.svelte b/src/routes/StatusPage.svelte index 435e53c..f38186d 100644 --- a/src/routes/StatusPage.svelte +++ b/src/routes/StatusPage.svelte @@ -15,6 +15,7 @@ import { useTimelineRefresh } from '$lib/timeline-refresh' import Module from '$components/common/Module.svelte' import Avatar from '$components/common/Avatar.svelte' + import EmojiText from '$components/common/EmojiText.svelte' import MfmContent from '$components/common/MfmContent.svelte' import BlogEntry from '$components/blog/BlogEntry.svelte' import Composer from '$components/blog/Composer.svelte' @@ -162,7 +163,9 @@

    {:else if status}

    - {displayNameOf(status.account)}'s Blog + + + 's Blog

    @@ -219,7 +222,10 @@

    - {displayNameOf(reply.status.account)} + {#if reply.status.spoiler_text}
    - {reply.status.spoiler_text} + + +