replies timeline only, appview-less bluesky client

feat: fetch reply chains downwards, add scroll to when clicking on replies, etc

ptr.pet b7dd46e0 5e163a7e

verified
+14
src/app.css
···
--picker-height: 8rem;
--picker-width: 8rem;
}
+
+
.animate-pulse-highlight {
+
animation: pulse-highlight 0.6s ease-in-out 3;
+
}
+
+
@keyframes pulse-highlight {
+
0%,
+
100% {
+
box-shadow: 0 0 0 0 var(--nucleus-selected-post);
+
}
+
50% {
+
box-shadow: 0 0 20px 5px var(--nucleus-selected-post);
+
}
+
}
+45 -10
src/components/BskyPost.svelte
···
import BskyPost from './BskyPost.svelte';
import Icon from '@iconify/svelte';
import { type Backlink, type BacklinksSource } from '$lib/at/constellation';
-
import { postActions, type PostActions } from '$lib/state.svelte';
+
import { postActions, pulsingPostId, type PostActions } from '$lib/state.svelte';
import * as TID from '@atcute/tid';
import type { PostWithUri } from '$lib/at/fetch';
import { onMount } from 'svelte';
import type { AtprotoDid } from '@atcute/lexicons/syntax';
+
import { derived } from 'svelte/store';
interface Props {
client: AtpClient;
···
did: Did;
rkey: RecordKey;
// replyBacklinks?: Backlinks;
-
depth?: number;
+
quoteDepth?: number;
data?: PostWithUri;
mini?: boolean;
isOnPostComposer?: boolean;
···
client,
did,
rkey,
-
depth = 0,
+
quoteDepth = 0,
data,
mini,
onQuote,
···
// 'app.bsky.feed.post:reply.parent.uri'
// );
+
const postId = `timeline-post-${aturi}-${quoteDepth}`;
+
const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId);
+
+
const scrollToAndPulse = (targetUri: ResourceUri) => {
+
const targetId = `timeline-post-${targetUri}-0`;
+
console.log(`Scrolling to ${targetId}`);
+
const element = document.getElementById(targetId);
+
if (!element) return;
+
+
// Smooth scroll to the target
+
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
+
+
// Trigger pulse after scroll completes
+
setTimeout(() => {
+
document.documentElement.style.setProperty(
+
'--nucleus-selected-post',
+
generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo)
+
);
+
pulsingPostId.set(targetId);
+
// Clear pulse after animation
+
setTimeout(() => pulsingPostId.set(null), 2000);
+
}, 500);
+
};
+
const getEmbedText = (embedType: string) => {
switch (embedType) {
case 'app.bsky.embed.external':
···
{:then post}
{#if post.ok}
{@const record = post.value.record}
-
<span style="color: {color};">@{handle}</span>: {@render embedBadge(record)}
-
<span title={record.text}>{record.text}</span>
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
+
<div
+
onclick={() => scrollToAndPulse(post.value.uri)}
+
class="select-none hover:cursor-pointer hover:underline"
+
>
+
<span style="color: {color};">@{handle}</span>: {@render embedBadge(record)}
+
<span title={record.text}>{record.text}</span>
+
</div>
{:else}
{post.error}
{/if}
···
{#if post.ok}
{@const record = post.value.record}
<div
-
class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all"
+
id="timeline-post-{post.value.uri}-{quoteDepth}"
+
class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all {$isPulsing
+
? 'animate-pulse-highlight'
+
: ''}"
style="background: {color}{isOnPostComposer
? '36'
: '18'}; border-color: {color}{isOnPostComposer ? '99' : '66'};"
···
{@const embed = record.embed}
<div class="mt-2">
{#snippet embedPost(uri: ResourceUri)}
-
{#if depth < 2}
+
{#if quoteDepth < 2}
{@const parsedUri = expect(parseCanonicalResourceUri(uri))}
<!-- reject recursive quotes -->
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
<BskyPost
{client}
-
depth={depth + 1}
+
quoteDepth={quoteDepth + 1}
did={parsedUri.repo}
rkey={parsedUri.rkey}
{isOnPostComposer}
···
{/if}
</div>
{:else}
-
<div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;">
-
<p class="text-sm font-medium" style="color: #fca5a5;">error: {post.error}</p>
+
<div class="error-disclaimer">
+
<p class="text-sm font-medium">error: {post.error}</p>
</div>
{/if}
{/await}
+60 -61
src/components/PostComposer.svelte
···
});
</script>
+
{#snippet renderPost(post: PostWithUri)}
+
{@const parsedUri = expect(parseCanonicalResourceUri(post.uri))}
+
<BskyPost
+
{client}
+
did={parsedUri.repo}
+
rkey={parsedUri.rkey}
+
data={post}
+
isOnPostComposer={true}
+
/>
+
{/snippet}
+
+
{#snippet composer()}
+
{#if replying}
+
{@render renderPost(replying)}
+
{/if}
+
<textarea
+
bind:this={textareaEl}
+
bind:value={postText}
+
onfocus={() => (isFocused = true)}
+
onblur={unfocus}
+
onkeydown={(event) => {
+
if (event.key === 'Escape') unfocus();
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
+
}}
+
placeholder="what's on your mind?"
+
rows="4"
+
class="field-sizing-content single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100"
+
style="border-color: color-mix(in srgb, {color} 27%, transparent);"
+
></textarea>
+
{#if quoting}
+
{@render renderPost(quoting)}
+
{/if}
+
<div class="flex items-center gap-2">
+
<div class="grow"></div>
+
<span
+
class="text-sm font-medium"
+
style="color: color-mix(in srgb, {postText.length > 300
+
? '#ef4444'
+
: 'var(--nucleus-fg)'} 53%, transparent);"
+
>
+
{postText.length} / 300
+
</span>
+
<button
+
onmousedown={(e) => {
+
e.preventDefault();
+
doPost();
+
}}
+
disabled={postText.length === 0 || postText.length > 300}
+
class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
+
style="background: color-mix(in srgb, {color} 87%, transparent);"
+
>
+
post
+
</button>
+
</div>
+
{/snippet}
+
<div class="relative min-h-16">
<!-- Spacer to maintain layout when focused -->
{#if isFocused}
···
e.preventDefault();
}
}}
-
class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300"
-
class:min-h-16={!isFocused}
-
class:items-center={!isFocused}
-
class:shadow-2xl={isFocused}
-
class:absolute={isFocused}
-
class:top-0={isFocused}
-
class:left-0={isFocused}
-
class:right-0={isFocused}
-
class:z-50={isFocused}
+
class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300
+
{!isFocused ? 'min-h-16 items-center' : ''}
+
{isFocused ? 'absolute top-0 right-0 left-0 z-50 shadow-2xl' : ''}"
style="background: {isFocused
? `color-mix(in srgb, var(--nucleus-bg) 80%, ${color} 20%)`
: `color-mix(in srgb, ${color} 9%, transparent)`};
···
</div>
{:else}
<div class="flex flex-col gap-2">
-
{#snippet renderPost(post: PostWithUri)}
-
{@const parsedUri = expect(parseCanonicalResourceUri(post.uri))}
-
<BskyPost
-
{client}
-
did={parsedUri.repo}
-
rkey={parsedUri.rkey}
-
data={post}
-
isOnPostComposer={true}
-
/>
-
{/snippet}
{#if isFocused}
-
{#if replying}
-
{@render renderPost(replying)}
-
{/if}
-
<textarea
-
bind:this={textareaEl}
-
bind:value={postText}
-
onfocus={() => (isFocused = true)}
-
onblur={unfocus}
-
onkeydown={(event) => {
-
if (event.key === 'Escape') unfocus();
-
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
-
}}
-
placeholder="what's on your mind?"
-
rows="4"
-
class="field-sizing-content single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100"
-
style="border-color: color-mix(in srgb, {color} 27%, transparent);"
-
></textarea>
-
{#if quoting}
-
{@render renderPost(quoting)}
-
{/if}
-
<div class="flex items-center gap-2">
-
<div class="grow"></div>
-
<span
-
class="text-sm font-medium"
-
style="color: color-mix(in srgb, {postText.length > 300
-
? '#ef4444'
-
: 'var(--nucleus-fg)'} 53%, transparent);"
-
>
-
{postText.length} / 300
-
</span>
-
<button
-
onmousedown={(e) => {
-
e.preventDefault();
-
doPost();
-
}}
-
disabled={postText.length === 0 || postText.length > 300}
-
class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
-
style="background: color-mix(in srgb, {color} 87%, transparent);"
-
>
-
post
-
</button>
-
</div>
+
{@render composer()}
{:else}
<input
bind:value={postText}
+11 -12
src/lib/at/client.ts
···
parseResourceUri,
type ActorIdentifier,
type AtprotoDid,
-
type CanonicalResourceUri,
type Cid,
type Did,
type Nsid,
···
const mapped = map(res, (data) => data.did as AtprotoDid);
-
if (mapped.ok) {
-
handleCache.set(identifier, mapped.value);
-
}
+
if (mapped.ok) handleCache.set(identifier, mapped.value);
return mapped;
}
···
cachedSignal.then((d): Result<MiniDoc, string> => ok(d))
]);
-
if (result.ok) {
-
didDocCache.set(handleOrDid, result.value);
-
}
+
if (result.ok) didDocCache.set(handleOrDid, result.value);
return result;
}
async getBacklinksUri(
-
uri: CanonicalResourceUri,
+
uri: ResourceUri,
source: BacklinksSource
): Promise<Result<Backlinks, string>> {
const parsedResourceUri = expect(parseCanonicalResourceUri(uri));
···
source: BacklinksSource
): Promise<Result<Backlinks, string>> {
const did = await this.resolveHandle(repo);
-
if (!did.ok) {
-
return err(`failed to resolve handle: ${did.error}`);
-
}
+
if (!did.ok) return err(`cant resolve handle: ${did.error}`);
-
return await fetchMicrocosm(constellationUrl, BacklinksQuery, {
+
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000));
+
const query = fetchMicrocosm(constellationUrl, BacklinksQuery, {
subject: `at://${did.value}/${collection}/${rkey}`,
source,
limit: 100
});
+
+
const results = await Promise.race([query, timeout]);
+
if (!results) return err('cant fetch backlinks: timeout');
+
+
return results;
}
streamNotifications(subjects: Did[], ...sources: BacklinksSource[]): NotificationsStream {
+90 -60
src/lib/at/fetch.ts
···
-
import type { ActorIdentifier, CanonicalResourceUri, Cid, ResourceUri } from '@atcute/lexicons';
+
import {
+
parseCanonicalResourceUri,
+
type CanonicalResourceUri,
+
type Cid,
+
type ResourceUri
+
} from '@atcute/lexicons';
import { recordCache, type AtpClient } from './client';
-
import { err, ok, type Result } from '$lib/result';
+
import { err, expect, ok, type Result } from '$lib/result';
import type { Backlinks } from './constellation';
import { AppBskyFeedPost } from '@atcute/bluesky';
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main };
export type PostWithBacklinks = PostWithUri & {
-
replies: Result<Backlinks, string>;
+
replies: Backlinks;
};
export type PostsWithReplyBacklinks = PostWithBacklinks[];
+
const replySource = 'app.bsky.feed.post:reply.parent.uri';
+
export const fetchPostsWithBacklinks = async (
client: AtpClient,
-
repo: ActorIdentifier,
+
repo: AtprotoDid,
cursor?: string,
limit?: number
): Promise<Result<{ posts: PostsWithReplyBacklinks; cursor?: string }, string>> => {
···
cursor = recordsList.value.cursor;
const records = recordsList.value.records;
-
const allBacklinks = await Promise.all(
-
records.map(async (r) => {
-
recordCache.set(r.uri, r);
-
const res = await client.getBacklinksUri(
-
r.uri as CanonicalResourceUri,
-
'app.bsky.feed.post:reply.parent.uri'
-
);
-
return {
-
uri: r.uri,
-
cid: r.cid,
-
record: r.value as AppBskyFeedPost.Main,
-
replies: res
-
};
-
})
-
);
-
-
return ok({ posts: allBacklinks, cursor });
+
try {
+
const allBacklinks = await Promise.all(
+
records.map(async (r): Promise<PostWithBacklinks> => {
+
recordCache.set(r.uri, r);
+
const replies = await client.getBacklinksUri(r.uri, replySource);
+
if (!replies.ok) throw `cant fetch replies: ${replies.error}`;
+
return {
+
uri: r.uri,
+
cid: r.cid,
+
record: r.value as AppBskyFeedPost.Main,
+
replies: replies.value
+
};
+
})
+
);
+
return ok({ posts: allBacklinks, cursor });
+
} catch (error) {
+
return err(`cant fetch posts backlinks: ${error}`);
+
}
};
export const hydratePosts = async (
client: AtpClient,
+
repo: AtprotoDid,
data: PostsWithReplyBacklinks
-
): Promise<Map<ResourceUri, PostWithUri>> => {
-
const allPosts = await Promise.all(
-
data.map(async (post) => {
-
const result: Result<PostWithUri, string>[] = [ok(post)];
-
if (post.replies.ok) {
+
): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => {
+
let posts: Map<ResourceUri, PostWithUri> = new Map();
+
try {
+
const allPosts = await Promise.all(
+
data.map(async (post) => {
+
const result: PostWithUri[] = [post];
const replies = await Promise.all(
-
post.replies.value.records.map((r) =>
-
client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey)
-
)
+
post.replies.records.map(async (r) => {
+
const reply = await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey);
+
if (!reply.ok) throw `cant fetch reply: ${reply.error}`;
+
return reply.value;
+
})
);
result.push(...replies);
+
return result;
+
})
+
);
+
posts = new Map(allPosts.flat().map((post) => [post.uri, post]));
+
} catch (error) {
+
return err(`cant hydrate immediate replies: ${error}`);
+
}
+
+
const fetchUpwardsChain = async (post: PostWithUri) => {
+
let parent = post.record.reply?.parent;
+
while (parent) {
+
// if we already have this parent, then we already fetched this chain / are fetching it
+
if (posts.has(parent.uri as CanonicalResourceUri)) return;
+
const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parent.uri);
+
if (p.ok) {
+
posts.set(p.value.uri, p.value);
+
parent = p.value.record.reply?.parent;
+
continue;
}
-
return result;
-
})
-
);
-
const posts = new Map(
-
allPosts
-
.flat()
-
.flatMap((res) => (res.ok ? [res.value] : []))
-
.map((post) => [post.uri, post])
-
);
+
// TODO: handle deleted parent posts
+
parent = undefined;
+
}
+
};
+
await Promise.all(posts.values().map(fetchUpwardsChain));
+
+
try {
+
const fetchDownwardsChain = async (post: PostWithUri) => {
+
const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri));
+
if (repo === postRepo) return;
+
+
// get chains that are the same author until we exhaust them
+
const backlinks = await client.getBacklinksUri(post.uri, replySource);
+
if (!backlinks.ok) return;
-
// hydrate posts
-
const missingPosts = await Promise.all(
-
Array.from(posts).map(async ([, post]) => {
-
let result: PostWithUri[] = [post];
-
let parent = post.record.reply?.parent;
-
while (parent) {
-
if (posts.has(parent.uri as CanonicalResourceUri)) {
-
return result;
-
}
-
const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parent.uri);
-
if (p.ok) {
-
result = [p.value, ...result];
-
parent = p.value.record.reply?.parent;
-
continue;
-
}
-
parent = undefined;
+
const promises = [];
+
for (const reply of backlinks.value.records) {
+
if (reply.did !== postRepo) continue;
+
// if we already have this reply, then we already fetched this chain / are fetching it
+
if (posts.has(`at://${reply.did}/${reply.collection}/${reply.rkey}`)) continue;
+
const record = await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey);
+
if (!record.ok) break; // TODO: this doesnt handle deleted posts in between
+
posts.set(record.value.uri, record.value);
+
promises.push(fetchDownwardsChain(record.value));
}
-
return result;
-
})
-
);
-
for (const post of missingPosts.flat()) {
-
posts.set(post.uri, post);
+
+
await Promise.all(promises);
+
};
+
await Promise.all(posts.values().map(fetchDownwardsChain));
+
} catch (error) {
+
return err(`cant fetch post reply chain: ${error}`);
}
-
return posts;
+
return ok(posts);
};
+2
src/lib/state.svelte.ts
···
// quote: Backlink | null;
};
export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>();
+
+
export const pulsingPostId = writable<string | null>(null);
+3 -1
src/lib/thread.ts
···
export type ThreadPost = {
data: PostWithUri;
+
account: Did;
did: Did;
rkey: string;
parentUri: ResourceUri | null;
···
const threadMap = new Map<ResourceUri, ThreadPost[]>();
// group posts by root uri into "thread" chains
-
for (const [, timeline] of timelines) {
+
for (const [account, timeline] of timelines) {
for (const [uri, data] of timeline) {
const parsedUri = expect(parseCanonicalResourceUri(uri));
const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri;
···
const post: ThreadPost = {
data,
+
account,
did: parsedUri.repo,
rkey: parsedUri.rkey,
parentUri,
+27 -14
src/routes/+page.svelte
···
import { type Did, parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons';
import { onMount } from 'svelte';
import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch';
-
import { expect, ok } from '$lib/result';
+
import { expect } from '$lib/result';
import { AppBskyFeedPost } from '@atcute/bluesky';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { InfiniteLoader, LoaderState } from 'svelte-infinite';
···
if (cursor && cursor.end) return;
const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6);
-
if (!accPosts.ok)
-
throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`;
+
if (!accPosts.ok) throw `cant fetch posts @${account.handle}: ${accPosts.error}`;
// if the cursor is undefined, we've reached the end of the timeline
if (!accPosts.value.cursor) {
···
}
cursors.set(account.did, { value: accPosts.value.cursor, end: false });
-
addPosts(account.did, await hydratePosts(client, accPosts.value.posts));
+
const hydrated = await hydratePosts(client, account.did, accPosts.value.posts);
+
if (!hydrated.ok) throw `cant hydrate posts @${account.handle}: ${hydrated.error}`;
+
+
addPosts(account.did, hydrated.value);
};
const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline));
···
if (!subjectPost.ok) return;
const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
-
const hydrated = await hydratePosts(viewClient, [
+
const hydrated = await hydratePosts(viewClient, parsedSubjectUri.repo as AtprotoDid, [
{
record: subjectPost.value.record,
uri: event.data.link.subject,
cid: subjectPost.value.cid,
-
replies: ok({
+
replies: {
cursor: null,
total: 1,
records: [
···
rkey: parsedSourceUri.rkey
}
]
-
})
+
}
}
]);
+
+
if (!hydrated.ok) {
+
errors.push(`cant hydrate posts @${parsedSubjectUri.repo}: ${hydrated.error}`);
+
return;
+
}
// console.log(hydrated);
-
addPosts(parsedSubjectUri.repo, hydrated);
+
addPosts(parsedSubjectUri.repo, hydrated.value);
}
};
···
loaderState.error();
} finally {
loading = false;
-
if (cursors.values().every((cursor) => cursor.end)) loaderState.complete();
+
// if (cursors.values().every((cursor) => cursor.end)) loaderState.complete();
}
};
···
</script>
<div class="mx-auto max-w-2xl">
-
<!-- Sticky header -->
+
<!-- header -->
<div class="sticky top-0 z-10 bg-(--nucleus-bg) pb-2">
<div class="mb-6 flex items-center justify-between">
<div>
···
</button>
</div>
-
<!-- Composer and error disclaimer (above thread list, not scrollable) -->
+
<!-- composer and error disclaimer (above thread list, not scrollable) -->
<div class="space-y-4">
<div class="flex min-h-16 items-stretch gap-2">
<AccountSelector
···
</div>
</div>
-
<!-- Thread list (page scrolls as a whole) -->
+
<!-- thread list (page scrolls as a whole) -->
<div class="mt-4 [scrollbar-color:var(--nucleus-accent)_transparent]" bind:this={scrollContainer}>
{#if $accounts.length > 0}
{@render renderThreads()}
···
</div>
{/snippet}
{#snippet error()}
-
<div class="flex justify-center py-4">
+
<div class="flex flex-col gap-4 py-4">
<p class="text-xl opacity-80">
-
<span class="text-4xl">:(</span> <br /> an error occurred while loading posts: {loadError}
+
<span class="text-4xl">x_x</span> <br />
+
{loadError}
</p>
+
<div>
+
<button class="flex action-button items-center gap-2" onclick={loadMore}>
+
<Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again
+
</button>
+
</div>
</div>
{/snippet}
</InfiniteLoader>
+4 -7
src/routes/+page.ts
···
import { replaceState } from '$app/navigation';
import { addAccount, loggingIn } from '$lib/accounts';
import { AtpClient } from '$lib/at/client';
-
import { flow } from '$lib/at/oauth';
+
import { flow, sessions } from '$lib/at/oauth';
import { err, ok, type Result } from '$lib/result';
import type { PageLoad } from './$types';
···
}
loggingIn.set(null);
+
await sessions.remove(account.did);
const agent = await flow.finalize(currentUrl);
if (!agent.ok || !agent.value) {
-
if (!agent.ok) {
-
return err(agent.error);
-
}
+
if (!agent.ok) return err(agent.error);
return err('no session was logged into?!');
}
const client = new AtpClient();
const result = await client.login(account.did, agent.value);
-
if (!result.ok) {
-
return err(result.error);
-
}
+
if (!result.ok) return err(result.error);
addAccount(account);
return ok(client);