diff --git a/src/app.html b/src/app.html index f6d6ce5..8104026 100644 --- a/src/app.html +++ b/src/app.html @@ -1,5 +1,5 @@ - + diff --git a/src/lib/reddit/bot.ts b/src/lib/reddit/bot.ts index 6cd3bd6..7661fc9 100644 --- a/src/lib/reddit/bot.ts +++ b/src/lib/reddit/bot.ts @@ -25,11 +25,32 @@ import { getPostCid, parseSubmission } from './submission'; import { WelcomeMatClient, createRookeryAccount, + getRecord, type EcPublicJwk, type RookeryAccount } from './welcomemat'; +import { + countGraphemes, + PROFILE_DESCRIPTION_MAX_GRAPHEMES +} from '$lib/utils/graphemes'; const PUBLIC_APPVIEW = 'https://public.api.bsky.app'; + +/** + * Remove the `https://.atmo.garden` line from a profile description + * before caching it for UI display. We keep that link in the on-network + * Bluesky profile so bsky users can click through to our site, but don't want + * to show it inside the atmo.garden UI (it's redundant there). + */ +function stripCommunityLink(desc: string | null | undefined): string | null { + if (!desc) return null; + const cleaned = desc + .split('\n') + .filter((line) => !/^\s*https?:\/\/\S*atmo\.garden\S*\s*$/i.test(line)) + .join('\n') + .trim(); + return cleaned || null; +} const CHAT_SERVICE_DID = 'did:web:api.bsky.chat'; const CHAT_SERVICE_BASE = 'https://api.bsky.chat/xrpc'; const PROCESSED_REACTION = '✅'; @@ -75,12 +96,15 @@ export type RegisterResult = { * 1. Generate a secp256k1 keypair * 2. Sign up on rookery (which auto-seeds chat.bsky.actor.declaration * and a bot-labeled app.bsky.actor.profile) - * 3. Encrypt the secret key and store everything in D1 + * 3. Write garden.atmo.community/self pinning the creator's DID + * 4. Encrypt the secret key and store everything in D1 */ export async function registerCommunity( env: App.Platform['env'], db: D1Database, - shortHandle: string + shortHandle: string, + creatorDid: Did, + userDescription?: string ): Promise { if (!env.ROOKERY_HOSTNAME) throw new Error('ROOKERY_HOSTNAME not configured'); if (!env.ROOKERY_SIGNUP_SECRET) @@ -92,6 +116,55 @@ export async function registerCommunity( signupSecret: env.ROOKERY_SIGNUP_SECRET }); + const client = WelcomeMatClient.forAccount(account); + + // Write garden.atmo.community/self on the community account itself, + // recording who created it. Non-fatal on failure — the community still + // exists in D1 and the bot can operate. + try { + await client.createRecord( + 'garden.atmo.community', + { + $type: 'garden.atmo.community', + creator: creatorDid, + createdAt: new Date().toISOString() + }, + 'self' + ); + } catch (e) { + console.error('[registerCommunity] failed to write garden.atmo.community/self', e); + } + + // Build the on-network profile description: `https://` on the + // first line (so bsky viewers have a clickable link to our site) followed + // by the user's own description text, if provided. We merge into the + // record Rookery seeded so we don't wipe displayName / avatar. + const trimmedUserDesc = userDescription?.trim() ?? ''; + const fullDescription = trimmedUserDesc + ? `https://${account.handle}\n\n${trimmedUserDesc}` + : `https://${account.handle}`; + if (countGraphemes(fullDescription) > PROFILE_DESCRIPTION_MAX_GRAPHEMES) { + throw new Error( + `Description too long: ${PROFILE_DESCRIPTION_MAX_GRAPHEMES} graphemes max (including the https://${account.handle} link prepended for bsky)` + ); + } + try { + const existing = await getRecord( + account.pds, + account.did, + 'app.bsky.actor.profile', + 'self' + ); + const baseValue = existing?.value ?? { $type: 'app.bsky.actor.profile' }; + await client.putRecord('app.bsky.actor.profile', 'self', { + ...baseValue, + $type: 'app.bsky.actor.profile', + description: fullDescription + }); + } catch (e) { + console.error('[registerCommunity] failed to update profile description', e); + } + // Encrypt the hex-encoded secret key. const { ciphertext, iv } = await encryptPassword( account.secretKeyHex, @@ -99,14 +172,13 @@ export async function registerCommunity( ); // Fetch the profile the signup seeded so we can cache display name / - // description for the UI. Rookery writes these immediately but the relay - // may not have propagated them yet — fall back to the short handle. + // avatar for the UI. Rookery writes these immediately but the relay may + // not have propagated them yet — fall back to the short handle. const appview = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW }) }); let displayName: string | null = shortHandle; let avatar: string | null = null; - let description: string | null = `https://${account.handle}`; try { const profile = await appview.get('app.bsky.actor.getProfile', { params: { actor: account.did as Did } @@ -114,12 +186,16 @@ export async function registerCommunity( if (profile.ok) { displayName = profile.data.displayName ?? shortHandle; avatar = profile.data.avatar ?? null; - description = profile.data.description ?? description; } } catch { // non-fatal — relay hasn't indexed yet } + // Cache the stripped (user-facing) description in D1. The cron refresh + // below also strips on every tick so edits made directly on bsky stay + // consistent. + const cachedDescription = stripCommunityLink(fullDescription); + await insertCommunity(db, { did: account.did as Did, handle: account.handle, @@ -130,7 +206,7 @@ export async function registerCommunity( thumbprint: account.thumbprint, display_name: displayName, avatar, - description + description: cachedDescription }); return { did: account.did as Did, handle: account.handle }; @@ -456,7 +532,7 @@ export async function runCronTick(env: App.Platform['env']): Promise<{ await updateCommunityProfile(db, row.did, { display_name: profile.data.displayName ?? null, avatar: profile.data.avatar ?? null, - description: profile.data.description ?? null + description: stripCommunityLink(profile.data.description) }); } } catch { diff --git a/src/lib/reddit/server/communities.remote.ts b/src/lib/reddit/server/communities.remote.ts index ec03cc7..304fc6c 100644 --- a/src/lib/reddit/server/communities.remote.ts +++ b/src/lib/reddit/server/communities.remote.ts @@ -45,15 +45,27 @@ export const register = command( v.regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'Must be [a-z0-9-], single label'), v.minLength(3), v.maxLength(32) - ) + ), + // Grapheme-accurate length check happens in registerCommunity; this + // is just a sanity cap to reject absurdly large payloads before we + // touch the PDS. 256 graphemes can be up to ~1024 UTF-16 code units + // in pathological emoji-heavy strings. + description: v.optional(v.pipe(v.string(), v.maxLength(2048))) }), async (input) => { - const { platform } = getRequestEvent(); + const { platform, locals } = getRequestEvent(); const env = platform?.env; if (!env || !env.DB) error(500, 'DB binding unavailable'); + if (!locals.did) error(401, 'You must be signed in to create a community'); try { - const result = await registerCommunity(env, env.DB, input.shortHandle); + const result = await registerCommunity( + env, + env.DB, + input.shortHandle, + locals.did, + input.description + ); return { ok: true, did: result.did, handle: result.handle }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); diff --git a/src/lib/reddit/welcomemat.ts b/src/lib/reddit/welcomemat.ts index ce4f902..15f9db6 100644 --- a/src/lib/reddit/welcomemat.ts +++ b/src/lib/reddit/welcomemat.ts @@ -125,6 +125,29 @@ function normalizeHtu(url: string): string { return u.origin + u.pathname; } +/** + * Fetch a record directly from the PDS via the public + * `com.atproto.repo.getRecord` endpoint. Does not require auth. + */ +export async function getRecord( + pds: string, + repo: string, + collection: string, + rkey: string +): Promise<{ uri: string; cid: string; value: Record } | null> { + const url = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`); + url.searchParams.set('repo', repo); + url.searchParams.set('collection', collection); + url.searchParams.set('rkey', rkey); + const res = await fetch(url); + if (!res.ok) return null; + return (await res.json()) as { + uri: string; + cid: string; + value: Record; + }; +} + // --------------------------------------------------------------------------- // Signup // --------------------------------------------------------------------------- @@ -351,6 +374,29 @@ export class WelcomeMatClient { return (await res.json()) as { uri: string; cid: string }; } + /** + * putRecord via rookery's DPoP-auth'd endpoint. Replaces the record at + * (repo, collection, rkey) with `record`. Upsert semantics: creates the + * record if it doesn't exist. + */ + async putRecord( + collection: string, + rkey: string, + record: Record + ): Promise<{ uri: string; cid: string }> { + const url = `${this.account.pds}/xrpc/com.atproto.repo.putRecord`; + const res = await this.dpopRequest('POST', url, { + repo: this.account.did, + collection, + rkey, + record + }); + if (!res.ok) { + throw new Error(`putRecord failed (${res.status}): ${await res.text()}`); + } + return (await res.json()) as { uri: string; cid: string }; + } + async deleteRecord(collection: string, rkey: string): Promise { const url = `${this.account.pds}/xrpc/com.atproto.repo.deleteRecord`; const res = await this.dpopRequest('POST', url, { diff --git a/src/lib/utils/graphemes.ts b/src/lib/utils/graphemes.ts new file mode 100644 index 0000000..760d6e8 --- /dev/null +++ b/src/lib/utils/graphemes.ts @@ -0,0 +1,17 @@ +/** + * Count graphemes (user-perceived characters) in a string using Intl.Segmenter. + * Emojis, combining marks, and surrogate pairs all count as 1. + * + * This matches how Bluesky enforces its profile / post length limits. + */ +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + +export function countGraphemes(s: string): number { + return Array.from(graphemeSegmenter.segment(s)).length; +} + +/** + * Maximum graphemes allowed in an `app.bsky.actor.profile` description + * per the Bluesky lexicon. Exceeding this causes `putRecord` to fail. + */ +export const PROFILE_DESCRIPTION_MAX_GRAPHEMES = 256; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index eb5156d..c674ef3 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,7 +2,7 @@ /* eslint-disable svelte/no-navigation-without-resolve */ import '../app.css'; import { Head, Avatar, Button, ThemeToggle } from '@foxui/core'; - import { House, Search, Bookmark, Settings, Menu, X } from '@lucide/svelte'; + import { House, Users } from '@lucide/svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; import { user } from '$lib/atproto/auth.svelte'; @@ -12,7 +12,6 @@ import Sidebar from '$lib/Sidebar.svelte'; let { children } = $props(); - let menuOpen = $state(false); let path = $derived(page.url?.pathname ?? '/'); function isActive(href: string): boolean { if (href === '/') return path === '/'; @@ -27,18 +26,10 @@ - - {#if user.did} - - {/if} - - +
{#if user.did} {@const profileHref = `/profile/${user.profile?.handle ?? user.did}`} - { e.preventDefault(); menuOpen = false; window.scrollTo(0, 0); goto('/'); }} - class="flex items-center gap-3 rounded-full px-4 py-2 text-sm transition-colors {navClass('/')}" - > - - Home - + - {:else} - -
- { e.preventDefault(); window.scrollTo(0, 0); goto('/'); }} - class="flex items-center justify-center p-2 {navClass('/')}" - > - - - { e.preventDefault(); goto('/search'); }} - class="flex items-center justify-center p-2 {navClass('/search')}" - > - - - {#if user.did} - {@const profileHref = `/profile/${user.profile?.handle ?? user.did}`} - { e.preventDefault(); goto(profileHref); }} - class="flex items-center justify-center p-2" - > - - - {:else} - - {/if} + {:else} -
- {/if} + {/if} + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index fce03b0..a57d5b5 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,30 +1,18 @@
-

atmo communities

+

atmo.garden

-
-
-

- Communities -

- - + Register - + {#if loading} +
+
- - {#if loading && communities.length === 0} -
Loading…
- {:else if communities.length === 0} -
- No communities registered yet. - Register the first one. -
- {:else} -
- {#each communities as c (c.did)} - {@const short = c.handle.split('.')[0]} - - - c/{short} - - {/each} -
- {/if} -
- -
-

- Latest -

- - {#if loading} -
- -
- {:else if feed.length === 0} -

- No submissions yet. -

- {:else} -
- {#each feed as p (p.uri)} - - {/each} -
- {/if} -
+ {:else if feed.length === 0} +

+ No submissions yet. +

+ {:else} +
+ {#each feed as p (p.uri)} + + {/each} +
+ {/if}
diff --git a/src/routes/bookmarks/+page.svelte b/src/routes/bookmarks/+page.svelte deleted file mode 100644 index 5586ca2..0000000 --- a/src/routes/bookmarks/+page.svelte +++ /dev/null @@ -1,70 +0,0 @@ - - -
-
-
- -

Bookmarks

-
- - -
-
diff --git a/src/routes/communities/+page.svelte b/src/routes/communities/+page.svelte new file mode 100644 index 0000000..b93d05d --- /dev/null +++ b/src/routes/communities/+page.svelte @@ -0,0 +1,67 @@ + + +
+
+

Communities

+ + + New + +
+ + {#if loading} +
+ +
+ {:else if communities.length === 0} +

+ No communities yet. Create the first one. +

+ {:else} + + {/if} +
diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte index e41ae0f..6558432 100644 --- a/src/routes/register/+page.svelte +++ b/src/routes/register/+page.svelte @@ -4,8 +4,15 @@ import { Button } from '@foxui/core'; import { Loader2, CheckCircle2 } from '@lucide/svelte'; import { register } from '$lib/reddit/server/communities.remote'; + import { user } from '$lib/atproto/auth.svelte'; + import { loginModalState } from '$lib/LoginModal.svelte'; + import { + countGraphemes, + PROFILE_DESCRIPTION_MAX_GRAPHEMES + } from '$lib/utils/graphemes'; let shortHandle = $state(''); + let description = $state(''); let submitting = $state(false); let errorMsg = $state(null); let success = $state<{ handle: string } | null>(null); @@ -14,16 +21,39 @@ shortHandle ? `${shortHandle}.atmo.garden` : 'yourname.atmo.garden' ); + // The on-network Bluesky profile description is + // `https://\n\n`. Bluesky caps the whole field at + // 256 graphemes, so the budget for the user's text shrinks with longer + // handles. Use a placeholder handle while the field is empty so the + // counter is always meaningful. + const prefixGraphemes = $derived( + countGraphemes(`https://${shortHandle || 'xxxx'}.atmo.garden\n\n`) + ); + const descriptionBudget = $derived( + PROFILE_DESCRIPTION_MAX_GRAPHEMES - prefixGraphemes + ); + const descriptionGraphemes = $derived(countGraphemes(description)); + const descriptionTooLong = $derived(descriptionGraphemes > descriptionBudget); + async function onSubmit(e: Event) { e.preventDefault(); if (submitting) return; + if (descriptionTooLong) { + errorMsg = `Description is ${descriptionGraphemes - descriptionBudget} graphemes over the limit`; + return; + } errorMsg = null; success = null; submitting = true; try { - const result = await register({ shortHandle: shortHandle.trim() }); + const trimmedDesc = description.trim(); + const result = await register({ + shortHandle: shortHandle.trim(), + ...(trimmedDesc ? { description: trimmedDesc } : {}) + }); success = { handle: result.handle }; shortHandle = ''; + description = ''; } catch (err) { const msg = err instanceof Error ? err.message : String(err); errorMsg = msg.replace(/^Registration failed:\s*/, '') || 'Registration failed'; @@ -39,6 +69,14 @@ Pick a name. We'll create a new Bluesky account for your community on pds.atmo.garden and set it up so people can submit posts by DM'ing it a link.

+ {#if !user.did} +
+

You need to be signed in with Bluesky to create a community.

+ +
+ {:else}
+ + {#if errorMsg}
{errorMsg} @@ -77,7 +134,12 @@
{/if} -
+ {/if} diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte deleted file mode 100644 index fcc0fa6..0000000 --- a/src/routes/search/+page.svelte +++ /dev/null @@ -1,106 +0,0 @@ - - -
-
-
-
- - -
-
- - {#if loading} -
- -
- {:else if searched && items.length === 0} -
- -

No results for "{query}"

-
- {:else if items.length > 0} - - {:else if !searched} -
- -

Search for posts on Bluesky

-
- {/if} -
-
diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte deleted file mode 100644 index 3dda196..0000000 --- a/src/routes/settings/+page.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - -
-
-
- -

Settings

-
- -
-
-

Dark Mode

- -
-

Colors

- -
-
-
diff --git a/wrangler.jsonc b/wrangler.jsonc index 7a396e6..53a8532 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -30,7 +30,8 @@ "binding": "DB", "database_name": "atmo-garden-db", "database_id": "bd23d3c4-8845-4b45-881f-72665088104e", - "migrations_dir": "migrations" + "migrations_dir": "migrations", + "remote": true } ], "triggers": {