diff --git a/src/lib/atproto/methods.ts b/src/lib/atproto/methods.ts index e4e77c0..2b92187 100644 --- a/src/lib/atproto/methods.ts +++ b/src/lib/atproto/methods.ts @@ -1,4 +1,10 @@ -import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons'; +import { + parseResourceUri, + type ActorIdentifier, + type Did, + type Handle, + type ResourceUri +} from '@atcute/lexicons'; import { user } from './auth.svelte'; import type { AllowedCollection } from './settings'; import { @@ -428,3 +434,31 @@ export async function getAuthorFeed(data?: { return response.data; } + +/** + * Fetches posts by their AT URIs. + * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") + * @param client - The client to use (defaults to public Bluesky API) + * @returns Array of posts or undefined on failure + */ +export async function getPosts(data: { uris: string[]; client?: Client }) { + data.client ??= new Client({ + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) + }); + + const response = await data.client.get('app.bsky.feed.getPosts', { + params: { uris: data.uris as ResourceUri[] } + }); + + if (!response.ok) return; + + return response.data.posts; +} + +export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier { + if (profile.handle && profile.handle !== 'handle.invalid') { + return profile.handle; + } else { + return profile.did; + } +} diff --git a/src/lib/atproto/settings.ts b/src/lib/atproto/settings.ts index 9f5f4c7..cedf739 100644 --- a/src/lib/atproto/settings.ts +++ b/src/lib/atproto/settings.ts @@ -44,4 +44,4 @@ export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collec // which PDS to use for signup // ATTENTION: pds.rip is only for development, all accounts get deleted automatically after a week -export const signUpPDS = 'https://selfhosted.social/'; +export const signUpPDS = 'https://pds.rip/'; diff --git a/src/lib/cards/BlueskyPostCard/BlueskyPostCard.svelte b/src/lib/cards/BlueskyPostCard/BlueskyPostCard.svelte index ceb9778..2a8f065 100644 --- a/src/lib/cards/BlueskyPostCard/BlueskyPostCard.svelte +++ b/src/lib/cards/BlueskyPostCard/BlueskyPostCard.svelte @@ -2,44 +2,46 @@ import type { Item } from '$lib/types'; import { onMount } from 'svelte'; import { BlueskyPost } from '../../components/bluesky-post'; - import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; - import { CardDefinitionsByType } from '..'; + import { getAdditionalUserData } from '$lib/website/context'; + import { getPosts, resolveHandle } from '$lib/atproto/methods'; + import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; + import type { Handle } from '@atcute/lexicons'; + import { isDid } from '@atcute/lexicons/syntax'; + import { resolveUri } from './utils'; let { item }: { item: Item } = $props(); const data = getAdditionalUserData(); - // svelte-ignore state_referenced_locally - let feed = $state((data[item.cardType] as any)?.feed); + let uri = $derived(item.cardData.uri as string); - let did = getDidContext(); - let handle = getHandleContext(); + // svelte-ignore state_referenced_locally + let post = $state((data['blueskyPost'] as Record)?.[uri]); onMount(async () => { - if (!feed) { - feed = ( - (await CardDefinitionsByType[item.cardType]?.loadData?.([], { - did, - handle - })) as any - ).feed; - - console.log(feed); + if (!post && uri) { + // Resolve handle to DID if needed + const resolvedUri = await resolveUri(uri); - data[item.cardType] = feed; + const posts = await getPosts({ uris: [resolvedUri] }); + if (posts && posts.length > 0) { + post = posts[0]; + // Store in data for future use (keyed by resolved URI) + if (!data['blueskyPost']) { + data['blueskyPost'] = {}; + } + (data['blueskyPost'] as Record)[resolvedUri] = post; + // Also store under original URI for lookup + (data['blueskyPost'] as Record)[uri] = post; + } } });
-
- My latest bluesky post -
- {#if feed?.[0]?.post} - + {#if post} +
{:else} - Your latest bluesky post will appear here. +

A bluesky post will appear here

{/if}
diff --git a/src/lib/cards/BlueskyPostCard/CreateBlueskyPostCardModal.svelte b/src/lib/cards/BlueskyPostCard/CreateBlueskyPostCardModal.svelte new file mode 100644 index 0000000..e5363c0 --- /dev/null +++ b/src/lib/cards/BlueskyPostCard/CreateBlueskyPostCardModal.svelte @@ -0,0 +1,75 @@ + + + +
{ + if (await validateAndCreate()) oncreate(); + }} + class="flex flex-col gap-2" + > + Enter a Bluesky post URL + + + {#if errorMessage} + {errorMessage} + {/if} + +

+ Paste a URL from bsky.app to embed a Bluesky post. +

+ +
+ + +
+
+
diff --git a/src/lib/cards/BlueskyPostCard/index.ts b/src/lib/cards/BlueskyPostCard/index.ts index 7626391..e220321 100644 --- a/src/lib/cards/BlueskyPostCard/index.ts +++ b/src/lib/cards/BlueskyPostCard/index.ts @@ -1,23 +1,68 @@ import type { CardDefinition } from '../types'; import BlueskyPostCard from './BlueskyPostCard.svelte'; -import SidebarItemBlueskyPostCard from './SidebarItemBlueskyPostCard.svelte'; -import { getAuthorFeed } from '$lib/atproto/methods'; +import CreateBlueskyPostCardModal from './CreateBlueskyPostCardModal.svelte'; +import { getPosts } from '$lib/atproto/methods'; +import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; +import { parseBlueskyPostUrl, resolveUri } from './utils'; + export const BlueskyPostCardDefinition = { - type: 'latestPost', + type: 'blueskyPost', contentComponent: BlueskyPostCard, + creationModalComponent: CreateBlueskyPostCardModal, + sidebarButtonText: 'Bluesky Post', createNew: (card) => { - card.cardType = 'latestPost'; + card.cardType = 'blueskyPost'; card.w = 4; card.mobileW = 8; card.h = 4; card.mobileH = 8; }, - sidebarButtonText: 'Latest Bluesky Post', - loadData: async (items, { did }) => { - const authorFeed = await getAuthorFeed({ did, filter: 'posts_no_replies', limit: 2 }); - return JSON.parse(JSON.stringify(authorFeed)); + onUrlHandler: (url, item) => { + const parsed = parseBlueskyPostUrl(url); + if (!parsed) return null; + + // Construct AT URI using handle (will be resolved to DID when loading) + item.cardData.uri = `at://${parsed.handle}/app.bsky.feed.post/${parsed.rkey}`; + item.cardData.href = url; + + item.w = 4; + item.mobileW = 8; + item.h = 4; + item.mobileH = 8; + + return item; + }, + urlHandlerPriority: 2, + + loadData: async (items) => { + // Collect all unique URIs from blueskyPost cards + const originalUris = items + .filter((item) => item.cardData?.uri) + .map((item) => item.cardData.uri as string); + + if (originalUris.length === 0) return {}; + + // Resolve handles to DIDs + const resolvedUris = await Promise.all(originalUris.map(resolveUri)); + + const posts = await getPosts({ uris: resolvedUris }); + if (!posts) return {}; + + // Create a map of URI -> PostView (keyed by both original and resolved URIs) + const postsMap: Record = {}; + for (let i = 0; i < posts.length; i++) { + const post = posts[i]; + postsMap[post.uri] = post; + // Also map by original URI for lookup + if (originalUris[i] && originalUris[i] !== post.uri) { + postsMap[originalUris[i]] = post; + } + } + + return postsMap; }, - minW: 4 -} as CardDefinition & { type: 'latestPost' }; + minW: 4, + name: 'Bluesky Post' +} as CardDefinition & { type: 'blueskyPost' }; diff --git a/src/lib/cards/BlueskyPostCard/utils.ts b/src/lib/cards/BlueskyPostCard/utils.ts new file mode 100644 index 0000000..76d66fe --- /dev/null +++ b/src/lib/cards/BlueskyPostCard/utils.ts @@ -0,0 +1,37 @@ +import { resolveHandle } from '$lib/atproto'; +import type { Handle } from '@atcute/lexicons'; + +// Matches URLs like https://bsky.app/profile/jyc.dev/post/3mdfjepjpls24 +const blueskyPostUrlPattern = + /^https?:\/\/(?:www\.)?bsky\.app\/profile\/([^/]+)\/post\/([A-Za-z0-9]+)\/?$/; + +/** + * Extract handle and rkey from a Bluesky post URL + * @param url URL to parse + * @returns Object with handle and rkey, or undefined if not a valid Bluesky post URL + */ +export function parseBlueskyPostUrl(url: string): { handle: string; rkey: string } | undefined { + const match = url.match(blueskyPostUrlPattern); + if (!match) return undefined; + return { handle: match[1], rkey: match[2] }; +} + +// Resolve handle to DID if URI contains a handle (not starting with did:) +export async function resolveUri(atUri: string): Promise { + const match = atUri.match(/^at:\/\/([^/]+)\/(.+)$/); + if (!match) return atUri; + + const [, authority, rest] = match; + + // If already a DID, return as-is + if (authority.startsWith('did:')) return atUri; + + // Resolve handle to DID + try { + const did = await resolveHandle({ handle: authority as Handle }); + if (!did) return atUri; + return `at://${did}/${rest}`; + } catch { + return atUri; + } +} diff --git a/src/lib/cards/LatestBlueskyPostCard/LatestBlueskyPostCard.svelte b/src/lib/cards/LatestBlueskyPostCard/LatestBlueskyPostCard.svelte new file mode 100644 index 0000000..ceb9778 --- /dev/null +++ b/src/lib/cards/LatestBlueskyPostCard/LatestBlueskyPostCard.svelte @@ -0,0 +1,45 @@ + + +
+
+ My latest bluesky post +
+ {#if feed?.[0]?.post} + +
+ {:else} + Your latest bluesky post will appear here. + {/if} +
diff --git a/src/lib/cards/BlueskyPostCard/SidebarItemBlueskyPostCard.svelte b/src/lib/cards/LatestBlueskyPostCard/SidebarItemLatestBlueskyPostCard.svelte similarity index 100% rename from src/lib/cards/BlueskyPostCard/SidebarItemBlueskyPostCard.svelte rename to src/lib/cards/LatestBlueskyPostCard/SidebarItemLatestBlueskyPostCard.svelte diff --git a/src/lib/cards/LatestBlueskyPostCard/index.ts b/src/lib/cards/LatestBlueskyPostCard/index.ts new file mode 100644 index 0000000..dc07aa4 --- /dev/null +++ b/src/lib/cards/LatestBlueskyPostCard/index.ts @@ -0,0 +1,22 @@ +import type { CardDefinition } from '../types'; +import LatestBlueskyPostCard from './LatestBlueskyPostCard.svelte'; +import { getAuthorFeed } from '$lib/atproto/methods'; + +export const LatestBlueskyPostCardDefinition = { + type: 'latestPost', + contentComponent: LatestBlueskyPostCard, + createNew: (card) => { + card.cardType = 'latestPost'; + card.w = 4; + card.mobileW = 8; + card.h = 4; + card.mobileH = 8; + }, + sidebarButtonText: 'Latest Bluesky Post', + loadData: async (items, { did }) => { + const authorFeed = await getAuthorFeed({ did, filter: 'posts_no_replies', limit: 2 }); + + return JSON.parse(JSON.stringify(authorFeed)); + }, + minW: 4 +} as CardDefinition & { type: 'latestPost' }; diff --git a/src/lib/cards/SpecialCards/UpdatedBlentos/UpdatedBlentosCard.svelte b/src/lib/cards/SpecialCards/UpdatedBlentos/UpdatedBlentosCard.svelte index adf6475..82134b6 100644 --- a/src/lib/cards/SpecialCards/UpdatedBlentos/UpdatedBlentosCard.svelte +++ b/src/lib/cards/SpecialCards/UpdatedBlentos/UpdatedBlentosCard.svelte @@ -9,6 +9,14 @@ const data = getAdditionalUserData(); // svelte-ignore state_referenced_locally const profiles = data[item.cardType] as AppBskyActorDefs.ProfileViewDetailed[]; + + function getLink(profile: AppBskyActorDefs.ProfileViewDetailed): string { + if (profile.handle && profile.handle !== 'handle.invalid') { + return `/${profile.handle}`; + } else { + return `/${profile.did}`; + } + }
@@ -16,7 +24,7 @@
{#each profiles as profile (profile.did)} diff --git a/src/lib/cards/index.ts b/src/lib/cards/index.ts index deda230..b86875f 100644 --- a/src/lib/cards/index.ts +++ b/src/lib/cards/index.ts @@ -3,6 +3,7 @@ import { ATProtoCollectionsCardDefinition } from './ATProtoCollectionsCard'; import { BigSocialCardDefinition } from './BigSocialCard'; import { BlueskyMediaCardDefinition } from './BlueskyMediaCard'; import { BlueskyPostCardDefinition } from './BlueskyPostCard'; +import { LatestBlueskyPostCardDefinition } from './LatestBlueskyPostCard'; import { DinoGameCardDefinition } from './GameCards/DinoGameCard'; import { EmbedCardDefinition } from './EmbedCard'; import { TetrisCardDefinition } from './GameCards/TetrisCard'; @@ -39,6 +40,7 @@ export const AllCardDefinitions = [ UpdatedBlentosCardDefitition, YoutubeCardDefinition, BlueskyPostCardDefinition, + LatestBlueskyPostCardDefinition, LivestreamCardDefitition, LivestreamEmbedCardDefitition, EmbedCardDefinition, diff --git a/src/lib/website/FloatingEditButton.svelte b/src/lib/website/FloatingEditButton.svelte index 4dac7c1..a2444d0 100644 --- a/src/lib/website/FloatingEditButton.svelte +++ b/src/lib/website/FloatingEditButton.svelte @@ -7,6 +7,7 @@ import { page } from '$app/state'; import type { ActorIdentifier } from '@atcute/lexicons'; import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; + import { getHandleOrDid } from '$lib/atproto/methods'; let { data }: { data: WebsiteData } = $props(); @@ -20,6 +21,11 @@ const showEditBlentoButton = $derived( isBlento && user.isLoggedIn && user.profile?.handle !== data.handle ); + + function getUserIdentifier(): ActorIdentifier { + const id = user.profile ? getHandleOrDid(user.profile) : (data.did as ActorIdentifier); + return id; + } {#if isOwnPage && !isEditPage} @@ -44,7 +50,7 @@
{:else if showLoginOnEditPage}
-