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 * when it's revealed, and the reveal is per-attachment because a single post
* can mix flagged and unflagged media. * 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 { interface Props {
attachments: MediaAttachment[] attachments: MediaAttachment[]
emojis?: CustomEmoji[]
sensitive?: boolean sensitive?: boolean
} }
let { attachments, sensitive = false }: Props = $props() let { attachments, emojis, sensitive = false }: Props = $props()
let revealed = $state<Record<string, boolean>>({}) let revealed = $state<Record<string, boolean>>({})
@@ -81,7 +83,9 @@
{/if} {/if}
{#if media.description} {#if media.description}
<figcaption class="attachment-caption">{media.description}</figcaption> <figcaption class="attachment-caption">
<EmojiText text={media.description} {emojis} />
</figcaption>
{/if} {/if}
</figure> </figure>
+11 -4
View File
@@ -17,6 +17,7 @@
import { isoDate, longDate, stampDate } from '$lib/util/time' import { isoDate, longDate, stampDate } from '$lib/util/time'
import { extractYouTubeVideoIds } from '$lib/util/youtube' import { extractYouTubeVideoIds } from '$lib/util/youtube'
import Avatar from '../common/Avatar.svelte' import Avatar from '../common/Avatar.svelte'
import EmojiText from '../common/EmojiText.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 EmojiReactions from './EmojiReactions.svelte'
@@ -145,7 +146,10 @@
> >
{#if booster} {#if booster}
<p class="blog-entry-attribution"> <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> </p>
{/if} {/if}
@@ -181,7 +185,9 @@
<div class="blog-entry-body"> <div class="blog-entry-body">
{#if entry.spoiler_text} {#if entry.spoiler_text}
<details class="content-warning"> <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 <MfmContent
html={entry.content} html={entry.content}
emojis={entry.emojis} emojis={entry.emojis}
@@ -190,7 +196,7 @@
lang={entry.language} lang={entry.language}
/> />
{#if entry.media_attachments.length > 0} {#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} {/if}
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} /> <YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
</details> </details>
@@ -203,7 +209,7 @@
lang={entry.language} lang={entry.language}
/> />
{#if entry.media_attachments.length > 0} {#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} {/if}
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} /> <YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
{/if} {/if}
@@ -211,6 +217,7 @@
{#if entry.poll} {#if entry.poll}
<PollView <PollView
poll={entry.poll} poll={entry.poll}
emojis={entry.poll.emojis?.length ? entry.poll.emojis : entry.emojis}
authorId={entry.account.id} authorId={entry.account.id}
onupdate={(poll) => onupdate?.(applyLocal(status, { poll }))} onupdate={(poll) => onupdate?.(applyLocal(status, { poll }))}
/> />
+5 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { EmojiReaction, Status } from '$lib/api/types' import type { EmojiReaction, Status } from '$lib/api/types'
import { useAppServices } from '$lib/app-services' import { useAppServices } from '$lib/app-services'
import { safeCustomEmojiUrl } from '$lib/util/html'
interface Props { interface Props {
status: Status status: Status
@@ -94,11 +95,12 @@
{#if reactions.length > 0} {#if reactions.length > 0}
<div class="emoji-reaction-bar" aria-label="Emoji reactions"> <div class="emoji-reaction-bar" aria-label="Emoji reactions">
{#each reactions as reaction (reaction.url ?? reaction.name)} {#each reactions as reaction (reaction.url ?? reaction.name)}
{@const imageUrl = reaction.url ? safeCustomEmojiUrl(reaction.url) : null}
<button <button
type="button" type="button"
class="emoji-reaction" class="emoji-reaction"
class:emoji-reaction--mine={reaction.me} class:emoji-reaction--mine={reaction.me}
data-custom={reaction.url ? 'true' : 'false'} data-custom={imageUrl ? 'true' : 'false'}
aria-label={accessibleLabel(reaction)} aria-label={accessibleLabel(reaction)}
aria-pressed={reaction.me ? 'true' : 'false'} aria-pressed={reaction.me ? 'true' : 'false'}
title={session.signedIn title={session.signedIn
@@ -107,10 +109,10 @@
disabled={!session.signedIn || busyEmoji !== null} disabled={!session.signedIn || busyEmoji !== null}
onclick={() => void toggle(reaction)} onclick={() => void toggle(reaction)}
> >
{#if reaction.url} {#if imageUrl}
<img <img
class="emoji-reaction-image" class="emoji-reaction-image"
src={reaction.url} src={imageUrl}
alt={customLabel(reaction)} alt={customLabel(reaction)}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
+6 -4
View File
@@ -1,17 +1,19 @@
<script lang="ts"> <script lang="ts">
/** Mastodon/Pleroma poll choices, voting and results. */ /** Mastodon/Pleroma poll choices, voting and results. */
import { untrack } from 'svelte' 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 { useAppServices } from '$lib/app-services'
import { formatCount } from '$lib/util/profile' import { formatCount } from '$lib/util/profile'
import EmojiText from '../common/EmojiText.svelte'
interface Props { interface Props {
poll: Poll poll: Poll
emojis?: CustomEmoji[]
authorId?: string authorId?: string
onupdate?: (poll: Poll) => void onupdate?: (poll: Poll) => void
} }
let { poll, authorId, onupdate }: Props = $props() let { poll, emojis = poll.emojis ?? [], authorId, onupdate }: Props = $props()
const { endpoints, session } = useAppServices() const { endpoints, session } = useAppServices()
function pollSignature(value: Poll): string { function pollSignature(value: Poll): string {
@@ -113,7 +115,7 @@
{#if currentPoll.own_votes?.includes(index)} {#if currentPoll.own_votes?.includes(index)}
<span class="poll-own-vote" aria-label="Your vote"></span> <span class="poll-own-vote" aria-label="Your vote"></span>
{/if} {/if}
{option.title} <EmojiText text={option.title} {emojis} />
</span> </span>
<span class="poll-option-share"> <span class="poll-option-share">
{option.votes_count === null ? '—' : `${share(option.votes_count)}%`} {option.votes_count === null ? '—' : `${share(option.votes_count)}%`}
@@ -137,7 +139,7 @@
{:else} {:else}
<input type="radio" name={`poll-${currentPoll.id}`} value={index} bind:group={singleChoice} /> <input type="radio" name={`poll-${currentPoll.id}`} value={index} bind:group={singleChoice} />
{/if} {/if}
<span>{option.title}</span> <EmojiText text={option.title} {emojis} />
</label> </label>
{/each} {/each}
</fieldset> </fieldset>
+29
View File
@@ -91,4 +91,33 @@ describe('PollView', () => {
expect(view.getByRole('link', { name: 'Sign in to vote' })).toHaveAttribute('href', '#/login') expect(view.getByRole('link', { name: 'Sign in to vote' })).toHaveAttribute('href', '#/login')
expect(view.queryByRole('button', { name: 'Vote' })).not.toBeInTheDocument() 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_DISMISS_AFTER_MS,
NOTIFICATION_POLL_INTERVAL_MS, NOTIFICATION_POLL_INTERVAL_MS,
NotificationTracker, NotificationTracker,
emojiForNotification,
presentNotification, presentNotification,
type NotificationPresentation, type NotificationPresentation,
} from '$lib/notifications' } from '$lib/notifications'
import Avatar from '../common/Avatar.svelte' import Avatar from '../common/Avatar.svelte'
import EmojiText from '../common/EmojiText.svelte'
interface Props { interface Props {
pollIntervalMs?: number pollIntervalMs?: number
@@ -138,6 +140,7 @@
<aside class="notification-toast-stack" aria-label="New notifications" aria-live="polite"> <aside class="notification-toast-stack" aria-label="New notifications" aria-live="polite">
{#each toasts as toast (toast.notification.id)} {#each toasts as toast (toast.notification.id)}
{@const reaction = emojiForNotification(toast.notification)}
<a <a
class="notification-toast" class="notification-toast"
href={toast.presentation.href} href={toast.presentation.href}
@@ -148,11 +151,23 @@
<Avatar account={toast.notification.account} plain class="notification-toast-avatar" /> <Avatar account={toast.notification.account} plain class="notification-toast-avatar" />
<span class="notification-toast-body"> <span class="notification-toast-body">
<span class="notification-toast-message"> <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} {toast.presentation.message}
{#if reaction}
with <EmojiText text={reaction.text} emojis={reaction.emojis} />
{/if}
</span> </span>
{#if toast.presentation.excerpt} {#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} {/if}
</span> </span>
</a> </a>
@@ -71,4 +71,37 @@ describe('NotificationToasts', () => {
}) })
expect(view.queryByText('A new mention')).not.toBeInTheDocument() 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) * panel blue caption bar (left rail)
* band peach caption bar (main column) * band peach caption bar (main column)
* plain no chrome, just the heading * plain no chrome, just the heading
*/ */
import type { Snippet } from 'svelte' import type { Snippet } from 'svelte'
import type { CustomEmoji } from '$lib/api/types'
import EmojiText from './EmojiText.svelte'
interface Props { interface Props {
title?: string title?: string
/** Custom emoji available to user-derived titles. */
titleEmojis?: CustomEmoji[]
variant?: 'panel' | 'band' | 'plain' variant?: 'panel' | 'band' | 'plain'
/** Right-aligned link in the caption bar, e.g. "[view all]". */ /** Right-aligned link in the caption bar, e.g. "[view all]". */
action?: Snippet action?: Snippet
@@ -23,6 +27,7 @@
let { let {
title, title,
titleEmojis,
variant = 'panel', variant = 'panel',
action, action,
flush = false, flush = false,
@@ -38,7 +43,7 @@
<section class="module {variantClass} {extraClass}" data-variant={variant}> <section class="module {variantClass} {extraClass}" data-variant={variant}>
{#if title} {#if title}
<h2 class="module-header"> <h2 class="module-header">
<span class="module-header-title">{title}</span> <EmojiText class="module-header-title" text={title} emojis={titleEmojis} />
{#if action} {#if action}
<span class="module-header-action">{@render action()}</span> <span class="module-header-action">{@render action()}</span>
{/if} {/if}
+5 -2
View File
@@ -10,6 +10,7 @@
import { useAppServices } from '$lib/app-services' import { useAppServices } from '$lib/app-services'
import { displayNameOf } from '$lib/util/profile' import { displayNameOf } from '$lib/util/profile'
import Module from '../common/Module.svelte' import Module from '../common/Module.svelte'
import EmojiText from '../common/EmojiText.svelte'
interface Props { interface Props {
account: Account account: Account
@@ -73,7 +74,7 @@
} }
</script> </script>
<Module title={`Contacting ${firstName}`}> <Module title={`Contacting ${firstName}`} titleEmojis={account.emojis}>
{#if error} {#if error}
<p class="error-note" role="alert">{error}</p> <p class="error-note" role="alert">{error}</p>
{/if} {/if}
@@ -137,6 +138,8 @@
</ul> </ul>
{#if relationship?.followed_by && !isSelf} {#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} {/if}
</Module> </Module>
+8 -3
View File
@@ -7,23 +7,28 @@
* an unverified link that looks verified is a phishing surface. * an unverified link that looks verified is a phishing surface.
*/ */
import type { ProfileField } from '$lib/util/profile' import type { ProfileField } from '$lib/util/profile'
import type { CustomEmoji } from '$lib/api/types'
import Module from '../common/Module.svelte' import Module from '../common/Module.svelte'
import EmojiText from '../common/EmojiText.svelte'
interface Props { interface Props {
title: string title: string
fields: ProfileField[] fields: ProfileField[]
emojis?: CustomEmoji[]
} }
let { title, fields }: Props = $props() let { title, fields, emojis }: Props = $props()
</script> </script>
{#if fields.length > 0} {#if fields.length > 0}
<Module {title} flush> <Module {title} titleEmojis={emojis} flush>
<table class="data-table details-table"> <table class="data-table details-table">
<tbody> <tbody>
{#each fields as field, index (`${field.name}:${index}`)} {#each fields as field, index (`${field.name}:${index}`)}
<tr class="details-row" data-verified={field.verified ? 'true' : 'false'}> <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'}> <td class="data-table-value details-value" data-verified={field.verified ? 'true' : 'false'}>
{#if field.verified} {#if field.verified}
<span class="verified-mark" title="Ownership of this link is verified"></span> <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 * accounts often return an empty list rather than an error, so the count and
* the grid are allowed to disagree; the count is authoritative. * 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 { displayNameOf, formatCount, profilePath } from '$lib/util/profile'
import Module from '../common/Module.svelte' import Module from '../common/Module.svelte'
import Avatar from '../common/Avatar.svelte' import Avatar from '../common/Avatar.svelte'
import EmojiText from '../common/EmojiText.svelte'
interface Props { interface Props {
title: string title: string
/** The subject, used in "Tom has 527 friends." */ /** The subject, used in "Tom has 527 friends." */
ownerName: string ownerName: string
ownerEmojis?: CustomEmoji[]
friends: Account[] friends: Account[]
total: number total: number
viewAllHref: string viewAllHref: string
@@ -29,6 +31,7 @@
let { let {
title, title,
ownerName, ownerName,
ownerEmojis,
friends, friends,
total, total,
viewAllHref, viewAllHref,
@@ -39,7 +42,7 @@
}: Props = $props() }: Props = $props()
</script> </script>
<Module {title} variant="band"> <Module {title} titleEmojis={ownerEmojis} variant="band">
{#snippet action()} {#snippet action()}
<a href={viewAllHref}>[view all]</a> <a href={viewAllHref}>[view all]</a>
{/snippet} {/snippet}
@@ -47,10 +50,13 @@
<!-- Never render a withheld count as "0 friends" — that reports a privacy <!-- Never render a withheld count as "0 friends" — that reports a privacy
setting as a fact about the person. --> setting as a fact about the person. -->
{#if countHidden} {#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} {:else}
<p class="friend-count"> <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'}. friend{total === 1 ? '' : 's'}.
</p> </p>
{/if} {/if}
@@ -66,7 +72,11 @@
{#each friends as friend (friend.id)} {#each friends as friend (friend.id)}
<li class="friend-card" data-account={friend.acct}> <li class="friend-card" data-account={friend.acct}>
<a class="friend-card-link" href={profilePath(friend)}> <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" /> <Avatar account={friend} plain size="friend" class="friend-card-photo" />
</a> </a>
</li> </li>
+4 -2
View File
@@ -6,18 +6,20 @@
* Mastodon account gets a compact box rather than six empty rows. * Mastodon account gets a compact box rather than six empty rows.
*/ */
import type { InterestEntry } from '$lib/util/profile' import type { InterestEntry } from '$lib/util/profile'
import type { CustomEmoji } from '$lib/api/types'
import Module from '../common/Module.svelte' import Module from '../common/Module.svelte'
interface Props { interface Props {
title: string title: string
interests: InterestEntry[] interests: InterestEntry[]
emojis?: CustomEmoji[]
} }
let { title, interests }: Props = $props() let { title, interests, emojis }: Props = $props()
</script> </script>
{#if interests.length > 0} {#if interests.length > 0}
<Module {title} flush> <Module {title} titleEmojis={emojis} flush>
<table class="data-table interests-table"> <table class="data-table interests-table">
<tbody> <tbody>
{#each interests as entry, index (`${entry.row}:${index}`)} {#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 * stream. Videos/audio stay in the Blog; reposted pictures are not somebody's
* own Pics. * 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 type { Feed } from '$lib/stores/feed.svelte'
import { toPlainText } from '$lib/util/html' import { toPlainText } from '$lib/util/html'
import { stampDate } from '$lib/util/time' import { stampDate } from '$lib/util/time'
import Pager from '../common/Pager.svelte' import Pager from '../common/Pager.svelte'
import EmojiText from '../common/EmojiText.svelte'
interface Props { interface Props {
feed: Feed<Status> feed: Feed<Status>
ownerName: string ownerName: string
ownerEmojis?: CustomEmoji[]
} }
interface Picture { interface Picture {
@@ -22,7 +24,7 @@
caption: string caption: string
} }
let { feed, ownerName }: Props = $props() let { feed, ownerName, ownerEmojis }: Props = $props()
let revealed = $state<Record<string, boolean>>({}) let revealed = $state<Record<string, boolean>>({})
const pictures = $derived.by<Picture[]>(() => const pictures = $derived.by<Picture[]>(() =>
@@ -46,7 +48,8 @@
</script> </script>
<p class="pic-stream-intro"> <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> </p>
{#if !feed.initialized && feed.loading} {#if !feed.initialized && feed.loading}
@@ -97,10 +100,17 @@
<figcaption class="pic-card-caption"> <figcaption class="pic-card-caption">
{#if hidden} {#if hidden}
<span class="pic-card-description"> <span class="pic-card-description">
{picture.status.spoiler_text || 'Sensitive picture'} <EmojiText
text={picture.status.spoiler_text || 'Sensitive picture'}
emojis={picture.status.emojis}
/>
</span> </span>
{:else if picture.caption} {: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} {/if}
<a class="pic-card-entry-link" href={`#/blog/${picture.status.id}`}> <a class="pic-card-entry-link" href={`#/blog/${picture.status.id}`}>
Posted {stampDate(picture.status.created_at)} &middot; view entry Posted {stampDate(picture.status.created_at)} &middot; view entry
@@ -115,6 +125,6 @@
<Pager <Pager
{feed} {feed}
label="View More Pictures" 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.' : ''} 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 type { ProfileView } from '$lib/util/profile'
import { displayNameOf, fullHandle } 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 { relativeTime, shortDate, yearsSince } from '$lib/util/time'
import { useAppServices } from '$lib/app-services' import { useAppServices } from '$lib/app-services'
import EmojiText from '../common/EmojiText.svelte'
interface Props { interface Props {
profile: ProfileView profile: ProfileView
@@ -22,7 +22,6 @@
const { session } = useAppServices() const { session } = useAppServices()
const account = $derived(profile.account) const account = $derived(profile.account)
const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
const handle = $derived(fullHandle(account, session.host)) const handle = $derived(fullHandle(account, session.host))
const accountAge = $derived(profile.age ?? yearsSince(account.created_at)) const accountAge = $derived(profile.age ?? yearsSince(account.created_at))
const photo = $derived(account.avatar || account.avatar_static) const photo = $derived(account.avatar || account.avatar_static)
@@ -41,12 +40,16 @@
</div> </div>
<div class="profile-vitals-wrap"> <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"> <dl class="profile-vitals">
{#if profile.gender} {#if profile.gender}
<dt>Gender</dt> <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}
{#if accountAge !== null} {#if accountAge !== null}
@@ -61,7 +64,9 @@
{#if profile.location} {#if profile.location}
<dt>Location</dt> <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} {/if}
<dt>Last active</dt> <dt>Last active</dt>
@@ -78,7 +83,8 @@
{#if profile.mood} {#if profile.mood}
<p class="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> </p>
{/if} {/if}
+3
View File
@@ -261,6 +261,9 @@ export interface Notification {
created_at: string created_at: string
account: Account account: Account
status?: Status | null status?: Status | null
/** Pleroma/Akkoma emoji-reaction notification payload. */
emoji?: string | null
emoji_url?: string | null
} }
export interface Context { export interface Context {
+25 -1
View File
@@ -1,4 +1,4 @@
import type { Notification } from './api/types' import type { CustomEmoji, Notification } from './api/types'
import { toPlainText } from './util/html' import { toPlainText } from './util/html'
import { displayNameOf, profilePath } from './util/profile' import { displayNameOf, profilePath } from './util/profile'
@@ -53,6 +53,30 @@ export interface NotificationPresentation {
href: string 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<string, string> = { const MESSAGE: Record<string, string> = {
mention: 'mentioned you in an entry', mention: 'mentioned you in an entry',
status: 'posted a new entry', status: 'posted a new entry',
+19 -3
View File
@@ -119,6 +119,15 @@ export function escapeHtml(value: string): string {
* Runs on the *sanitized* string and only injects `<img>` with a URL taken from * Runs on the *sanitized* string and only injects `<img>` with a URL taken from
* the emoji list, so it cannot reintroduce markup from the original content. * 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 { function applyEmojis(html: string, emojis: CustomEmoji[] | undefined): string {
if (!emojis?.length) return html if (!emojis?.length) return html
const table = new Map(emojis.map((emoji) => [emoji.shortcode, emoji])) 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 replaced = text.replace(/:([a-zA-Z0-9_+-]+):/g, (whole, shortcode: string) => {
const emoji = table.get(shortcode) const emoji = table.get(shortcode)
if (!emoji) return whole if (!emoji) return whole
return `<img class="custom-emoji" src="${escapeHtml(emoji.url)}" alt=":${escapeHtml( const url = safeCustomEmojiUrl(emoji.url)
if (!url) return whole
return `<img class="custom-emoji" src="${escapeHtml(url)}" alt=":${escapeHtml(
shortcode, shortcode,
)}:" title=":${escapeHtml(shortcode)}:" draggable="false" />` )}:" title=":${escapeHtml(shortcode)}:" draggable="false" />`
}) })
@@ -213,7 +224,12 @@ export function toPlainText(source: string | null | undefined): string {
return (container.textContent ?? '').replace(/\s+/g, ' ').trim() 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 { export function renderDisplayName(name: string, emojis: CustomEmoji[] | undefined): string {
return applyEmojis(escapeHtml(name), emojis) return renderEmojiText(name, emojis)
} }
+24 -8
View File
@@ -15,12 +15,13 @@
instanceThumbnail, instanceThumbnail,
} from '$lib/api/endpoints' } from '$lib/api/endpoints'
import { displayNameOf, fallbackMood, formatCount, profilePath } from '$lib/util/profile' 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 { relativeTime, shortDate, stampDate } from '$lib/util/time'
import { useTimelineRefresh } from '$lib/timeline-refresh' import { useTimelineRefresh } from '$lib/timeline-refresh'
import { reconcileRefreshItems } from '$lib/stores/feed.svelte' import { reconcileRefreshItems } from '$lib/stores/feed.svelte'
import Module from '$components/common/Module.svelte' import Module from '$components/common/Module.svelte'
import Avatar from '$components/common/Avatar.svelte' import Avatar from '$components/common/Avatar.svelte'
import EmojiText from '$components/common/EmojiText.svelte'
import RichText from '$components/common/RichText.svelte' import RichText from '$components/common/RichText.svelte'
import MfmContent from '$components/common/MfmContent.svelte' import MfmContent from '$components/common/MfmContent.svelte'
import Composer from '$components/blog/Composer.svelte' import Composer from '$components/blog/Composer.svelte'
@@ -217,7 +218,11 @@
</div> </div>
{:else} {:else}
<h1 class="page-title"> <h1 class="page-title">
{#if me}Hello, {displayNameOf(me).split(/\s+/)[0]}!{:else}{domain}{/if} {#if me}
Hello, <EmojiText text={displayNameOf(me).split(/\s+/)[0]} emojis={me.emojis} />!
{:else}
{domain}
{/if}
</h1> </h1>
{#if me} {#if me}
<p class="page-subtitle"> <p class="page-subtitle">
@@ -243,7 +248,9 @@
<Avatar account={me} size="friend" /> <Avatar account={me} size="friend" />
</p> </p>
<p class="center"> <p class="center">
<a href={profilePath(me)}>{displayNameOf(me)}</a> <a href={profilePath(me)}>
<EmojiText text={displayNameOf(me)} emojis={me.emojis} />
</a>
</p> </p>
<p class="center muted"> <p class="center muted">
Profile views: {formatCount(me.statuses_count)} entries Profile views: {formatCount(me.statuses_count)} entries
@@ -327,10 +334,10 @@
<Avatar account={entry.account} /> <Avatar account={entry.account} />
<div class="status-line-body"> <div class="status-line-body">
<a class="status-line-author" href={profilePath(entry.account)}> <a class="status-line-author" href={profilePath(entry.account)}>
{displayNameOf(entry.account)} <EmojiText text={displayNameOf(entry.account)} emojis={entry.account.emojis} />
</a> </a>
<MfmContent <MfmContent
html={entry.spoiler_text ? `<p>${entry.spoiler_text}</p>` : entry.content} html={entry.spoiler_text ? `<p>${escapeHtml(entry.spoiler_text)}</p>` : entry.content}
emojis={entry.emojis} emojis={entry.emojis}
mentions={entry.mentions} mentions={entry.mentions}
tags={entry.tags} tags={entry.tags}
@@ -376,12 +383,17 @@
{@const entry = status.reblog ?? status} {@const entry = status.reblog ?? status}
<tr> <tr>
<td class="bulletin-from"> <td class="bulletin-from">
<a href={profilePath(entry.account)}>{displayNameOf(entry.account)}</a> <a href={profilePath(entry.account)}>
<EmojiText text={displayNameOf(entry.account)} emojis={entry.account.emojis} />
</a>
</td> </td>
<td class="bulletin-date">{stampDate(entry.created_at)}</td> <td class="bulletin-date">{stampDate(entry.created_at)}</td>
<td class="bulletin-subject"> <td class="bulletin-subject">
<a href={`#/blog/${entry.id}`}> <a href={`#/blog/${entry.id}`}>
{toPlainText(entry.spoiler_text || entry.content).slice(0, 90) || '(no text)'} <EmojiText
text={toPlainText(entry.spoiler_text || entry.content).slice(0, 90) || '(no text)'}
emojis={entry.emojis}
/>
</a> </a>
</td> </td>
</tr> </tr>
@@ -407,7 +419,11 @@
{#each following as friend (friend.id)} {#each following as friend (friend.id)}
<li class="friend-card" data-account={friend.acct}> <li class="friend-card" data-account={friend.acct}>
<a class="friend-card-link" href={profilePath(friend)}> <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}
/>
<img <img
class="friend-card-photo" class="friend-card-photo"
src={friend.avatar_static || friend.avatar} src={friend.avatar_static || friend.avatar}
+17 -4
View File
@@ -13,10 +13,12 @@
import { Feed } from '$lib/stores/feed.svelte' import { Feed } from '$lib/stores/feed.svelte'
import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile' import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile'
import { toPlainText } from '$lib/util/html' import { toPlainText } from '$lib/util/html'
import { emojiForNotification } from '$lib/notifications'
import { stampDate } from '$lib/util/time' import { stampDate } from '$lib/util/time'
import Module from '$components/common/Module.svelte' import Module from '$components/common/Module.svelte'
import Pager from '$components/common/Pager.svelte' import Pager from '$components/common/Pager.svelte'
import Avatar from '$components/common/Avatar.svelte' import Avatar from '$components/common/Avatar.svelte'
import EmojiText from '$components/common/EmojiText.svelte'
interface Props { interface Props {
folder?: string folder?: string
@@ -160,7 +162,9 @@
</td> </td>
<td> <td>
<strong> <strong>
<a href={profilePath(account)}>{displayNameOf(account)}</a> <a href={profilePath(account)}>
<EmojiText text={displayNameOf(account)} emojis={account.emojis} />
</a>
</strong> </strong>
wants to be your friend! wants to be your friend!
<div class="person-row-handle">{fullHandle(account, session.host)}</div> <div class="person-row-handle">{fullHandle(account, session.host)}</div>
@@ -205,6 +209,7 @@
</thead> </thead>
<tbody> <tbody>
{#each notifications.items as item (item.id)} {#each notifications.items as item (item.id)}
{@const reaction = emojiForNotification(item)}
<tr class="mail-row" data-kind={item.type} data-account={item.account.acct}> <tr class="mail-row" data-kind={item.type} data-account={item.account.acct}>
<td class="mail-table-date">{stampDate(item.created_at)}</td> <td class="mail-table-date">{stampDate(item.created_at)}</td>
<td class="mail-table-from"> <td class="mail-table-from">
@@ -214,14 +219,22 @@
</td> </td>
<td class="mail-table-subject"> <td class="mail-table-subject">
<strong> <strong>
<a href={profilePath(item.account)}>{displayNameOf(item.account)}</a> <a href={profilePath(item.account)}>
<EmojiText text={displayNameOf(item.account)} emojis={item.account.emojis} />
</a>
</strong> </strong>
{VERB[item.type] ?? item.type} {VERB[item.type] ?? item.type}
{#if reaction}
with <EmojiText text={reaction.text} emojis={reaction.emojis} />
{/if}
{#if item.status} {#if item.status}
<p class="mail-table-excerpt"> <p class="mail-table-excerpt">
<a href={`#/blog/${item.status.id}`}> <a href={`#/blog/${item.status.id}`}>
{toPlainText(item.status.spoiler_text || item.status.content).slice(0, 140) || <EmojiText
'(no text)'} text={toPlainText(item.status.spoiler_text || item.status.content).slice(0, 140) ||
'(no text)'}
emojis={item.status.emojis}
/>
</a> </a>
</p> </p>
{/if} {/if}
+34 -11
View File
@@ -25,6 +25,7 @@
} from '$lib/util/profile' } from '$lib/util/profile'
import { toPlainText } from '$lib/util/html' import { toPlainText } from '$lib/util/html'
import Module from '$components/common/Module.svelte' import Module from '$components/common/Module.svelte'
import EmojiText from '$components/common/EmojiText.svelte'
import ProfileIdentity from '$components/profile/ProfileIdentity.svelte' import ProfileIdentity from '$components/profile/ProfileIdentity.svelte'
import ContactBox from '$components/profile/ContactBox.svelte' import ContactBox from '$components/profile/ContactBox.svelte'
import InterestsTable from '$components/profile/InterestsTable.svelte' import InterestsTable from '$components/profile/InterestsTable.svelte'
@@ -204,7 +205,9 @@
{error} {error}
</p> </p>
{:else if account && profile} {:else if account && profile}
<h1 class="page-title profile-name">{displayNameOf(account)}</h1> <h1 class="page-title profile-name">
<EmojiText text={displayNameOf(account)} emojis={account.emojis} />
</h1>
{#if account.moved} {#if account.moved}
<p class="profile-moved"> <p class="profile-moved">
@@ -229,10 +232,18 @@
</p> </p>
</Module> </Module>
<InterestsTable title={`${firstName}'s Interests`} interests={profile.interests} /> <InterestsTable
<DetailsTable title={`${firstName}'s Details`} fields={profile.details} /> title={`${firstName}'s Interests`}
interests={profile.interests}
emojis={account.emojis}
/>
<DetailsTable
title={`${firstName}'s Details`}
fields={profile.details}
emojis={account.emojis}
/>
<Module title={`${firstName}'s Stats`} flush> <Module title={`${firstName}'s Stats`} titleEmojis={account.emojis} flush>
<table class="data-table stats-table"> <table class="data-table stats-table">
<tbody> <tbody>
<tr> <tr>
@@ -275,6 +286,7 @@
<FriendSpace <FriendSpace
title={`${firstName}'s Friend Space`} title={`${firstName}'s Friend Space`}
ownerName={firstName} ownerName={firstName}
ownerEmojis={account.emojis}
{friends} {friends}
total={account.followers_count} total={account.followers_count}
viewAllHref={`#/@${account.acct}`} viewAllHref={`#/@${account.acct}`}
@@ -283,7 +295,7 @@
{countHidden} {countHidden}
/> />
{:else if view === 'blog'} {:else if view === 'blog'}
<Module title={`${firstName}'s Blog`} variant="band"> <Module title={`${firstName}'s Blog`} titleEmojis={account.emojis} variant="band">
{#snippet action()} {#snippet action()}
<a href={base}>[Back to Profile]</a> <a href={base}>[Back to Profile]</a>
{/snippet} {/snippet}
@@ -309,12 +321,12 @@
/> />
</Module> </Module>
{:else if view === 'pics'} {:else if view === 'pics'}
<Module title={`${firstName}'s Pics`} variant="band"> <Module title={`${firstName}'s Pics`} titleEmojis={account.emojis} variant="band">
{#snippet action()} {#snippet action()}
<a href={base}>[Back to Profile]</a> <a href={base}>[Back to Profile]</a>
{/snippet} {/snippet}
<PicStream feed={entries} ownerName={firstName} /> <PicStream feed={entries} ownerName={firstName} ownerEmojis={account.emojis} />
</Module> </Module>
{:else} {:else}
<!-- <!--
@@ -323,7 +335,11 @@
posts of media push the Blurbs and Friend Space off the bottom, posts of media push the Blurbs and Friend Space off the bottom,
which is the wrong shape for a profile. which is the wrong shape for a profile.
--> -->
<Module title={`${firstName}'s Latest Blog Entries`} variant="band"> <Module
title={`${firstName}'s Latest Blog Entries`}
titleEmojis={account.emojis}
variant="band"
>
{#snippet action()} {#snippet action()}
<a href={`${base}/blog`}>[View Blog]</a> <a href={`${base}/blog`}>[View Blog]</a>
{/snippet} {/snippet}
@@ -337,7 +353,11 @@
{#each entries.items.slice(0, 6) as status (status.id)} {#each entries.items.slice(0, 6) as status (status.id)}
{@const entry = status.reblog ?? status} {@const entry = status.reblog ?? status}
<li class="entry-teaser" data-status-id={entry.id}> <li class="entry-teaser" data-status-id={entry.id}>
<span class="entry-teaser-text">{teaserFor(entry)}</span> <EmojiText
class="entry-teaser-text"
text={teaserFor(entry)}
emojis={entry.emojis}
/>
<a class="entry-teaser-link" href={`#/blog/${entry.id}`}>(view more)</a> <a class="entry-teaser-link" href={`#/blog/${entry.id}`}>(view more)</a>
</li> </li>
{/each} {/each}
@@ -348,7 +368,7 @@
{/if} {/if}
</Module> </Module>
<Module title={`${firstName}'s Blurbs`} variant="band"> <Module title={`${firstName}'s Blurbs`} titleEmojis={account.emojis} variant="band">
<h3 class="section-heading">About me:</h3> <h3 class="section-heading">About me:</h3>
{#if profile.about} {#if profile.about}
<div class="rich-text blurb-body"> <div class="rich-text blurb-body">
@@ -356,7 +376,9 @@
{@html profile.about} {@html profile.about}
</div> </div>
{:else} {:else}
<p class="empty-note">{firstName} hasn't written an About me yet.</p> <p class="empty-note">
<EmojiText text={firstName} emojis={account.emojis} /> hasn't written an About me yet.
</p>
{/if} {/if}
<h3 class="section-heading">Who I'd like to meet:</h3> <h3 class="section-heading">Who I'd like to meet:</h3>
@@ -375,6 +397,7 @@
<FriendSpace <FriendSpace
title={`${firstName}'s Friend Space`} title={`${firstName}'s Friend Space`}
ownerName={firstName} ownerName={firstName}
ownerEmojis={account.emojis}
friends={friends.slice(0, FRIEND_PREVIEW)} friends={friends.slice(0, FRIEND_PREVIEW)}
total={account.followers_count} total={account.followers_count}
viewAllHref={`#/@${account.acct}/friends`} viewAllHref={`#/@${account.acct}/friends`}
+45
View File
@@ -7,6 +7,51 @@ import { account, deferred, session, status, testServices, theme } from '$test/f
import Profile from './Profile.svelte' import Profile from './Profile.svelte'
describe('Profile', () => { describe('Profile', () => {
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 () => { it('does not apply profile CSS after it has unmounted', async () => {
const lookup = deferred<Account>() const lookup = deferred<Account>()
const applyProfileCss = vi.fn() const applyProfileCss = vi.fn()
+4 -1
View File
@@ -13,6 +13,7 @@
import { instanceDomain } from '$lib/api/endpoints' import { instanceDomain } from '$lib/api/endpoints'
import { displayNameOf, profilePath } from '$lib/util/profile' import { displayNameOf, profilePath } from '$lib/util/profile'
import Module from '$components/common/Module.svelte' import Module from '$components/common/Module.svelte'
import EmojiText from '$components/common/EmojiText.svelte'
import PublicProfileEditor from '$components/profile/PublicProfileEditor.svelte' import PublicProfileEditor from '$components/profile/PublicProfileEditor.svelte'
import PublishedCssEditor from '$components/profile/PublishedCssEditor.svelte' import PublishedCssEditor from '$components/profile/PublishedCssEditor.svelte'
@@ -97,7 +98,9 @@
{#if session.signedIn && session.me} {#if session.signedIn && session.me}
<p> <p>
Signed in as Signed in as
<a href={profilePath(session.me)}>{displayNameOf(session.me)}</a> <a href={profilePath(session.me)}>
<EmojiText text={displayNameOf(session.me)} emojis={session.me.emojis} />
</a>
on <strong>{domain}</strong>. on <strong>{domain}</strong>.
</p> </p>
<p class="field-row"> <p class="field-row">
+11 -3
View File
@@ -15,6 +15,7 @@
import { useTimelineRefresh } from '$lib/timeline-refresh' import { useTimelineRefresh } from '$lib/timeline-refresh'
import Module from '$components/common/Module.svelte' import Module from '$components/common/Module.svelte'
import Avatar from '$components/common/Avatar.svelte' import Avatar from '$components/common/Avatar.svelte'
import EmojiText from '$components/common/EmojiText.svelte'
import MfmContent from '$components/common/MfmContent.svelte' import MfmContent from '$components/common/MfmContent.svelte'
import BlogEntry from '$components/blog/BlogEntry.svelte' import BlogEntry from '$components/blog/BlogEntry.svelte'
import Composer from '$components/blog/Composer.svelte' import Composer from '$components/blog/Composer.svelte'
@@ -162,7 +163,9 @@
</p> </p>
{:else if status} {:else if status}
<h1 class="page-title"> <h1 class="page-title">
<a href={profilePath(status.account)}>{displayNameOf(status.account)}</a>'s Blog <a href={profilePath(status.account)}>
<EmojiText text={displayNameOf(status.account)} emojis={status.account.emojis} />
</a>'s Blog
</h1> </h1>
<p class="page-subtitle"> <p class="page-subtitle">
<time datetime={isoDate(status.created_at)}>{stampDate(status.created_at)}</time> <time datetime={isoDate(status.created_at)}>{stampDate(status.created_at)}</time>
@@ -219,7 +222,10 @@
</div> </div>
<div class="comment-body"> <div class="comment-body">
<a class="comment-author" href={profilePath(reply.status.account)}> <a class="comment-author" href={profilePath(reply.status.account)}>
{displayNameOf(reply.status.account)} <EmojiText
text={displayNameOf(reply.status.account)}
emojis={reply.status.account.emojis}
/>
</a> </a>
<a class="comment-date" href={`#/blog/${reply.status.id}`}> <a class="comment-date" href={`#/blog/${reply.status.id}`}>
<time datetime={isoDate(reply.status.created_at)}> <time datetime={isoDate(reply.status.created_at)}>
@@ -228,7 +234,9 @@
</a> </a>
{#if reply.status.spoiler_text} {#if reply.status.spoiler_text}
<details class="content-warning"> <details class="content-warning">
<summary class="content-warning-summary">{reply.status.spoiler_text}</summary> <summary class="content-warning-summary">
<EmojiText text={reply.status.spoiler_text} emojis={reply.status.emojis} />
</summary>
<MfmContent <MfmContent
html={reply.status.content} html={reply.status.content}
emojis={reply.status.emojis} emojis={reply.status.emojis}
+2 -1
View File
@@ -175,8 +175,9 @@ table {
/* Custom emoji injected into sanitized HTML by lib/util/html.ts. */ /* Custom emoji injected into sanitized HTML by lib/util/html.ts. */
.custom-emoji { .custom-emoji {
width: var(--ms-emoji-size);
height: var(--ms-emoji-size); height: var(--ms-emoji-size);
width: auto;
max-width: calc(var(--ms-emoji-size) * 2);
vertical-align: text-bottom; vertical-align: text-bottom;
object-fit: contain; object-fit: contain;
} }
+2 -1
View File
@@ -406,8 +406,9 @@
.emoji-reaction-image { .emoji-reaction-image {
display: block; display: block;
width: 20px;
height: 20px; height: 20px;
width: auto;
max-width: 40px;
object-fit: contain; object-fit: contain;
} }
+2 -2
View File
@@ -62,8 +62,8 @@
} }
.rich-text .mfm .custom-emoji { .rich-text .mfm .custom-emoji {
/* Misskey's emoji width knows no bounds. */ /* Wide emoji are supported, but never wider than twice their height. */
max-width: unset !important; max-width: calc(var(--emoji-size) * 2);
} }
.rich-text:hover .mfm { .rich-text:hover .mfm {