mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
271 lines
9.3 KiB
Svelte
271 lines
9.3 KiB
Svelte
<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 MfmContent from '../common/MfmContent.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>
|
||
<MfmContent
|
||
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}
|
||
<MfmContent
|
||
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}
|
||
authorId={entry.account.id}
|
||
onupdate={(poll) => onupdate?.(applyLocal(status, { 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 can’t 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>
|