license added, and toast notifications.

This commit is contained in:
Moon.eth
2026-07-29 14:22:08 +09:00
parent a42145c678
commit cc132ad6b4
8 changed files with 1109 additions and 0 deletions
@@ -0,0 +1,160 @@
<script lang="ts">
/**
* Quiet background notification polling plus a transient, clickable stack.
* Polls are serialized: the next delay begins only after the previous
* request settles, so a slow or offline server can never accumulate calls.
*/
import type { ApiClient } from '$lib/api/client'
import type { Notification } from '$lib/api/types'
import { useAppServices } from '$lib/app-services'
import {
NOTIFICATION_DISMISS_AFTER_MS,
NOTIFICATION_POLL_INTERVAL_MS,
NotificationTracker,
presentNotification,
type NotificationPresentation,
} from '$lib/notifications'
import Avatar from '../common/Avatar.svelte'
interface Props {
pollIntervalMs?: number
dismissAfterMs?: number
maxToasts?: number
}
interface Toast {
notification: Notification
presentation: NotificationPresentation
}
let {
pollIntervalMs = NOTIFICATION_POLL_INTERVAL_MS,
dismissAfterMs = NOTIFICATION_DISMISS_AFTER_MS,
maxToasts = 4,
}: Props = $props()
const { endpoints, session } = useAppServices()
const tracker = new NotificationTracker()
const dismissTimers = new Map<string, number>()
let toasts = $state<Toast[]>([])
let pollTimer: number | undefined
let generation = 0
$effect(() => {
const signedIn = session.signedIn
// Tracking the identity restarts the baseline after sign-in or account/
// server changes, so one account can never see another account's toasts.
const identity = `${session.host}:${session.me?.id ?? ''}:${session.token ?? ''}`
const api = session.api
identity
const currentGeneration = ++generation
clearPollTimer()
clearToasts()
tracker.reset()
if (!signedIn) return
void poll(api, currentGeneration)
return () => {
if (generation === currentGeneration) generation += 1
clearPollTimer()
clearDismissTimers()
}
})
async function poll(api: ApiClient, currentGeneration: number): Promise<void> {
try {
const page = await endpoints.fetchNotifications(api, {
since_id: tracker.cursor,
limit: 20,
})
if (currentGeneration !== generation) return
// API pages are newest-first. Add oldest-to-newest while prepending so
// the newest toast finishes at the top of the stack.
for (const notification of [...tracker.ingest(page.items)].reverse()) {
show(notification)
}
} catch {
// Polling is ambient: offline/CORS errors must not disturb the page.
}
if (currentGeneration === generation) {
pollTimer = window.setTimeout(
() => void poll(api, currentGeneration),
Math.max(1, pollIntervalMs),
)
}
}
function show(notification: Notification): void {
const toast: Toast = {
notification,
presentation: presentNotification(notification),
}
const next = [toast, ...toasts.filter((item) => item.notification.id !== notification.id)]
const dropped = next.slice(Math.max(1, maxToasts))
toasts = next.slice(0, Math.max(1, maxToasts))
for (const item of dropped) clearDismissTimer(item.notification.id)
clearDismissTimer(notification.id)
dismissTimers.set(
notification.id,
window.setTimeout(
() => dismiss(notification.id),
Math.max(1, dismissAfterMs),
),
)
}
function dismiss(id: string): void {
clearDismissTimer(id)
toasts = toasts.filter((toast) => toast.notification.id !== id)
}
function clearDismissTimer(id: string): void {
const timer = dismissTimers.get(id)
if (timer !== undefined) window.clearTimeout(timer)
dismissTimers.delete(id)
}
function clearToasts(): void {
clearDismissTimers()
toasts = []
}
function clearDismissTimers(): void {
for (const timer of dismissTimers.values()) window.clearTimeout(timer)
dismissTimers.clear()
}
function clearPollTimer(): void {
if (pollTimer !== undefined) window.clearTimeout(pollTimer)
pollTimer = undefined
}
</script>
<aside class="notification-toast-stack" aria-label="New notifications" aria-live="polite">
{#each toasts as toast (toast.notification.id)}
<a
class="notification-toast"
href={toast.presentation.href}
data-kind={toast.notification.type}
data-account={toast.notification.account.acct}
onclick={() => dismiss(toast.notification.id)}
>
<Avatar account={toast.notification.account} plain class="notification-toast-avatar" />
<span class="notification-toast-body">
<span class="notification-toast-message">
<strong>{toast.presentation.actor}</strong>
{toast.presentation.message}
</span>
{#if toast.presentation.excerpt}
<span class="notification-toast-excerpt">{toast.presentation.excerpt}</span>
{/if}
</span>
</a>
{/each}
</aside>
@@ -0,0 +1,74 @@
import { act, render } from '@testing-library/svelte'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { APP_SERVICES } from '$lib/app-services'
import type { Notification } from '$lib/api/types'
import { account, session, status, testServices } from '$test/fixtures'
import NotificationToasts from './NotificationToasts.svelte'
function notification(overrides: Partial<Notification> = {}): Notification {
return {
id: 'notification-1',
type: 'mention',
created_at: '2026-01-01T00:00:00.000Z',
account: account({ id: 'bob', display_name: 'Bob', acct: 'bob' }),
status: status({ id: 'mentioned-entry', content: '<p>A new mention</p>' }),
...overrides,
}
}
afterEach(() => {
vi.useRealTimers()
})
describe('NotificationToasts', () => {
it('polls quietly, shows only new notifications, then dismisses them', async () => {
vi.useFakeTimers()
const oldNotification = notification({ id: 'old-notification' })
const newNotification = notification({ id: 'new-notification' })
const fetchNotifications = vi
.fn()
.mockResolvedValueOnce({ items: [oldNotification], links: {} })
.mockResolvedValueOnce({
items: [newNotification, oldNotification],
links: {},
})
const services = testServices({
session: session({
token: 'token',
me: account(),
signedIn: true,
}),
endpoints: { fetchNotifications },
})
const view = render(NotificationToasts, {
props: { pollIntervalMs: 1_000, dismissAfterMs: 500 },
context: new Map([[APP_SERVICES, services]]),
})
await act(async () => {
await Promise.resolve()
})
expect(fetchNotifications).toHaveBeenCalledOnce()
expect(view.queryByText('A new mention')).not.toBeInTheDocument()
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
})
expect(fetchNotifications).toHaveBeenCalledTimes(2)
expect(fetchNotifications.mock.calls[1][1]).toMatchObject({
since_id: 'old-notification',
limit: 20,
})
expect(view.getByRole('link', { name: /Bob mentioned you in an entry/ })).toHaveAttribute(
'href',
'#/blog/mentioned-entry',
)
expect(view.getByText('A new mention')).toBeInTheDocument()
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(view.queryByText('A new mention')).not.toBeInTheDocument()
})
})