replies timeline only, appview-less bluesky client

feat: implement a bunch of shit part 2

+6 -4
src/app.css
···
.grain:before {
content: '';
background-color: transparent;
-
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)'/%3E%3C/svg%3E");
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='4' stitchTiles='stitch' /%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='2' intercept='-0.5' /%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)' /%3E%3C/svg%3E");
background-repeat: repeat;
-
background-size: 364px;
-
opacity: 0.05;
+
background-size: 40vmax;
+
opacity: 0.06;
top: 0;
left: 0;
-
position: absolute;
+
position: fixed;
width: 100%;
height: 100%;
+
pointer-events: none;
+
z-index: 1;
}
+53 -29
src/components/AccountSelector.svelte
···
import type { Did, Handle } from '@atcute/lexicons';
import { theme } from '$lib/theme.svelte';
-
let {
-
accounts = [],
-
selectedDid = $bindable(null),
-
onAccountSelected,
-
onLoginSucceed
-
}: {
+
interface Props {
accounts: Array<Account>;
selectedDid?: Did | null;
onAccountSelected: (did: Did) => void;
onLoginSucceed: (did: Did, handle: Handle, password: string) => void;
-
} = $props();
+
onLogout: (did: Did) => void;
+
}
+
+
let {
+
accounts = [],
+
selectedDid = $bindable(null),
+
onAccountSelected,
+
onLoginSucceed,
+
onLogout
+
}: Props = $props();
let color = $derived(selectedDid ? (generateColorForDid(selectedDid) ?? theme.fg) : theme.fg);
···
<div class="relative">
<button
onclick={toggleDropdown}
-
class="group flex h-full items-center gap-2 rounded-2xl border-2 px-4 font-medium shadow-lg transition-all hover:scale-105 hover:shadow-xl"
+
class="group flex h-full items-center gap-2 rounded-sm border-2 px-2 font-medium shadow-lg transition-all hover:scale-105 hover:shadow-xl"
style="border-color: {theme.accent}66; background: {theme.accent}18; color: {color}; backdrop-filter: blur(8px);"
>
-
<span class="text-sm">
+
<span class="font-bold">
{selectedAccount ? `@${selectedAccount.handle}` : 'select account'}
</span>
<svg
···
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
-
class="absolute left-0 z-10 mt-3 min-w-52 overflow-hidden rounded-2xl border-2 shadow-2xl backdrop-blur-lg"
+
class="absolute left-0 z-10 mt-3 min-w-52 overflow-hidden rounded-sm border-2 shadow-2xl backdrop-blur-lg"
style="border-color: {theme.accent}; background: {theme.bg}f0;"
onclick={(e) => e.stopPropagation()}
>
···
{@const color = generateColorForDid(account.did)}
<button
onclick={() => selectAccount(account.did)}
-
class="flex w-full items-center gap-3 rounded-xl p-2 text-left text-sm font-medium transition-all {account.did ===
-
selectedDid
-
? 'shadow-lg'
-
: 'hover:scale-[1.02]'}"
+
class="
+
group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all
+
{account.did === selectedDid ? 'shadow-lg' : ''}
+
"
style="color: {color}; background: {account.did === selectedDid
? `linear-gradient(135deg, ${theme.accent}33, ${theme.accent2}33)`
: 'transparent'};"
>
<span>@{account.handle}</span>
+
<svg
+
xmlns="http://www.w3.org/2000/svg"
+
onclick={() => onLogout(account.did)}
+
class="ml-auto hidden h-5 w-5 transition-all group-hover:[display:block] hover:scale-[1.2] hover:shadow-md"
+
style="color: {theme.accent};"
+
width="24"
+
height="24"
+
viewBox="0 0 20 20"
+
><path
+
fill="currentColor"
+
fill-rule="evenodd"
+
d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443q-1.193.115-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022l.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52l.149.023a.75.75 0 0 0 .23-1.482A41 41 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1zM10 4q1.26 0 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325Q8.74 4 10 4M8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06z"
+
clip-rule="evenodd"
+
/></svg
+
>
+
{#if account.did === selectedDid}
<svg
-
class="ml-auto h-5 w-5"
+
xmlns="http://www.w3.org/2000/svg"
+
class="ml-auto h-5 w-5 group-hover:hidden"
style="color: {theme.accent};"
-
fill="currentColor"
-
viewBox="0 0 20 20"
-
>
-
<path
+
width="24"
+
height="24"
+
viewBox="0 0 24 24"
+
><path
+
fill="currentColor"
fill-rule="evenodd"
-
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
+
d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353l8.493-12.74a.75.75 0 0 1 1.04-.207"
clip-rule="evenodd"
-
/>
-
</svg>
+
stroke-width="1.5"
+
stroke="currentColor"
+
/></svg
+
>
{/if}
</button>
{/each}
···
{/if}
<button
onclick={openLoginModal}
-
class="flex w-full items-center gap-3 p-3 text-left text-sm font-semibold transition-all hover:scale-[1.02]"
+
class="group flex w-full origin-left items-center gap-3 p-3 text-left text-sm font-semibold transition-all hover:scale-[1.1]"
style="color: {theme.accent};"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
···
<!-- svelte-ignore a11y_interactive_supports_focus -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
-
class="w-full max-w-md rounded-3xl border-2 p-5 shadow-2xl"
+
class="w-full max-w-md rounded-sm border-2 p-5 shadow-2xl"
style="background: {theme.bg}; border-color: {theme.accent};"
onclick={(e) => e.stopPropagation()}
role="dialog"
···
type="text"
bind:value={loginHandle}
placeholder="example.bsky.social"
-
class="placeholder-opacity-40 w-full rounded-xl border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none"
+
class="placeholder-opacity-40 w-full rounded-sm border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none"
style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};"
disabled={isLoggingIn}
/>
···
type="password"
bind:value={loginPassword}
placeholder="xxxx-xxxx-xxxx-xxxx"
-
class="placeholder-opacity-40 w-full rounded-xl border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none"
+
class="placeholder-opacity-40 w-full rounded-sm border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none"
style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};"
disabled={isLoggingIn}
/>
···
{#if loginError}
<div
-
class="rounded-xl border-2 p-4"
+
class="rounded-sm border-2 p-4"
style="background: #ef444422; border-color: #ef4444;"
>
<p class="text-sm font-medium" style="color: #fca5a5;">{loginError}</p>
···
<div class="flex gap-3 pt-3">
<button
onclick={closeLoginModal}
-
class="flex-1 rounded-xl border-2 px-5 py-3 font-semibold transition-all hover:scale-105"
+
class="flex-1 rounded-sm border-2 px-5 py-3 font-semibold transition-all hover:scale-105"
style="background: {theme.bg}; border-color: {theme.fg}33; color: {theme.fg};"
disabled={isLoggingIn}
>
···
</button>
<button
onclick={handleLogin}
-
class="flex-1 rounded-xl border-2 px-5 py-3 font-semibold transition-all hover:scale-105 hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-50"
+
class="flex-1 rounded-sm border-2 px-5 py-3 font-semibold transition-all hover:scale-105 hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-50"
style="background: linear-gradient(135deg, {theme.accent}, {theme.accent2}); border-color: transparent; color: {theme.fg};"
disabled={isLoggingIn}
>
+74 -50
src/components/BskyPost.svelte
···
import type { ActorIdentifier, RecordKey } from '@atcute/lexicons';
import { theme } from '$lib/theme.svelte';
import { map, ok } from '$lib/result';
-
import type { Backlinks } from '$lib/at/constellation';
import { generateColorForDid } from '$lib/accounts';
interface Props {
client: AtpClient;
identifier: ActorIdentifier;
rkey: RecordKey;
-
replyBacklinks?: Backlinks;
+
// replyBacklinks?: Backlinks;
record?: AppBskyFeedPost.Main;
+
mini?: boolean;
}
-
const { client, identifier, rkey, record, replyBacklinks }: Props = $props();
+
const { client, identifier, rkey, record, mini /* replyBacklinks */ }: Props = $props();
const color = generateColorForDid(identifier) ?? theme.accent2;
···
const post = record
? Promise.resolve(ok(record))
: client.getRecord(AppBskyFeedPost.mainSchema, identifier, rkey);
-
const replies = replyBacklinks
-
? Promise.resolve(ok(replyBacklinks))
-
: client.getBacklinks(
-
identifier,
-
'app.bsky.feed.post',
-
rkey,
-
'app.bsky.feed.post:reply.parent.uri'
-
);
+
// const replies = replyBacklinks
+
// ? Promise.resolve(ok(replyBacklinks))
+
// : client.getBacklinks(
+
// identifier,
+
// 'app.bsky.feed.post',
+
// rkey,
+
// 'app.bsky.feed.post:reply.parent.uri'
+
// );
const getEmbedText = (embedType: string) => {
switch (embedType) {
···
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
-
return `${seconds} second${seconds > 1 ? 's' : ''} ago`;
+
if (seconds > 0) return `${seconds} second${seconds > 1 ? 's' : ''} ago`;
+
return 'just now';
};
</script>
-
{#await post}
+
{#snippet embedBadge(record: AppBskyFeedPost.Main)}
+
{#if record.embed}
+
<span
+
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
+
style="background: {mini ? theme.fg : color}22; color: {mini ? theme.fg : color};"
+
>
+
{getEmbedText(record.embed.$type)}
+
</span>
+
{/if}
+
{/snippet}
+
+
{#if mini}
<div
-
class="rounded-xl border-2 p-3 text-center backdrop-blur-sm"
-
style="background: {color}18; border-color: {color}66;"
+
class="overflow-hidden text-sm text-nowrap overflow-ellipsis opacity-60"
+
style="color: {theme.fg};"
>
-
<div
-
class="inline-block h-6 w-6 animate-spin rounded-full border-3"
-
style="border-color: {theme.accent}; border-left-color: transparent;"
-
></div>
-
<p class="mt-3 text-sm font-medium opacity-60" style="color: {theme.fg};">loading post...</p>
+
{#await post}
+
loading...
+
{:then post}
+
{#if post.ok}
+
{@const record = post.value}
+
<span style="color: {color};">@{handle}</span>: {@render embedBadge(record)}
+
{record.text}
+
{:else}
+
{post.error}
+
{/if}
+
{/await}
</div>
-
{:then post}
-
{#if post.ok}
-
{@const record = post.value}
+
{:else}
+
{#await post}
<div
-
class="rounded-xl border-2 p-3 shadow-lg backdrop-blur-sm transition-all hover:scale-[1.01]"
+
class="rounded-sm border-2 p-3 text-center backdrop-blur-sm"
style="background: {color}18; border-color: {color}66;"
>
-
<div class="mb-3 flex items-center gap-1.5">
-
<span class="font-bold" style="color: {color};">
-
@{handle}
-
</span>
-
<span>·</span>
+
<div
+
class="inline-block h-6 w-6 animate-spin rounded-full border-3"
+
style="border-color: {theme.accent}; border-left-color: transparent;"
+
></div>
+
<p class="mt-3 text-sm font-medium opacity-60" style="color: {theme.fg};">loading post...</p>
+
</div>
+
{:then post}
+
{#if post.ok}
+
{@const record = post.value}
+
<div
+
class="rounded-sm border-2 p-3 shadow-lg backdrop-blur-sm transition-all hover:scale-[1.01]"
+
style="background: {color}18; border-color: {color}66;"
+
>
+
<div class="mb-3 flex items-center gap-1.5">
+
<span class="font-bold" style="color: {color};">
+
@{handle}
+
</span>
+
<!-- <span>·</span>
{#await replies}
<span style="color: {theme.fg}aa;">… replies</span>
{:then replies}
···
style="color: {theme.fg}aa;">{replies.error}</span
>
{/if}
-
{/await}
-
<span>·</span>
-
<span style="color: {theme.fg}aa;">{getRelativeTime(new Date(record.createdAt))}</span>
+
{/await} -->
+
<span>·</span>
+
<span style="color: {theme.fg}aa;">{getRelativeTime(new Date(record.createdAt))}</span>
+
</div>
+
<p class="leading-relaxed text-wrap" style="color: {theme.fg};">
+
{record.text}
+
{@render embedBadge(record)}
+
</p>
</div>
-
<p class="leading-relaxed text-wrap" style="color: {theme.fg};">
-
{record.text}
-
{#if record.embed}
-
<span
-
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
-
style="background: {color}22; color: {color};"
-
>
-
{getEmbedText(record.embed.$type)}
-
</span>
-
{/if}
-
</p>
-
</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>
-
{/if}
-
{/await}
+
{: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>
+
{/if}
+
{/await}
+
{/if}
+29 -22
src/components/PostComposer.svelte
···
<script lang="ts">
import type { AtpClient } from '$lib/at/client';
import { ok, err, type Result } from '$lib/result';
-
import type { ComAtprotoRepoCreateRecord } from '@atcute/atproto';
import type { AppBskyFeedPost } from '@atcute/bluesky';
-
import type { InferOutput } from '@atcute/lexicons';
+
import type { ResourceUri } from '@atcute/lexicons';
import { theme } from '$lib/theme.svelte';
interface Props {
client: AtpClient;
+
onPostSent: (uri: ResourceUri, post: AppBskyFeedPost.Main) => void;
}
-
const { client }: Props = $props();
+
const { client, onPostSent }: Props = $props();
const post = async (
text: string
-
): Promise<
-
Result<InferOutput<(typeof ComAtprotoRepoCreateRecord.mainSchema)['output']['schema']>, string>
-
> => {
+
): Promise<Result<{ uri: ResourceUri; record: AppBskyFeedPost.Main }, string>> => {
const record: AppBskyFeedPost.Main = {
$type: 'app.bsky.feed.post',
text,
···
return err(`failed to post: ${res.data.error}: ${res.data.message ?? 'no details'}`);
}
-
return ok(res.data);
+
return ok({
+
uri: res.data.uri,
+
record
+
});
};
let postText = $state('');
let info = $state('');
+
+
const doPost = () => {
+
post(postText).then((res) => {
+
if (res.ok) {
+
onPostSent(res.value.uri, res.value.record);
+
postText = '';
+
info = 'posted!';
+
setTimeout(() => (info = ''), 1000 * 3);
+
} else {
+
info = res.error;
+
}
+
});
+
};
</script>
<div
-
class="flex min-h-16 max-w-full items-center rounded-xl border-2 px-1 shadow-lg backdrop-blur-sm"
+
class="flex min-h-16 max-w-full items-center rounded-sm border-2 px-1 shadow-lg backdrop-blur-sm"
style="background: {theme.accent}18; border-color: {theme.accent}66;"
>
<div class="w-full p-1">
{#if info.length > 0}
<div
-
class="rounded-lg px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis"
+
class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis"
style="background: {theme.accent}22; color: {theme.accent};"
>
{info}
···
<div class="flex gap-2">
<input
bind:value={postText}
+
onkeydown={(event) => {
+
if (event.key === 'Enter') doPost();
+
}}
type="text"
placeholder="what's on your mind?"
-
class="placeholder-opacity-50 flex-1 rounded-lg border-2 px-3 py-2 text-sm font-medium transition-all focus:scale-[1.01] focus:shadow-lg focus:outline-none"
+
class="placeholder-opacity-50 flex-1 rounded-sm border-2 px-3 py-2 text-sm font-medium transition-all focus:scale-[1.01] focus:shadow-lg focus:outline-none"
style="background: {theme.bg}66; border-color: {theme.accent}44; color: {theme.fg};"
/>
<button
-
onclick={() => {
-
post(postText).then((res) => {
-
if (res.ok) {
-
postText = '';
-
info = 'posted! aaaaaaaaaasdf asdlfkasl;df kjasdfjalsdkfjaskd fajksdhf';
-
setTimeout(() => (info = ''), 1000 * 3);
-
} else {
-
info = res.error;
-
}
-
});
-
}}
-
class="rounded-lg border-none px-5 py-2 text-sm font-bold transition-all hover:scale-105 hover:shadow-xl"
+
onclick={doPost}
+
class="rounded-sm border-none px-5 py-2 text-sm font-bold transition-all hover:scale-105 hover:shadow-xl"
style="background: linear-gradient(120deg, {theme.accent}c0, {theme.accent2}c0); color: {theme.fg}f0;"
>
post
+20 -2
src/lib/at/client.ts
···
import {
isHandle,
parseCanonicalResourceUri,
+
parseResourceUri,
type ActorIdentifier,
type AtprotoDid,
type CanonicalResourceUri,
type Nsid,
-
type RecordKey
+
type RecordKey,
+
type ResourceUri
} from '@atcute/lexicons/syntax';
import type {
+
InferInput,
InferXRPCBodyOutput,
ObjectSchema,
RecordKeySchema,
···
return ok(null);
}
+
async getRecordUri<
+
Collection extends Nsid,
+
TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } },
+
TKey extends RecordKeySchema,
+
Schema extends RecordSchema<TObject, TKey>,
+
Output extends InferInput<Schema>
+
>(schema: Schema, uri: ResourceUri): Promise<Result<Output, string>> {
+
const parsedUri = expect(parseResourceUri(uri));
+
if (parsedUri.collection !== schema.object.shape.$type.expected)
+
return err(
+
`collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}`
+
);
+
return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!);
+
}
+
async getRecord<
Collection extends Nsid,
TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } },
TKey extends RecordKeySchema,
Schema extends RecordSchema<TObject, TKey>,
-
Output extends InferOutput<Schema>
+
Output extends InferInput<Schema>
>(schema: Schema, repo: ActorIdentifier, rkey: RecordKey): Promise<Result<Output, string>> {
const collection = schema.object.shape.$type.expected;
const cacheKey = `${repo}:${collection}:${rkey}`;
+67 -30
src/lib/at/fetch.ts
···
import type { Backlinks } from './constellation';
import { AppBskyFeedPost } from '@atcute/bluesky';
-
export type PostWithBacklinks = {
-
post: AppBskyFeedPost.Main;
-
replies: Backlinks | string;
+
export type PostWithUri = { uri: CanonicalResourceUri; record: AppBskyFeedPost.Main };
+
export type PostWithBacklinks = PostWithUri & {
+
replies: Result<Backlinks, string>;
};
-
export type PostsWithReplyBacklinks = Map<CanonicalResourceUri, PostWithBacklinks>;
+
export type PostsWithReplyBacklinks = PostWithBacklinks[];
-
export const fetchPostsWithReplyBacklinks = async (
+
export const fetchPostsWithBacklinks = async (
client: AtpClient,
repo: ActorIdentifier,
cursor?: string,
···
records.map((r) =>
client
.getBacklinksUri(r.uri as CanonicalResourceUri, 'app.bsky.feed.post:reply.parent.uri')
-
.then((res) => ({
-
key: r.uri as CanonicalResourceUri,
-
value: {
-
post: r.value as AppBskyFeedPost.Main,
-
// filter out posts from the same repo
-
replies: res.ok
-
? { ...res.value, records: res.value.records.filter((r) => r.did !== repo) }
-
: res.error
-
}
-
}))
+
.then(
+
(res): PostWithBacklinks => ({
+
uri: r.uri as CanonicalResourceUri,
+
record: r.value as AppBskyFeedPost.Main,
+
replies: res
+
})
+
)
)
);
-
return ok({ posts: new Map(allBacklinks.map((b) => [b.key, b.value])), cursor });
+
return ok({ posts: allBacklinks, cursor });
};
-
export const fetchReplies = async (client: AtpClient, data: PostsWithReplyBacklinks) => {
-
const allReplies = await Promise.all(
-
Array.from(data.values()).map(async (d) => {
-
if (typeof d.replies === 'string') return [];
-
const replies = await Promise.all(
-
d.replies.records.map((r) =>
-
client
-
.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey)
-
.then((res) =>
-
map(res, (d) => ({ uri: `at://${r.did}/app.bsky.feed.post/${r.rkey}`, record: d }))
+
export const hydratePosts = async (
+
client: AtpClient,
+
data: PostsWithReplyBacklinks
+
): Promise<Map<CanonicalResourceUri, AppBskyFeedPost.Main>> => {
+
const allPosts = await Promise.all(
+
data.map(async (post) => {
+
const result: Result<PostWithUri, string>[] = [ok({ uri: post.uri, record: post.record })];
+
if (post.replies.ok) {
+
const replies = await Promise.all(
+
post.replies.value.records.map((r) =>
+
client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey).then((res) =>
+
map(
+
res,
+
(d): PostWithUri => ({
+
uri: `at://${r.did}/app.bsky.feed.post/${r.rkey}` as CanonicalResourceUri,
+
record: d
+
})
+
)
)
-
)
-
);
-
return replies;
+
)
+
);
+
result.push(...replies);
+
}
+
return result;
})
);
+
const posts = new Map(
+
allPosts
+
.flat()
+
.flatMap((res) => (res.ok ? [res.value] : []))
+
.map((post) => [post.uri, post.record])
+
);
-
return allReplies.flat();
+
// hydrate posts
+
const missingPosts = await Promise.all(
+
Array.from(posts).map(async ([uri, record]) => {
+
let result: PostWithUri[] = [{ uri, record }];
+
let parent = 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 = [{ uri: parent.uri as CanonicalResourceUri, record: p.value }, ...result];
+
parent = p.value.reply?.parent;
+
continue;
+
}
+
parent = undefined;
+
}
+
return result;
+
})
+
);
+
for (const post of missingPosts.flat()) {
+
posts.set(post.uri, post.record);
+
}
+
+
return posts;
};
+1 -1
src/lib/theme.svelte.ts
···
export const theme = $state({
-
bg: '#0f172a', // slate-900 - deep blue-grey background
+
bg: '#11001c', // slate-900 - deep blue-grey background
fg: '#f8fafc', // slate-50 - crisp white foreground
accent: '#ec4899', // pink-500 - vibrant pink accent
accent2: '#8b5cf6' // violet-500 - purple secondary accent
+250 -51
src/routes/+page.svelte
···
import AccountSelector from '$components/AccountSelector.svelte';
import { AtpClient } from '$lib/at/client';
import { accounts, addAccount, type Account } from '$lib/accounts';
-
import { type Did, type Handle, parseCanonicalResourceUri } from '@atcute/lexicons';
+
import {
+
type Did,
+
type Handle,
+
parseCanonicalResourceUri,
+
type ResourceUri
+
} from '@atcute/lexicons';
import { onMount } from 'svelte';
import { theme } from '$lib/theme.svelte';
-
import { fetchPostsWithReplyBacklinks, fetchReplies } from '$lib/at/fetch';
+
import { fetchPostsWithBacklinks, hydratePosts } from '$lib/at/fetch';
import { expect } from '$lib/result';
-
import { writable } from 'svelte/store';
import type { AppBskyFeedPost } from '@atcute/bluesky';
+
import { SvelteMap } from 'svelte/reactivity';
let selectedDid = $state<Did | null>(null);
-
let clients = writable<Map<Did, AtpClient>>(new Map());
-
let selectedClient = $derived(selectedDid ? $clients.get(selectedDid) : null);
+
let clients = new SvelteMap<Did, AtpClient>();
+
let selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null);
let viewClient = $state<AtpClient>(new AtpClient());
onMount(async () => {
if ($accounts.length > 0) {
selectedDid = $accounts[0].did;
-
Promise.all($accounts.map(loginAccount)).then(() => fetchTimeline($accounts));
+
Promise.all($accounts.map(loginAccount)).then(() => fetchTimelines($accounts));
}
});
const loginAccount = async (account: Account) => {
const client = new AtpClient();
const result = await client.login(account.handle, account.password);
-
if (result.ok) {
-
clients.update((map) => map.set(account.did, client));
-
}
+
if (result.ok) clients.set(account.did, client);
};
const handleAccountSelected = async (did: Did) => {
selectedDid = did;
const account = $accounts.find((acc) => acc.did === did);
-
if (account && (!$clients.has(account.did) || !$clients.get(account.did)?.atcute))
+
if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute))
await loginAccount(account);
};
-
const handleLoginSucceed = (did: Did, handle: Handle, password: string) => {
+
const handleLogout = async (did: Did) => {
+
$accounts = $accounts.filter((acc) => acc.did !== did);
+
clients.delete(did);
+
posts.delete(did);
+
};
+
+
const handleLoginSucceed = async (did: Did, handle: Handle, password: string) => {
const newAccount: Account = { did, handle, password };
addAccount(newAccount);
selectedDid = did;
-
loginAccount(newAccount);
+
loginAccount(newAccount).then(() => fetchTimeline(newAccount));
};
-
let timeline = writable<Map<string, AppBskyFeedPost.Main>>(new Map());
-
const fetchTimeline = async (newAccounts: Account[]) => {
-
await Promise.all(
-
newAccounts.map(async (account) => {
-
const client = $clients.get(account.did);
-
if (!client) return;
-
const accPosts = await fetchPostsWithReplyBacklinks(client, account.did, undefined, 20);
-
if (!accPosts.ok) {
-
console.error(`failed to fetch posts for account ${account.handle}: ${accPosts.error}`);
-
return;
+
let posts = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyFeedPost.Main>>();
+
const fetchTimeline = async (account: Account) => {
+
const client = clients.get(account.did);
+
if (!client) return;
+
const accPosts = await fetchPostsWithBacklinks(client, account.did, undefined, 20);
+
if (!accPosts.ok) {
+
console.error(`failed to fetch posts for account ${account.handle}: ${accPosts.error}`);
+
return;
+
}
+
const accTimeline = await hydratePosts(client, accPosts.value.posts);
+
if (!posts.has(account.did)) {
+
posts.set(account.did, new SvelteMap(accTimeline));
+
return;
+
}
+
const map = posts.get(account.did)!;
+
for (const [uri, record] of accTimeline) map.set(uri, record);
+
};
+
const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline));
+
+
let reverseChronological = $state(true);
+
let viewOwnPosts = $state(true);
+
+
type ThreadPost = {
+
uri: ResourceUri;
+
did: Did;
+
rkey: string;
+
record: AppBskyFeedPost.Main;
+
parentUri: ResourceUri | null;
+
depth: number;
+
newestTime: number;
+
};
+
+
type Thread = {
+
rootUri: ResourceUri;
+
posts: ThreadPost[];
+
newestTime: number;
+
branchParentPost?: ThreadPost;
+
};
+
+
const buildThreads = (timelines: Map<Did, Map<ResourceUri, AppBskyFeedPost.Main>>): Thread[] => {
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
+
const threadMap = new Map<ResourceUri, ThreadPost[]>();
+
+
// Single pass: create posts and group by thread
+
for (const [, timeline] of timelines) {
+
for (const [uri, record] of timeline) {
+
const parsedUri = expect(parseCanonicalResourceUri(uri));
+
const rootUri = (record.reply?.root.uri as ResourceUri) || uri;
+
const parentUri = (record.reply?.parent.uri as ResourceUri) || null;
+
+
const post: ThreadPost = {
+
uri,
+
did: parsedUri.repo,
+
rkey: parsedUri.rkey,
+
record,
+
parentUri,
+
depth: 0,
+
newestTime: new Date(record.createdAt).getTime()
+
};
+
+
if (!threadMap.has(rootUri)) {
+
threadMap.set(rootUri, []);
+
}
+
threadMap.get(rootUri)!.push(post);
+
}
+
}
+
+
const threads: Thread[] = [];
+
+
for (const [rootUri, posts] of threadMap) {
+
const uriToPost = new Map(posts.map((p) => [p.uri, p]));
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
+
const childrenMap = new Map<ResourceUri | null, ThreadPost[]>();
+
+
// Calculate depth and group by parent
+
for (const post of posts) {
+
let depth = 0;
+
let currentUri = post.parentUri;
+
+
while (currentUri && uriToPost.has(currentUri)) {
+
depth++;
+
currentUri = uriToPost.get(currentUri)!.parentUri;
+
}
+
+
post.depth = depth;
+
+
if (!childrenMap.has(post.parentUri)) {
+
childrenMap.set(post.parentUri, []);
}
-
const accTimeline = await fetchReplies(client, accPosts.value.posts);
-
for (const reply of accTimeline) {
-
if (!reply.ok) {
-
console.error(`failed to fetch reply: ${reply.error}`);
-
return;
+
childrenMap.get(post.parentUri)!.push(post);
+
}
+
+
// Sort children by time (newest first)
+
for (const children of childrenMap.values()) {
+
children.sort((a, b) => b.newestTime - a.newestTime);
+
}
+
+
// Helper to create a thread from posts
+
const createThread = (
+
posts: ThreadPost[],
+
rootUri: ResourceUri,
+
branchParentUri?: ResourceUri
+
): Thread => {
+
return {
+
rootUri,
+
posts,
+
newestTime: Math.max(...posts.map((p) => p.newestTime)),
+
branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined
+
};
+
};
+
+
// Helper to collect all posts in a subtree
+
const collectSubtree = (startPost: ThreadPost): ThreadPost[] => {
+
const result: ThreadPost[] = [];
+
const addWithChildren = (post: ThreadPost) => {
+
result.push(post);
+
const children = childrenMap.get(post.uri) || [];
+
for (const child of children) {
+
addWithChildren(child);
}
-
timeline.update((map) => map.set(reply.value.uri, reply.value.record));
+
};
+
addWithChildren(startPost);
+
return result;
+
};
+
+
// Find branching points (posts with 2+ children)
+
const branchingPoints = Array.from(childrenMap.entries())
+
.filter(([, children]) => children.length > 1)
+
.map(([uri]) => uri);
+
+
if (branchingPoints.length === 0) {
+
// No branches - single thread
+
const roots = childrenMap.get(null) || [];
+
const allPosts = roots.flatMap((root) => collectSubtree(root));
+
threads.push(createThread(allPosts, rootUri));
+
} else {
+
// Has branches - split into separate threads
+
for (const branchParentUri of branchingPoints) {
+
const branches = childrenMap.get(branchParentUri) || [];
+
+
// Sort branches oldest to newest for processing
+
const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime);
+
+
sortedBranches.forEach((branchRoot, index) => {
+
const isOldestBranch = index === 0;
+
const branchPosts: ThreadPost[] = [];
+
+
// If oldest branch, include parent chain
+
if (isOldestBranch && branchParentUri !== null) {
+
const parentChain: ThreadPost[] = [];
+
let currentUri: ResourceUri | null = branchParentUri;
+
while (currentUri && uriToPost.has(currentUri)) {
+
parentChain.unshift(uriToPost.get(currentUri)!);
+
currentUri = uriToPost.get(currentUri)!.parentUri;
+
}
+
branchPosts.push(...parentChain);
+
}
+
+
// Add branch posts
+
branchPosts.push(...collectSubtree(branchRoot));
+
+
// Recalculate depths for display
+
const minDepth = Math.min(...branchPosts.map((p) => p.depth));
+
branchPosts.forEach((p) => (p.depth = p.depth - minDepth));
+
+
threads.push(
+
createThread(
+
branchPosts,
+
branchRoot.uri,
+
isOldestBranch ? undefined : (branchParentUri ?? undefined)
+
)
+
);
+
});
}
-
})
-
);
-
};
-
accounts.subscribe(fetchTimeline);
+
}
+
}
-
const getSortedTimeline = (_timeline: Map<string, AppBskyFeedPost.Main>) => {
-
const sortedTimeline = Array.from(_timeline).sort(
-
([_a, post], [_b, post2]) =>
-
new Date(post2.createdAt).getTime() - new Date(post.createdAt).getTime()
-
);
-
return sortedTimeline;
+
// Sort threads by newest time (descending) so older branches appear first
+
threads.sort((a, b) => b.newestTime - a.newestTime);
+
+
return threads;
};
-
let sortedTimeline = $derived(getSortedTimeline($timeline));
+
+
// Filtering functions (now much simpler!)
+
const isOwnPost = (post: ThreadPost, accounts: Account[]) =>
+
accounts.some((account) => account.did === post.did);
+
const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) =>
+
posts.some((post) => !isOwnPost(post, accounts));
+
const filterThreads = (threads: Thread[], accounts: Account[]) =>
+
threads.filter((thread) => {
+
if (!viewOwnPosts) {
+
return hasNonOwnPost(thread.posts, accounts);
+
}
+
return true;
+
});
+
+
// Usage
+
let threads = $derived(filterThreads(buildThreads(posts), $accounts));
</script>
<div class="mx-auto max-w-2xl p-4">
···
bind:selectedDid
onAccountSelected={handleAccountSelected}
onLoginSucceed={handleLoginSucceed}
+
onLogout={handleLogout}
/>
{#if selectedClient}
<div class="flex-1">
-
<PostComposer client={selectedClient} />
+
<PostComposer
+
client={selectedClient}
+
onPostSent={(uri, record) => posts.get(selectedDid!)?.set(uri, record)}
+
/>
</div>
{:else}
<div
-
class="flex flex-1 items-center justify-center rounded-xl border-2 px-4 py-2.5 backdrop-blur-sm"
+
class="flex flex-1 items-center justify-center rounded-sm border-2 px-4 py-2.5 backdrop-blur-sm"
style="border-color: {theme.accent}33; background: {theme.accent}0a;"
>
<p class="text-sm opacity-80" style="color: {theme.fg};">
···
</div>
<hr
-
class="h-[3px] w-full rounded-full border-0"
+
class="h-[4px] w-full rounded-full border-0"
style="background: linear-gradient(to right, {theme.accent}, {theme.accent2});"
/>
-
<div class="flex flex-col gap-3">
-
{#each sortedTimeline as [postUri, data] (postUri)}
-
{@const parsedUri = expect(parseCanonicalResourceUri(postUri))}
-
<BskyPost
-
client={viewClient}
-
identifier={parsedUri.repo}
-
rkey={parsedUri.rkey}
-
record={data}
-
/>
+
<div class="flex flex-col">
+
{#each threads as thread (thread.rootUri)}
+
<div class="flex {reverseChronological ? 'flex-col' : 'flex-col-reverse'} mb-6.5">
+
{#if thread.branchParentPost}
+
{@const post = thread.branchParentPost}
+
<div class="mb-1.5 flex items-center gap-1.5">
+
<span class="text-sm opacity-60" style="color: {theme.fg};"
+
>{reverseChronological ? '↱' : '↳'}</span
+
>
+
<BskyPost
+
mini
+
client={viewClient}
+
identifier={post.did}
+
rkey={post.rkey}
+
record={post.record}
+
/>
+
</div>
+
{/if}
+
{#each thread.posts as post (post.uri)}
+
<div class="mb-1.5">
+
<BskyPost
+
client={viewClient}
+
identifier={post.did}
+
rkey={post.rkey}
+
record={post.record}
+
/>
+
</div>
+
{/each}
+
</div>
{/each}
</div>
</div>