initial commit

This commit is contained in:
Moon.eth
2026-07-29 09:16:38 +09:00
commit 586b599d4c
67 changed files with 9906 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
/**
* Browse — the profile directory.
*
* `/api/v1/directory` is opt-in per user and disabled entirely on some
* servers, so an empty result is a normal outcome and says so rather than
* looking broken.
*/
import { untrack } from 'svelte'
import type { Account } from '$lib/api/types'
import { session } from '$lib/stores/session.svelte'
import { Feed } from '$lib/stores/feed.svelte'
import { fetchDirectory, instanceDomain } from '$lib/api/endpoints'
import Module from '$components/common/Module.svelte'
import PersonList from '$components/people/PersonList.svelte'
import { router } from '$lib/router.svelte'
let order = $state<'active' | 'new'>('active')
let localOnly = $state(true)
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
let feed = $state<Feed<Account>>(new Feed<Account>(async () => ({ items: [], links: {} })))
$effect(() => {
const currentOrder = order
const currentLocal = localOnly
const host = session.host
if (!host) return
untrack(() => {
// The directory paginates by offset, not by cursor, so the loader keeps
// its own running offset rather than using the Link header.
let offset = 0
feed = new Feed<Account>(async (cursor) => {
if (!cursor.max_id) offset = 0
const limit = cursor.limit ?? 20
const items = await fetchDirectory(session.api, {
offset,
limit,
order: currentOrder,
local: currentLocal,
})
offset += items.length
return {
items,
// Synthesise a cursor so `Feed` knows whether more may exist.
links: items.length === limit ? { maxId: String(offset) } : {},
}
})
void feed.reload()
document.title = 'Browse | plspace'
})
})
</script>
<div class="page browse-page">
<h1 class="page-title">Browse</h1>
<p class="page-subtitle">People who've listed themselves in {domain}'s directory.</p>
<div class="layout--single">
<Module title="Find people" variant="band">
<div class="row browse-controls">
<label class="field-row">
<span>Sort by</span>
<select bind:value={order}>
<option value="active">Recently active</option>
<option value="new">Newest members</option>
</select>
</label>
<label class="checkbox-field browse-scope">
<input type="checkbox" bind:checked={localOnly} />
<span>Only people on {domain}</span>
</label>
<button type="button" class="button" onclick={() => router.go('#/search')}>
Search instead
</button>
</div>
</Module>
<Module title="Members" variant="band">
<PersonList
{feed}
emptyText="Nobody is listed in this server's directory. Try the search page instead."
/>
</Module>
</div>
</div>
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
/** A full-page composer, reachable from the nav and from "Send Message". */
import { session } from '$lib/stores/session.svelte'
import { router } from '$lib/router.svelte'
import Module from '$components/common/Module.svelte'
import Composer from '$components/blog/Composer.svelte'
interface Props {
/** Handle to address the entry to, from `?to=`. */
to?: string
}
let { to }: Props = $props()
const prefill = $derived(to ? `@${to.replace(/^@/, '')} ` : '')
</script>
<div class="page compose-page">
<h1 class="page-title">{to ? 'Send a Message' : 'Post a Blog Entry'}</h1>
{#if to}
<p class="page-subtitle">
Addressed to <a href={`#/@${to.replace(/^@/, '')}`}>@{to.replace(/^@/, '')}</a>. Set the
audience to <em>Mentioned people only</em> to keep it private.
</p>
{/if}
<div class="layout--single">
<Module title={to ? 'New message' : 'New entry'}>
<Composer
initialText={prefill}
placeholder={to ? 'Say something…' : 'What are you up to?'}
submitLabel={to ? 'Send' : 'Post Entry'}
onposted={(status) => router.go(`#/blog/${status.id}`)}
/>
</Module>
{#if !session.signedIn}
<p class="notice"><a href="#/login">Sign in</a> to post.</p>
{/if}
</div>
</div>
+378
View File
@@ -0,0 +1,378 @@
<script lang="ts">
/**
* "Hello, Tom!" — the logged-in dashboard, modelled on the 2007 home page:
* a narrow left rail with the control panel, a main column of Friend Status
* and Bulletin Space, and a right rail of server odds and ends.
*
* Logged out, the same page becomes a front door: what this server is, and
* what its local timeline looks like right now.
*/
import type { Account, Notification, Status } from '$lib/api/types'
import { session } from '$lib/stores/session.svelte'
import {
fetchFollowing,
fetchNotifications,
fetchTimeline,
instanceDomain,
instanceStats,
instanceThumbnail,
} from '$lib/api/endpoints'
import { displayNameOf, fallbackMood, formatCount, profilePath } from '$lib/util/profile'
import { toPlainText } from '$lib/util/html'
import { relativeTime, shortDate, stampDate } from '$lib/util/time'
import Module from '$components/common/Module.svelte'
import Avatar from '$components/common/Avatar.svelte'
import RichText from '$components/common/RichText.svelte'
import Composer from '$components/blog/Composer.svelte'
let friendStatus = $state<Status[]>([])
let bulletins = $state<Status[]>([])
let following = $state<Account[]>([])
let notifications = $state<Notification[]>([])
let loading = $state(true)
let error = $state<string | null>(null)
/**
* Per-module failures. Several large servers (mastodon.social among them)
* refuse timeline reads without a token, so "empty" and "not allowed" must
* look different or the page reads as broken.
*/
let friendStatusError = $state<string | null>(null)
let bulletinError = $state<string | null>(null)
const me = $derived(session.me)
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
const thumbnail = $derived(instanceThumbnail(session.instance))
const stats = $derived(instanceStats(session.instance))
/** Notification counts by kind, for the "New Messages!" summary. */
const counts = $derived.by(() => {
const table: Record<string, number> = {}
for (const notification of notifications) {
table[notification.type] = (table[notification.type] ?? 0) + 1
}
return table
})
const summary = $derived([
{ label: 'New Messages!', count: counts.mention ?? 0, href: '#/mail/mentions', kind: 'mention' },
{ label: 'New Friend Requests!', count: counts.follow_request ?? 0, href: '#/mail/requests', kind: 'follow_request' },
{ label: 'New Friends!', count: counts.follow ?? 0, href: '#/mail/follows', kind: 'follow' },
{ label: 'New Kudos!', count: counts.favourite ?? 0, href: '#/mail/kudos', kind: 'favourite' },
{ label: 'New Reposts!', count: counts.reblog ?? 0, href: '#/mail/reposts', kind: 'reblog' },
])
$effect(() => {
const host = session.host
const signedIn = session.signedIn
if (!host) {
loading = false
return
}
void load(signedIn)
})
async function load(signedIn: boolean): Promise<void> {
loading = true
error = null
friendStatusError = null
bulletinError = null
try {
// Everything here is independent; a failure in one shouldn't blank the page.
const [statusPage, bulletinPage] = await Promise.all([
signedIn
? fetchTimeline(session.api, 'home', { limit: 10 }).catch((cause) => {
friendStatusError = messageOf(cause)
return { items: [], links: {} }
})
: Promise.resolve({ items: [], links: {} }),
fetchTimeline(session.api, 'local', { limit: 10 }).catch((cause) => {
bulletinError = messageOf(cause)
return { items: [], links: {} }
}),
])
friendStatus = statusPage.items
bulletins = bulletinPage.items
if (signedIn && session.me) {
void fetchFollowing(session.api, session.me.id, { limit: 12 })
.then((page) => (following = page.items))
.catch(() => (following = []))
void fetchNotifications(session.api, { limit: 40 })
.then((page) => (notifications = page.items))
.catch(() => (notifications = []))
}
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Could not load your home page.'
} finally {
loading = false
}
}
function moodFor(status: Status): string {
return fallbackMood(status.account.id)
}
function messageOf(cause: unknown): string {
return cause instanceof Error ? cause.message : 'Could not load that.'
}
</script>
<div class="page home-page">
{#if !session.host}
<h1 class="page-title">Welcome to plspace</h1>
<p class="page-subtitle">It&rsquo;s always Pleroma&trade;.</p>
<div class="layout--single">
<Module title="Get started">
<p>
plspace is a web client for Pleroma. Point it at your server to begin.
</p>
<p><a class="button button--primary" href="#/login">Choose your server</a></p>
</Module>
</div>
{:else}
<h1 class="page-title">
{#if me}Hello, {displayNameOf(me).split(/\s+/)[0]}!{:else}{domain}{/if}
</h1>
{#if me}
<p class="page-subtitle">
My URL: <a href={profilePath(me)}>#/@{me.acct}</a>
&nbsp;&middot;&nbsp; Last login: {shortDate(me.last_status_at ?? new Date())}
</p>
{:else}
<p class="page-subtitle">
Browsing as a guest. <a href="#/login">Sign in</a> to post, follow and read your own feed.
</p>
{/if}
{#if error}
<p class="error-note" role="alert">{error}</p>
{/if}
<div class="layout--dashboard">
<!-- ------------------------------------------------ left: control panel -->
<div class="layout-column layout-column--left">
{#if me}
<Module title="My Profile">
<p class="center">
<Avatar account={me} size="friend" />
</p>
<p class="center">
<a href={profilePath(me)}>{displayNameOf(me)}</a>
</p>
<p class="center muted">
Profile views: {formatCount(me.statuses_count)} entries
</p>
</Module>
<Module title="Updates">
<ul class="mail-summary">
{#each summary as item (item.kind)}
<li class="mail-summary-item" data-kind={item.kind} data-unread={item.count > 0 ? 'true' : 'false'}>
<a href={item.href}>{item.label}</a>
{#if item.count > 0}<span class="mail-folder-count">({item.count})</span>{/if}
</li>
{/each}
</ul>
<div class="mail-summary-buttons">
<a class="button button--small" href="#/mail">inbox</a>
<a class="button button--small" href="#/mail/requests">friend requests</a>
<a class="button button--small" href="#/timeline/home">my feed</a>
<a class="button button--small" href="#/compose">post bulletin</a>
</div>
</Module>
{/if}
<Module title="Control Panel">
<ul class="action-list action-list--single">
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true"></span>
<a class="action-list-label" href="#/compose">Compose</a>
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true">📥</span>
<a class="action-list-label" href="#/mail">Inbox</a>
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true">🌐</span>
<a class="action-list-label" href="#/timeline/public">The whole network</a>
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true">🔍</span>
<a class="action-list-label" href="#/browse">Browse people</a>
</li>
<li class="action-list-item">
<span class="action-list-icon" aria-hidden="true">🎨</span>
<a class="action-list-label" href="#/settings">Layouts &amp; settings</a>
</li>
</ul>
</Module>
</div>
<!-- --------------------------------------------------- main: the feed -->
<div class="layout-column layout-column--main">
{#if session.signedIn}
<Module title="Post a bulletin">
<Composer placeholder="What are you up to?" submitLabel="Post" onposted={() => void load(true)} />
</Module>
{/if}
<Module title="Friend Status">
{#snippet action()}
<a href="#/timeline/home">view all</a>
{/snippet}
{#if !session.signedIn}
<p class="empty-note"><a href="#/login">Sign in</a> to see what your friends are up to.</p>
{:else if friendStatusError}
<p class="error-note" role="alert">{friendStatusError}</p>
{:else if loading && friendStatus.length === 0}
<p class="loading-note">Loading&hellip;</p>
{:else if friendStatus.length === 0}
<p class="empty-note">Nothing yet. Add some friends to fill this up.</p>
{:else}
<ul class="status-line-list">
{#each friendStatus as status (status.id)}
{@const entry = status.reblog ?? status}
<li class="status-line" data-account={entry.account.acct}>
<Avatar account={entry.account} />
<div class="status-line-body">
<a class="status-line-author" href={profilePath(entry.account)}>
{displayNameOf(entry.account)}
</a>
<RichText
html={entry.spoiler_text ? `<p>${entry.spoiler_text}</p>` : entry.content}
emojis={entry.emojis}
mentions={entry.mentions}
tags={entry.tags}
inline
/>
<a class="status-line-time" href={`#/blog/${entry.id}`}>
{relativeTime(entry.created_at)}
</a>
<span class="status-line-mood">Mood: {moodFor(entry)}</span>
</div>
</li>
{/each}
</ul>
{/if}
</Module>
<Module title="Bulletin Space">
{#snippet action()}
<a href="#/timeline/local">view all</a>
{/snippet}
{#if bulletinError}
<p class="error-note" role="alert">
{bulletinError}
{#if !session.signedIn}
<a href="#/login">Signing in</a> usually fixes this &mdash; some servers don't serve
timelines to guests.
{/if}
</p>
{:else if bulletins.length === 0}
<p class="empty-note">{loading ? 'Loading…' : 'No bulletins right now.'}</p>
{:else}
<table class="bulletin-table">
<thead>
<tr>
<th scope="col">From</th>
<th scope="col">Date</th>
<th scope="col">Bulletin</th>
</tr>
</thead>
<tbody>
{#each bulletins as status (status.id)}
{@const entry = status.reblog ?? status}
<tr>
<td class="bulletin-from">
<a href={profilePath(entry.account)}>{displayNameOf(entry.account)}</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)'}
</a>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Module>
{#if session.signedIn}
<Module title="Friend Space">
{#snippet action()}
<a href={me ? `#/@${me.acct}/friends` : '#/browse'}>view all</a>
{/snippet}
{#if following.length === 0}
<p class="empty-note">You haven't added any friends yet. <a href="#/browse">Find some.</a></p>
{:else}
<p class="friend-count">
You have <span class="friend-count-value">{formatCount(me?.following_count ?? 0)}</span> friends.
</p>
<ul class="friend-grid friend-grid--compact">
{#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>
<img
class="friend-card-photo"
src={friend.avatar_static || friend.avatar}
alt=""
loading="lazy"
/>
</a>
</li>
{/each}
</ul>
{/if}
</Module>
{/if}
</div>
<!-- ------------------------------------------------- right: the server -->
<div class="layout-column layout-column--right">
<Module title={domain}>
{#if thumbnail}
<p class="center">
<img class="instance-thumbnail" src={thumbnail} alt="" loading="lazy" />
</p>
{/if}
<p class="instance-title"><strong>{session.instance?.title ?? domain}</strong></p>
{#if session.instance?.short_description || session.instance?.description}
<RichText
html={session.instance.short_description || session.instance.description}
class="instance-description"
/>
{/if}
{#if stats.length > 0}
<table class="data-table instance-stats">
<tbody>
{#each stats as stat (stat.label)}
<tr>
<th class="data-table-label" scope="row">{stat.label}</th>
<td class="data-table-value">{formatCount(stat.value)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Module>
<Module title="plspace Tip">
<p>
Name a profile field <code>Music</code>, <code>Movies</code>, <code>Books</code> or
<code>Heroes</code> and it fills in the Interests table on your profile. Name one
<code>Mood</code> and it shows beside your photo.
</p>
<p><a href="#/settings">Customise your layout &rarr;</a></p>
</Module>
</div>
</div>
{/if}
</div>
+140
View File
@@ -0,0 +1,140 @@
<script lang="ts">
/**
* Sign in, or browse a server anonymously.
*
* Two paths on purpose: signing in registers an OAuth app on the target
* server and redirects to its consent screen, while "just look around" only
* needs the host and works on any server that allows anonymous API reads.
*/
import { session } from '$lib/stores/session.svelte'
import { normalizeHost } from '$lib/api/client'
import { router } from '$lib/router.svelte'
import Module from '$components/common/Module.svelte'
let host = $state(session.host)
let busy = $state(false)
let error = $state<string | null>(null)
/**
* Servers to try, all verified to serve `/api/v1/timelines/public` without a
* token so that "Just look around" actually shows something.
*
* That is the bar for being on this list. Plenty of instances set Pleroma's
* `restrict_unauthenticated` (or Mastodon's equivalent) and answer 401 to
* anonymous timeline reads; suggesting one of those hands a first-time
* visitor an empty page. Re-check before adding to this list.
*/
const SUGGESTIONS = ['pleroma.soykaf.com', 'lain.com', 'spinster.xyz']
async function signIn(event: SubmitEvent): Promise<void> {
event.preventDefault()
if (busy) return
busy = true
error = null
try {
await session.login(host, '#/')
// On success the browser has already navigated away.
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Could not start sign-in.'
busy = false
}
}
async function browseOnly(): Promise<void> {
if (busy) return
busy = true
error = null
try {
await session.connect(host)
router.go('#/')
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Could not reach that server.'
} finally {
busy = false
}
}
</script>
<div class="page login-page">
<h1 class="page-title">Sign in</h1>
<p class="page-subtitle">plspace works with any Pleroma server.</p>
<div class="layout--single">
{#if session.error}
<p class="notice">{session.error}</p>
{/if}
{#if error}
<p class="error-note" role="alert">{error}</p>
{/if}
<Module title="Your server">
<form onsubmit={signIn}>
<div class="field">
<label class="field-label" for="login-host">Server address</label>
<input
id="login-host"
class="field-input"
type="text"
bind:value={host}
placeholder="pleroma.soykaf.com"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
required
/>
<span class="field-hint">
The domain of the server your account lives on. You can paste your full
<code>@you@server</code> handle instead.
</span>
</div>
<div class="field-row">
<button class="button button--primary" type="submit" disabled={busy || !normalizeHost(host)}>
{busy ? 'Redirecting…' : 'Sign in'}
</button>
<button
class="button"
type="button"
disabled={busy || !normalizeHost(host)}
onclick={() => void browseOnly()}
>
Just look around
</button>
</div>
</form>
</Module>
<Module title="Not sure where to start?">
<p>A few servers to try. All of these let you look around without signing in:</p>
<ul class="server-suggestions">
{#each SUGGESTIONS as suggestion (suggestion)}
<li class="server-suggestion">
<button type="button" class="link-button" onclick={() => (host = suggestion)}>
{suggestion}
</button>
</li>
{/each}
</ul>
</Module>
<Module title="What happens when you sign in">
<p>
plspace registers itself as an application on your server, then sends you there to approve
it. Your password is never typed into plspace &mdash; you enter it on your own server, and
plspace only ever receives an access token.
</p>
<p>
That token is stored in this browser's local storage and used directly from your browser.
There is no plspace backend; nothing you read or post passes through anyone else's server.
</p>
{#if session.signedIn}
<p>
<button type="button" class="button" onclick={() => void session.logout()}>Sign out</button>
<button type="button" class="button" onclick={() => session.disconnect()}>
Forget this server
</button>
</p>
{/if}
</Module>
</div>
</div>
+241
View File
@@ -0,0 +1,241 @@
<script lang="ts">
/**
* The Mail Center — notifications as an inbox, plus the Friend Request
* Manager with its Approve / Deny buttons.
*
* Folders map onto notification types. The "requests" folder is a different
* endpoint (`/api/v1/follow_requests`) because pending requests aren't
* notifications once they've been read.
*/
import { untrack } from 'svelte'
import type { Account, Notification } from '$lib/api/types'
import { session } from '$lib/stores/session.svelte'
import { Feed } from '$lib/stores/feed.svelte'
import {
authorizeFollowRequest,
fetchFollowRequests,
fetchNotifications,
rejectFollowRequest,
} from '$lib/api/endpoints'
import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile'
import { toPlainText } from '$lib/util/html'
import { stampDate } from '$lib/util/time'
import Module from '$components/common/Module.svelte'
import Pager from '$components/common/Pager.svelte'
interface Props {
folder?: string
}
let { folder = 'inbox' }: Props = $props()
interface Folder {
key: string
label: string
icon: string
/** Notification types this folder shows; empty means everything. */
types: string[]
}
const FOLDERS: Folder[] = [
{ key: 'inbox', label: 'Inbox', icon: '📥', types: [] },
{ key: 'mentions', label: 'Messages', icon: '✉', types: ['mention'] },
{ key: 'requests', label: 'Friend Requests', icon: '', types: [] },
{ key: 'follows', label: 'New Friends', icon: '👥', types: ['follow'] },
{ key: 'kudos', label: 'Kudos', icon: '★', types: ['favourite'] },
{ key: 'reposts', label: 'Reposts', icon: '↻', types: ['reblog'] },
]
const active = $derived(FOLDERS.find((entry) => entry.key === folder) ?? FOLDERS[0])
const isRequests = $derived(active.key === 'requests')
let notifications = $state<Feed<Notification>>(new Feed<Notification>(async () => ({ items: [], links: {} })))
let requests = $state<Feed<Account>>(new Feed<Account>(async () => ({ items: [], links: {} })))
let busyIds = $state<Record<string, boolean>>({})
let actionError = $state<string | null>(null)
const VERB: Record<string, string> = {
mention: 'sent you a message',
follow: 'added you as a friend',
follow_request: 'wants to be your friend',
favourite: 'gave your entry kudos',
reblog: 'reposted your entry',
poll: 'closed a poll you voted in',
status: 'posted a new entry',
update: 'edited an entry you interacted with',
'pleroma:emoji_reaction': 'reacted to your entry',
}
$effect(() => {
const key = active.key
const types = active.types
if (!session.signedIn) return
untrack(() => {
if (key === 'requests') {
requests = new Feed<Account>((cursor) => fetchFollowRequests(session.api, cursor))
void requests.reload()
} else {
notifications = new Feed<Notification>((cursor) =>
fetchNotifications(session.api, cursor, types.length > 0 ? types : undefined),
)
void notifications.reload()
}
document.title = `${key === 'requests' ? 'Friend Requests' : 'Mail Center'} | plspace`
})
})
async function respond(account: Account, approve: boolean): Promise<void> {
if (busyIds[account.id]) return
busyIds = { ...busyIds, [account.id]: true }
actionError = null
try {
await (approve ? authorizeFollowRequest : rejectFollowRequest)(session.api, account.id)
requests.remove(account.id)
} catch (cause) {
actionError = cause instanceof Error ? cause.message : 'That didnt work.'
} finally {
busyIds = { ...busyIds, [account.id]: false }
}
}
</script>
<div class="page mail-page" data-folder={active.key}>
<h1 class="page-title">Mail Center</h1>
<p class="page-subtitle">
{isRequests ? 'Friend Request Manager' : active.label}
</p>
{#if !session.signedIn}
<p class="notice">
<a href="#/login">Sign in</a> to read your mail.
</p>
{:else}
<div class="layout--split">
<div class="layout-column layout-column--left">
<Module title="Folders" flush>
<ul class="mail-folders">
{#each FOLDERS as entry (entry.key)}
<li class="mail-folder">
<a
class="mail-folder-link"
href={entry.key === 'inbox' ? '#/mail' : `#/mail/${entry.key}`}
aria-current={entry.key === active.key ? 'page' : undefined}
>
<span class="action-list-icon" aria-hidden="true">{entry.icon}</span>
<span class="mail-folder-label">{entry.label}</span>
</a>
</li>
{/each}
</ul>
</Module>
</div>
<div class="layout-column layout-column--main">
{#if actionError}
<p class="error-note" role="alert">{actionError}</p>
{/if}
{#if isRequests}
<Module title="Approve or Deny Your Friend Requests" variant="band">
{#if requests.items.length > 0}
<p class="mail-listing-count">
Listing 1&ndash;{requests.items.length} of {requests.items.length}
</p>
{/if}
<table class="mail-table">
<thead>
<tr>
<th scope="col">From</th>
<th scope="col">Confirmation</th>
</tr>
</thead>
<tbody>
{#each requests.items as account (account.id)}
<tr class="mail-row" data-kind="follow_request" data-account={account.acct}>
<td class="mail-table-from">
<a href={profilePath(account)}>
<img src={account.avatar_static || account.avatar} alt="" loading="lazy" />
</a>
</td>
<td>
<strong>
<a href={profilePath(account)}>{displayNameOf(account)}</a>
</strong>
wants to be your friend!
<div class="person-row-handle">{fullHandle(account, session.host)}</div>
<div class="mail-table-actions">
<button
type="button"
class="button"
disabled={busyIds[account.id]}
onclick={() => void respond(account, true)}
>
Approve
</button>
<button
type="button"
class="button"
disabled={busyIds[account.id]}
onclick={() => void respond(account, false)}
>
Deny
</button>
<a class="button" href={`#/compose?to=${encodeURIComponent(account.acct)}`}>
Send Message
</a>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
<Pager feed={requests} label="View More Requests" emptyText="No pending friend requests." />
</Module>
{:else}
<Module title={active.label} variant="band">
<table class="mail-table">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">From</th>
<th scope="col">Subject</th>
</tr>
</thead>
<tbody>
{#each notifications.items as item (item.id)}
<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">
<a href={profilePath(item.account)}>
<img src={item.account.avatar_static || item.account.avatar} alt="" loading="lazy" />
</a>
</td>
<td class="mail-table-subject">
<strong>
<a href={profilePath(item.account)}>{displayNameOf(item.account)}</a>
</strong>
{VERB[item.type] ?? item.type}
{#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) ||
'(no text)'}
</a>
</p>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<Pager feed={notifications} label="View More Mail" emptyText="Your inbox is empty." />
</Module>
{/if}
</div>
</div>
{/if}
</div>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import { router } from '$lib/router.svelte'
import Module from '$components/common/Module.svelte'
</script>
<div class="page notfound-page">
<h1 class="page-title">Page not found</h1>
<div class="layout--single">
<Module title="Sorry!">
<p>
There's nothing at <code>{router.current.path}</code>.
</p>
<p>
<a href="#/">Go home</a> &middot;
<a href="#/browse">Browse people</a> &middot;
<a href="#/search">Search</a>
</p>
</Module>
</div>
</div>
+350
View File
@@ -0,0 +1,350 @@
<script lang="ts">
/**
* The profile page — the whole point of the exercise.
*
* Left rail: photo, vitals, contacting box, URL, interests, details.
* Main column: latest blog entries, blurbs, friend space.
*
* The account's own published CSS (a profile field named `css`) is applied
* while this page is mounted and torn down on unmount, scoped to
* `.profile-page` — see lib/stores/theme.svelte.ts.
*/
import { untrack } from 'svelte'
import type { Account, Relationship, Status } from '$lib/api/types'
import { session } from '$lib/stores/session.svelte'
import { Feed } from '$lib/stores/feed.svelte'
import { theme, profileCssFromFields } from '$lib/stores/theme.svelte'
import {
fetchAccountStatuses,
fetchFollowers,
fetchRelationship,
lookupAccount,
} from '$lib/api/endpoints'
import {
buildProfileView,
displayNameOf,
followerCountHidden,
followersHidden,
followingCountHidden,
formatCount,
} from '$lib/util/profile'
import { toPlainText } from '$lib/util/html'
import Module from '$components/common/Module.svelte'
import ProfileIdentity from '$components/profile/ProfileIdentity.svelte'
import ContactBox from '$components/profile/ContactBox.svelte'
import InterestsTable from '$components/profile/InterestsTable.svelte'
import DetailsTable from '$components/profile/DetailsTable.svelte'
import FriendSpace from '$components/profile/FriendSpace.svelte'
import BlogEntry from '$components/blog/BlogEntry.svelte'
import Pager from '$components/common/Pager.svelte'
interface Props {
acct: string
/** Which sub-page: the profile itself, or a full list. */
view?: 'profile' | 'blog' | 'friends' | 'pics'
}
let { acct, view = 'profile' }: Props = $props()
let account = $state<Account | null>(null)
let relationship = $state<Relationship | null>(null)
let loading = $state(true)
let error = $state<string | null>(null)
let friends = $state<Account[]>([])
let friendsLoading = $state(false)
// Recreated whenever the account changes, so the feed never shows one
// person's entries under another's name.
let entries = $state<Feed<Status>>(new Feed<Status>(async () => ({ items: [], links: {} })))
const profile = $derived(account ? buildProfileView(account) : null)
const firstName = $derived(account ? displayNameOf(account).split(/\s+/)[0] : '')
const listHidden = $derived(account ? followersHidden(account) : false)
const countHidden = $derived(account ? followerCountHidden(account) : false)
const followsCountHidden = $derived(account ? followingCountHidden(account) : false)
/** Route prefix for this profile; snippets can't see the null-narrowing. */
const base = $derived(account ? `#/@${account.acct}` : '#/')
/** How many friend tiles the compact grid shows before "[view all]". */
const FRIEND_PREVIEW = 12
$effect(() => {
// Track the inputs that change what's on screen; the load itself is
// untracked so reading state inside it can't retrigger this effect.
const handle = acct
const currentView = view
const host = session.host
if (!host) return
untrack(() => void load(handle, currentView))
})
async function load(handle: string, currentView: Props['view']): Promise<void> {
loading = true
error = null
account = null
relationship = null
friends = []
try {
const found = await lookupAccount(session.api, handle)
// A newer navigation won the race.
if (acct !== handle) return
account = found
document.title = `${displayNameOf(found)} | plspace`
theme.applyProfileCss(profileCssFromFields(found.fields))
entries = new Feed<Status>(
(cursor) =>
fetchAccountStatuses(session.api, found.id, cursor, {
// The profile page mirrors "Latest Blog Entries": top-level posts.
exclude_replies: currentView !== 'blog',
}),
currentView === 'blog' ? 20 : 10,
)
void entries.reload()
void loadFriends(found, currentView)
void loadRelationship(found)
} catch (cause) {
if (acct !== handle) return
error = cause instanceof Error ? cause.message : 'Could not load that profile.'
} finally {
if (acct === handle) loading = false
}
}
async function loadFriends(target: Account, currentView: Props['view']): Promise<void> {
if (followersHidden(target)) return
friendsLoading = true
try {
const page = await fetchFollowers(session.api, target.id, {
limit: currentView === 'friends' ? 40 : FRIEND_PREVIEW,
})
if (account?.id === target.id) friends = page.items
} catch {
// Hidden or unavailable follower lists are normal; the count still shows.
if (account?.id === target.id) friends = []
} finally {
friendsLoading = false
}
}
async function loadRelationship(target: Account): Promise<void> {
try {
const found = await fetchRelationship(session.api, target.id)
if (account?.id === target.id) relationship = found
} catch {
/* relationships need auth; absence is fine */
}
}
$effect(() => () => theme.clearProfileCss())
/** One line of an entry, for the headline list on the profile page. */
function teaserFor(entry: Status): string {
const text = toPlainText(entry.spoiler_text || entry.content)
if (text) return text.length > 110 ? `${text.slice(0, 110).trimEnd()}…` : text
if (entry.media_attachments.length > 0) {
const count = entry.media_attachments.length
return `(${count} photo${count === 1 ? '' : 's'})`
}
return '(no text)'
}
</script>
<div class="page profile-page" data-account={account?.acct ?? acct} data-view={view}>
{#if loading}
<p class="loading-note">Loading profile&hellip;</p>
{:else if error}
<p class="error-note" role="alert">
<strong class="error-note-title">Profile not found.</strong>
{error}
</p>
{:else if account && profile}
<h1 class="page-title profile-name">{displayNameOf(account)}</h1>
{#if account.moved}
<p class="profile-moved">
This account has moved to
<a href={`#/@${account.moved.acct}`}>@{account.moved.acct}</a>.
</p>
{/if}
<div class="layout--split">
<div class="layout-column layout-column--left">
<ProfileIdentity {profile} />
<ContactBox
{account}
{relationship}
onrelationship={(next) => (relationship = next)}
/>
<Module title="plspace URL">
<p class="profile-url">
<a href={`#/@${account.acct}`}>{location.origin}{location.pathname}#/@{account.acct}</a>
</p>
</Module>
<InterestsTable title={`${firstName}'s Interests`} interests={profile.interests} />
<DetailsTable title={`${firstName}'s Details`} fields={profile.details} />
<Module title={`${firstName}'s Stats`} flush>
<table class="data-table stats-table">
<tbody>
<tr>
<th class="data-table-label" scope="row">Blog entries</th>
<td class="data-table-value">{formatCount(account.statuses_count)}</td>
</tr>
<!-- Pleroma zeroes these counts when the user hides them, so the
flag has to be checked before the number is believed. -->
<tr>
<th class="data-table-label" scope="row">Friends</th>
<td class="data-table-value" data-private={countHidden ? 'true' : 'false'}>
{#if countHidden}
<span class="muted">private</span>
{:else}
<a href={`#/@${account.acct}/friends`}>{formatCount(account.followers_count)}</a>
{/if}
</td>
</tr>
<tr>
<th class="data-table-label" scope="row">Friend of</th>
<td class="data-table-value" data-private={followsCountHidden ? 'true' : 'false'}>
{#if followsCountHidden}
<span class="muted">private</span>
{:else}
{formatCount(account.following_count)}
{/if}
</td>
</tr>
<tr>
<th class="data-table-label" scope="row">Joined</th>
<td class="data-table-value">{new Date(account.created_at).getFullYear()}</td>
</tr>
</tbody>
</table>
</Module>
</div>
<div class="layout-column layout-column--main">
{#if view === 'friends'}
<FriendSpace
title={`${firstName}'s Friend Space`}
ownerName={firstName}
{friends}
total={account.followers_count}
viewAllHref={`#/@${account.acct}`}
loading={friendsLoading}
hidden={listHidden}
{countHidden}
/>
{:else if view === 'blog'}
<Module title={`${firstName}'s Blog`} variant="band">
{#snippet action()}
<a href={base}>[Back to Profile]</a>
{/snippet}
<ul class="blog-list">
{#each entries.items as status (status.id)}
<li class="blog-list-item">
<BlogEntry
{status}
compact
longFormDate
onupdate={(next) => entries.update(status.id, () => next)}
ondelete={(id) => entries.remove(id)}
/>
</li>
{/each}
</ul>
<Pager
feed={entries}
emptyText="There are no Blog Entries yet."
endText="Thats the whole blog."
/>
</Module>
{:else if view === 'pics'}
<Module title={`${firstName}'s Pics`} variant="band">
<p class="empty-note">
Photos appear here as they're attached to blog entries.
<a href={`#/@${account.acct}/blog`}>Read the blog</a> to see them in context.
</p>
</Module>
{:else}
<!--
The profile page lists entry headlines with "(view more)", exactly
as the 2005 page did. Full entries live on /blog — otherwise ten
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">
{#snippet action()}
<a href={`${base}/blog`}>[View Blog]</a>
{/snippet}
{#if entries.loading && entries.items.length === 0}
<p class="loading-note">Loading&hellip;</p>
{:else if entries.items.length === 0}
<p class="empty-note">There are no Blog Entries yet.</p>
{:else}
<ul class="entry-teaser-list">
{#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>
<a class="entry-teaser-link" href={`#/blog/${entry.id}`}>(view more)</a>
</li>
{/each}
</ul>
<p class="entry-teaser-all">
<a href={`${base}/blog`}>[View All Blog Entries]</a>
</p>
{/if}
</Module>
<Module title={`${firstName}'s Blurbs`} variant="band">
<h3 class="section-heading">About me:</h3>
{#if profile.about}
<div class="rich-text blurb-body">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
{@html profile.about}
</div>
{:else}
<p class="empty-note">{firstName} hasn't written an About me yet.</p>
{/if}
<h3 class="section-heading">Who I'd like to meet:</h3>
{#if profile.wantsToMeet}
<div class="rich-text blurb-body">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in buildProfileView -->
{@html profile.wantsToMeet}
</div>
{:else}
<p class="blurb-body">
People who educate, inspire or entertain me. And you, apparently.
</p>
{/if}
</Module>
<FriendSpace
title={`${firstName}'s Friend Space`}
ownerName={firstName}
friends={friends.slice(0, FRIEND_PREVIEW)}
total={account.followers_count}
viewAllHref={`#/@${account.acct}/friends`}
loading={friendsLoading}
hidden={listHidden}
{countHidden}
compact
/>
{/if}
</div>
</div>
{/if}
</div>
+143
View File
@@ -0,0 +1,143 @@
<script lang="ts">
/**
* Search across people, entries and hashtags.
*
* `resolve` is only sent when signed in — it makes the server fetch unknown
* remote accounts, which anonymous callers aren't allowed to trigger.
*/
import { untrack } from 'svelte'
import type { SearchResults } from '$lib/api/types'
import { session } from '$lib/stores/session.svelte'
import { search } from '$lib/api/endpoints'
import { router, routeTo } from '$lib/router.svelte'
import Module from '$components/common/Module.svelte'
import PersonRow from '$components/people/PersonRow.svelte'
import BlogEntry from '$components/blog/BlogEntry.svelte'
import { formatCount } from '$lib/util/profile'
interface Props {
q?: string
}
let { q = '' }: Props = $props()
// Seeded from the route, then kept in sync by the effect below.
let input = $state(untrack(() => q))
let results = $state<SearchResults | null>(null)
let loading = $state(false)
let error = $state<string | null>(null)
$effect(() => {
const query = q
input = query
if (!query || !session.host) {
results = null
return
}
untrack(() => void run(query))
})
async function run(query: string): Promise<void> {
loading = true
error = null
try {
const found = await search(session.api, query, { limit: 20 })
if (q === query) results = found
} catch (cause) {
if (q === query) error = cause instanceof Error ? cause.message : 'Search failed.'
} finally {
if (q === query) loading = false
}
}
function submit(event: SubmitEvent): void {
event.preventDefault()
const trimmed = input.trim()
if (trimmed) router.go(routeTo('/search', { q: trimmed }))
}
</script>
<div class="page search-page">
<h1 class="page-title">Search</h1>
<div class="layout--single">
<Module title="Search plspace">
<form class="search-form" onsubmit={submit}>
<div class="field-row">
<label class="visually-hidden" for="search-input">Search terms</label>
<input
id="search-input"
class="field-input search-input"
type="search"
bind:value={input}
placeholder="A name, @user@server, #hashtag, or a link to a post"
/>
<button class="button button--primary" type="submit">Search</button>
</div>
<span class="field-hint">
Paste a full <code>@user@server</code> handle or a post URL to pull it in from another server.
</span>
</form>
</Module>
{#if error}
<p class="error-note" role="alert">{error}</p>
{/if}
{#if loading}
<p class="loading-note">Searching&hellip;</p>
{:else if results}
<Module title={`People (${formatCount(results.accounts.length)})`} variant="band">
{#if results.accounts.length === 0}
<p class="empty-note">No people matched.</p>
{:else}
<ul class="person-list">
{#each results.accounts as account (account.id)}
<PersonRow {account} />
{/each}
</ul>
{/if}
</Module>
<Module title={`Hashtags (${formatCount(results.hashtags.length)})`} variant="band">
{#if results.hashtags.length === 0}
<p class="empty-note">No hashtags matched.</p>
{:else}
<ul class="tag-list">
{#each results.hashtags as tag (tag.name)}
<li class="tag-list-item">
<a href={`#/tag/${encodeURIComponent(tag.name)}`}>#{tag.name}</a>
</li>
{/each}
</ul>
{/if}
</Module>
<Module title={`Blog Entries (${formatCount(results.statuses.length)})`} variant="band">
{#if results.statuses.length === 0}
<p class="empty-note">No entries matched. Many servers only search entries you wrote.</p>
{:else}
<ul class="blog-list">
{#each results.statuses as status (status.id)}
<li class="blog-list-item">
<BlogEntry
{status}
onupdate={(next) => {
if (results) {
results = {
...results,
statuses: results.statuses.map((item) => (item.id === next.id ? next : item)),
}
}
}}
/>
</li>
{/each}
</ul>
{/if}
</Module>
{:else}
<p class="empty-note">Enter something to search for.</p>
{/if}
</div>
</div>
+216
View File
@@ -0,0 +1,216 @@
<script lang="ts">
/**
* Settings — mostly the layout editor, which is the feature this whole app
* exists to have.
*
* Two things are editable: the CSS applied to *your* view of plspace (stored
* locally), and whether the CSS other people publish on their profiles is
* honoured when you visit them.
*/
import { session } from '$lib/stores/session.svelte'
import { theme, CSS_FIELD_NAMES, PROFILE_SCOPE } from '$lib/stores/theme.svelte'
import { PRESETS, EXAMPLE_CSS } from '$lib/themes'
import { instanceDomain } from '$lib/api/endpoints'
import { displayNameOf, profilePath } from '$lib/util/profile'
import Module from '$components/common/Module.svelte'
let draft = $state(theme.viewerCss)
let saved = $state(false)
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
/** Class hooks worth documenting, grouped the way pages are built. */
const HOOKS: Array<{ group: string; entries: Array<[string, string]> }> = [
{
group: 'Page skeletons',
entries: [
['.page', 'Every pages outer container'],
['.profile-page', 'The profile page — also the scope for published profile CSS'],
['.layout--split', 'Two-column pages (left rail + main)'],
['.layout--dashboard', 'The three-column home page'],
['.layout-column--left / --main / --right', 'The individual columns'],
],
},
{
group: 'Boxes',
entries: [
['.module', 'A bordered box'],
['.module-header', 'Its caption bar'],
['.module-body', 'Its contents'],
['.module--band', 'The peach-bar variant used in the main column'],
['.section-heading', '“About me:” style orange headings'],
],
},
{
group: 'Profile',
entries: [
['.profile-photo', 'The big photo'],
['.profile-headline', 'The quoted line beside it'],
['.profile-vitals', 'Gender / age / location / last active'],
['.interests-table, .interests-label, .interests-value', 'The Interests table'],
['.friend-grid, .friend-card, .friend-card-photo', 'Friend Space'],
],
},
{
group: 'Blog entries',
entries: [
['.blog-entry', 'One entry'],
['.blog-entry[data-mine="true"]', 'Entries you wrote'],
['.blog-entry[data-visibility="private"]', 'Friends-only entries'],
['.blog-entry[data-boosted="true"]', 'Reposts'],
['.blog-action[aria-pressed="true"]', 'Kudos/Repost buttons youve activated'],
['.comment[data-depth="2"]', 'Comments by nesting depth'],
],
},
]
function save(): void {
theme.setViewerCss(draft)
saved = true
setTimeout(() => (saved = false), 2000)
}
function applyPreset(css: string): void {
draft = css
theme.setViewerCss(css)
}
function reset(): void {
draft = ''
theme.setViewerCss('')
}
</script>
<div class="page settings-page">
<h1 class="page-title">Settings</h1>
<p class="page-subtitle">Everything here is stored in this browser only.</p>
<div class="layout--single">
<Module title="Your account">
{#if session.signedIn && session.me}
<p>
Signed in as
<a href={profilePath(session.me)}>{displayNameOf(session.me)}</a>
on <strong>{domain}</strong>.
</p>
<p class="field-row">
<button type="button" class="button" onclick={() => void session.logout()}>Sign out</button>
<button type="button" class="button" onclick={() => session.disconnect()}>
Forget this server
</button>
</p>
{:else if session.host}
<p>Browsing <strong>{domain}</strong> as a guest.</p>
<p class="field-row">
<a class="button button--primary" href="#/login">Sign in</a>
<button type="button" class="button" onclick={() => session.disconnect()}>
Choose a different server
</button>
</p>
{:else}
<p>Not connected to a server. <a href="#/login">Choose one</a>.</p>
{/if}
</Module>
<Module title="Pick a layout" variant="band">
<ul class="preset-list">
{#each PRESETS as preset (preset.id)}
<li class="preset">
<button type="button" class="button preset-button" onclick={() => applyPreset(preset.css)}>
{preset.name}
</button>
<span class="preset-description muted">{preset.description}</span>
</li>
{/each}
</ul>
</Module>
<Module title="Your CSS" variant="band">
<p>
This is applied to every page you view in plspace. Overriding the custom properties in
<code>styles/tokens.css</code> retints the entire app; the class hooks below let you go
further.
</p>
<label class="visually-hidden" for="viewer-css">Your CSS</label>
<textarea
id="viewer-css"
class="css-editor"
bind:value={draft}
rows="14"
spellcheck="false"
placeholder={EXAMPLE_CSS}
></textarea>
<div class="field-row">
<button type="button" class="button button--primary" onclick={save}>Save CSS</button>
<button type="button" class="button" onclick={reset}>Reset to default</button>
<button type="button" class="button" onclick={() => (draft = EXAMPLE_CSS)}>
Load the example
</button>
{#if saved}<span class="muted">Saved.</span>{/if}
</div>
<p class="field-hint">
<code>@import</code> and non-HTTPS <code>url()</code> values are stripped before your CSS is
applied.
</p>
</Module>
<Module title="Other people's layouts" variant="band">
<label class="checkbox-field">
<input
type="checkbox"
checked={theme.allowProfileCss}
onchange={(event) => theme.setAllowProfileCss(event.currentTarget.checked)}
/>
<span>
Show profile layouts published by the people I visit
</span>
</label>
<p class="field-hint">
A profile can publish a stylesheet by putting CSS in a profile field named
{#each CSS_FIELD_NAMES as name, index (name)}<code>{name}</code>{#if index < CSS_FIELD_NAMES.length - 1}, {/if}{/each}.
Their rules are rewritten to apply only inside <code>{PROFILE_SCOPE}</code>, so a profile
can restyle its own page but not the rest of plspace.
</p>
</Module>
<Module title="Publish your own layout" variant="band">
<p>
Add a profile field on <strong>{domain || 'your server'}</strong> named <code>css</code> and
paste a stylesheet into its value. Anyone viewing your profile in plspace sees it. Since
it's an ordinary profile field, it survives elsewhere too &mdash; other clients just show it
as text.
</p>
{#if session.signedIn}
<p>
<a
class="button"
href={`https://${session.host}/settings/profile`}
target="_blank"
rel="noopener noreferrer"
>
Edit your profile on {domain}
</a>
</p>
{/if}
</Module>
<Module title="Class reference" variant="band">
{#each HOOKS as section (section.group)}
<h3 class="section-heading">{section.group}</h3>
<table class="data-table hooks-table">
<tbody>
{#each section.entries as [selector, description] (selector)}
<tr>
<th class="data-table-label" scope="row"><code>{selector}</code></th>
<td class="data-table-value">{description}</td>
</tr>
{/each}
</tbody>
</table>
{/each}
</Module>
</div>
</div>
+214
View File
@@ -0,0 +1,214 @@
<script lang="ts">
/**
* A single blog entry with its comment thread.
*
* `/context` returns ancestors and descendants flat; the descendants are
* re-nested here so replies-to-replies indent, capped at three levels because
* a 2005 layout has nowhere to put the fourth.
*/
import { untrack } from 'svelte'
import type { Status } from '$lib/api/types'
import { session } from '$lib/stores/session.svelte'
import { fetchContext, fetchStatus } from '$lib/api/endpoints'
import { displayNameOf, profilePath } from '$lib/util/profile'
import { stampDate, isoDate } from '$lib/util/time'
import { toPlainText } from '$lib/util/html'
import Module from '$components/common/Module.svelte'
import Avatar from '$components/common/Avatar.svelte'
import RichText from '$components/common/RichText.svelte'
import BlogEntry from '$components/blog/BlogEntry.svelte'
import Composer from '$components/blog/Composer.svelte'
interface Props {
id: string
}
let { id }: Props = $props()
let status = $state<Status | null>(null)
let ancestors = $state<Status[]>([])
let descendants = $state<Status[]>([])
let loading = $state(true)
let error = $state<string | null>(null)
interface ThreadedReply {
status: Status
depth: number
}
/** Flatten the descendant tree depth-first so it renders as one list. */
const thread = $derived.by<ThreadedReply[]>(() => {
if (!status) return []
const byParent = new Map<string, Status[]>()
for (const reply of descendants) {
const parent = reply.in_reply_to_id ?? status.id
const bucket = byParent.get(parent) ?? []
bucket.push(reply)
byParent.set(parent, bucket)
}
const out: ThreadedReply[] = []
const walk = (parentId: string, depth: number): void => {
for (const reply of byParent.get(parentId) ?? []) {
out.push({ status: reply, depth: Math.min(depth, 3) })
walk(reply.id, depth + 1)
}
}
walk(status.id, 0)
// Anything whose parent fell outside the context still deserves showing.
const seen = new Set(out.map((entry) => entry.status.id))
for (const reply of descendants) {
if (!seen.has(reply.id)) out.push({ status: reply, depth: 0 })
}
return out
})
/** Prefill a comment with the mentions a reply conventionally carries. */
const replyPrefill = $derived.by(() => {
if (!status) return ''
const handles = new Set<string>()
if (status.account.id !== session.me?.id) handles.add(status.account.acct)
for (const mention of status.mentions) {
if (mention.id !== session.me?.id) handles.add(mention.acct)
}
return handles.size > 0 ? `${[...handles].map((acct) => `@${acct}`).join(' ')} ` : ''
})
$effect(() => {
const currentId = id
const host = session.host
if (!host) return
untrack(() => void load(currentId))
})
async function load(currentId: string): Promise<void> {
loading = true
error = null
try {
const [entry, context] = await Promise.all([
fetchStatus(session.api, currentId),
fetchContext(session.api, currentId).catch(() => ({ ancestors: [], descendants: [] })),
])
if (id !== currentId) return
status = entry
ancestors = context.ancestors
descendants = context.descendants
document.title = `${toPlainText(entry.content).slice(0, 60)} | plspace`
} catch (cause) {
if (id !== currentId) return
error = cause instanceof Error ? cause.message : 'Could not load that entry.'
} finally {
if (id === currentId) loading = false
}
}
function onPosted(created: Status): void {
descendants = [...descendants, created]
if (status) status = { ...status, replies_count: status.replies_count + 1 }
}
</script>
<div class="page entry-page">
{#if loading}
<p class="loading-note">Loading entry&hellip;</p>
{:else if error}
<p class="error-note" role="alert">
<strong class="error-note-title">Entry not available.</strong>
{error}
</p>
{:else if status}
<h1 class="page-title">
<a href={profilePath(status.account)}>{displayNameOf(status.account)}</a>'s Blog
</h1>
<p class="page-subtitle">
<time datetime={isoDate(status.created_at)}>{stampDate(status.created_at)}</time>
</p>
<div class="layout--single">
{#if ancestors.length > 0}
<Module title="Earlier in this thread" variant="band">
<ul class="blog-list">
{#each ancestors as ancestor (ancestor.id)}
<li class="blog-list-item">
<BlogEntry
status={ancestor}
compact
onupdate={(next) =>
(ancestors = ancestors.map((item) => (item.id === next.id ? next : item)))}
/>
</li>
{/each}
</ul>
</Module>
{/if}
<Module title="Blog Entry" variant="band">
<BlogEntry
{status}
longFormDate
onupdate={(next) => (status = next)}
ondelete={() => history.back()}
/>
</Module>
<Module title={`Comments (${status.replies_count})`} variant="band">
{#if session.signedIn}
<Composer
inReplyTo={status}
initialText={replyPrefill}
placeholder="Leave a comment…"
submitLabel="Post Comment"
onposted={onPosted}
/>
{:else}
<p class="empty-note"><a href="#/login">Sign in</a> to leave a comment.</p>
{/if}
{#if thread.length === 0}
<p class="empty-note">No comments yet. Be the first.</p>
{:else}
<ul class="comment-list">
{#each thread as reply (reply.status.id)}
<li class="comment" data-depth={reply.depth} data-account={reply.status.account.acct}>
<div class="comment-avatar">
<Avatar account={reply.status.account} />
</div>
<div class="comment-body">
<a class="comment-author" href={profilePath(reply.status.account)}>
{displayNameOf(reply.status.account)}
</a>
<a class="comment-date" href={`#/blog/${reply.status.id}`}>
<time datetime={isoDate(reply.status.created_at)}>
{stampDate(reply.status.created_at)}
</time>
</a>
{#if reply.status.spoiler_text}
<details class="content-warning">
<summary class="content-warning-summary">{reply.status.spoiler_text}</summary>
<RichText
html={reply.status.content}
emojis={reply.status.emojis}
mentions={reply.status.mentions}
tags={reply.status.tags}
/>
</details>
{:else}
<RichText
html={reply.status.content}
emojis={reply.status.emojis}
mentions={reply.status.mentions}
tags={reply.status.tags}
/>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</Module>
</div>
{/if}
</div>
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
/**
* A timeline, presented as a blog: "My Blog" (home), "This Server" (local),
* "The Whole Network" (federated), or a hashtag.
*/
import { untrack } from 'svelte'
import type { Status } from '$lib/api/types'
import type { TimelineKind } from '$lib/api/endpoints'
import { session } from '$lib/stores/session.svelte'
import { Feed } from '$lib/stores/feed.svelte'
import { fetchTimeline, instanceDomain } from '$lib/api/endpoints'
import Module from '$components/common/Module.svelte'
import TabBar from '$components/common/TabBar.svelte'
import BlogList from '$components/blog/BlogList.svelte'
import Composer from '$components/blog/Composer.svelte'
interface Props {
kind: TimelineKind
tag?: string
}
let { kind, tag }: Props = $props()
let feed = $state<Feed<Status>>(new Feed<Status>(async () => ({ items: [], links: {} })))
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
const title = $derived(
kind === 'home'
? 'My Blog'
: kind === 'local'
? `Blogs on ${domain}`
: kind === 'tag'
? `#${tag}`
: 'The Whole Network',
)
const tabs = $derived([
...(session.signedIn ? [{ label: 'My Blog', href: '#/timeline/home' }] : []),
{ label: 'This Server', href: '#/timeline/local' },
{ label: 'Whole Network', href: '#/timeline/public' },
])
const currentTab = $derived(kind === 'tag' ? '' : `#/timeline/${kind}`)
$effect(() => {
const currentKind = kind
const currentTag = tag
const host = session.host
const authed = session.signedIn
if (!host) return
untrack(() => {
// Home needs a token; fall back rather than showing a 401.
const resolved: TimelineKind = currentKind === 'home' && !authed ? 'local' : currentKind
feed = new Feed<Status>((cursor) =>
fetchTimeline(session.api, resolved, cursor, { tag: currentTag }),
)
void feed.reload()
document.title = `${title} | plspace`
})
})
</script>
<div class="page timeline-page" data-timeline={kind} data-tag={tag ?? ''}>
<h1 class="page-title">{title}</h1>
{#if kind !== 'tag'}
<TabBar {tabs} current={currentTab} label="Timelines" />
{/if}
{#if kind === 'home' && !session.signedIn}
<p class="notice">
You're browsing as a guest, so this is <strong>{domain}</strong>'s local timeline.
<a href="#/login">Sign in</a> to read your own.
</p>
{/if}
<div class="layout--single">
{#if kind === 'home' && session.signedIn}
<Module title="Post a new entry">
<Composer onposted={(status) => feed.prepend(status)} />
</Module>
{/if}
<Module title={title} variant="band">
<BlogList
{feed}
emptyText={kind === 'tag'
? `Nobody has posted with #${tag} that this server knows about.`
: 'There are no Blog Entries yet.'}
longFormDate
/>
</Module>
</div>
</div>