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
+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>