Files
plspace/src/routes/Home.svelte
T

483 lines
18 KiB
Svelte

<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 { useAppServices } from '$lib/app-services'
import {
instanceDomain,
instanceStats,
instanceThumbnail,
} from '$lib/api/endpoints'
import { displayNameOf, fallbackMood, formatCount, profilePath } from '$lib/util/profile'
import { escapeHtml, toPlainText } from '$lib/util/html'
import { relativeTime, shortDate, stampDate } from '$lib/util/time'
import { useTimelineRefresh } from '$lib/timeline-refresh'
import { reconcileRefreshItems } from '$lib/stores/feed.svelte'
import Module from '$components/common/Module.svelte'
import Avatar from '$components/common/Avatar.svelte'
import EmojiText from '$components/common/EmojiText.svelte'
import RichText from '$components/common/RichText.svelte'
import MfmContent from '$components/common/MfmContent.svelte'
import Composer from '$components/blog/Composer.svelte'
const { endpoints, session } = useAppServices()
const timelineRefresh = useTimelineRefresh()
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)
let loadGeneration = 0
let primaryRefreshGeneration = 0
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) {
loadGeneration += 1
loading = false
return
}
const generation = ++loadGeneration
void load(signedIn, host, generation)
return () => {
if (generation === loadGeneration) loadGeneration += 1
}
})
$effect(() => {
const host = session.host
const signedIn = session.signedIn
if (!timelineRefresh || !host) return
return timelineRefresh.register(() => void refreshPrimaryTimeline(signedIn, host))
})
async function load(signedIn: boolean, host = session.host, generation = ++loadGeneration): 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
? endpoints.fetchTimeline(session.api, 'home', { limit: 10 }).catch((cause) => {
if (generation === loadGeneration) friendStatusError = messageOf(cause)
return { items: [], links: {} }
})
: Promise.resolve({ items: [], links: {} }),
endpoints.fetchTimeline(session.api, 'local', { limit: 10 }).catch((cause) => {
if (generation === loadGeneration) bulletinError = messageOf(cause)
return { items: [], links: {} }
}),
])
if (generation !== loadGeneration || session.host !== host) return
friendStatus = statusPage.items
bulletins = bulletinPage.items
if (signedIn && session.me) {
const accountId = session.me.id
void endpoints
.fetchFollowing(session.api, accountId, { limit: 12 })
.then((page) => {
if (generation === loadGeneration) following = page.items
})
.catch(() => {
if (generation === loadGeneration) following = []
})
void endpoints
.fetchNotifications(session.api, { limit: 40 })
.then((page) => {
if (generation === loadGeneration) notifications = page.items
})
.catch(() => {
if (generation === loadGeneration) notifications = []
})
} else {
following = []
notifications = []
}
} catch (cause) {
if (generation === loadGeneration) {
error = cause instanceof Error ? cause.message : 'Could not load your home page.'
}
} finally {
if (generation === loadGeneration) loading = false
}
}
/** Refresh only the dashboard's visible primary timeline, not its sidebars. */
async function refreshPrimaryTimeline(signedIn: boolean, host: string): Promise<void> {
const generation = ++primaryRefreshGeneration
if (signedIn) {
friendStatusError = null
try {
const page = await endpoints.fetchTimeline(session.api, 'home', { limit: 10 })
if (
generation === primaryRefreshGeneration &&
session.host === host &&
session.signedIn === signedIn
) {
friendStatus = reconcileRefreshItems(
friendStatus,
page.items,
page.deletedIds,
{ emptyIsAuthoritative: !page.links.maxId },
)
}
} catch (cause) {
if (generation === primaryRefreshGeneration && session.host === host) {
friendStatusError = messageOf(cause)
}
}
return
}
bulletinError = null
try {
const page = await endpoints.fetchTimeline(session.api, 'local', { limit: 10 })
if (
generation === primaryRefreshGeneration &&
session.host === host &&
session.signedIn === signedIn
) {
bulletins = reconcileRefreshItems(
bulletins,
page.items,
page.deletedIds,
{ emptyIsAuthoritative: !page.links.maxId },
)
}
} catch (cause) {
if (generation === primaryRefreshGeneration && session.host === host) {
bulletinError = messageOf(cause)
}
}
}
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, <EmojiText text={displayNameOf(me).split(/\s+/)[0]} emojis={me.emojis} />!
{: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)}>
<EmojiText text={displayNameOf(me)} emojis={me.emojis} />
</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)}>
<EmojiText text={displayNameOf(entry.account)} emojis={entry.account.emojis} />
</a>
<MfmContent
html={entry.spoiler_text ? `<p>${escapeHtml(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)}>
<EmojiText text={displayNameOf(entry.account)} emojis={entry.account.emojis} />
</a>
</td>
<td class="bulletin-date">{stampDate(entry.created_at)}</td>
<td class="bulletin-subject">
<a href={`#/blog/${entry.id}`}>
<EmojiText
text={toPlainText(entry.spoiler_text || entry.content).slice(0, 90) || '(no text)'}
emojis={entry.emojis}
/>
</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)}>
<EmojiText
class="friend-card-name"
text={displayNameOf(friend)}
emojis={friend.emojis}
/>
<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>