replies timeline only, appview-less bluesky client

feat: implement like a bunch of shit

+16
deno.lock
···
"npm:@tailwindcss/forms@~0.5.10": "0.5.10_tailwindcss@4.1.14",
"npm:@tailwindcss/vite@^4.1.13": "4.1.14_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0",
"npm:@types/node@24": "24.8.0",
+
"npm:@wora/cache-persist@^2.2.1": "2.2.1",
"npm:eslint-config-prettier@^10.1.8": "10.1.8_eslint@9.37.0",
"npm:eslint-plugin-svelte@^3.12.4": "3.12.4_eslint@9.37.0_svelte@5.40.1__acorn@8.15.0_postcss@8.5.6",
"npm:eslint@^9.36.0": "9.37.0",
"npm:globals@^16.4.0": "16.4.0",
+
"npm:hash-wasm@^4.12.0": "4.12.0",
"npm:lru-cache@^11.2.2": "11.2.2",
"npm:prettier-plugin-svelte@^3.4.0": "3.4.0_prettier@3.6.2_svelte@5.40.1__acorn@8.15.0",
"npm:prettier-plugin-tailwindcss@~0.6.14": "0.6.14_prettier@3.6.2_prettier-plugin-svelte@3.4.0__prettier@3.6.2__svelte@5.40.1___acorn@8.15.0_svelte@5.40.1__acorn@8.15.0",
···
"dependencies": [
"@typescript-eslint/types",
"eslint-visitor-keys@4.2.1"
+
]
+
},
+
"@wora/cache-persist@2.2.1": {
+
"integrity": "sha512-X9MHiML5F8z3mabnl6J8hAwjn9g6Sria6+wUGwo97UDLMOWpZtJ+Jp/DQ7GjI1JirVXMQUDXBftVDgpvjhpNcw==",
+
"dependencies": [
+
"idb"
]
},
"acorn-jsx@5.3.2_acorn@8.15.0": {
···
"has-flag@4.0.0": {
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
},
+
"hash-wasm@4.12.0": {
+
"integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ=="
+
},
+
"idb@4.0.5": {
+
"integrity": "sha512-P+Fk9HT2h1DhXoE1YNK183SY+CRh2GHNh28de94sGwhe0bUA75JJeVJWt3SenE5p0BXK7maflIq29dl6UZHrFw=="
+
},
"ignore@5.3.2": {
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="
},
···
"npm:@tailwindcss/forms@~0.5.10",
"npm:@tailwindcss/vite@^4.1.13",
"npm:@types/node@24",
+
"npm:@wora/cache-persist@^2.2.1",
"npm:eslint-config-prettier@^10.1.8",
"npm:eslint-plugin-svelte@^3.12.4",
"npm:eslint@^9.36.0",
"npm:globals@^16.4.0",
+
"npm:hash-wasm@^4.12.0",
"npm:lru-cache@^11.2.2",
"npm:prettier-plugin-svelte@^3.4.0",
"npm:prettier-plugin-tailwindcss@~0.6.14",
+2
package.json
···
"@atcute/client": "^4.0.5",
"@atcute/identity": "^1.1.1",
"@atcute/lexicons": "^1.2.2",
+
"@wora/cache-persist": "^2.2.1",
+
"hash-wasm": "^4.12.0",
"lru-cache": "^11.2.2"
},
"devDependencies": {
+14
src/app.css
···
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
+
+
.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-repeat: repeat;
+
background-size: 364px;
+
opacity: 0.05;
+
top: 0;
+
left: 0;
+
position: absolute;
+
width: 100%;
+
height: 100%;
+
}
+295
src/components/AccountSelector.svelte
···
+
<script lang="ts">
+
import { generateColorForDid, type Account } from '$lib/accounts';
+
import { AtpClient } from '$lib/at/client';
+
import type { Did, Handle } from '@atcute/lexicons';
+
import { theme } from '$lib/theme.svelte';
+
+
let {
+
accounts = [],
+
selectedDid = $bindable(null),
+
onAccountSelected,
+
onLoginSucceed
+
}: {
+
accounts: Array<Account>;
+
selectedDid?: Did | null;
+
onAccountSelected: (did: Did) => void;
+
onLoginSucceed: (did: Did, handle: Handle, password: string) => void;
+
} = $props();
+
+
let color = $derived(selectedDid ? (generateColorForDid(selectedDid) ?? theme.fg) : theme.fg);
+
+
let isDropdownOpen = $state(false);
+
let isLoginModalOpen = $state(false);
+
let loginHandle = $state('');
+
let loginPassword = $state('');
+
let loginError = $state('');
+
let isLoggingIn = $state(false);
+
+
const toggleDropdown = (e: MouseEvent) => {
+
e.stopPropagation();
+
isDropdownOpen = !isDropdownOpen;
+
};
+
+
const selectAccount = (did: Did) => {
+
onAccountSelected(did);
+
isDropdownOpen = false;
+
};
+
+
const openLoginModal = () => {
+
isLoginModalOpen = true;
+
isDropdownOpen = false;
+
loginHandle = '';
+
loginPassword = '';
+
loginError = '';
+
};
+
+
const closeLoginModal = () => {
+
isLoginModalOpen = false;
+
loginHandle = '';
+
loginPassword = '';
+
loginError = '';
+
};
+
+
const handleLogin = async () => {
+
if (!loginHandle || !loginPassword) {
+
loginError = 'please enter both handle and password';
+
return;
+
}
+
+
isLoggingIn = true;
+
loginError = '';
+
+
try {
+
const client = new AtpClient();
+
const result = await client.login(loginHandle as Handle, loginPassword);
+
+
if (!result.ok) {
+
loginError = result.error;
+
isLoggingIn = false;
+
return;
+
}
+
+
if (!client.didDoc) {
+
loginError = 'failed to get did document';
+
isLoggingIn = false;
+
return;
+
}
+
+
onLoginSucceed(client.didDoc.did, loginHandle as Handle, loginPassword);
+
closeLoginModal();
+
} catch (error) {
+
loginError = `login failed: ${error}`;
+
} finally {
+
isLoggingIn = false;
+
}
+
};
+
+
const handleKeydown = (event: KeyboardEvent) => {
+
if (event.key === 'Escape') {
+
closeLoginModal();
+
} else if (event.key === 'Enter' && !isLoggingIn) {
+
handleLogin();
+
}
+
};
+
+
const closeDropdown = () => {
+
isDropdownOpen = false;
+
};
+
+
let selectedAccount = $derived(accounts.find((acc) => acc.did === selectedDid));
+
</script>
+
+
<svelte:window onclick={closeDropdown} />
+
+
<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"
+
style="border-color: {theme.accent}66; background: {theme.accent}18; color: {color}; backdrop-filter: blur(8px);"
+
>
+
<span class="text-sm">
+
{selectedAccount ? `@${selectedAccount.handle}` : 'select account'}
+
</span>
+
<svg
+
class="h-4 w-4 transition-transform {isDropdownOpen ? 'rotate-180' : ''}"
+
style="color: {theme.accent};"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
+
</svg>
+
</button>
+
+
{#if isDropdownOpen}
+
<!-- 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"
+
style="border-color: {theme.accent}; background: {theme.bg}f0;"
+
onclick={(e) => e.stopPropagation()}
+
>
+
{#if accounts.length > 0}
+
<div class="p-2">
+
{#each accounts as account (account.did)}
+
{@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]'}"
+
style="color: {color}; background: {account.did === selectedDid
+
? `linear-gradient(135deg, ${theme.accent}33, ${theme.accent2}33)`
+
: 'transparent'};"
+
>
+
<span>@{account.handle}</span>
+
{#if account.did === selectedDid}
+
<svg
+
class="ml-auto h-5 w-5"
+
style="color: {theme.accent};"
+
fill="currentColor"
+
viewBox="0 0 20 20"
+
>
+
<path
+
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"
+
clip-rule="evenodd"
+
/>
+
</svg>
+
{/if}
+
</button>
+
{/each}
+
</div>
+
<div
+
class="mx-2 h-px"
+
style="background: linear-gradient(to right, {theme.accent}, {theme.accent2});"
+
></div>
+
{/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]"
+
style="color: {theme.accent};"
+
>
+
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2.5"
+
d="M12 4v16m8-8H4"
+
/>
+
</svg>
+
<span>add account</span>
+
</button>
+
</div>
+
{/if}
+
</div>
+
+
{#if isLoginModalOpen}
+
<div
+
class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm"
+
style="background: {theme.bg}cc;"
+
onclick={closeLoginModal}
+
onkeydown={handleKeydown}
+
role="button"
+
tabindex="-1"
+
>
+
<!-- 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"
+
style="background: {theme.bg}; border-color: {theme.accent};"
+
onclick={(e) => e.stopPropagation()}
+
role="dialog"
+
>
+
<div class="mb-6 flex items-center justify-between">
+
<div>
+
<h2 class="text-2xl font-bold" style="color: {theme.fg};">add account</h2>
+
<div class="mt-2 flex gap-2">
+
<div class="h-1 w-10 rounded-full" style="background: {theme.accent};"></div>
+
<div class="h-1 w-9 rounded-full" style="background: {theme.accent2};"></div>
+
</div>
+
</div>
+
<!-- svelte-ignore a11y_consider_explicit_label -->
+
<button
+
onclick={closeLoginModal}
+
class="rounded-xl p-2 transition-all hover:scale-110"
+
style="color: {theme.fg}66; hover:color: {theme.fg};"
+
>
+
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2.5"
+
d="M6 18L18 6M6 6l12 12"
+
/>
+
</svg>
+
</button>
+
</div>
+
+
<div class="space-y-5">
+
<div>
+
<label for="handle" class="mb-2 block text-sm font-semibold" style="color: {theme.fg}cc;">
+
handle
+
</label>
+
<input
+
id="handle"
+
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"
+
style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};"
+
disabled={isLoggingIn}
+
/>
+
</div>
+
+
<div>
+
<label
+
for="password"
+
class="mb-2 block text-sm font-semibold"
+
style="color: {theme.fg}cc;"
+
>
+
app password
+
</label>
+
<input
+
id="password"
+
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"
+
style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};"
+
disabled={isLoggingIn}
+
/>
+
</div>
+
+
{#if loginError}
+
<div
+
class="rounded-xl border-2 p-4"
+
style="background: #ef444422; border-color: #ef4444;"
+
>
+
<p class="text-sm font-medium" style="color: #fca5a5;">{loginError}</p>
+
</div>
+
{/if}
+
+
<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"
+
style="background: {theme.bg}; border-color: {theme.fg}33; color: {theme.fg};"
+
disabled={isLoggingIn}
+
>
+
cancel
+
</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"
+
style="background: linear-gradient(135deg, {theme.accent}, {theme.accent2}); border-color: transparent; color: {theme.fg};"
+
disabled={isLoggingIn}
+
>
+
{isLoggingIn ? 'logging in...' : 'login'}
+
</button>
+
</div>
+
</div>
+
</div>
+
</div>
+
{/if}
+109 -12
src/components/BskyPost.svelte
···
import type { AtpClient } from '$lib/at/client';
import { AppBskyFeedPost } from '@atcute/bluesky';
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;
+
record?: AppBskyFeedPost.Main;
}
-
const { client, identifier, rkey }: Props = $props();
+
const { client, identifier, rkey, record, replyBacklinks }: Props = $props();
-
const post = client.getRecord(AppBskyFeedPost.mainSchema, identifier, rkey);
+
const color = generateColorForDid(identifier) ?? theme.accent2;
+
+
let handle = $state(identifier);
+
client
+
.resolveDidDoc(identifier)
+
.then((res) => map(res, (data) => data.handle))
+
.then((res) => {
+
if (res.ok) handle = res.value;
+
});
+
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 getEmbedText = (embedType: string) => {
switch (embedType) {
case 'app.bsky.embed.external':
-
return 'contains external link';
+
return '🔗 has external link';
case 'app.bsky.embed.record':
-
return 'quotes post';
+
return '💬 has quote';
case 'app.bsky.embed.images':
-
return 'contains images';
+
return '🖼️ has images';
case 'app.bsky.embed.video':
-
return 'contains video';
+
return '🎥 has video';
case 'app.bsky.embed.recordWithMedia':
-
return 'quotes post with media';
+
return '📎 has quote with media';
default:
-
return 'contains unknown embed';
+
return '❓ has unknown embed';
}
};
+
+
const getRelativeTime = (date: Date) => {
+
const now = new Date();
+
const diff = now.getTime() - date.getTime();
+
const seconds = Math.floor(diff / 1000);
+
const minutes = Math.floor(seconds / 60);
+
const hours = Math.floor(minutes / 60);
+
const days = Math.floor(hours / 24);
+
const months = Math.floor(days / 30);
+
const years = Math.floor(months / 12);
+
+
if (years > 0) return `${years} year${years > 1 ? 's' : ''} ago`;
+
if (months > 0) return `${months} month${months > 1 ? 's' : ''} ago`;
+
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`;
+
};
</script>
{#await post}
-
loading post...
+
<div
+
class="rounded-xl border-2 p-3 text-center backdrop-blur-sm"
+
style="background: {color}18; border-color: {color}66;"
+
>
+
<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}
-
{identifier} - [{record.embed ? getEmbedText(record.embed.$type) : null}]
-
{record.text}
+
<div
+
class="rounded-xl 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}
+
{#if replies.ok}
+
{@const repliesValue = replies.value}
+
<span style="color: {theme.fg}aa;">
+
{#if repliesValue.total > 0}
+
{repliesValue.total}
+
{repliesValue.total > 1 ? 'replies' : 'reply'}
+
{:else}
+
no replies
+
{/if}
+
</span>
+
{:else}
+
<span
+
title={`${replies.error}`}
+
class="max-w-[32ch] overflow-hidden text-nowrap"
+
style="color: {theme.fg}aa;">{replies.error}</span
+
>
+
{/if}
+
{/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}
+
{#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}
-
error fetching post: {post.error}
+
<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}
+41 -19
src/components/PostComposer.svelte
···
import type { ComAtprotoRepoCreateRecord } from '@atcute/atproto';
import type { AppBskyFeedPost } from '@atcute/bluesky';
import type { InferOutput } from '@atcute/lexicons';
+
import { theme } from '$lib/theme.svelte';
interface Props {
client: AtpClient;
···
let info = $state('');
</script>
-
<div class="flex flex-col gap-0.5">
-
{#if info.length > 0}
-
<span class="text-sm text-gray-500">{info}</span>
-
{/if}
-
<div class="flex gap-2">
-
<input bind:value={postText} type="text" placeholder="write your post here..." />
-
<button
-
onclick={() => {
-
post(postText).then((res) => {
-
if (res.ok) {
-
postText = '';
-
info = 'posted!';
-
setTimeout(() => (info = ''), 1000 * 3);
-
} else {
-
info = res.error;
-
}
-
});
-
}}>post</button
-
>
+
<div
+
class="flex min-h-16 max-w-full items-center rounded-xl 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"
+
style="background: {theme.accent}22; color: {theme.accent};"
+
>
+
{info}
+
</div>
+
{:else}
+
<div class="flex gap-2">
+
<input
+
bind:value={postText}
+
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"
+
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"
+
style="background: linear-gradient(120deg, {theme.accent}c0, {theme.accent2}c0); color: {theme.fg}f0;"
+
>
+
post
+
</button>
+
</div>
+
{/if}
</div>
</div>
+41
src/lib/accounts.ts
···
+
import type { Did, Handle } from '@atcute/lexicons';
+
import { writable } from 'svelte/store';
+
import { createXXHash3, type IHasher } from 'hash-wasm';
+
+
export type Account = {
+
did: Did;
+
handle: Handle;
+
password: string;
+
};
+
+
let _accounts: Account[] = [];
+
export const accounts = (() => {
+
const raw = localStorage.getItem('accounts');
+
_accounts = raw ? JSON.parse(raw) : [];
+
const store = writable<Account[]>(_accounts);
+
store.subscribe((accounts) => {
+
_accounts = accounts;
+
localStorage.setItem('accounts', JSON.stringify(accounts));
+
});
+
return store;
+
})();
+
+
export const addAccount = (account: Account): void => {
+
accounts.update((accounts) => [...accounts, account]);
+
};
+
+
// fucked up and evil (i hate promises :3)
+
const _initHasher = () => {
+
createXXHash3(90001, 8008135).then((s) => (hasher = s));
+
return null;
+
};
+
let hasher: IHasher | null = _initHasher();
+
+
export const generateColorForDid = (did: string): string | null => {
+
const h = hasher!;
+
h.init();
+
h.update(did);
+
const hex = h.digest();
+
const color = hex.slice(-6);
+
return `#${color}`;
+
};
+90 -33
src/lib/at/client.ts
···
-
import { err, map, ok, type Result } from '$lib/result';
-
import { ComAtprotoIdentityResolveIdentity, ComAtprotoRepoGetRecord } from '@atcute/atproto';
+
import { err, expect, map, ok, type Result } from '$lib/result';
+
import {
+
ComAtprotoIdentityResolveHandle,
+
ComAtprotoRepoGetRecord,
+
ComAtprotoRepoListRecords
+
} from '@atcute/atproto';
import { Client as AtcuteClient, CredentialManager } from '@atcute/client';
import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons';
-
import type { ActorIdentifier, AtprotoDid, Nsid, RecordKey } from '@atcute/lexicons/syntax';
+
import {
+
isHandle,
+
parseCanonicalResourceUri,
+
type ActorIdentifier,
+
type AtprotoDid,
+
type CanonicalResourceUri,
+
type Nsid,
+
type RecordKey
+
} from '@atcute/lexicons/syntax';
import type {
InferXRPCBodyOutput,
ObjectSchema,
···
XRPCQueryMetadata
} from '@atcute/lexicons/validations';
import * as v from '@atcute/lexicons/validations';
-
import { LRUCache } from 'lru-cache';
-
-
export const MiniDocQuery = v.query('com.bad-example.identity.resolveMiniDoc', {
-
params: v.object({
-
identifier: v.actorIdentifierString()
-
}),
-
output: {
-
type: 'lex',
-
schema: v.object({
-
did: v.didString(),
-
handle: v.handleString(),
-
pds: v.genericUriString(),
-
signing_key: v.string()
-
})
-
}
-
});
-
export type MiniDoc = InferOutput<typeof MiniDocQuery.output.schema>;
+
import { MiniDocQuery, type MiniDoc } from './slingshot';
+
import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation';
+
import type { Records } from '@atcute/lexicons/ambient';
+
import { PersistedLRU } from '$lib/cache';
const cacheTtl = 1000 * 60 * 60 * 24;
-
const handleCache = new LRUCache<Handle, AtprotoDid>({
+
const handleCache = new PersistedLRU<Handle, AtprotoDid>({
max: 1000,
-
ttl: cacheTtl
+
ttl: cacheTtl,
+
prefix: 'handle'
});
-
const didDocCache = new LRUCache<ActorIdentifier, MiniDoc>({
+
const didDocCache = new PersistedLRU<ActorIdentifier, MiniDoc>({
max: 1000,
-
ttl: cacheTtl
+
ttl: cacheTtl,
+
prefix: 'didDoc'
});
-
const recordCache = new LRUCache<
+
const recordCache = new PersistedLRU<
string,
InferOutput<typeof ComAtprotoRepoGetRecord.mainSchema.output.schema>
>({
max: 5000,
-
ttl: cacheTtl
+
ttl: cacheTtl,
+
prefix: 'record'
});
export class AtpClient {
···
private slingshotUrl: URL = new URL('https://slingshot.microcosm.blue');
private spacedustUrl: URL = new URL('https://spacedust.microcosm.blue');
+
private constellationUrl: URL = new URL('https://constellation.microcosm.blue');
async login(handle: Handle, password: string): Promise<Result<null, string>> {
const didDoc = await this.resolveDidDoc(handle);
···
return ok(parsed.value as Output);
}
+
async listRecords<Collection extends keyof Records>(
+
collection: Collection,
+
repo: ActorIdentifier,
+
cursor?: string,
+
limit?: number
+
): Promise<
+
Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string>
+
> {
+
if (!this.atcute) return err('not authenticated');
+
const res = await this.atcute.get('com.atproto.repo.listRecords', {
+
params: {
+
repo,
+
collection,
+
cursor,
+
limit
+
}
+
});
+
if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`);
+
return ok(res.data);
+
}
+
async resolveHandle(handle: Handle): Promise<Result<AtprotoDid, string>> {
const cached = handleCache.get(handle);
if (cached) return ok(cached);
const res = await fetchMicrocosm(
this.slingshotUrl,
-
ComAtprotoIdentityResolveIdentity.mainSchema,
+
ComAtprotoIdentityResolveHandle.mainSchema,
{
-
handle: handle
+
handle
}
);
···
return result;
}
+
+
async getBacklinksUri(
+
uri: CanonicalResourceUri,
+
source: BacklinksSource
+
): Promise<Result<Backlinks, string>> {
+
const parsedResourceUri = expect(parseCanonicalResourceUri(uri));
+
return await this.getBacklinks(
+
parsedResourceUri.repo,
+
parsedResourceUri.collection,
+
parsedResourceUri.rkey,
+
source
+
);
+
}
+
+
async getBacklinks(
+
repo: ActorIdentifier,
+
collection: Nsid,
+
rkey: RecordKey,
+
source: BacklinksSource
+
): Promise<Result<Backlinks, string>> {
+
let did = repo;
+
if (isHandle(did)) {
+
const resolvedDid = await this.resolveHandle(did);
+
if (!resolvedDid.ok) {
+
return err(`failed to resolve handle: ${resolvedDid.error}`);
+
}
+
did = resolvedDid.value;
+
}
+
return await fetchMicrocosm(this.constellationUrl, BacklinksQuery, {
+
subject: `at://${did}/${collection}/${rkey}`,
+
source,
+
limit: 100
+
});
+
}
}
const fetchMicrocosm = async <
Schema extends XRPCQueryMetadata,
+
Input extends Schema['params'] extends ObjectSchema ? InferOutput<Schema['params']> : undefined,
Output extends InferXRPCBodyOutput<Schema['output']>
>(
api: URL,
schema: Schema,
-
params?: URLSearchParams | Record<string, string>,
+
params: Input,
init?: RequestInit
): Promise<Result<Output, string>> => {
if (!schema.output || schema.output.type === 'blob') return err('schema must be blob');
-
if (params && !(params instanceof URLSearchParams)) params = new URLSearchParams(params);
-
if (params?.size === 0) params = undefined;
try {
api.pathname = `/xrpc/${schema.nsid}`;
-
api.search = params ? `?${params}` : '';
-
// console.info(`fetching:`, api.href);
+
api.search = params ? `?${new URLSearchParams(params)}` : '';
+
console.info(`fetching:`, api.href);
const response = await fetch(api, init);
const body = await response.json();
if (response.status === 400) return err(`${body.error}: ${body.message}`);
+27
src/lib/at/constellation.ts
···
+
import type { Nsid } from '@atcute/lexicons';
+
import * as v from '@atcute/lexicons/validations';
+
+
export type BacklinksSource = `${Nsid}:${string}`;
+
export const BacklinkSchema = v.object({
+
did: v.didString(),
+
collection: v.nsidString(),
+
rkey: v.recordKeyString()
+
});
+
export const BacklinksQuery = v.query('blue.microcosm.links.getBacklinks', {
+
params: v.object({
+
subject: v.resourceUriString(),
+
source: v.string(),
+
did: v.optional(v.array(v.didString())),
+
limit: v.optional(v.integer())
+
}),
+
output: {
+
type: 'lex',
+
schema: v.object({
+
total: v.integer(),
+
records: v.array(BacklinkSchema),
+
cursor: v.nullable(v.string())
+
})
+
}
+
});
+
export type Backlink = v.InferOutput<typeof BacklinkSchema>;
+
export type Backlinks = v.InferOutput<typeof BacklinksQuery.output.schema>;
+59
src/lib/at/fetch.ts
···
+
import type { ActorIdentifier, CanonicalResourceUri } from '@atcute/lexicons';
+
import type { AtpClient } from './client';
+
import { err, map, ok, type Result } from '$lib/result';
+
import type { Backlinks } from './constellation';
+
import { AppBskyFeedPost } from '@atcute/bluesky';
+
+
export type PostWithBacklinks = {
+
post: AppBskyFeedPost.Main;
+
replies: Backlinks | string;
+
};
+
export type PostsWithReplyBacklinks = Map<CanonicalResourceUri, PostWithBacklinks>;
+
+
export const fetchPostsWithReplyBacklinks = async (
+
client: AtpClient,
+
repo: ActorIdentifier,
+
cursor?: string,
+
limit?: number
+
): Promise<Result<{ posts: PostsWithReplyBacklinks; cursor?: string }, string>> => {
+
const recordsList = await client.listRecords('app.bsky.feed.post', repo, cursor, limit);
+
if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`);
+
cursor = recordsList.value.cursor;
+
const records = recordsList.value.records;
+
+
const allBacklinks = await Promise.all(
+
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,
+
replies: res.ok ? res.value : res.error
+
}
+
}))
+
)
+
);
+
+
return ok({ posts: new Map(allBacklinks.map((b) => [b.key, b.value])), 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 }))
+
)
+
)
+
);
+
return replies;
+
})
+
);
+
+
return allReplies.flat();
+
};
+17
src/lib/at/slingshot.ts
···
+
import * as v from '@atcute/lexicons/validations';
+
+
export const MiniDocQuery = v.query('com.bad-example.identity.resolveMiniDoc', {
+
params: v.object({
+
identifier: v.actorIdentifierString()
+
}),
+
output: {
+
type: 'lex',
+
schema: v.object({
+
did: v.didString(),
+
handle: v.handleString(),
+
pds: v.genericUriString(),
+
signing_key: v.string()
+
})
+
}
+
});
+
export type MiniDoc = v.InferOutput<typeof MiniDocQuery.output.schema>;
+71
src/lib/cache.ts
···
+
import { Cache, type CacheOptions } from '@wora/cache-persist';
+
import { LRUCache } from 'lru-cache';
+
+
export interface PersistedLRUOptions {
+
prefix?: string;
+
max: number;
+
ttl?: number;
+
persistOptions?: CacheOptions;
+
}
+
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+
export class PersistedLRU<K extends string, V extends {}> {
+
private memory: LRUCache<K, V>;
+
private storage: Cache; // from wora/cache-persist
+
+
private prefix = ''; // or derive from options
+
+
constructor(opts: PersistedLRUOptions) {
+
this.memory = new LRUCache<K, V>({
+
max: opts.max,
+
ttl: opts.ttl
+
});
+
this.storage = new Cache(opts.persistOptions);
+
this.prefix = opts.prefix ? `${opts.prefix}%` : '';
+
+
this.init();
+
}
+
+
async init(): Promise<void> {
+
await this.storage.restore();
+
+
const state = this.storage.getState();
+
for (const [key, val] of Object.entries(state)) {
+
try {
+
console.log('restoring', key);
+
const k = this.unprefix(key) as unknown as K;
+
const v = val as V;
+
this.memory.set(k, v);
+
} catch (err) {
+
console.warn('skipping invalid persisted entry', key, err);
+
}
+
}
+
}
+
+
get(key: K): V | undefined {
+
return this.memory.get(key);
+
}
+
set(key: K, value: V): void {
+
this.memory.set(key, value);
+
this.storage.set(this.prefixed(key), value);
+
this.storage.flush(); // TODO: uh evil and fucked up (this whole file is evil honestly)
+
}
+
has(key: K): boolean {
+
return this.memory.has(key);
+
}
+
delete(key: K): void {
+
this.memory.delete(key);
+
this.storage.delete(this.prefixed(key));
+
}
+
clear(): void {
+
this.memory.clear();
+
this.storage.purge(); // clears stored state
+
}
+
+
private prefixed(key: K): string {
+
return this.prefix + key;
+
}
+
private unprefix(prefixed: string): string {
+
return prefixed.slice(this.prefix.length);
+
}
+
}
+13
src/lib/theme.svelte.ts
···
+
export const theme = $state({
+
bg: '#0f172a', // 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
+
});
+
+
export const setTheme = (bg: string, fg: string, accent: string, accent2: string) => {
+
theme.bg = bg;
+
theme.fg = fg;
+
theme.accent = accent;
+
theme.accent2 = accent2;
+
};
+7 -1
src/routes/+layout.svelte
···
<script lang="ts">
import '../app.css';
+
import { theme } from '$lib/theme.svelte';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
···
<link rel="icon" href={favicon} />
</svelte:head>
-
{@render children?.()}
+
<div
+
class="grain min-h-screen transition-colors duration-300"
+
style="background: {theme.bg}; color: {theme.fg};"
+
>
+
{@render children?.()}
+
</div>
+129 -5
src/routes/+page.svelte
···
<script lang="ts">
import BskyPost from '$components/BskyPost.svelte';
import PostComposer from '$components/PostComposer.svelte';
-
import { client } from '$lib';
+
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 { onMount } from 'svelte';
+
import { theme } from '$lib/theme.svelte';
+
import { fetchPostsWithReplyBacklinks, fetchReplies } from '$lib/at/fetch';
+
import { expect } from '$lib/result';
+
import { writable } from 'svelte/store';
+
import type { AppBskyFeedPost } from '@atcute/bluesky';
+
+
let selectedDid = $state<Did | null>(null);
+
let clients = writable<Map<Did, AtpClient>>(new Map());
+
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));
+
}
+
});
+
+
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));
+
}
+
};
+
+
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))
+
await loginAccount(account);
+
};
+
+
const handleLoginSucceed = (did: Did, handle: Handle, password: string) => {
+
const newAccount: Account = { did, handle, password };
+
addAccount(newAccount);
+
selectedDid = did;
+
loginAccount(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, 10);
+
if (!accPosts.ok) {
+
console.error(`failed to fetch posts for account ${account.handle}: ${accPosts.error}`);
+
return;
+
}
+
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;
+
}
+
timeline.update((map) => map.set(reply.value.uri, reply.value.record));
+
}
+
})
+
);
+
};
+
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;
+
};
+
let sortedTimeline = $derived(getSortedTimeline($timeline));
</script>
-
<div class="flex flex-col gap-4">
-
<PostComposer {client} />
-
<hr />
-
<BskyPost {client} identifier="nil.ptr.pet" rkey="3m3d5zguuxk2c" />
+
<div class="mx-auto max-w-2xl p-4">
+
<div class="mb-6">
+
<h1 class="text-3xl font-bold tracking-tight" style="color: {theme.fg};">nucleus</h1>
+
<div class="mt-1 flex gap-2">
+
<div class="h-1 w-11 rounded-full" style="background: {theme.accent};"></div>
+
<div class="h-1 w-8 rounded-full" style="background: {theme.accent2};"></div>
+
</div>
+
</div>
+
+
<div class="space-y-4">
+
<div class="flex min-h-16 items-stretch gap-2">
+
<AccountSelector
+
accounts={$accounts}
+
bind:selectedDid
+
onAccountSelected={handleAccountSelected}
+
onLoginSucceed={handleLoginSucceed}
+
/>
+
+
{#if selectedClient}
+
<div class="flex-1">
+
<PostComposer client={selectedClient} />
+
</div>
+
{:else}
+
<div
+
class="flex flex-1 items-center justify-center rounded-xl 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};">
+
select or add an account to post
+
</p>
+
</div>
+
{/if}
+
</div>
+
+
<hr
+
class="h-[3px] 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}
+
/>
+
{/each}
+
</div>
+
</div>
</div>