mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
483 lines
18 KiB
Svelte
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’s always Pleroma™.</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>
|
|
· 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 & 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…</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 — 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 →</a></p>
|
|
</Module>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|