Files
plspace/src/components/blog/BlogEntry.svelte
T
2026-07-29 11:53:15 +09:00

267 lines
9.2 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
/**
* One status, dressed as a MySpace blog entry.
*
* Vocabulary mapping, applied consistently across the app:
* favourite -> Kudos (MySpace blogs really did call them that)
* reblog -> Repost
* reply -> Comment
*
* Toggling kudos/repost updates optimistically and rolls back on failure —
* a federated round trip is slow enough that waiting feels broken.
*/
import type { Status } from '$lib/api/types'
import { useAppServices } from '$lib/app-services'
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
import { renderDisplayName } from '$lib/util/html'
import { isoDate, longDate, stampDate } from '$lib/util/time'
import { extractYouTubeVideoIds } from '$lib/util/youtube'
import Avatar from '../common/Avatar.svelte'
import RichText from '../common/RichText.svelte'
import Attachments from './Attachments.svelte'
import PollView from './PollView.svelte'
import PreviewCardView from './PreviewCardView.svelte'
import YouTubeEmbeds from './YouTubeEmbeds.svelte'
interface Props {
status: Status
/** Called with the updated status after an action succeeds. */
onupdate?: (status: Status) => void
/** Called after the viewer deletes their own entry. */
ondelete?: (id: string) => void
/** Drop the avatar gutter — used inside threads. */
compact?: boolean
/** Show the full date rather than a short stamp. */
longFormDate?: boolean
}
let { status, onupdate, ondelete, compact = false, longFormDate = false }: Props = $props()
const { endpoints, session } = useAppServices()
/** The status actually being displayed; a boost renders its target. */
const entry = $derived(status.reblog ?? status)
const booster = $derived(status.reblog ? status.account : null)
const author = $derived(entry.account)
const authorName = $derived(renderDisplayName(displayNameOf(author), author.emojis))
const handle = $derived(fullHandle(author, session.host))
const permalink = $derived(`#/blog/${entry.id}`)
const isMine = $derived(session.me?.id === entry.account.id)
const youtubeVideoIds = $derived(extractYouTubeVideoIds(entry.content))
let busy = $state(false)
let actionError = $state<string | null>(null)
const VISIBILITY_ICON: Record<string, string> = {
public: '🌐',
unlisted: '🔓',
private: '🔒',
direct: '✉',
}
const VISIBILITY_LABEL: Record<string, string> = {
public: 'Public',
unlisted: 'Unlisted',
private: 'Friends only',
direct: 'Private message',
}
async function toggleKudos(): Promise<void> {
if (!session.signedIn || busy) return
const next = !entry.favourited
busy = true
actionError = null
// Optimistic: reflect the new state before the request settles.
onupdate?.(applyLocal(status, { favourited: next, favourites_count: entry.favourites_count + (next ? 1 : -1) }))
try {
const updated = await endpoints.favouriteStatus(session.api, entry.id, next)
onupdate?.(rewrap(status, updated))
} catch (cause) {
onupdate?.(status)
actionError = cause instanceof Error ? cause.message : 'Could not save that.'
} finally {
busy = false
}
}
async function toggleRepost(): Promise<void> {
if (!session.signedIn || busy) return
const next = !entry.reblogged
busy = true
actionError = null
onupdate?.(applyLocal(status, { reblogged: next, reblogs_count: entry.reblogs_count + (next ? 1 : -1) }))
try {
const updated = await endpoints.reblogStatus(session.api, entry.id, next)
// Reblogging returns the *wrapper* status; unwrap to the original.
onupdate?.(rewrap(status, updated.reblog ?? updated))
} catch (cause) {
onupdate?.(status)
actionError = cause instanceof Error ? cause.message : 'Could not save that.'
} finally {
busy = false
}
}
async function remove(): Promise<void> {
if (!isMine || busy) return
if (!confirm('Delete this entry? This cannot be undone.')) return
busy = true
try {
await endpoints.deleteStatus(session.api, entry.id)
ondelete?.(status.id)
} catch (cause) {
actionError = cause instanceof Error ? cause.message : 'Could not delete that.'
} finally {
busy = false
}
}
/** Patch the inner status, preserving the boost wrapper if there is one. */
function applyLocal(wrapper: Status, patch: Partial<Status>): Status {
if (wrapper.reblog) return { ...wrapper, reblog: { ...wrapper.reblog, ...patch } }
return { ...wrapper, ...patch }
}
function rewrap(wrapper: Status, fresh: Status): Status {
return wrapper.reblog ? { ...wrapper, reblog: fresh } : fresh
}
</script>
<article
class="blog-entry"
data-status-id={entry.id}
data-account={author.acct}
data-visibility={entry.visibility}
data-boosted={booster ? 'true' : 'false'}
data-reply={entry.in_reply_to_id ? 'true' : 'false'}
data-sensitive={entry.sensitive ? 'true' : 'false'}
data-compact={compact ? 'true' : 'false'}
data-mine={isMine ? 'true' : 'false'}
>
{#if booster}
<p class="blog-entry-attribution">
<a href={profilePath(booster)}>{displayNameOf(booster)}</a> reposted this
</p>
{/if}
<header class="blog-entry-header">
{#if !compact}
<div class="blog-entry-avatar">
<Avatar account={author} />
</div>
{/if}
<div class="blog-entry-byline">
<a class="blog-entry-author" href={profilePath(author)}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- escaped in renderDisplayName -->
{@html authorName}
</a>
<span class="blog-entry-handle">{handle}</span>
<span class="blog-entry-date">
<a href={permalink}>
<time datetime={isoDate(entry.created_at)}>
{longFormDate ? longDate(entry.created_at) : stampDate(entry.created_at)}
</time>
</a>
<span class="blog-entry-visibility" title={VISIBILITY_LABEL[entry.visibility] ?? entry.visibility}>
{VISIBILITY_ICON[entry.visibility] ?? ''}
<span class="visually-hidden">{VISIBILITY_LABEL[entry.visibility] ?? entry.visibility}</span>
</span>
{#if entry.edited_at}
<span class="blog-entry-edited">(edited)</span>
{/if}
</span>
</div>
</header>
<div class="blog-entry-body">
{#if entry.spoiler_text}
<details class="content-warning">
<summary class="content-warning-summary">{entry.spoiler_text}</summary>
<RichText
html={entry.content}
emojis={entry.emojis}
mentions={entry.mentions}
tags={entry.tags}
lang={entry.language}
/>
{#if entry.media_attachments.length > 0}
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
{/if}
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
</details>
{:else}
<RichText
html={entry.content}
emojis={entry.emojis}
mentions={entry.mentions}
tags={entry.tags}
lang={entry.language}
/>
{#if entry.media_attachments.length > 0}
<Attachments attachments={entry.media_attachments} sensitive={entry.sensitive} />
{/if}
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
{/if}
{#if entry.poll}
<PollView poll={entry.poll} />
{/if}
{#if entry.card && entry.media_attachments.length === 0 && youtubeVideoIds.length === 0}
<PreviewCardView card={entry.card} />
{/if}
{#if actionError}
<p class="error-note" role="alert">{actionError}</p>
{/if}
<footer class="blog-entry-actions">
<a class="blog-action blog-action--comment" href={permalink}>
Comment <span class="blog-action-count">({formatCount(entry.replies_count)})</span>
</a>
<button
type="button"
class="link-button blog-action blog-action--kudos"
aria-pressed={entry.favourited ? 'true' : 'false'}
disabled={!session.signedIn || busy}
title={session.signedIn ? 'Give kudos' : 'Sign in to give kudos'}
onclick={toggleKudos}
>
{entry.favourited ? 'Kudos given' : 'Kudos'}
<span class="blog-action-count">({formatCount(entry.favourites_count)})</span>
</button>
<button
type="button"
class="link-button blog-action blog-action--repost"
aria-pressed={entry.reblogged ? 'true' : 'false'}
disabled={!session.signedIn || busy || entry.visibility === 'direct' || entry.visibility === 'private'}
title={entry.visibility === 'private' || entry.visibility === 'direct'
? 'This entry cant be reposted'
: 'Repost to your friends'}
onclick={toggleRepost}
>
{entry.reblogged ? 'Reposted' : 'Repost'}
<span class="blog-action-count">({formatCount(entry.reblogs_count)})</span>
</button>
{#if entry.url}
<a class="blog-action blog-action--source" href={entry.url} target="_blank" rel="noopener noreferrer">
Original
</a>
{/if}
{#if isMine}
<button type="button" class="link-button blog-action blog-action--delete" disabled={busy} onclick={remove}>
Delete
</button>
{/if}
</footer>
</div>
</article>