mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-14 03:02:31 +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()
|
||||
})
|
||||
})
|
||||
@@ -6,11 +6,15 @@
|
||||
* panel blue caption bar (left rail)
|
||||
* band peach caption bar (main column)
|
||||
* plain no chrome, just the heading
|
||||
*/
|
||||
*/
|
||||
import type { Snippet } from 'svelte'
|
||||
import type { CustomEmoji } from '$lib/api/types'
|
||||
import EmojiText from './EmojiText.svelte'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
/** Custom emoji available to user-derived titles. */
|
||||
titleEmojis?: CustomEmoji[]
|
||||
variant?: 'panel' | 'band' | 'plain'
|
||||
/** Right-aligned link in the caption bar, e.g. "[view all]". */
|
||||
action?: Snippet
|
||||
@@ -23,6 +27,7 @@
|
||||
|
||||
let {
|
||||
title,
|
||||
titleEmojis,
|
||||
variant = 'panel',
|
||||
action,
|
||||
flush = false,
|
||||
@@ -38,7 +43,7 @@
|
||||
<section class="module {variantClass} {extraClass}" data-variant={variant}>
|
||||
{#if title}
|
||||
<h2 class="module-header">
|
||||
<span class="module-header-title">{title}</span>
|
||||
<EmojiText class="module-header-title" text={title} emojis={titleEmojis} />
|
||||
{#if action}
|
||||
<span class="module-header-action">{@render action()}</span>
|
||||
{/if}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user