[READ-ONLY] Mirror of https://github.com/flo-bit/atmo-garden. atmo.garden
0

Configure Feed

Select the types of activity you want to include in your feed.

reposts

+85 -92
+57 -38
src/lib/reddit/RedditPostCard.svelte
··· 2 2 /* eslint-disable svelte/no-navigation-without-resolve */ 3 3 import { goto } from '$app/navigation'; 4 4 import { Avatar } from '@foxui/core'; 5 - import { Heart, MessageCircle, Repeat2 } from '@lucide/svelte'; 5 + import { Heart, MessageCircle, Repeat2, Repeat } from '@lucide/svelte'; 6 6 import { wireEmbedClicks } from '$lib/components/embed'; 7 7 import { blueskyPostToPostData } from '$lib/components'; 8 8 import { Post } from '$lib/components'; ··· 11 11 import { loginModalState } from '$lib/LoginModal.svelte'; 12 12 import { isAccentColor } from './accent-colors'; 13 13 import type { PostRow, PostWithCommunity } from './db'; 14 - type PostView = Record<string, unknown> & { 14 + 15 + // `getQuotedPosts` uses the authenticated appview client, so this view 16 + // already includes per-viewer state like `viewer.like` plus the live 17 + // like/reply/repost counts. We treat the QUOTED post as the canonical 18 + // thing users interact with — the community's wrapping quote post is 19 + // just a forwarder. 20 + type PostView = { 15 21 uri: string; 22 + cid: string; 16 23 author: { handle: string }; 24 + likeCount?: number; 25 + replyCount?: number; 26 + repostCount?: number; 27 + viewer?: { like?: string; repost?: string }; 28 + [key: string]: unknown; 17 29 }; 18 30 19 31 type CardRow = PostRow | PostWithCommunity; ··· 25 37 /** Tailwind color label, e.g. "pink". Only needed when `row` is a bare 26 38 * PostRow (no community_accent_color). On a PostWithCommunity the row 27 39 * already carries the cached accent color. */ 28 - accentColor: accentColorProp, 29 - /** Initial viewer like URI (from getPostsViewerState) — null = not liked. */ 30 - likeUri: initialLikeUri = null 40 + accentColor: accentColorProp 31 41 }: { 32 42 row: CardRow; 33 43 quoted?: PostView | null; 34 44 showCommunity?: boolean; 35 45 accentColor?: string | null; 36 - likeUri?: string | null; 37 46 } = $props(); 38 47 39 48 const communityHandle = $derived( ··· 82 91 return `${Math.floor(s / 86400)}d`; 83 92 } 84 93 85 - // Optimistic like state. `likeUri` comes in from the parent (seeded by 86 - // getPostsViewerState); we also track an optimistic delta on like_count 87 - // so the visible number flips immediately on click. 94 + // All counts + viewer state come straight from the quoted PostView. 95 + // We track optimistic state locally so the heart flips instantly on 96 + // click without waiting for a server roundtrip. 88 97 let likeUri = $state<string | null>(null); 89 98 let likeDelta = $state(0); 90 99 let likeBusy = $state(false); 91 100 const isLiked = $derived(likeUri !== null); 92 - const displayLikeCount = $derived(row.like_count + likeDelta); 101 + const baseLikeCount = $derived(quoted?.likeCount ?? 0); 102 + const displayLikeCount = $derived(baseLikeCount + likeDelta); 103 + const replyCount = $derived(quoted?.replyCount ?? 0); 104 + const repostCount = $derived(quoted?.repostCount ?? 0); 93 105 94 106 $effect(() => { 95 - // Reset optimistic state when the parent swaps in fresh viewer data. 96 - // Reading initialLikeUri inside the effect makes it reactive. 97 - likeUri = initialLikeUri; 107 + // Re-seed optimistic state whenever the parent swaps in a fresh 108 + // PostView. Reading inside the effect keeps it reactive. 109 + likeUri = quoted?.viewer?.like ?? null; 98 110 likeDelta = 0; 99 111 }); 100 112 ··· 105 117 loginModalState.open = true; 106 118 return; 107 119 } 108 - if (likeBusy) return; 120 + if (likeBusy || !quoted) return; 109 121 likeBusy = true; 110 122 const wasLiked = isLiked; 111 123 // Optimistic flip. ··· 123 135 } else { 124 136 likeDelta = 1; 125 137 try { 126 - const result = await likePost({ uri: row.uri, cid: row.cid }); 138 + const result = await likePost({ uri: quoted.uri, cid: quoted.cid }); 127 139 likeUri = result.uri; 128 140 } catch (err) { 129 141 console.error('[RedditPostCard] like failed', err); ··· 153 165 </div> 154 166 {/if} 155 167 156 - <h2 class="mb-2 mt-1 text-lg font-semibold leading-tight">{row.title}</h2> 168 + {#if row.title} 169 + <h2 class="mb-2 mt-1 text-lg font-semibold leading-tight">{row.title}</h2> 170 + {:else} 171 + <div class="text-base-500 dark:text-base-400 mt-1 mb-2 flex items-center gap-1 text-xs"> 172 + <Repeat size={12} /> 173 + <span>reposted</span> 174 + </div> 175 + {/if} 157 176 158 177 {#if quoted && quotedPostData} 159 - <div class="border-base-300 dark:border-base-600/30 bg-base-500/10 dark:bg-black/30 mb-2 rounded-2xl border p-3"> 178 + <div class="border-base-300 dark:border-base-600/30 bg-base-500/10 dark:bg-black/30 rounded-2xl border p-3"> 160 179 <Post 161 180 data={quotedPostData} 162 181 embeds={quotedEmbeds} ··· 165 184 handleHref={(handle) => `/profile/${handle}`} 166 185 onclickhandle={(handle) => goto(`/profile/${handle}`)} 167 186 /> 187 + 188 + <div class="mt-4 flex items-center gap-4 text-xs text-base-500 dark:text-base-400"> 189 + <button 190 + type="button" 191 + onclick={onLikeClick} 192 + disabled={likeBusy} 193 + class="hover:text-accent-500 flex cursor-pointer items-center gap-1 transition-colors {isLiked ? 'text-accent-500' : ''}" 194 + aria-label={isLiked ? 'Unlike' : 'Like'} 195 + > 196 + <Heart size={14} fill={isLiked ? 'currentColor' : 'none'} /> 197 + {displayLikeCount} 198 + </button> 199 + <span class="flex items-center gap-1"> 200 + <MessageCircle size={14} /> {replyCount} 201 + </span> 202 + <span class="flex items-center gap-1"> 203 + <Repeat2 size={14} /> {repostCount} 204 + </span> 205 + </div> 168 206 </div> 169 207 {:else if quoted === null} 170 - <div class="text-base-500 dark:text-base-400 mb-2 text-xs italic"> 208 + <div class="text-base-500 dark:text-base-400 text-xs italic"> 171 209 (quoted post unavailable) 172 210 </div> 173 211 {:else} 174 - <div class="border-base-300 dark:border-base-700 bg-base-200/50 dark:bg-base-800/50 mb-2 animate-pulse rounded-2xl border p-3"> 212 + <div class="border-base-300 dark:border-base-700 bg-base-200/50 dark:bg-base-800/50 animate-pulse rounded-2xl border p-3"> 175 213 <div class="h-3 w-32 rounded bg-base-300 dark:bg-base-700"></div> 176 214 <div class="mt-2 h-3 w-full rounded bg-base-300 dark:bg-base-700"></div> 177 215 <div class="mt-1 h-3 w-3/4 rounded bg-base-300 dark:bg-base-700"></div> 178 216 </div> 179 217 {/if} 180 - 181 - <div class="flex items-center gap-4 text-xs text-base-500 dark:text-base-400"> 182 - <button 183 - type="button" 184 - onclick={onLikeClick} 185 - disabled={likeBusy} 186 - class="hover:text-accent-500 flex cursor-pointer items-center gap-1 transition-colors {isLiked ? 'text-accent-500' : ''}" 187 - aria-label={isLiked ? 'Unlike' : 'Like'} 188 - > 189 - <Heart size={14} fill={isLiked ? 'currentColor' : 'none'} /> 190 - {displayLikeCount} 191 - </button> 192 - <span class="flex items-center gap-1"> 193 - <MessageCircle size={14} /> {row.reply_count} 194 - </span> 195 - <span class="flex items-center gap-1"> 196 - <Repeat2 size={14} /> {row.repost_count} 197 - </span> 198 - </div> 199 218 </article>
+20 -18
src/lib/reddit/bot.ts
··· 470 470 if (!quotedCid) return false; 471 471 472 472 const createdAt = new Date().toISOString(); 473 + const isRepost = submission.title.length === 0; 474 + 473 475 let result: { uri: string; cid: string }; 474 476 try { 475 - result = await client.createRecord('app.bsky.feed.post', { 476 - $type: 'app.bsky.feed.post', 477 - text: submission.title, 478 - createdAt, 479 - embed: { 480 - $type: 'app.bsky.embed.record', 481 - record: { 482 - uri: submission.postUri, 483 - cid: quotedCid 477 + if (isRepost) { 478 + // No title → straight repost. The community account boosts the 479 + // original post, no commentary added. 480 + result = await client.createRecord('app.bsky.feed.repost', { 481 + $type: 'app.bsky.feed.repost', 482 + subject: { uri: submission.postUri, cid: quotedCid }, 483 + createdAt 484 + }); 485 + } else { 486 + result = await client.createRecord('app.bsky.feed.post', { 487 + $type: 'app.bsky.feed.post', 488 + text: submission.title, 489 + createdAt, 490 + embed: { 491 + $type: 'app.bsky.embed.record', 492 + record: { uri: submission.postUri, cid: quotedCid } 484 493 } 485 - } 486 - }); 494 + }); 495 + } 487 496 } catch (e) { 488 497 console.error('[createSubmissionPost] rookery createRecord failed', e); 489 498 return false; ··· 548 557 export async function runCronTick(env: App.Platform['env']): Promise<{ 549 558 communitiesChecked: number; 550 559 postsCreated: number; 551 - postsRefreshed: number; 552 560 errors: string[]; 553 561 }> { 554 562 const db = env.DB; ··· 592 600 } 593 601 } 594 602 595 - const postsRefreshed = await refreshPostMetrics(db).catch((e) => { 596 - errors.push(`refresh: ${String(e)}`); 597 - return 0; 598 - }); 599 - 600 603 return { 601 604 communitiesChecked: communities.length, 602 605 postsCreated, 603 - postsRefreshed, 604 606 errors 605 607 }; 606 608 }
+3 -5
src/lib/reddit/submission.ts
··· 31 31 * (`app.bsky.embed.record`). 32 32 * 33 33 * Returns null if the message doesn't reference a recognizable Bluesky post. 34 + * Empty title is allowed — the bot turns title-less submissions into reposts 35 + * instead of quote posts. 34 36 */ 35 37 export async function parseSubmission(msg: DmInput): Promise<ParsedSubmission | null> { 36 38 const text = msg.text ?? ''; ··· 39 41 // flow, which attaches an app.bsky.embed.record to the message. 40 42 const embedUri = msg.embed?.record?.uri; 41 43 if (embedUri && /^at:\/\/did:[^/]+\/app\.bsky\.feed\.post\//.test(embedUri)) { 42 - const title = text.trim(); 43 - if (!title) return null; 44 - return { title, postUri: embedUri as ResourceUri }; 44 + return { title: text.trim(), postUri: embedUri as ResourceUri }; 45 45 } 46 46 47 47 if (!text) return null; ··· 51 51 if (atMatch) { 52 52 const uri = atMatch[0] as ResourceUri; 53 53 const title = stripRange(text, atMatch.index!, atMatch[0].length); 54 - if (!title) return null; 55 54 return { title, postUri: uri }; 56 55 } 57 56 ··· 75 74 76 75 const postUri = `at://${did}/app.bsky.feed.post/${rkey}` as ResourceUri; 77 76 const title = stripRange(text, urlMatch.index!, urlMatch[0].length); 78 - if (!title) return null; 79 77 80 78 return { title, postUri }; 81 79 }
+2 -13
src/routes/+page.svelte
··· 3 3 import { Loader2 } from '@lucide/svelte'; 4 4 import { getHomeFeed } from '$lib/reddit/server/communities.remote'; 5 5 import { getQuotedPosts } from '$lib/reddit/server/quoted-posts.remote'; 6 - import { getPostsViewerState } from '$lib/atproto/server/feed.remote'; 7 - import { user } from '$lib/atproto/auth.svelte'; 8 6 import RedditPostCard from '$lib/reddit/RedditPostCard.svelte'; 9 7 import type { PostWithCommunity } from '$lib/reddit/db'; 10 8 11 9 let loading = $state(true); 12 10 let feed = $state<PostWithCommunity[]>([]); 13 11 let quoted = $state<Record<string, unknown>>({}); 14 - let viewerStates = $state<Record<string, { likeUri: string | null }>>({}); 15 12 16 13 onMount(async () => { 17 14 try { 18 15 const f = await getHomeFeed({ limit: 50 }); 19 16 feed = f; 20 17 if (f.length > 0) { 21 - const uris = f.map((p) => p.uri); 22 - const [quotedRes, viewerRes] = await Promise.all([ 23 - getQuotedPosts({ uris: f.map((p) => p.quoted_post_uri) }), 24 - user.did 25 - ? getPostsViewerState({ uris }) 26 - : Promise.resolve({ states: {} }) 27 - ]); 28 - quoted = quotedRes.posts; 29 - viewerStates = viewerRes.states; 18 + const res = await getQuotedPosts({ uris: f.map((p) => p.quoted_post_uri) }); 19 + quoted = res.posts; 30 20 } 31 21 } catch (e) { 32 22 console.error(e); ··· 54 44 row={p} 55 45 quoted={(quoted[p.quoted_post_uri] ?? undefined) as never} 56 46 showCommunity 57 - likeUri={viewerStates[p.uri]?.likeUri ?? null} 58 47 /> 59 48 {/each} 60 49 </div>
+3 -18
src/routes/c/[handle]/+page.svelte
··· 5 5 import { Loader2, Check, UserPlus } from '@lucide/svelte'; 6 6 import { getCommunity, getCommunityPosts } from '$lib/reddit/server/communities.remote'; 7 7 import { getQuotedPosts } from '$lib/reddit/server/quoted-posts.remote'; 8 - import { 9 - followUser, 10 - unfollowUser, 11 - getProfile, 12 - getPostsViewerState 13 - } from '$lib/atproto/server/feed.remote'; 8 + import { followUser, unfollowUser, getProfile } from '$lib/atproto/server/feed.remote'; 14 9 import { user } from '$lib/atproto/auth.svelte'; 15 10 import { loginModalState } from '$lib/LoginModal.svelte'; 16 11 import RedditPostCard from '$lib/reddit/RedditPostCard.svelte'; ··· 23 18 let community = $state<CommunityInfo>(null); 24 19 let posts = $state<PostRow[]>([]); 25 20 let quoted = $state<Record<string, unknown>>({}); 26 - let viewerStates = $state<Record<string, { likeUri: string | null }>>({}); 27 21 28 22 // Follow state — populated after the community loads, if the user is 29 23 // signed in. `followUri` is the AT-URI of the user's follow record ··· 38 32 community = null; 39 33 posts = []; 40 34 quoted = {}; 41 - viewerStates = {}; 42 35 followUri = null; 43 36 try { 44 37 const info = await getCommunity({ handle }); ··· 52 45 posts = rows; 53 46 54 47 if (rows.length > 0) { 55 - const postUris = rows.map((r) => r.uri); 56 - const [quotedRes, viewerRes] = await Promise.all([ 57 - getQuotedPosts({ uris: rows.map((r) => r.quoted_post_uri) }), 58 - user.did 59 - ? getPostsViewerState({ uris: postUris }) 60 - : Promise.resolve({ states: {} }) 61 - ]); 62 - quoted = quotedRes.posts; 63 - viewerStates = viewerRes.states; 48 + const res = await getQuotedPosts({ uris: rows.map((r) => r.quoted_post_uri) }); 49 + quoted = res.posts; 64 50 } 65 51 66 52 // If the viewer is signed in, fetch viewer.following from the ··· 175 161 row={p} 176 162 quoted={(quoted[p.quoted_post_uri] ?? undefined) as never} 177 163 accentColor={community.accentColor} 178 - likeUri={viewerStates[p.uri]?.likeUri ?? null} 179 164 /> 180 165 {/each} 181 166 </div>