mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
refactor and fix all references to emojis
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }))}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -8,9 +8,13 @@
|
||||
* 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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)}
|
||||
|
||||
@@ -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)} · 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 ? 'That’s the whole picture stream.' : ''}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
mention: 'mentioned you in an entry',
|
||||
status: 'posted a new entry',
|
||||
|
||||
+19
-3
@@ -119,6 +119,15 @@ export function escapeHtml(value: string): string {
|
||||
* 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.
|
||||
*/
|
||||
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 `<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,
|
||||
)}:" title=":${escapeHtml(shortcode)}:" draggable="false" />`
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+24
-8
@@ -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 @@
|
||||
</div>
|
||||
{:else}
|
||||
<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>
|
||||
{#if me}
|
||||
<p class="page-subtitle">
|
||||
@@ -243,7 +248,9 @@
|
||||
<Avatar account={me} size="friend" />
|
||||
</p>
|
||||
<p class="center">
|
||||
<a href={profilePath(me)}>{displayNameOf(me)}</a>
|
||||
<a href={profilePath(me)}>
|
||||
<EmojiText text={displayNameOf(me)} emojis={me.emojis} />
|
||||
</a>
|
||||
</p>
|
||||
<p class="center muted">
|
||||
Profile views: {formatCount(me.statuses_count)} entries
|
||||
@@ -327,10 +334,10 @@
|
||||
<Avatar account={entry.account} />
|
||||
<div class="status-line-body">
|
||||
<a class="status-line-author" href={profilePath(entry.account)}>
|
||||
{displayNameOf(entry.account)}
|
||||
<EmojiText text={displayNameOf(entry.account)} emojis={entry.account.emojis} />
|
||||
</a>
|
||||
<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}
|
||||
mentions={entry.mentions}
|
||||
tags={entry.tags}
|
||||
@@ -376,12 +383,17 @@
|
||||
{@const entry = status.reblog ?? status}
|
||||
<tr>
|
||||
<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 class="bulletin-date">{stampDate(entry.created_at)}</td>
|
||||
<td class="bulletin-subject">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -407,7 +419,11 @@
|
||||
{#each following 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}
|
||||
/>
|
||||
<img
|
||||
class="friend-card-photo"
|
||||
src={friend.avatar_static || friend.avatar}
|
||||
|
||||
+16
-3
@@ -13,10 +13,12 @@
|
||||
import { Feed } from '$lib/stores/feed.svelte'
|
||||
import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile'
|
||||
import { toPlainText } from '$lib/util/html'
|
||||
import { emojiForNotification } from '$lib/notifications'
|
||||
import { stampDate } from '$lib/util/time'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import Pager from '$components/common/Pager.svelte'
|
||||
import Avatar from '$components/common/Avatar.svelte'
|
||||
import EmojiText from '$components/common/EmojiText.svelte'
|
||||
|
||||
interface Props {
|
||||
folder?: string
|
||||
@@ -160,7 +162,9 @@
|
||||
</td>
|
||||
<td>
|
||||
<strong>
|
||||
<a href={profilePath(account)}>{displayNameOf(account)}</a>
|
||||
<a href={profilePath(account)}>
|
||||
<EmojiText text={displayNameOf(account)} emojis={account.emojis} />
|
||||
</a>
|
||||
</strong>
|
||||
wants to be your friend!
|
||||
<div class="person-row-handle">{fullHandle(account, session.host)}</div>
|
||||
@@ -205,6 +209,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each notifications.items as item (item.id)}
|
||||
{@const reaction = emojiForNotification(item)}
|
||||
<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-from">
|
||||
@@ -214,14 +219,22 @@
|
||||
</td>
|
||||
<td class="mail-table-subject">
|
||||
<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>
|
||||
{VERB[item.type] ?? item.type}
|
||||
{#if reaction}
|
||||
with <EmojiText text={reaction.text} emojis={reaction.emojis} />
|
||||
{/if}
|
||||
{#if item.status}
|
||||
<p class="mail-table-excerpt">
|
||||
<a href={`#/blog/${item.status.id}`}>
|
||||
{toPlainText(item.status.spoiler_text || item.status.content).slice(0, 140) ||
|
||||
<EmojiText
|
||||
text={toPlainText(item.status.spoiler_text || item.status.content).slice(0, 140) ||
|
||||
'(no text)'}
|
||||
emojis={item.status.emojis}
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
+34
-11
@@ -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}
|
||||
</p>
|
||||
{: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}
|
||||
<p class="profile-moved">
|
||||
@@ -229,10 +232,18 @@
|
||||
</p>
|
||||
</Module>
|
||||
|
||||
<InterestsTable title={`${firstName}'s Interests`} interests={profile.interests} />
|
||||
<DetailsTable title={`${firstName}'s Details`} fields={profile.details} />
|
||||
<InterestsTable
|
||||
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">
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -275,6 +286,7 @@
|
||||
<FriendSpace
|
||||
title={`${firstName}'s Friend Space`}
|
||||
ownerName={firstName}
|
||||
ownerEmojis={account.emojis}
|
||||
{friends}
|
||||
total={account.followers_count}
|
||||
viewAllHref={`#/@${account.acct}`}
|
||||
@@ -283,7 +295,7 @@
|
||||
{countHidden}
|
||||
/>
|
||||
{:else if view === 'blog'}
|
||||
<Module title={`${firstName}'s Blog`} variant="band">
|
||||
<Module title={`${firstName}'s Blog`} titleEmojis={account.emojis} variant="band">
|
||||
{#snippet action()}
|
||||
<a href={base}>[Back to Profile]</a>
|
||||
{/snippet}
|
||||
@@ -309,12 +321,12 @@
|
||||
/>
|
||||
</Module>
|
||||
{:else if view === 'pics'}
|
||||
<Module title={`${firstName}'s Pics`} variant="band">
|
||||
<Module title={`${firstName}'s Pics`} titleEmojis={account.emojis} variant="band">
|
||||
{#snippet action()}
|
||||
<a href={base}>[Back to Profile]</a>
|
||||
{/snippet}
|
||||
|
||||
<PicStream feed={entries} ownerName={firstName} />
|
||||
<PicStream feed={entries} ownerName={firstName} ownerEmojis={account.emojis} />
|
||||
</Module>
|
||||
{:else}
|
||||
<!--
|
||||
@@ -323,7 +335,11 @@
|
||||
posts of media push the Blurbs and Friend Space off the bottom,
|
||||
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()}
|
||||
<a href={`${base}/blog`}>[View Blog]</a>
|
||||
{/snippet}
|
||||
@@ -337,7 +353,11 @@
|
||||
{#each entries.items.slice(0, 6) as status (status.id)}
|
||||
{@const entry = status.reblog ?? status}
|
||||
<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>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -348,7 +368,7 @@
|
||||
{/if}
|
||||
</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>
|
||||
{#if profile.about}
|
||||
<div class="rich-text blurb-body">
|
||||
@@ -356,7 +376,9 @@
|
||||
{@html profile.about}
|
||||
</div>
|
||||
{: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}
|
||||
|
||||
<h3 class="section-heading">Who I'd like to meet:</h3>
|
||||
@@ -375,6 +397,7 @@
|
||||
<FriendSpace
|
||||
title={`${firstName}'s Friend Space`}
|
||||
ownerName={firstName}
|
||||
ownerEmojis={account.emojis}
|
||||
friends={friends.slice(0, FRIEND_PREVIEW)}
|
||||
total={account.followers_count}
|
||||
viewAllHref={`#/@${account.acct}/friends`}
|
||||
|
||||
@@ -7,6 +7,51 @@ import { account, deferred, session, status, testServices, theme } from '$test/f
|
||||
import Profile from './Profile.svelte'
|
||||
|
||||
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 () => {
|
||||
const lookup = deferred<Account>()
|
||||
const applyProfileCss = vi.fn()
|
||||
|
||||
@@ -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}
|
||||
<p>
|
||||
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>.
|
||||
</p>
|
||||
<p class="field-row">
|
||||
|
||||
@@ -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 @@
|
||||
</p>
|
||||
{:else if status}
|
||||
<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>
|
||||
<p class="page-subtitle">
|
||||
<time datetime={isoDate(status.created_at)}>{stampDate(status.created_at)}</time>
|
||||
@@ -219,7 +222,10 @@
|
||||
</div>
|
||||
<div class="comment-body">
|
||||
<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 class="comment-date" href={`#/blog/${reply.status.id}`}>
|
||||
<time datetime={isoDate(reply.status.created_at)}>
|
||||
@@ -228,7 +234,9 @@
|
||||
</a>
|
||||
{#if reply.status.spoiler_text}
|
||||
<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
|
||||
html={reply.status.content}
|
||||
emojis={reply.status.emojis}
|
||||
|
||||
+2
-1
@@ -175,8 +175,9 @@ table {
|
||||
|
||||
/* Custom emoji injected into sanitized HTML by lib/util/html.ts. */
|
||||
.custom-emoji {
|
||||
width: var(--ms-emoji-size);
|
||||
height: var(--ms-emoji-size);
|
||||
width: auto;
|
||||
max-width: calc(var(--ms-emoji-size) * 2);
|
||||
vertical-align: text-bottom;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
+2
-1
@@ -406,8 +406,9 @@
|
||||
|
||||
.emoji-reaction-image {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: auto;
|
||||
max-width: 40px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -62,8 +62,8 @@
|
||||
}
|
||||
|
||||
.rich-text .mfm .custom-emoji {
|
||||
/* Misskey's emoji width knows no bounds. */
|
||||
max-width: unset !important;
|
||||
/* Wide emoji are supported, but never wider than twice their height. */
|
||||
max-width: calc(var(--emoji-size) * 2);
|
||||
}
|
||||
|
||||
.rich-text:hover .mfm {
|
||||
|
||||
Reference in New Issue
Block a user