From 3f28f797a6175ca49a1cb784418f27ff619f36cf Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Sat, 23 May 2026 11:53:24 +0200 Subject: [PATCH] redesign --- apps/homepage/src/lib/config.ts | 2 +- apps/relay/src/telegram/commands.ts | 2 +- apps/web/package.json | 1 - apps/web/src/app.html | 8 + apps/web/src/lib/components/AppMark.svelte | 27 ++ apps/web/src/lib/components/EmptyState.svelte | 22 -- apps/web/src/lib/components/IOSToggle.svelte | 32 +++ apps/web/src/lib/components/Icon.svelte | 107 ++++++++ apps/web/src/lib/components/SenderCard.svelte | 72 ----- apps/web/src/lib/components/Toggle.svelte | 30 -- apps/web/src/lib/components/Wordmark.svelte | 19 ++ apps/web/src/lib/config.ts | 39 +-- apps/web/src/lib/server/highlight.ts | 37 --- apps/web/src/routes/(app)/+layout.server.ts | 12 + apps/web/src/routes/(app)/+layout.svelte | 118 ++++++++ .../web/src/routes/(app)/apps/+page.server.ts | 17 ++ apps/web/src/routes/(app)/apps/+page.svelte | 225 +++++++++++++++ apps/web/src/routes/(app)/inbox/+page.svelte | 24 ++ .../src/routes/(app)/settings/+page.server.ts | 17 ++ .../src/routes/(app)/settings/+page.svelte | 141 ++++++++++ apps/web/src/routes/+layout.svelte | 70 +---- apps/web/src/routes/+page.server.ts | 4 +- apps/web/src/routes/+page.svelte | 141 ++++------ apps/web/src/routes/dashboard/+page.server.ts | 27 -- apps/web/src/routes/dashboard/+page.svelte | 259 ------------------ .../dashboard/pending/[id]/+page.server.ts | 14 - .../dashboard/pending/[id]/+page.svelte | 152 ---------- apps/web/src/routes/docs/+page.server.ts | 79 ------ apps/web/src/routes/docs/+page.svelte | 159 ----------- apps/web/src/service-worker.ts | 51 ++++ apps/web/static/icon.svg | 1 + apps/web/static/manifest.webmanifest | 13 + pnpm-lock.yaml | 3 - 33 files changed, 881 insertions(+), 1044 deletions(-) create mode 100644 apps/web/src/lib/components/AppMark.svelte delete mode 100644 apps/web/src/lib/components/EmptyState.svelte create mode 100644 apps/web/src/lib/components/IOSToggle.svelte create mode 100644 apps/web/src/lib/components/Icon.svelte delete mode 100644 apps/web/src/lib/components/SenderCard.svelte delete mode 100644 apps/web/src/lib/components/Toggle.svelte create mode 100644 apps/web/src/lib/components/Wordmark.svelte delete mode 100644 apps/web/src/lib/server/highlight.ts create mode 100644 apps/web/src/routes/(app)/+layout.server.ts create mode 100644 apps/web/src/routes/(app)/+layout.svelte create mode 100644 apps/web/src/routes/(app)/apps/+page.server.ts create mode 100644 apps/web/src/routes/(app)/apps/+page.svelte create mode 100644 apps/web/src/routes/(app)/inbox/+page.svelte create mode 100644 apps/web/src/routes/(app)/settings/+page.server.ts create mode 100644 apps/web/src/routes/(app)/settings/+page.svelte delete mode 100644 apps/web/src/routes/dashboard/+page.server.ts delete mode 100644 apps/web/src/routes/dashboard/+page.svelte delete mode 100644 apps/web/src/routes/dashboard/pending/[id]/+page.server.ts delete mode 100644 apps/web/src/routes/dashboard/pending/[id]/+page.svelte delete mode 100644 apps/web/src/routes/docs/+page.server.ts delete mode 100644 apps/web/src/routes/docs/+page.svelte create mode 100644 apps/web/src/service-worker.ts create mode 100644 apps/web/static/icon.svg create mode 100644 apps/web/static/manifest.webmanifest diff --git a/apps/homepage/src/lib/config.ts b/apps/homepage/src/lib/config.ts index 9845031..b3414d0 100644 --- a/apps/homepage/src/lib/config.ts +++ b/apps/homepage/src/lib/config.ts @@ -2,7 +2,7 @@ // relay live on their own domains; these point at them. /** Product name shown in the UI. */ -export const PROJECT_NAME = 'notify.atmo.tools'; +export const PROJECT_NAME = 'atmo.pub'; /** The webapp (PWA) — where the header/landing "Sign in" buttons send users. */ export const WEBAPP_URL = 'https://notify.atmo.tools'; diff --git a/apps/relay/src/telegram/commands.ts b/apps/relay/src/telegram/commands.ts index af8ef30..da8b56b 100644 --- a/apps/relay/src/telegram/commands.ts +++ b/apps/relay/src/telegram/commands.ts @@ -49,7 +49,7 @@ async function handleStart(env: Env, message: TelegramMessage, token: string): P await replyText( env, chatId, - `👋 Welcome to the atmo notifications bot!\n\nLink your account from the dashboard to start receiving notifications:\n${DASHBOARD_URL}`, + `👋 Welcome to atmo.pub!\n\nLink your account from the dashboard to start receiving notifications:\n${DASHBOARD_URL}`, ); return; } diff --git a/apps/web/package.json b/apps/web/package.json index 7e702d3..7d4741e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -48,7 +48,6 @@ "@atcute/uint8array": "^1.1.2", "@atmo/notifs-lexicons": "workspace:*", "@svelte-atproto/oauth": "^0.3.0", - "shiki": "^4.1.0", "valibot": "^1.4.0" } } diff --git a/apps/web/src/app.html b/apps/web/src/app.html index 38da952..7ac1fa6 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -6,6 +6,14 @@ + + + + + + + + + + diff --git a/apps/web/src/lib/components/EmptyState.svelte b/apps/web/src/lib/components/EmptyState.svelte deleted file mode 100644 index 410ff99..0000000 --- a/apps/web/src/lib/components/EmptyState.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - -
-

