diff --git a/src/lib/reddit/accent-colors.ts b/src/lib/reddit/accent-colors.ts new file mode 100644 index 0000000..b99f7df --- /dev/null +++ b/src/lib/reddit/accent-colors.ts @@ -0,0 +1,33 @@ +/** + * Allowed tailwind color labels for community accent colors. Matches the + * non-gray accent palette in @foxui/colors `SelectTheme`. Adding one of + * these as a class on a wrapper element rebinds the `--accent-*` CSS + * variables defined by @foxui/core. + */ +export const ACCENT_COLORS = [ + 'red', + 'orange', + 'amber', + 'yellow', + 'lime', + 'green', + 'emerald', + 'teal', + 'cyan', + 'sky', + 'blue', + 'indigo', + 'violet', + 'purple', + 'fuchsia', + 'pink', + 'rose' +] as const; + +export type AccentColor = (typeof ACCENT_COLORS)[number]; + +export const DEFAULT_ACCENT_COLOR: AccentColor = 'pink'; + +export function isAccentColor(x: unknown): x is AccentColor { + return typeof x === 'string' && (ACCENT_COLORS as readonly string[]).includes(x); +} diff --git a/src/lib/reddit/bot.ts b/src/lib/reddit/bot.ts index 7661fc9..df6c8c5 100644 --- a/src/lib/reddit/bot.ts +++ b/src/lib/reddit/bot.ts @@ -99,17 +99,27 @@ export type RegisterResult = { * 3. Write garden.atmo.community/self pinning the creator's DID * 4. Encrypt the secret key and store everything in D1 */ +export type RegisterCommunityOptions = { + shortHandle: string; + creatorDid: Did; + description?: string; + /** Raw bytes + MIME type for the community avatar. */ + avatar?: { bytes: Uint8Array; mimeType: string }; + /** Tailwind color label, e.g. "pink", "blue", "lime". */ + accentColor?: string; +}; + export async function registerCommunity( env: App.Platform['env'], db: D1Database, - shortHandle: string, - creatorDid: Did, - userDescription?: string + opts: RegisterCommunityOptions ): Promise { if (!env.ROOKERY_HOSTNAME) throw new Error('ROOKERY_HOSTNAME not configured'); if (!env.ROOKERY_SIGNUP_SECRET) throw new Error('ROOKERY_SIGNUP_SECRET not configured'); + const { shortHandle, creatorDid, description: userDescription, avatar, accentColor } = opts; + const { account } = await createRookeryAccount({ hostname: env.ROOKERY_HOSTNAME, handle: shortHandle, @@ -118,15 +128,17 @@ export async function registerCommunity( 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. + // Write garden.atmo.community/self. This record is the canonical + // location for per-community metadata we own (creator, accent color, + // future app-specific fields). Non-fatal on failure — the community + // still exists and the bot can operate. try { await client.createRecord( 'garden.atmo.community', { $type: 'garden.atmo.community', creator: creatorDid, + accentColor: accentColor ?? 'pink', createdAt: new Date().toISOString() }, 'self' @@ -135,10 +147,23 @@ export async function registerCommunity( console.error('[registerCommunity] failed to write garden.atmo.community/self', e); } + // Upload the avatar blob (if provided) so we can reference it in the + // profile record below. + let avatarBlob: + | { $type: 'blob'; ref: { $link: string }; mimeType: string; size: number } + | null = null; + if (avatar) { + try { + avatarBlob = await client.uploadBlob(avatar.bytes, avatar.mimeType); + } catch (e) { + console.error('[registerCommunity] avatar upload failed', 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. + // record Rookery seeded so we don't wipe displayName. const trimmedUserDesc = userDescription?.trim() ?? ''; const fullDescription = trimmedUserDesc ? `https://${account.handle}\n\n${trimmedUserDesc}` @@ -159,10 +184,11 @@ export async function registerCommunity( await client.putRecord('app.bsky.actor.profile', 'self', { ...baseValue, $type: 'app.bsky.actor.profile', - description: fullDescription + description: fullDescription, + ...(avatarBlob ? { avatar: avatarBlob } : {}) }); } catch (e) { - console.error('[registerCommunity] failed to update profile description', e); + console.error('[registerCommunity] failed to update profile', e); } // Encrypt the hex-encoded secret key. @@ -178,14 +204,14 @@ export async function registerCommunity( handler: simpleFetchHandler({ service: PUBLIC_APPVIEW }) }); let displayName: string | null = shortHandle; - let avatar: string | null = null; + let avatarUrl: string | null = null; try { const profile = await appview.get('app.bsky.actor.getProfile', { params: { actor: account.did as Did } }); if (profile.ok) { displayName = profile.data.displayName ?? shortHandle; - avatar = profile.data.avatar ?? null; + avatarUrl = profile.data.avatar ?? null; } } catch { // non-fatal — relay hasn't indexed yet @@ -205,7 +231,7 @@ export async function registerCommunity( public_jwk_json: JSON.stringify(account.publicJwk), thumbprint: account.thumbprint, display_name: displayName, - avatar, + avatar: avatarUrl, description: cachedDescription }); diff --git a/src/lib/reddit/server/communities.remote.ts b/src/lib/reddit/server/communities.remote.ts index 304fc6c..2dde6d8 100644 --- a/src/lib/reddit/server/communities.remote.ts +++ b/src/lib/reddit/server/communities.remote.ts @@ -11,6 +11,13 @@ import { type PostWithCommunity } from '../db'; import { registerCommunity } from '../bot'; +import { getRecord } from '../welcomemat'; +import { + ACCENT_COLORS, + DEFAULT_ACCENT_COLOR, + isAccentColor, + type AccentColor +} from '../accent-colors'; const HANDLE_DOMAIN = '.atmo.garden'; @@ -23,8 +30,12 @@ function fullHandle(input: string): string { type PublicCommunity = Omit< CommunityRow, 'secret_key_ciphertext' | 'secret_key_iv' | 'public_jwk_json' | 'thumbprint' ->; -function sanitize(row: CommunityRow): PublicCommunity { +> & { + accentColor: AccentColor; + creator: string | null; +}; + +function sanitize(row: CommunityRow): Omit { /* eslint-disable @typescript-eslint/no-unused-vars */ const { secret_key_ciphertext, @@ -37,6 +48,45 @@ function sanitize(row: CommunityRow): PublicCommunity { return rest; } +/** + * Fetch `garden.atmo.community/self` from the community's PDS and extract + * the UI-relevant fields. Non-fatal on failure — returns defaults so the + * community still renders. + */ +async function fetchCommunityRecord( + pds: string, + did: string +): Promise<{ accentColor: AccentColor; creator: string | null }> { + try { + const rec = await getRecord(pds, did, 'garden.atmo.community', 'self'); + const value = (rec?.value ?? {}) as { accentColor?: unknown; creator?: unknown }; + return { + accentColor: isAccentColor(value.accentColor) ? value.accentColor : DEFAULT_ACCENT_COLOR, + creator: typeof value.creator === 'string' ? value.creator : null + }; + } catch { + return { accentColor: DEFAULT_ACCENT_COLOR, creator: null }; + } +} + +async function enrich(row: CommunityRow): Promise { + const base = sanitize(row); + const extra = await fetchCommunityRecord(row.pds, row.did); + return { ...base, ...extra }; +} + +/** Decode a base64-encoded string into a Uint8Array (Workers-safe). */ +function decodeBase64(b64: string): Uint8Array { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +// 1 MiB cap — Bluesky's profile avatar limit is ~1MB. +const MAX_AVATAR_BYTES = 1024 * 1024; +const ALLOWED_AVATAR_MIMES = ['image/jpeg', 'image/png', 'image/webp']; + export const register = command( v.object({ // Short handle (single label). Rookery appends the configured domain. @@ -50,7 +100,15 @@ export const register = command( // 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))) + description: v.optional(v.pipe(v.string(), v.maxLength(2048))), + accentColor: v.optional(v.picklist(ACCENT_COLORS)), + avatar: v.optional( + v.object({ + // Base64-encoded image bytes (no data URL prefix). + base64: v.pipe(v.string(), v.maxLength(2 * 1024 * 1024)), + mimeType: v.picklist(ALLOWED_AVATAR_MIMES) + }) + ) }), async (input) => { const { platform, locals } = getRequestEvent(); @@ -58,14 +116,23 @@ export const register = command( if (!env || !env.DB) error(500, 'DB binding unavailable'); if (!locals.did) error(401, 'You must be signed in to create a community'); + let avatarPayload: { bytes: Uint8Array; mimeType: string } | undefined; + if (input.avatar) { + const bytes = decodeBase64(input.avatar.base64); + if (bytes.byteLength > MAX_AVATAR_BYTES) { + error(400, `Avatar too large: max ${MAX_AVATAR_BYTES} bytes`); + } + avatarPayload = { bytes, mimeType: input.avatar.mimeType }; + } + try { - const result = await registerCommunity( - env, - env.DB, - input.shortHandle, - locals.did, - input.description - ); + const result = await registerCommunity(env, env.DB, { + shortHandle: input.shortHandle, + creatorDid: locals.did, + description: input.description, + accentColor: input.accentColor, + avatar: avatarPayload + }); return { ok: true, did: result.did, handle: result.handle }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -83,7 +150,7 @@ export const getCommunities = command( if (!env || !env.DB) return []; const rows = await listCommunities(env.DB); - return rows.map(sanitize); + return Promise.all(rows.map(enrich)); } ); @@ -95,7 +162,7 @@ export const getCommunity = command( if (!env || !env.DB) return null; const row = await getCommunityByHandle(env.DB, fullHandle(input.handle)); - return row ? sanitize(row) : null; + return row ? enrich(row) : null; } ); diff --git a/src/lib/reddit/welcomemat.ts b/src/lib/reddit/welcomemat.ts index 15f9db6..9975924 100644 --- a/src/lib/reddit/welcomemat.ts +++ b/src/lib/reddit/welcomemat.ts @@ -374,6 +374,46 @@ export class WelcomeMatClient { return (await res.json()) as { uri: string; cid: string }; } + /** + * Upload a blob to the PDS via DPoP. Returns the blob ref that can be + * embedded in records (e.g. `app.bsky.actor.profile.avatar`). + */ + async uploadBlob( + bytes: Uint8Array, + mimeType: string + ): Promise<{ + $type: 'blob'; + ref: { $link: string }; + mimeType: string; + size: number; + }> { + const url = `${this.account.pds}/xrpc/com.atproto.repo.uploadBlob`; + const { accessToken, dpopProof } = await this.buildDpopProof('POST', url); + // Copy into a fresh ArrayBuffer so the fetch body type check is happy + // even when `bytes` is a view over a shared buffer. + const body = new ArrayBuffer(bytes.byteLength); + new Uint8Array(body).set(bytes); + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `DPoP ${accessToken}`, + DPoP: dpopProof, + 'Content-Type': mimeType + }, + body + }); + if (!res.ok) { + throw new Error(`uploadBlob failed (${res.status}): ${await res.text()}`); + } + const json = (await res.json()) as { blob: unknown }; + return json.blob as { + $type: 'blob'; + ref: { $link: string }; + mimeType: string; + size: number; + }; + } + /** * putRecord via rookery's DPoP-auth'd endpoint. Replaces the record at * (repo, collection, rkey) with `record`. Upsert semantics: creates the diff --git a/src/routes/c/[handle]/+page.svelte b/src/routes/c/[handle]/+page.svelte index 2ceb7eb..01c5d18 100644 --- a/src/routes/c/[handle]/+page.svelte +++ b/src/routes/c/[handle]/+page.svelte @@ -52,7 +52,7 @@ }); -
+
{#if loading}
@@ -61,8 +61,8 @@

{loadError}

{:else if community} {@const communityShort = community.handle.split('.')[0]} -
- +
+

c/{communityShort}

{#each communities as c (c.did)} {@const short = c.handle.split('.')[0]} -
  • +
  • - +
    -
    c/{short}
    +
    c/{short}
    {#if c.description}
    {c.description} diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte index 6558432..0568b93 100644 --- a/src/routes/register/+page.svelte +++ b/src/routes/register/+page.svelte @@ -2,7 +2,8 @@ /* eslint-disable svelte/no-navigation-without-resolve */ import { goto } from '$app/navigation'; import { Button } from '@foxui/core'; - import { Loader2, CheckCircle2 } from '@lucide/svelte'; + import { ColorSelect } from '@foxui/colors'; + import { Loader2, CheckCircle2, ImagePlus, X } from '@lucide/svelte'; import { register } from '$lib/reddit/server/communities.remote'; import { user } from '$lib/atproto/auth.svelte'; import { loginModalState } from '$lib/LoginModal.svelte'; @@ -10,13 +11,25 @@ countGraphemes, PROFILE_DESCRIPTION_MAX_GRAPHEMES } from '$lib/utils/graphemes'; + import { ACCENT_COLORS, DEFAULT_ACCENT_COLOR, type AccentColor } from '$lib/reddit/accent-colors'; let shortHandle = $state(''); let description = $state(''); + let accentColor = $state<{ class: string; label: AccentColor }>({ + class: `text-${DEFAULT_ACCENT_COLOR}-500`, + label: DEFAULT_ACCENT_COLOR + }); + let avatarFile = $state(null); + let avatarPreview = $state(null); let submitting = $state(false); let errorMsg = $state(null); let success = $state<{ handle: string } | null>(null); + const accentOptions = ACCENT_COLORS.map((label) => ({ + class: `text-${label}-500`, + label + })); + const handlePreview = $derived( shortHandle ? `${shortHandle}.atmo.garden` : 'yourname.atmo.garden' ); @@ -35,6 +48,42 @@ const descriptionGraphemes = $derived(countGraphemes(description)); const descriptionTooLong = $derived(descriptionGraphemes > descriptionBudget); + const ALLOWED_AVATAR_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp']); + const MAX_AVATAR_BYTES = 1024 * 1024; + + function onAvatarChange(e: Event) { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + if (!ALLOWED_AVATAR_MIMES.has(file.type)) { + errorMsg = 'Avatar must be a JPEG, PNG, or WebP image.'; + input.value = ''; + return; + } + if (file.size > MAX_AVATAR_BYTES) { + errorMsg = `Avatar too large (${(file.size / 1024).toFixed(0)} KB). Max 1 MB.`; + input.value = ''; + return; + } + errorMsg = null; + avatarFile = file; + avatarPreview = URL.createObjectURL(file); + } + + function clearAvatar() { + if (avatarPreview) URL.revokeObjectURL(avatarPreview); + avatarFile = null; + avatarPreview = null; + } + + async function fileToBase64(file: File): Promise { + const buf = await file.arrayBuffer(); + const bytes = new Uint8Array(buf); + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); + } + async function onSubmit(e: Event) { e.preventDefault(); if (submitting) return; @@ -47,13 +96,22 @@ submitting = true; try { const trimmedDesc = description.trim(); + const avatarPayload = avatarFile + ? { + base64: await fileToBase64(avatarFile), + mimeType: avatarFile.type as 'image/jpeg' | 'image/png' | 'image/webp' + } + : undefined; const result = await register({ shortHandle: shortHandle.trim(), - ...(trimmedDesc ? { description: trimmedDesc } : {}) + ...(trimmedDesc ? { description: trimmedDesc } : {}), + accentColor: accentColor.label, + ...(avatarPayload ? { avatar: avatarPayload } : {}) }); success = { handle: result.handle }; shortHandle = ''; description = ''; + clearAvatar(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); errorMsg = msg.replace(/^Registration failed:\s*/, '') || 'Registration failed'; @@ -63,7 +121,7 @@ } -
    +

    Create a community

    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. @@ -78,20 +136,50 @@

    {:else}
    - +
    +
    + Avatar + + {#if avatarPreview} + + {/if} +
    + + +
    +
    + Accent color + +
    + {#if errorMsg}
    {errorMsg}