mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-14 03:02:31 +00:00
license added, and toast notifications.
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user