From 048b46a386d43001d8e4819a7e71fcbe626dc7d3 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:07:16 +0200 Subject: [PATCH] commit --- migrations/0002_accent_color.sql | 6 ++ src/lib/atproto/server/feed.remote.ts | 35 +++++++ src/lib/components/post/Post.svelte | 17 +--- src/lib/reddit/RedditPostCard.svelte | 98 ++++++++++++++++-- src/lib/reddit/bot.ts | 38 +++++-- src/lib/reddit/db.ts | 47 ++++++--- src/lib/reddit/server/communities.remote.ts | 47 +++------ src/routes/+page.svelte | 21 +++- src/routes/c/[handle]/+page.svelte | 104 ++++++++++++++++++-- src/routes/communities/+page.svelte | 4 +- 10 files changed, 331 insertions(+), 86 deletions(-) create mode 100644 migrations/0002_accent_color.sql diff --git a/migrations/0002_accent_color.sql b/migrations/0002_accent_color.sql new file mode 100644 index 0000000..68994d6 --- /dev/null +++ b/migrations/0002_accent_color.sql @@ -0,0 +1,6 @@ +-- Read-through cache of the community's accent color. The canonical value +-- lives on the `garden.atmo.community/self` record on the community's PDS; +-- we duplicate it here so `getCombinedFeed` (home feed) can JOIN and return +-- each post's accent color without an N+1 getRecord roundtrip per render. +-- The cron tick refreshes this column from the record on every run. +ALTER TABLE communities ADD COLUMN accent_color TEXT; diff --git a/src/lib/atproto/server/feed.remote.ts b/src/lib/atproto/server/feed.remote.ts index e8baa6b..3d61029 100644 --- a/src/lib/atproto/server/feed.remote.ts +++ b/src/lib/atproto/server/feed.remote.ts @@ -5,6 +5,41 @@ import type { ResourceUri } from '@atcute/lexicons'; import { Client, simpleFetchHandler } from '@atcute/client'; import * as TID from '@atcute/tid'; +/** + * Fetch viewer-specific state for a batch of post URIs. Returns a map + * `{ [uri]: { likeUri: string | null } }` so the caller can render + * per-post "liked by me" UI. Requires an authenticated viewer — returns + * an empty map when signed out. + */ +export const getPostsViewerState = command( + v.object({ + uris: v.array(v.string()) + }), + async (input) => { + const { locals } = getRequestEvent(); + const out: Record = {}; + if (!locals.client || !locals.did) return { states: out }; + + for (let i = 0; i < input.uris.length; i += 25) { + const batch = input.uris.slice(i, i + 25) as ResourceUri[]; + try { + const res = await locals.client.get('app.bsky.feed.getPosts', { + params: { uris: batch } + }); + if (res.ok) { + for (const p of res.data.posts) { + out[p.uri] = { likeUri: p.viewer?.like ?? null }; + } + } + } catch (e) { + console.error('[getPostsViewerState] batch failed', e); + } + } + + return { states: out }; + } +); + export const likePost = command( v.object({ uri: v.string(), diff --git a/src/lib/components/post/Post.svelte b/src/lib/components/post/Post.svelte index 8763751..8672e39 100644 --- a/src/lib/components/post/Post.svelte +++ b/src/lib/components/post/Post.svelte @@ -154,7 +154,11 @@
{#if data.htmlContent} @@ -190,14 +194,3 @@
- diff --git a/src/lib/reddit/RedditPostCard.svelte b/src/lib/reddit/RedditPostCard.svelte index 74ff078..b246160 100644 --- a/src/lib/reddit/RedditPostCard.svelte +++ b/src/lib/reddit/RedditPostCard.svelte @@ -6,6 +6,10 @@ import { wireEmbedClicks } from '$lib/components/embed'; import { blueskyPostToPostData } from '$lib/components'; import { Post } from '$lib/components'; + import { likePost, unlikePost } from '$lib/atproto/server/feed.remote'; + import { user } from '$lib/atproto/auth.svelte'; + import { loginModalState } from '$lib/LoginModal.svelte'; + import { isAccentColor } from './accent-colors'; import type { PostRow, PostWithCommunity } from './db'; type PostView = Record & { uri: string; @@ -17,11 +21,19 @@ let { row, quoted, - showCommunity = false + showCommunity = false, + /** Tailwind color label, e.g. "pink". Only needed when `row` is a bare + * PostRow (no community_accent_color). On a PostWithCommunity the row + * already carries the cached accent color. */ + accentColor: accentColorProp, + /** Initial viewer like URI (from getPostsViewerState) — null = not liked. */ + likeUri: initialLikeUri = null }: { row: CardRow; quoted?: PostView | null; showCommunity?: boolean; + accentColor?: string | null; + likeUri?: string | null; } = $props(); const communityHandle = $derived( @@ -32,6 +44,18 @@ 'community_avatar' in row ? ((row as PostWithCommunity).community_avatar ?? undefined) : undefined ); + // Prefer the value carried on the row (home feed uses JOIN), fall back + // to the explicit prop (community page passes it in), else leave empty + // so the ambient page theme bleeds through. + const accentClass = $derived.by(() => { + const fromRow = + 'community_accent_color' in row + ? (row as PostWithCommunity).community_accent_color + : null; + const candidate = fromRow ?? accentColorProp ?? null; + return isAccentColor(candidate) ? candidate : ''; + }); + const quotedEmbeds = $derived.by(() => { if (!quoted) return []; const { embeds } = blueskyPostToPostData(quoted as never, 'https://bsky.app'); @@ -57,16 +81,69 @@ if (s < 86400) return `${Math.floor(s / 3600)}h`; return `${Math.floor(s / 86400)}d`; } + + // Optimistic like state. `likeUri` comes in from the parent (seeded by + // getPostsViewerState); we also track an optimistic delta on like_count + // so the visible number flips immediately on click. + let likeUri = $state(null); + let likeDelta = $state(0); + let likeBusy = $state(false); + const isLiked = $derived(likeUri !== null); + const displayLikeCount = $derived(row.like_count + likeDelta); + + $effect(() => { + // Reset optimistic state when the parent swaps in fresh viewer data. + // Reading initialLikeUri inside the effect makes it reactive. + likeUri = initialLikeUri; + likeDelta = 0; + }); + + async function onLikeClick(e: Event) { + e.preventDefault(); + e.stopPropagation(); + if (!user.did) { + loginModalState.open = true; + return; + } + if (likeBusy) return; + likeBusy = true; + const wasLiked = isLiked; + // Optimistic flip. + if (wasLiked) { + likeDelta = -1; + const prevUri = likeUri; + likeUri = null; + try { + if (prevUri) await unlikePost({ likeUri: prevUri }); + } catch (err) { + console.error('[RedditPostCard] unlike failed', err); + likeUri = prevUri; + likeDelta = 0; + } + } else { + likeDelta = 1; + try { + const result = await likePost({ uri: row.uri, cid: row.cid }); + likeUri = result.uri; + } catch (err) { + console.error('[RedditPostCard] like failed', err); + likeDelta = 0; + } + } + likeBusy = false; + } -
+
{#if showCommunity && communityShort} - - c/{communityShort} + {#if communityAvatar} + + {/if} + c/{communityShort} · {fmtRelative(row.indexed_at)} @@ -102,9 +179,16 @@ {/if}
- - {row.like_count} - + {row.reply_count} diff --git a/src/lib/reddit/bot.ts b/src/lib/reddit/bot.ts index df6c8c5..613ab10 100644 --- a/src/lib/reddit/bot.ts +++ b/src/lib/reddit/bot.ts @@ -33,6 +33,11 @@ import { countGraphemes, PROFILE_DESCRIPTION_MAX_GRAPHEMES } from '$lib/utils/graphemes'; +import { + DEFAULT_ACCENT_COLOR, + isAccentColor, + type AccentColor +} from './accent-colors'; const PUBLIC_APPVIEW = 'https://public.api.bsky.app'; @@ -42,6 +47,21 @@ const PUBLIC_APPVIEW = 'https://public.api.bsky.app'; * 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). */ +/** + * Fetch the community's `garden.atmo.community/self` record and return its + * accent color, validated against the allowlist. Falls back to the default + * if the record is missing or the field is invalid. + */ +async function fetchRecordAccentColor(pds: string, did: string): Promise { + try { + const rec = await getRecord(pds, did, 'garden.atmo.community', 'self'); + const value = (rec?.value ?? {}) as { accentColor?: unknown }; + return isAccentColor(value.accentColor) ? value.accentColor : DEFAULT_ACCENT_COLOR; + } catch { + return DEFAULT_ACCENT_COLOR; + } +} + function stripCommunityLink(desc: string | null | undefined): string | null { if (!desc) return null; const cleaned = desc @@ -232,7 +252,8 @@ export async function registerCommunity( thumbprint: account.thumbprint, display_name: displayName, avatar: avatarUrl, - description: cachedDescription + description: cachedDescription, + accent_color: accentColor ?? DEFAULT_ACCENT_COLOR }); return { did: account.did as Did, handle: account.handle }; @@ -549,16 +570,21 @@ export async function runCronTick(env: App.Platform['env']): Promise<{ errors.push(`${row.handle}: ${String(e)}`); } - // Best-effort: refresh cached profile metadata (avatar, display name, desc). + // Best-effort: refresh cached profile metadata (avatar, display name, + // desc) from the appview, and the accent color from the community's + // `garden.atmo.community/self` record on the PDS. We fetch both in + // parallel since they hit different services. try { - const profile = await appview.get('app.bsky.actor.getProfile', { - params: { actor: row.did } - }); + const [profile, recordAccent] = await Promise.all([ + appview.get('app.bsky.actor.getProfile', { params: { actor: row.did } }), + fetchRecordAccentColor(row.pds, row.did) + ]); if (profile.ok) { await updateCommunityProfile(db, row.did, { display_name: profile.data.displayName ?? null, avatar: profile.data.avatar ?? null, - description: stripCommunityLink(profile.data.description) + description: stripCommunityLink(profile.data.description), + accent_color: recordAccent }); } } catch { diff --git a/src/lib/reddit/db.ts b/src/lib/reddit/db.ts index d7b9a7c..12ed384 100644 --- a/src/lib/reddit/db.ts +++ b/src/lib/reddit/db.ts @@ -13,6 +13,11 @@ export type CommunityRow = { display_name: string | null; avatar: string | null; description: string | null; + /** + * Read-through cache of the community's accent color. Canonical value + * lives on `garden.atmo.community/self`; cron refreshes this column. + */ + accent_color: string | null; created_at: string; }; @@ -35,12 +40,13 @@ export type PostWithCommunity = PostRow & { community_handle: string; community_display_name: string | null; community_avatar: string | null; + community_accent_color: string | null; }; export async function listCommunities(db: D1Database): Promise { const res = await db .prepare( - 'SELECT did, handle, pds, secret_key_ciphertext, secret_key_iv, public_jwk_json, thumbprint, display_name, avatar, description, created_at FROM communities ORDER BY created_at DESC' + 'SELECT did, handle, pds, secret_key_ciphertext, secret_key_iv, public_jwk_json, thumbprint, display_name, avatar, description, accent_color, created_at FROM communities ORDER BY created_at DESC' ) .all(); return res.results ?? []; @@ -52,7 +58,7 @@ export async function getCommunityByHandle( ): Promise { const res = await db .prepare( - 'SELECT did, handle, pds, secret_key_ciphertext, secret_key_iv, public_jwk_json, thumbprint, display_name, avatar, description, created_at FROM communities WHERE handle = ?' + 'SELECT did, handle, pds, secret_key_ciphertext, secret_key_iv, public_jwk_json, thumbprint, display_name, avatar, description, accent_color, created_at FROM communities WHERE handle = ?' ) .bind(handle) .first(); @@ -65,7 +71,7 @@ export async function getCommunityByDid( ): Promise { const res = await db .prepare( - 'SELECT did, handle, pds, secret_key_ciphertext, secret_key_iv, public_jwk_json, thumbprint, display_name, avatar, description, created_at FROM communities WHERE did = ?' + 'SELECT did, handle, pds, secret_key_ciphertext, secret_key_iv, public_jwk_json, thumbprint, display_name, avatar, description, accent_color, created_at FROM communities WHERE did = ?' ) .bind(did) .first(); @@ -78,7 +84,7 @@ export async function insertCommunity( ): Promise { await db .prepare( - 'INSERT INTO communities (did, handle, pds, secret_key_ciphertext, secret_key_iv, public_jwk_json, thumbprint, display_name, avatar, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO communities (did, handle, pds, secret_key_ciphertext, secret_key_iv, public_jwk_json, thumbprint, display_name, avatar, description, accent_color) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ) .bind( row.did, @@ -90,7 +96,8 @@ export async function insertCommunity( row.thumbprint, row.display_name, row.avatar, - row.description + row.description, + row.accent_color ) .run(); } @@ -98,14 +105,28 @@ export async function insertCommunity( export async function updateCommunityProfile( db: D1Database, did: string, - data: { display_name: string | null; avatar: string | null; description: string | null } + data: { + display_name: string | null; + avatar: string | null; + description: string | null; + accent_color?: string | null; + } ): Promise { - await db - .prepare( - 'UPDATE communities SET display_name = ?, avatar = ?, description = ? WHERE did = ?' - ) - .bind(data.display_name, data.avatar, data.description, did) - .run(); + if (data.accent_color !== undefined) { + await db + .prepare( + 'UPDATE communities SET display_name = ?, avatar = ?, description = ?, accent_color = ? WHERE did = ?' + ) + .bind(data.display_name, data.avatar, data.description, data.accent_color, did) + .run(); + } else { + await db + .prepare( + 'UPDATE communities SET display_name = ?, avatar = ?, description = ? WHERE did = ?' + ) + .bind(data.display_name, data.avatar, data.description, did) + .run(); + } } /** @@ -186,7 +207,7 @@ export async function getCombinedFeed( ): Promise { const res = await db .prepare( - `SELECT p.*, c.handle AS community_handle, c.display_name AS community_display_name, c.avatar AS community_avatar + `SELECT p.*, c.handle AS community_handle, c.display_name AS community_display_name, c.avatar AS community_avatar, c.accent_color AS community_accent_color FROM posts p JOIN communities c ON c.did = p.community_did ORDER BY p.indexed_at DESC diff --git a/src/lib/reddit/server/communities.remote.ts b/src/lib/reddit/server/communities.remote.ts index 2dde6d8..9156475 100644 --- a/src/lib/reddit/server/communities.remote.ts +++ b/src/lib/reddit/server/communities.remote.ts @@ -11,7 +11,6 @@ import { type PostWithCommunity } from '../db'; import { registerCommunity } from '../bot'; -import { getRecord } from '../welcomemat'; import { ACCENT_COLORS, DEFAULT_ACCENT_COLOR, @@ -29,50 +28,30 @@ function fullHandle(input: string): string { // Never ship encrypted key material to the client. type PublicCommunity = Omit< CommunityRow, - 'secret_key_ciphertext' | 'secret_key_iv' | 'public_jwk_json' | 'thumbprint' + | 'secret_key_ciphertext' + | 'secret_key_iv' + | 'public_jwk_json' + | 'thumbprint' + | 'accent_color' > & { accentColor: AccentColor; - creator: string | null; }; -function sanitize(row: CommunityRow): Omit { +function sanitize(row: CommunityRow): PublicCommunity { /* eslint-disable @typescript-eslint/no-unused-vars */ const { secret_key_ciphertext, secret_key_iv, public_jwk_json, thumbprint, + accent_color, ...rest } = row; /* eslint-enable @typescript-eslint/no-unused-vars */ - 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 }; + return { + ...rest, + accentColor: isAccentColor(accent_color) ? accent_color : DEFAULT_ACCENT_COLOR + }; } /** Decode a base64-encoded string into a Uint8Array (Workers-safe). */ @@ -150,7 +129,7 @@ export const getCommunities = command( if (!env || !env.DB) return []; const rows = await listCommunities(env.DB); - return Promise.all(rows.map(enrich)); + return rows.map(sanitize); } ); @@ -162,7 +141,7 @@ export const getCommunity = command( if (!env || !env.DB) return null; const row = await getCommunityByHandle(env.DB, fullHandle(input.handle)); - return row ? enrich(row) : null; + return row ? sanitize(row) : null; } ); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a57d5b5..6857dd1 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -3,20 +3,30 @@ import { Loader2 } from '@lucide/svelte'; import { getHomeFeed } from '$lib/reddit/server/communities.remote'; import { getQuotedPosts } from '$lib/reddit/server/quoted-posts.remote'; + import { getPostsViewerState } from '$lib/atproto/server/feed.remote'; + import { user } from '$lib/atproto/auth.svelte'; import RedditPostCard from '$lib/reddit/RedditPostCard.svelte'; import type { PostWithCommunity } from '$lib/reddit/db'; let loading = $state(true); let feed = $state([]); let quoted = $state>({}); + let viewerStates = $state>({}); onMount(async () => { try { const f = await getHomeFeed({ limit: 50 }); feed = f; if (f.length > 0) { - const res = await getQuotedPosts({ uris: f.map((p) => p.quoted_post_uri) }); - quoted = res.posts; + const uris = f.map((p) => p.uri); + const [quotedRes, viewerRes] = await Promise.all([ + getQuotedPosts({ uris: f.map((p) => p.quoted_post_uri) }), + user.did + ? getPostsViewerState({ uris }) + : Promise.resolve({ states: {} }) + ]); + quoted = quotedRes.posts; + viewerStates = viewerRes.states; } } catch (e) { console.error(e); @@ -40,7 +50,12 @@ {:else}
{#each feed as p (p.uri)} - + {/each}
{/if} diff --git a/src/routes/c/[handle]/+page.svelte b/src/routes/c/[handle]/+page.svelte index 01c5d18..189d3fe 100644 --- a/src/routes/c/[handle]/+page.svelte +++ b/src/routes/c/[handle]/+page.svelte @@ -1,10 +1,18 @@