{title}

- {#if description} -

{description}

- {/if} - {#if cta} -
{@render cta()}
- {/if} -
diff --git a/apps/web/src/lib/components/IOSToggle.svelte b/apps/web/src/lib/components/IOSToggle.svelte new file mode 100644 index 0000000..e7b4713 --- /dev/null +++ b/apps/web/src/lib/components/IOSToggle.svelte @@ -0,0 +1,32 @@ + + + diff --git a/apps/web/src/lib/components/Icon.svelte b/apps/web/src/lib/components/Icon.svelte new file mode 100644 index 0000000..98c7390 --- /dev/null +++ b/apps/web/src/lib/components/Icon.svelte @@ -0,0 +1,107 @@ + + + diff --git a/apps/web/src/lib/components/SenderCard.svelte b/apps/web/src/lib/components/SenderCard.svelte deleted file mode 100644 index eec3bae..0000000 --- a/apps/web/src/lib/components/SenderCard.svelte +++ /dev/null @@ -1,72 +0,0 @@ - - -
- {#if icon} - - {:else} - - {/if} -
-
{title}
-
{shortDid}
- {#if description} -

{description}

- {/if} - {#if senderHandle} -

- - Verified on Bluesky: @{senderHandle} -

- {/if} -
-
diff --git a/apps/web/src/lib/components/Toggle.svelte b/apps/web/src/lib/components/Toggle.svelte deleted file mode 100644 index 53e3daa..0000000 --- a/apps/web/src/lib/components/Toggle.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - - diff --git a/apps/web/src/lib/components/Wordmark.svelte b/apps/web/src/lib/components/Wordmark.svelte new file mode 100644 index 0000000..0e72704 --- /dev/null +++ b/apps/web/src/lib/components/Wordmark.svelte @@ -0,0 +1,19 @@ + + + + + + atmo.pub + + diff --git a/apps/web/src/lib/config.ts b/apps/web/src/lib/config.ts index 395d280..3ab6cf5 100644 --- a/apps/web/src/lib/config.ts +++ b/apps/web/src/lib/config.ts @@ -1,36 +1,15 @@ -// Single source of truth for relay-facing constants. See the relay's README -// "Configuration" section to keep these in sync if the relay is re-homed. +// Single source of truth for app-facing constants. -/** The notification relay's domain. */ -export const RELAY_DOMAIN = 'notifs.atmo.tools'; - -/** `https://notifs.atmo.tools` — base for relay XRPC calls. */ -export const RELAY_ORIGIN = `https://${RELAY_DOMAIN}`; - -/** The relay's `did:web` identity (the `aud` for service-auth JWTs). */ -export const RELAY_DID = 'did:web:notifs.atmo.tools'; - -/** Service-ref form (the relay's `#notif_relay` service). */ -export const RELAY_SERVICE_REF = `${RELAY_DID}#notif_relay`; +/** Product name shown in the UI. */ +export const PROJECT_NAME = 'atmo.pub'; -/** Lexicon NSID prefix for relay methods (still used by the /docs examples). */ -export const LEXICON_PREFIX = 'tools.atmo.notifs'; +/** GitHub repo link in the footer. */ +export const GITHUB_URL = 'https://github.com/flo-bit/atproto-notify'; /** - * OAuth scope: identity only. - * - * The website talks to the relay's management methods over a private Cloudflare - * service binding (see src/lib/server/relay.ts), passing the signed-in user's - * DID directly — it never mints service-auth JWTs on the user's behalf. So it - * needs no `rpc?lxm=…` scopes; plain `atproto` (identity) is sufficient, which - * also means a phished web OAuth grant yields nothing but the user's identity. + * OAuth scope: identity only. The webapp talks to the relay's management methods + * over a private Cloudflare service binding (see src/lib/server/relay.ts) and + * never mints service-auth JWTs, so it needs no `rpc?lxm=…` scopes — plain + * `atproto` (identity) is sufficient. */ export const OAUTH_SCOPE = 'atproto'; - -// --- Branding (placeholders — rename freely) ------------------------------- - -/** Product name shown in the UI. */ -export const PROJECT_NAME = 'notify.atmo.tools'; - -/** GitHub repo link in the footer. */ -export const GITHUB_URL = 'https://github.com/flo-bit/atproto-notify'; diff --git a/apps/web/src/lib/server/highlight.ts b/apps/web/src/lib/server/highlight.ts deleted file mode 100644 index 6995d76..0000000 --- a/apps/web/src/lib/server/highlight.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Server-only syntax highlighting via Shiki. Uses the fine-grained core + the -// JavaScript regex engine (no WASM) so it runs on Cloudflare Workers, and a -// single lazily-created highlighter is reused across requests. -import { createHighlighterCore, type HighlighterCore } from 'shiki/core'; -import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; -import bash from 'shiki/langs/bash.mjs'; -import typescript from 'shiki/langs/typescript.mjs'; -import githubDark from 'shiki/themes/github-dark.mjs'; -import githubLight from 'shiki/themes/github-light.mjs'; - -export type CodeLang = 'bash' | 'ts'; - -const LANG_ID: Record = { bash: 'bash', ts: 'typescript' }; - -let highlighterPromise: Promise | undefined; - -function getHighlighter(): Promise { - highlighterPromise ??= createHighlighterCore({ - themes: [githubLight, githubDark], - langs: [bash, typescript], - engine: createJavaScriptRegexEngine() - }); - return highlighterPromise; -} - -/** - * Render code to themed HTML. Emits dual-theme output (light inline + a - * `--shiki-dark` CSS variable); `layout.css` swaps to the dark variable under our - * dark theme. - */ -export async function highlight(code: string, lang: CodeLang): Promise { - const highlighter = await getHighlighter(); - return highlighter.codeToHtml(code, { - lang: LANG_ID[lang], - themes: { light: 'github-light', dark: 'github-dark' } - }); -} diff --git a/apps/web/src/routes/(app)/+layout.server.ts b/apps/web/src/routes/(app)/+layout.server.ts new file mode 100644 index 0000000..db074fe --- /dev/null +++ b/apps/web/src/routes/(app)/+layout.server.ts @@ -0,0 +1,12 @@ +import { redirect } from '@sveltejs/kit'; + +import type { LayoutServerLoad } from './$types'; + +// Everything under (app) requires a signed-in user. Send anonymous visitors to +// the login page. +export const load: LayoutServerLoad = ({ locals }) => { + if (!locals.did) { + redirect(303, '/'); + } + return { did: locals.did }; +}; diff --git a/apps/web/src/routes/(app)/+layout.svelte b/apps/web/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..a0138c1 --- /dev/null +++ b/apps/web/src/routes/(app)/+layout.svelte @@ -0,0 +1,118 @@ + + +
+ + + + +
+ +
+ + +
+ +
+ {@render children()} +
+
+
+ + + diff --git a/apps/web/src/routes/(app)/apps/+page.server.ts b/apps/web/src/routes/(app)/apps/+page.server.ts new file mode 100644 index 0000000..74ecbac --- /dev/null +++ b/apps/web/src/routes/(app)/apps/+page.server.ts @@ -0,0 +1,17 @@ +import { relayFor } from '$lib/server/relay'; + +import type { PageServerLoad } from './$types'; + +// Apps screen: pending permission requests + the apps the user has granted. +// The (app) layout guard guarantees `locals.did` is present by the time we run. +export const load: PageServerLoad = async ({ locals, platform }) => { + const relay = relayFor(platform, locals.did); + + const [pending, grants] = await Promise.all([relay.listPending(), relay.listGrants()]); + + // Be defensive about relay responses — render fallbacks rather than crash. + return { + pending: pending?.pending ?? [], + grants: grants?.grants ?? [] + }; +}; diff --git a/apps/web/src/routes/(app)/apps/+page.svelte b/apps/web/src/routes/(app)/apps/+page.svelte new file mode 100644 index 0000000..1302b31 --- /dev/null +++ b/apps/web/src/routes/(app)/apps/+page.svelte @@ -0,0 +1,225 @@ + + +Apps · atmo.pub + +
+
+

Apps

+

+ {data.grants.length} connected · {data.pending.length} pending +

+
+ + {#if errorMsg} + + {/if} +
{errorMsg}
+ + +
+

+ Pending requests · {data.pending.length} +

+ {#if data.pending.length === 0} +
+

No pending requests

+

+ When an app asks to notify you, it'll show up here for approval. +

+
+ {:else} +
    + {#each data.pending as p (p.id)} +
  • +
    + {#if p.iconUrl ?? p.senderBskyAvatar} + + {:else} + + {/if} +
    +
    + {p.title} + + requesting + +
    +
    + {shortDid(p.sender)} +
    +
    + +
    + + {#if p.description} +

    {p.description}

    + {/if} + + {#if p.senderHandle} +

    + + Verified on Bluesky: @{p.senderHandle} +

    + {/if} + +
    + + +
    +
  • + {/each} +
+ {/if} +
+ + +
+

+ Your apps · {data.grants.length} +

+ {#if data.grants.length === 0} +
+

No apps yet

+

+ When you approve an app, it shows up here. You can mute or revoke it any time. +

+
+ {:else} +
    + {#each data.grants as g, i (g.sender)} +
  • +
    + {#if g.iconUrl ?? g.senderBskyAvatar} + + {:else} + + {/if} +
    +
    + + {g.title} + + {#if g.muted} + + {/if} +
    +
    + {g.senderHandle ? `@${g.senderHandle}` : shortDid(g.sender)} +
    +
    + authorized +
    +
    + +
    + +
    + {#if confirming[g.sender]} +
    + + +
    + {:else} + + {/if} +
    +
  • + {/each} +
+ {/if} +
+
diff --git a/apps/web/src/routes/(app)/inbox/+page.svelte b/apps/web/src/routes/(app)/inbox/+page.svelte new file mode 100644 index 0000000..da4f93c --- /dev/null +++ b/apps/web/src/routes/(app)/inbox/+page.svelte @@ -0,0 +1,24 @@ + + +Inbox · atmo.pub + +
+
+

Inbox

+
+ +
+ +

Inbox

+

+ Coming soon — your notifications will show up here. +

+
+
diff --git a/apps/web/src/routes/(app)/settings/+page.server.ts b/apps/web/src/routes/(app)/settings/+page.server.ts new file mode 100644 index 0000000..c568336 --- /dev/null +++ b/apps/web/src/routes/(app)/settings/+page.server.ts @@ -0,0 +1,17 @@ +import { relayFor } from '$lib/server/relay'; + +import type { PageServerLoad } from './$types'; + +// Settings screen: linked delivery channels + notification preferences. +// The (app) layout guard guarantees `locals.did` is present by the time we run. +export const load: PageServerLoad = async ({ locals, platform }) => { + const relay = relayFor(platform, locals.did); + + const [channels, settings] = await Promise.all([relay.listChannels(), relay.getSettings()]); + + // Be defensive about relay responses — render fallbacks rather than crash. + return { + channels: channels?.channels ?? [], + notifyPendingViaTelegram: settings?.notifyPendingViaTelegram ?? false + }; +}; diff --git a/apps/web/src/routes/(app)/settings/+page.svelte b/apps/web/src/routes/(app)/settings/+page.svelte new file mode 100644 index 0000000..ef5170b --- /dev/null +++ b/apps/web/src/routes/(app)/settings/+page.svelte @@ -0,0 +1,141 @@ + + +Settings · atmo.pub + +
+
+

Settings

+
+ + {#if errorMsg} + + {/if} +
{errorMsg}
+ + +
+

Channels

+
+ +
+ +
+
Telegram
+ {#if telegram} +
+ {#if telegram.displayName}{telegram.displayName} · {/if}linked + +
+ {:else} +
Get notifications in a Telegram chat.
+ {/if} +
+ {#if telegram} + + {:else} + + {/if} +
+ + +
+ +
+
Push
+
Native push on iOS, Android & web.
+
+ Coming soon +
+
+
+ + +
+

Notifications

+
+
+ + run('setting', () => setNotifyPending({ value }))} + /> +
+

+ When on, you'll get a Telegram message with Approve/Deny buttons whenever an app asks to + notify you. When off (the default), permission requests only appear in Apps. +

+
+
+
diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index e2453cc..33c6077 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -2,74 +2,12 @@ import './layout.css'; import type { Snippet } from 'svelte'; import favicon from '$lib/assets/favicon.svg'; - import { oauthLogout } from '$lib/atproto/oauth.remote'; - import Logomark from '$lib/components/Logomark.svelte'; - import ThemeToggle from '$lib/components/ThemeToggle.svelte'; - import { GITHUB_URL, PROJECT_NAME } from '$lib/config'; - import type { LayoutData } from './$types'; - let { children, data }: { children: Snippet; data: LayoutData } = $props(); - - let signingOut = $state(false); - async function signOut() { - signingOut = true; - try { - await oauthLogout(); - window.location.href = '/'; - } catch { - signingOut = false; - } - } - - const navLink = - 'rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg'; + // Minimal root wrapper. The signed-in app chrome (sidebar / tab bar) lives in + // the (app) route group's layout; this only frames the public login page. + let { children }: { children: Snippet } = $props(); -
-
-
- - - {PROJECT_NAME} - - -
-
- -
- {@render children()} -
- - -
+{@render children()} diff --git a/apps/web/src/routes/+page.server.ts b/apps/web/src/routes/+page.server.ts index 054d632..3b980ca 100644 --- a/apps/web/src/routes/+page.server.ts +++ b/apps/web/src/routes/+page.server.ts @@ -2,10 +2,10 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -// Signed-in users skip the landing and go straight to the dashboard — including +// Signed-in users skip the login screen and go straight to the app — including // right after the OAuth callback returns to `/`. export const load: PageServerLoad = ({ locals }) => { if (locals.did) { - redirect(303, '/dashboard'); + redirect(303, '/apps'); } }; diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index 1824281..80d86b0 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -1,10 +1,8 @@ - -Dashboard - -

Dashboard

- -{#if errorMsg} - -{/if} - -
{errorMsg}
- - -{#if data.pending.length > 0} -
-

Pending requests

-
    - {#each data.pending as p (p.id)} -
  • -
    - - -
    -
    - - - - Open ↗ - -
    -
  • - {/each} -
-
-{/if} - - -
-

- Apps you've authorized -

- {#if data.grants.length === 0} - - {:else} -
    - {#each data.grants as g (g.sender)} -
  • -
    -
    - - {#if g.muted} - muted - {/if} -
    - - authorized - -
    -
    - - {#if confirming[g.sender]} - - - {:else} - - {/if} -
    -
  • - {/each} -
- {/if} -
- - -
-

- Where your notifications go -

- {#if data.channels.length === 0} - - {#snippet cta()} - - {/snippet} - - {:else} -
    - {#each data.channels as channel (channel.platform)} -
  • -
    -
    {channel.platform}
    -
    - {#if channel.displayName}{channel.displayName} ·{/if} - linked -
    -
    - -
  • - {/each} -
- {#if !telegramChannel} - - {/if} - {/if} -
- - -
-

Settings

-
-
- - run('setting', () => setNotifyPending({ value }))} - /> -
-

- When on, you'll get a Telegram message with Approve/Deny buttons whenever an app asks to - notify you. When off (the default), permission requests only appear here. -

-
-
diff --git a/apps/web/src/routes/dashboard/pending/[id]/+page.server.ts b/apps/web/src/routes/dashboard/pending/[id]/+page.server.ts deleted file mode 100644 index 40bfa6f..0000000 --- a/apps/web/src/routes/dashboard/pending/[id]/+page.server.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { redirect } from '@sveltejs/kit'; - -import { relayFor } from '$lib/server/relay'; - -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals, platform, params }) => { - if (!locals.did) { - redirect(303, '/'); - } - const res = await relayFor(platform, locals.did).listPending(); - const request = (res?.pending ?? []).find((p) => p.id === params.id) ?? null; - return { request }; -}; diff --git a/apps/web/src/routes/dashboard/pending/[id]/+page.svelte b/apps/web/src/routes/dashboard/pending/[id]/+page.svelte deleted file mode 100644 index 6ad0182..0000000 --- a/apps/web/src/routes/dashboard/pending/[id]/+page.svelte +++ /dev/null @@ -1,152 +0,0 @@ - - -Permission request - -← Dashboard - -{#if !data.request} -
-

This request is no longer available

-

- It may have expired, been approved, or been denied already. -

- - Back to dashboard - -
-{:else} - {@const request = data.request} - {@const docUrl = didDocUrl(request.sender)} -
-

An app is asking to send you notifications.

- - - -

- Requested · expires - -

- - -
- - {#if showAbout} -
-

- You're approving this exact DID. Only its key can send you notifications. -

- {request.sender} -
- - {#if docUrl} - - View DID document ↗ - - {/if} -
-
- {/if} -
- - {#if errorMsg} - - {/if} - -
- - -
-
-{/if} diff --git a/apps/web/src/routes/docs/+page.server.ts b/apps/web/src/routes/docs/+page.server.ts deleted file mode 100644 index 9c6bd49..0000000 --- a/apps/web/src/routes/docs/+page.server.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { LEXICON_PREFIX, RELAY_DID, RELAY_ORIGIN } from '$lib/config'; -import { highlight, type CodeLang } from '$lib/server/highlight'; - -import type { PageServerLoad } from './$types'; - -const requestExample = `curl -X POST ${RELAY_ORIGIN}/xrpc/${LEXICON_PREFIX}.requestPermission \\ - -H "Authorization: Bearer $USER_JWT" \\ - -H "Content-Type: application/json" \\ - -d '{ - "senderDid": "did:web:yourapp.example", - "title": "Bookhive", - "description": "New comments on your books" - }'`; - -const sendJwtExample = `import { createServiceJwt } from '@atcute/xrpc-server/auth'; - -// Signed with YOUR app's key — this proves the sender identity. -const jwt = await createServiceJwt({ - keypair: yourKeypair, - issuer: 'did:web:yourapp.example', - audience: '${RELAY_DID}', - lxm: '${LEXICON_PREFIX}.send' -});`; - -// Easier: use @atcute/client instead of hand-rolling fetch (pass the JWT per call). -const sendAtcuteExample = `import { Client, simpleFetchHandler } from '@atcute/client'; - -const client = new Client({ - handler: simpleFetchHandler({ service: '${RELAY_ORIGIN}' }) -}); - -await client.post('${LEXICON_PREFIX}.send', { - headers: { authorization: \`Bearer \${jwt}\` }, - input: { - recipient: 'did:plc:recipient', - title: 'New reply', - body: 'alice replied to your post', - uri: 'https://yourapp.example/thread/123' - } -});`; - -const sendCurlExample = `curl -X POST ${RELAY_ORIGIN}/xrpc/${LEXICON_PREFIX}.send \\ - -H "Authorization: Bearer $JWT" \\ - -H "Content-Type: application/json" \\ - -d '{ - "recipient": "did:plc:recipient", - "title": "New reply", - "body": "alice replied to your post", - "uri": "https://yourapp.example/thread/123" - }'`; - -export interface CodeBlock { - lang: CodeLang; - raw: string; - html: string; -} - -// Highlight the (static) examples once per server instance, then reuse. -let cached: Promise> | undefined; -function buildBlocks() { - cached ??= (async () => { - const make = async (raw: string, lang: CodeLang): Promise => ({ - lang, - raw, - html: await highlight(raw, lang) - }); - return { - request: await make(requestExample, 'bash'), - sendJwt: await make(sendJwtExample, 'ts'), - sendAtcute: await make(sendAtcuteExample, 'ts'), - sendCurl: await make(sendCurlExample, 'bash') - }; - })(); - return cached; -} - -export const load: PageServerLoad = async () => { - return { code: await buildBlocks() }; -}; diff --git a/apps/web/src/routes/docs/+page.svelte b/apps/web/src/routes/docs/+page.svelte deleted file mode 100644 index b1429b5..0000000 --- a/apps/web/src/routes/docs/+page.svelte +++ /dev/null @@ -1,159 +0,0 @@ - - -Developer docs — {PROJECT_NAME} - -{#snippet codeblock(block: Block)} -
-
- {block.lang} - -
-
- - {@html block.html} -
-
-{/snippet} - -
-
-

Developer docs

-

- Any atproto app can ask users to receive notifications via {PROJECT_NAME}. Users approve in - the dashboard; the relay delivers via Telegram. -

-

- Two endpoints, two auth mechanisms. - requestPermission proves - the user authorized this request (user OAuth); - send proves - the sender identity (your app's own DID key). -

-

- Prefer a working example? A complete, ~300-line app wiring up - both flows is live at - example.notify.atmo.tools - — try it, then read the - source ↗. -

-
- -
-

1. Get a DID for your app

-

- Needed for send. The simplest option is - did:web: -

-
    -
  • Host /.well-known/did.json on your app's domain.
  • -
  • - Generate a P-256 keypair and put the public key in the DID document as a - verificationMethod whose id ends in - #atproto. -
  • -
  • - Reference: - atproto DID spec ↗ -
  • -
-
- -
-

2. Request permission (user OAuth)

-

- The user signs into your app via atproto OAuth. Add just the - requestPermission method to your app's OAuth scope — - send uses your app's own key, not the user's session, - so it doesn't belong here: -

-

- atproto rpc?lxm=tools.atmo.notifs.requestPermission&aud=* -

-

- Then mint a service-auth JWT on the user's PDS via - com.atproto.server.getServiceAuth and call: -

- {@render codeblock(data.code.request)} -

- Returns { id, status } - (pending or - alreadyGranted). The user approves in their dashboard or - via Telegram. title ≤ 50 chars, - description ≤ 200 chars, optional - iconUrl. -

-
- -
-

3. Send a notification (your app's key)

-

- Once granted, sign with your app's own key (no user involved) and send. Field limits: - title ≤ 100, body - ≤ 500, optional uri and - threadKey. -

- {@render codeblock(data.code.sendJwt)} -

- Easiest with @atcute/client (pass the JWT per call): -

- {@render codeblock(data.code.sendAtcute)} -

…or any HTTP client:

- {@render codeblock(data.code.sendCurl)} -
- -
-

4. Rate limits

-
    -
  • At most 1 outstanding pending request per (sender, recipient).
  • -
  • requestPermission: 50 / hour per recipient and 100 / hour per sender.
  • -
  • send: 1 / second and 100 / day per (sender, recipient).
  • -
-
- -
-

5. Error handling

-

Common XRPC errors:

-
    -
  • AuthenticationRequired — missing/invalid JWT.
  • -
  • NotAuthorized — no active grant for this recipient.
  • -
  • RateLimitExceeded — slow down (see Retry-After).
  • -
  • InvalidRequest — malformed body (e.g. bad senderDid).
  • -
-
-
diff --git a/apps/web/src/service-worker.ts b/apps/web/src/service-worker.ts new file mode 100644 index 0000000..3320f23 --- /dev/null +++ b/apps/web/src/service-worker.ts @@ -0,0 +1,51 @@ +/// +/// + +// Minimal PWA service worker (Phase 1): precache the built app shell + static +// files so the app launches offline. Pages and relay/auth requests are NOT +// cached (they're user-specific). Phase 2 adds `push` / `notificationclick` +// handlers for web push. +import { build, files, version } from '$service-worker'; + +const sw = self as unknown as ServiceWorkerGlobalScope; + +const CACHE = `atmo-pub-${version}`; +// Built JS/CSS (immutable, hashed) + static assets (icon, manifest, robots). +const PRECACHE = [...build, ...files]; + +sw.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(CACHE) + .then((cache) => cache.addAll(PRECACHE)) + .then(() => sw.skipWaiting()) + ); +}); + +sw.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + for (const key of await caches.keys()) { + if (key !== CACHE) await caches.delete(key); + } + await sw.clients.claim(); + })() + ); +}); + +sw.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method !== 'GET') return; + + const url = new URL(request.url); + if (url.protocol !== 'https:' && url.protocol !== 'http:') return; + + // Only serve precached, build/static assets from the cache; everything else + // (pages, OAuth, relay data) goes straight to the network so we never serve + // stale or another user's content. + if (PRECACHE.includes(url.pathname)) { + event.respondWith( + caches.match(request).then((cached) => cached ?? fetch(request)) + ); + } +}); diff --git a/apps/web/static/icon.svg b/apps/web/static/icon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/apps/web/static/icon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/apps/web/static/manifest.webmanifest b/apps/web/static/manifest.webmanifest new file mode 100644 index 0000000..056e966 --- /dev/null +++ b/apps/web/static/manifest.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "atmo.pub", + "short_name": "atmo.pub", + "description": "Notifications for the atmosphere — choose which apps can notify you, and how.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#191917", + "theme_color": "#191917", + "icons": [ + { "src": "/icon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05318a5..4b1450c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,9 +239,6 @@ importers: '@svelte-atproto/oauth': specifier: ^0.3.0 version: 0.3.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.9(@typescript-eslint/types@8.59.4))(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3) - shiki: - specifier: ^4.1.0 - version: 4.1.0 valibot: specifier: ^1.4.0 version: 1.4.0(typescript@6.0.3) -- 2.51.2