refactor and fix all references to emojis

This commit is contained in:
Moon.eth
2026-07-30 17:59:15 +09:00
parent b814d79f19
commit 49c9c2ba57
28 changed files with 422 additions and 77 deletions
+7 -3
View File
@@ -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<Record<string, boolean>>({})
@@ -81,7 +83,9 @@
{/if}
{#if media.description}
<figcaption class="attachment-caption">{media.description}</figcaption>
<figcaption class="attachment-caption">
<EmojiText text={media.description} {emojis} />
</figcaption>
{/if}
</figure>
+11 -4
View File
@@ -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}
<p class="blog-entry-attribution">
<a href={profilePath(booster)}>{displayNameOf(booster)}</a> reposted this
<a href={profilePath(booster)}>
<EmojiText text={displayNameOf(booster)} emojis={booster.emojis} />
</a>
reposted this
</p>
{/if}
@@ -181,7 +185,9 @@
<div class="blog-entry-body">
{#if entry.spoiler_text}
<details class="content-warning">
<summary class="content-warning-summary">{entry.spoiler_text}</summary>
<summary class="content-warning-summary">
<EmojiText text={entry.spoiler_text} emojis={entry.emojis} />
</summary>
<MfmContent
html={entry.content}
emojis={entry.emojis}
@@ -190,7 +196,7 @@
lang={entry.language}
/>
{#if entry.media_attachments.length > 0}
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
<Attachments attachments={entry.media_attachments} emojis={entry.emojis} sensitive={entry.sensitive} />
{/if}
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
</details>
@@ -203,7 +209,7 @@
lang={entry.language}
/>
{#if entry.media_attachments.length > 0}
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
<Attachments attachments={entry.media_attachments} emojis={entry.emojis} sensitive={entry.sensitive} />
{/if}
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
{/if}
@@ -211,6 +217,7 @@
{#if entry.poll}
<PollView
poll={entry.poll}
emojis={entry.poll.emojis?.length ? entry.poll.emojis : entry.emojis}
authorId={entry.account.id}
onupdate={(poll) => onupdate?.(applyLocal(status, { poll }))}
/>
+5 -3
View File
@@ -1,6 +1,7 @@
<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
@@ -94,11 +95,12 @@
{#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={reaction.url ? 'true' : 'false'}
data-custom={imageUrl ? 'true' : 'false'}
aria-label={accessibleLabel(reaction)}
aria-pressed={reaction.me ? 'true' : 'false'}
title={session.signedIn
@@ -107,10 +109,10 @@
disabled={!session.signedIn || busyEmoji !== null}
onclick={() => void toggle(reaction)}
>
{#if reaction.url}
{#if imageUrl}
<img
class="emoji-reaction-image"
src={reaction.url}
src={imageUrl}
alt={customLabel(reaction)}
loading="lazy"
decoding="async"
+6 -4
View File
@@ -1,17 +1,19 @@
<script lang="ts">
/** Mastodon/Pleroma poll choices, voting and results. */
import { untrack } from 'svelte'
import type { Poll } from '$lib/api/types'
import type { CustomEmoji, Poll } from '$lib/api/types'
import { useAppServices } from '$lib/app-services'
import { formatCount } from '$lib/util/profile'
import EmojiText from '../common/EmojiText.svelte'
interface Props {
poll: Poll
emojis?: CustomEmoji[]
authorId?: string
onupdate?: (poll: Poll) => void
}
let { poll, authorId, onupdate }: Props = $props()
let { poll, emojis = poll.emojis ?? [], authorId, onupdate }: Props = $props()
const { endpoints, session } = useAppServices()
function pollSignature(value: Poll): string {
@@ -113,7 +115,7 @@
{#if currentPoll.own_votes?.includes(index)}
<span class="poll-own-vote" aria-label="Your vote"></span>
{/if}
{option.title}
<EmojiText text={option.title} {emojis} />
</span>
<span class="poll-option-share">
{option.votes_count === null ? '—' : `${share(option.votes_count)}%`}
@@ -137,7 +139,7 @@
{:else}
<input type="radio" name={`poll-${currentPoll.id}`} value={index} bind:group={singleChoice} />
{/if}
<span>{option.title}</span>
<EmojiText text={option.title} {emojis} />
</label>
{/each}
</fieldset>
+29
View File
@@ -91,4 +91,33 @@ describe('PollView', () => {
expect(view.getByRole('link', { name: 'Sign in to vote' })).toHaveAttribute('href', '#/login')
expect(view.queryByRole('button', { name: 'Vote' })).not.toBeInTheDocument()
})
it('renders custom emoji in poll choices', () => {
const view = render(PollView, {
props: {
poll: poll({
options: [
{ title: 'Choose :blobcat:', votes_count: null },
{ title: 'No thanks', votes_count: null },
],
emojis: [
{
shortcode: 'blobcat',
url: 'https://cdn.example/blobcat.png',
static_url: 'https://cdn.example/blobcat.png',
visible_in_picker: true,
},
],
}),
authorId: 'someone-else',
},
context: new Map([[APP_SERVICES, testServices()]]),
})
expect(view.getByAltText(':blobcat:')).toHaveAttribute(
'src',
'https://cdn.example/blobcat.png',
)
expect(view.getByRole('radio', { name: 'Choose :blobcat:' })).toBeDisabled()
})
})
@@ -11,10 +11,12 @@
NOTIFICATION_DISMISS_AFTER_MS,
NOTIFICATION_POLL_INTERVAL_MS,
NotificationTracker,
emojiForNotification,
presentNotification,
type NotificationPresentation,
} from '$lib/notifications'
import Avatar from '../common/Avatar.svelte'
import EmojiText from '../common/EmojiText.svelte'
interface Props {
pollIntervalMs?: number
@@ -138,6 +140,7 @@
<aside class="notification-toast-stack" aria-label="New notifications" aria-live="polite">
{#each toasts as toast (toast.notification.id)}
{@const reaction = emojiForNotification(toast.notification)}
<a
class="notification-toast"
href={toast.presentation.href}
@@ -148,11 +151,23 @@
<Avatar account={toast.notification.account} plain class="notification-toast-avatar" />
<span class="notification-toast-body">
<span class="notification-toast-message">
<strong>{toast.presentation.actor}</strong>
<strong>
<EmojiText
text={toast.presentation.actor}
emojis={toast.notification.account.emojis}
/>
</strong>
{toast.presentation.message}
{#if reaction}
with <EmojiText text={reaction.text} emojis={reaction.emojis} />
{/if}
</span>
{#if toast.presentation.excerpt}
<span class="notification-toast-excerpt">{toast.presentation.excerpt}</span>
<EmojiText
class="notification-toast-excerpt"
text={toast.presentation.excerpt}
emojis={toast.notification.status?.emojis}
/>
{/if}
</span>
</a>
@@ -71,4 +71,37 @@ describe('NotificationToasts', () => {
})
expect(view.queryByText('A new mention')).not.toBeInTheDocument()
})
it('shows a safely sized custom emoji from a reaction notification', async () => {
vi.useFakeTimers()
const oldNotification = notification({ id: 'old-notification' })
const reaction = notification({
id: 'reaction-notification',
type: 'pleroma:emoji_reaction',
emoji: ':dinosaur:',
emoji_url: 'https://cdn.example/dinosaur.gif',
})
const fetchNotifications = vi
.fn()
.mockResolvedValueOnce({ items: [oldNotification], links: {} })
.mockResolvedValueOnce({ items: [reaction, oldNotification], links: {} })
const services = testServices({
session: session({ token: 'token', me: account(), signedIn: true }),
endpoints: { fetchNotifications },
})
const view = render(NotificationToasts, {
props: { pollIntervalMs: 1_000 },
context: new Map([[APP_SERVICES, services]]),
})
await act(async () => {
await Promise.resolve()
await vi.advanceTimersByTimeAsync(1_000)
})
expect(view.getByAltText(':dinosaur:')).toHaveAttribute(
'src',
'https://cdn.example/dinosaur.gif',
)
})
})
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
/**
* Escaped plain text with server-declared `:shortcode:` values replaced by
* custom emoji. Use this for names, content warnings, poll choices and other
* API strings that are not HTML.
*/
import type { CustomEmoji } from '$lib/api/types'
import { renderEmojiText } from '$lib/util/html'
interface Props {
text: string | null | undefined
emojis?: CustomEmoji[]
class?: string
}
let { text, emojis, class: extraClass = '' }: Props = $props()
const rendered = $derived(renderEmojiText(text ?? '', emojis))
</script>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- escaped in renderEmojiText -->
<span class="emoji-text {extraClass}">{@html rendered}</span>
+38
View File
@@ -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: '<b>unsafe</b> :wide:',
emojis: [wideEmoji],
},
})
expect(view.getByText('<b>unsafe</b>')).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,<svg></svg>' }],
},
})
expect(view.queryByRole('img')).not.toBeInTheDocument()
expect(view.getByText(':wide:')).toBeInTheDocument()
})
})
+7 -2
View File
@@ -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 @@
<section class="module {variantClass} {extraClass}" data-variant={variant}>
{#if title}
<h2 class="module-header">
<span class="module-header-title">{title}</span>
<EmojiText class="module-header-title" text={title} emojis={titleEmojis} />
{#if action}
<span class="module-header-action">{@render action()}</span>
{/if}
+5 -2
View File
@@ -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 @@
}
</script>
<Module title={`Contacting ${firstName}`}>
<Module title={`Contacting ${firstName}`} titleEmojis={account.emojis}>
{#if error}
<p class="error-note" role="alert">{error}</p>
{/if}
@@ -137,6 +138,8 @@
</ul>
{#if relationship?.followed_by && !isSelf}
<p class="contact-note muted">{firstName} has you on their friends list.</p>
<p class="contact-note muted">
<EmojiText text={firstName} emojis={account.emojis} /> has you on their friends list.
</p>
{/if}
</Module>
+8 -3
View File
@@ -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()
</script>
{#if fields.length > 0}
<Module {title} flush>
<Module {title} titleEmojis={emojis} flush>
<table class="data-table details-table">
<tbody>
{#each fields as field, index (`${field.name}:${index}`)}
<tr class="details-row" data-verified={field.verified ? 'true' : 'false'}>
<th class="data-table-label details-label" scope="row">{field.name}</th>
<th class="data-table-label details-label" scope="row">
<EmojiText text={field.name} {emojis} />
</th>
<td class="data-table-value details-value" data-verified={field.verified ? 'true' : 'false'}>
{#if field.verified}
<span class="verified-mark" title="Ownership of this link is verified"></span>
+15 -5
View File
@@ -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()
</script>
<Module {title} variant="band">
<Module {title} titleEmojis={ownerEmojis} variant="band">
{#snippet action()}
<a href={viewAllHref}>[view all]</a>
{/snippet}
@@ -47,10 +50,13 @@
<!-- Never render a withheld count as "0 friends" — that reports a privacy
setting as a fact about the person. -->
{#if countHidden}
<p class="friend-count">{ownerName} keeps their friend count private.</p>
<p class="friend-count">
<EmojiText text={ownerName} emojis={ownerEmojis} /> keeps their friend count private.
</p>
{:else}
<p class="friend-count">
{ownerName} has <span class="friend-count-value">{formatCount(total)}</span>
<EmojiText text={ownerName} emojis={ownerEmojis} /> has
<span class="friend-count-value">{formatCount(total)}</span>
friend{total === 1 ? '' : 's'}.
</p>
{/if}
@@ -66,7 +72,11 @@
{#each friends as friend (friend.id)}
<li class="friend-card" data-account={friend.acct}>
<a class="friend-card-link" href={profilePath(friend)}>
<span class="friend-card-name">{displayNameOf(friend)}</span>
<EmojiText
class="friend-card-name"
text={displayNameOf(friend)}
emojis={friend.emojis}
/>
<Avatar account={friend} plain size="friend" class="friend-card-photo" />
</a>
</li>
+4 -2
View File
@@ -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()
</script>
{#if interests.length > 0}
<Module {title} flush>
<Module {title} titleEmojis={emojis} flush>
<table class="data-table interests-table">
<tbody>
{#each interests as entry, index (`${entry.row}:${index}`)}
+16 -6
View File
@@ -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<Status>
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<Record<string, boolean>>({})
const pictures = $derived.by<Picture[]>(() =>
@@ -46,7 +48,8 @@
</script>
<p class="pic-stream-intro">
Pictures from {ownerName}'s Blog Entries. Click a picture to view the full-size original.
Pictures from <EmojiText text={ownerName} emojis={ownerEmojis} />'s Blog Entries. Click a
picture to view the full-size original.
</p>
{#if !feed.initialized && feed.loading}
@@ -97,10 +100,17 @@
<figcaption class="pic-card-caption">
{#if hidden}
<span class="pic-card-description">
{picture.status.spoiler_text || 'Sensitive picture'}
<EmojiText
text={picture.status.spoiler_text || 'Sensitive picture'}
emojis={picture.status.emojis}
/>
</span>
{:else if picture.caption}
<span class="pic-card-description">{picture.caption}</span>
<EmojiText
class="pic-card-description"
text={picture.caption}
emojis={picture.status.emojis}
/>
{/if}
<a class="pic-card-entry-link" href={`#/blog/${picture.status.id}`}>
Posted {stampDate(picture.status.created_at)} &middot; view entry
@@ -115,6 +125,6 @@
<Pager
{feed}
label="View More Pictures"
emptyText={`${ownerName} hasn't posted any pictures yet.`}
emptyText="There aren't any pictures here yet."
endText={pictures.length > 0 ? 'Thats the whole picture stream.' : ''}
/>
+12 -6
View File
@@ -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 @@
</div>
<div class="profile-vitals-wrap">
<p class="profile-headline">{profile.headline}</p>
<p class="profile-headline">
<EmojiText text={profile.headline} emojis={account.emojis} />
</p>
<dl class="profile-vitals">
{#if profile.gender}
<dt>Gender</dt>
<dd class="profile-vital profile-vital--gender">{profile.gender}</dd>
<dd class="profile-vital profile-vital--gender">
<EmojiText text={profile.gender} emojis={account.emojis} />
</dd>
{/if}
{#if accountAge !== null}
@@ -61,7 +64,9 @@
{#if profile.location}
<dt>Location</dt>
<dd class="profile-vital profile-vital--location">{profile.location}</dd>
<dd class="profile-vital profile-vital--location">
<EmojiText text={profile.location} emojis={account.emojis} />
</dd>
{/if}
<dt>Last active</dt>
@@ -78,7 +83,8 @@
{#if profile.mood}
<p class="profile-mood">
Mood: <span class="profile-mood-value">{profile.mood}</span>
Mood:
<EmojiText class="profile-mood-value" text={profile.mood} emojis={account.emojis} />
</p>
{/if}