diff --git a/.claude/settings.local.json b/.claude/settings.local.json --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -24,7 +24,8 @@ "Bash(pnpm dev)", "Bash(pnpm exec svelte-kit:*)", "Bash(pnpm build:*)", - "Bash(pnpm remove:*)" + "Bash(pnpm remove:*)", + "Bash(grep:*)" ] } } diff --git a/src/lib/atproto/index.ts b/src/lib/atproto/index.ts --- a/src/lib/atproto/index.ts +++ b/src/lib/atproto/index.ts @@ -16,5 +16,7 @@ getBlobURL, getCDNImageBlobUrl, searchActorsTypeahead, - getAuthorFeed + getAuthorFeed, + getPostThread, + createPost } from './methods'; diff --git a/src/lib/atproto/methods.ts b/src/lib/atproto/methods.ts --- a/src/lib/atproto/methods.ts +++ b/src/lib/atproto/methods.ts @@ -465,3 +465,72 @@ return profile.did; } } + +/** + * Fetches a post's thread including replies. + * @param uri - The AT URI of the post + * @param depth - How many levels of replies to fetch (default 1) + * @param client - The client to use (defaults to public Bluesky API) + * @returns The thread data or undefined on failure + */ +export async function getPostThread({ + uri, + depth = 1, + client +}: { + uri: string; + depth?: number; + client?: Client; +}) { + client ??= new Client({ + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) + }); + + const response = await client.get('app.bsky.feed.getPostThread', { + params: { uri: uri as ResourceUri, depth } + }); + + if (!response.ok) return; + + return response.data.thread; +} + +/** + * Creates a Bluesky post on the authenticated user's account. + * @param text - The post text + * @param facets - Optional rich text facets (links, mentions, etc.) + * @returns The response containing the post's URI and CID + * @throws If the user is not logged in + */ +export async function createPost({ + text, + facets +}: { + text: string; + facets?: Array<{ + index: { byteStart: number; byteEnd: number }; + features: Array<{ $type: string; uri?: string; did?: string; tag?: string }>; + }>; +}) { + if (!user.client || !user.did) throw new Error('No client or did'); + + const record: Record = { + $type: 'app.bsky.feed.post', + text, + createdAt: new Date().toISOString() + }; + + if (facets) { + record.facets = facets; + } + + const response = await user.client.post('com.atproto.repo.createRecord', { + input: { + collection: 'app.bsky.feed.post', + repo: user.did, + record + } + }); + + return response; +} diff --git a/src/lib/atproto/settings.ts b/src/lib/atproto/settings.ts --- a/src/lib/atproto/settings.ts +++ b/src/lib/atproto/settings.ts @@ -20,6 +20,7 @@ 'app.blento.settings', 'app.blento.comment', 'app.blento.guestbook.entry', + 'app.bsky.feed.post', 'site.standard.publication', 'site.standard.document', 'xyz.statusphere.status' diff --git a/src/lib/cards/index.ts b/src/lib/cards/index.ts --- a/src/lib/cards/index.ts +++ b/src/lib/cards/index.ts @@ -32,9 +32,11 @@ import { TimerCardDefinition } from './TimerCard'; import { SpotifyCardDefinition } from './SpotifyCard'; import { ButtonCardDefinition } from './ButtonCard'; +import { GuestbookCardDefinition } from './GuestbookCard'; // import { Model3DCardDefinition } from './Model3DCard'; export const AllCardDefinitions = [ + GuestbookCardDefinition, ButtonCardDefinition, ImageCardDefinition, VideoCardDefinition, diff --git a/src/lib/cards/GuestbookCard/CreateGuestbookCardModal.svelte b/src/lib/cards/GuestbookCard/CreateGuestbookCardModal.svelte new file mode 100644 --- /dev/null +++ b/src/lib/cards/GuestbookCard/CreateGuestbookCardModal.svelte @@ -0,0 +1,166 @@ + + + +
{ + e.preventDefault(); + handleSubmit(); + }} + class="flex flex-col gap-2" + > + Guestbook + +
+ + +
+ + {#if mode === 'create'} +

+ This will create a post on your Bluesky account. Replies to that post will appear on your + guestbook card. +

+ + {:else} +

+ Paste a Bluesky post URL to use as your guestbook. Replies to that post will appear on your + card. +

+ + {/if} + + {#if errorMessage} + {errorMessage} + {/if} + +
+ + {#if mode === 'create'} + + {:else} + + {/if} +
+
+
diff --git a/src/lib/cards/GuestbookCard/GuestbookCard.svelte b/src/lib/cards/GuestbookCard/GuestbookCard.svelte new file mode 100644 --- /dev/null +++ b/src/lib/cards/GuestbookCard/GuestbookCard.svelte @@ -0,0 +1,126 @@ + + +
+ {#if item.cardData.href} + + {/if} + +
+ {#if replies.length > 0} +
+ {#each replies as reply (reply.post.uri)} +
+ +
+ {/each} +
+ {:else if isLoaded} +
+ No comments yet — share your Bluesky post to get started! +
+ {:else} +
+ Loading comments... +
+ {/if} +
+
+ + diff --git a/src/lib/cards/GuestbookCard/index.ts b/src/lib/cards/GuestbookCard/index.ts new file mode 100644 --- /dev/null +++ b/src/lib/cards/GuestbookCard/index.ts @@ -0,0 +1,64 @@ +import { getPostThread } from '$lib/atproto/methods'; +import type { CardDefinition } from '../types'; +import GuestbookCard from './GuestbookCard.svelte'; +import CreateGuestbookCardModal from './CreateGuestbookCardModal.svelte'; + +export const GuestbookCardDefinition = { + type: 'guestbook', + contentComponent: GuestbookCard, + creationModalComponent: CreateGuestbookCardModal, + sidebarButtonText: 'Guestbook', + createNew: (card) => { + card.w = 4; + card.h = 6; + card.mobileW = 8; + card.mobileH = 12; + card.cardData.label = 'Guestbook'; + }, + minW: 4, + minH: 4, + defaultColor: 'base', + canHaveLabel: true, + loadData: async (items) => { + const uris = items + .filter((item) => item.cardData?.uri) + .map((item) => item.cardData.uri as string); + + if (uris.length === 0) return {}; + + const results: Record = {}; + + await Promise.all( + uris.map(async (uri) => { + try { + const thread = await getPostThread({ uri, depth: 1 }); + if (thread && '$type' in thread && thread.$type === 'app.bsky.feed.defs#threadViewPost') { + const typedThread = thread as { replies?: unknown[] }; + results[uri] = (typedThread.replies ?? []) + .filter( + (r: unknown) => + r != null && + typeof r === 'object' && + '$type' in r && + (r as { $type: string }).$type === 'app.bsky.feed.defs#threadViewPost' + ) + .sort((a: unknown, b: unknown) => { + const timeA = new Date( + ((a as any).post?.record?.createdAt as string) ?? 0 + ).getTime(); + const timeB = new Date( + ((b as any).post?.record?.createdAt as string) ?? 0 + ).getTime(); + return timeB - timeA; + }); + } + } catch (e) { + console.error('Failed to load guestbook thread for', uri, e); + } + }) + ); + + return results; + }, + name: 'Guestbook' +} as CardDefinition & { type: 'guestbook' }; diff --git a/src/lib/components/bluesky-post/BlueskyPost.svelte b/src/lib/components/bluesky-post/BlueskyPost.svelte --- a/src/lib/components/bluesky-post/BlueskyPost.svelte +++ b/src/lib/components/bluesky-post/BlueskyPost.svelte @@ -8,8 +8,16 @@ feedViewPost, children, showLogo = false, + showAvatar = false, + compact = false, ...restProps - }: { feedViewPost?: PostView; children?: Snippet; showLogo?: boolean } = $props(); + }: { + feedViewPost?: PostView; + children?: Snippet; + showLogo?: boolean; + showAvatar?: boolean; + compact?: boolean; + } = $props(); const postData = $derived(feedViewPost ? blueskyPostToPostData(feedViewPost) : undefined); @@ -37,6 +45,8 @@ likeHref={postData?.href} showBookmark={false} logo={showLogo ? logo : undefined} + {showAvatar} + {compact} {...restProps} > {@render children?.()} diff --git a/src/lib/components/post/Post.svelte b/src/lib/components/post/Post.svelte --- a/src/lib/components/post/Post.svelte +++ b/src/lib/components/post/Post.svelte @@ -36,7 +36,10 @@ children, - logo + logo, + + showAvatar = false, + compact = false }: WithElementRef>> & { data: PostData; class?: string; @@ -61,6 +64,9 @@ customActions?: Snippet; logo?: Snippet; + + showAvatar?: boolean; + compact?: boolean; } = $props(); @@ -121,6 +127,15 @@ {/if}
+ {#if showAvatar && data.author.avatar} + + + + {/if}
@@ -161,7 +176,10 @@ {/if}
@@ -173,7 +191,7 @@
{#if data.htmlContent} @@ -185,7 +203,7 @@ - {#if showReply || showRepost || showLike || showBookmark || customActions} + {#if !compact && (showReply || showRepost || showLike || showBookmark || customActions)}