replies timeline only, appview-less bluesky client

feat: implement post actions, replying and quoting

+17
deno.lock
···
"npm:@atcute/client@^4.0.5": "4.0.5",
"npm:@atcute/identity@^1.1.1": "1.1.1",
"npm:@atcute/lexicons@^1.2.2": "1.2.2",
+
"npm:@atcute/tid@^1.0.3": "1.0.3",
"npm:@eslint/compat@^1.4.0": "1.4.0_eslint@9.37.0",
"npm:@eslint/js@^9.36.0": "9.37.0",
+
"npm:@iconify/svelte@^5.0.2": "5.0.2_svelte@5.40.1__acorn@8.15.0",
"npm:@soffinal/websocket@~0.2.1": "0.2.1_typescript@5.9.3",
"npm:@sveltejs/adapter-auto@^6.1.0": "6.1.1_@sveltejs+kit@2.47.0__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.40.1____acorn@8.15.0___vite@7.1.10____@types+node@24.8.0____picomatch@4.0.3___@types+node@24.8.0__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__acorn@8.15.0__@types+node@24.8.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__@types+node@24.8.0_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0",
"npm:@sveltejs/kit@^2.43.2": "2.47.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__@types+node@24.8.0_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_acorn@8.15.0_@types+node@24.8.0",
···
"@standard-schema/spec",
"esm-env"
]
+
},
+
"@atcute/tid@1.0.3": {
+
"integrity": "sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w=="
},
"@badrap/valita@0.4.6": {
"integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="
···
},
"@humanwhocodes/retry@0.4.3": {
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="
+
},
+
"@iconify/svelte@5.0.2_svelte@5.40.1__acorn@8.15.0": {
+
"integrity": "sha512-1iWUT+1veS/QOAzKDG0NPgBtJYGoJqEPwF97voTm8jw6PQ6yU0hL73lEwFoTGMrZmatLvh9cjRBmeSHHaltmrg==",
+
"dependencies": [
+
"@iconify/types",
+
"svelte"
+
]
+
},
+
"@iconify/types@2.0.0": {
+
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="
},
"@isaacs/fs-minipass@4.0.1": {
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
···
"npm:@atcute/client@^4.0.5",
"npm:@atcute/identity@^1.1.1",
"npm:@atcute/lexicons@^1.2.2",
+
"npm:@atcute/tid@^1.0.3",
"npm:@eslint/compat@^1.4.0",
"npm:@eslint/js@^9.36.0",
+
"npm:@iconify/svelte@^5.0.2",
"npm:@soffinal/websocket@~0.2.1",
"npm:@sveltejs/adapter-auto@^6.1.0",
"npm:@sveltejs/kit@^2.43.2",
+2
package.json
···
"@atcute/client": "^4.0.5",
"@atcute/identity": "^1.1.1",
"@atcute/lexicons": "^1.2.2",
+
"@atcute/tid": "^1.0.3",
"@soffinal/websocket": "^0.2.1",
"@wora/cache-persist": "^2.2.1",
"hash-wasm": "^4.12.0",
···
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.36.0",
+
"@iconify/svelte": "^5.0.2",
"@sveltejs/adapter-auto": "^6.1.0",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
+186 -38
src/components/BskyPost.svelte
···
<script lang="ts">
-
import type { AtpClient } from '$lib/at/client';
+
import { type AtpClient } from '$lib/at/client';
import { AppBskyFeedPost } from '@atcute/bluesky';
import {
parseCanonicalResourceUri,
type ActorIdentifier,
+
type CanonicalResourceUri,
type Did,
+
type Nsid,
type RecordKey,
type ResourceUri
} from '@atcute/lexicons';
···
import { isBlob } from '@atcute/lexicons/interfaces';
import { blob, img } from '$lib/cdn';
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';
+
import * as TID from '@atcute/tid';
+
import type { PostWithUri } from '$lib/at/fetch';
+
import type { Writable } from 'svelte/store';
+
import { onMount } from 'svelte';
interface Props {
client: AtpClient;
+
selectedDid: Writable<Did | null>;
+
// post
did: Did;
rkey: RecordKey;
// replyBacklinks?: Backlinks;
-
record?: AppBskyFeedPost.Main;
+
data?: PostWithUri;
mini?: boolean;
+
isOnPostComposer?: boolean;
+
onQuote?: (quote: PostWithUri) => void;
+
onReply?: (reply: PostWithUri) => void;
}
-
const { client, did, rkey, record, mini /* replyBacklinks */ }: Props = $props();
+
const {
+
client,
+
selectedDid,
+
did,
+
rkey,
+
data,
+
mini,
+
onQuote,
+
onReply,
+
isOnPostComposer = false /* replyBacklinks */
+
}: Props = $props();
+
const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`;
const color = generateColorForDid(did);
let handle: ActorIdentifier = $state(did);
···
if (res.ok) handle = res.value.handle;
return res;
});
-
const post = record
-
? Promise.resolve(ok(record))
+
const post = data
+
? Promise.resolve(ok(data))
: client.getRecord(AppBskyFeedPost.mainSchema, did, rkey);
// const replies = replyBacklinks
// ? Promise.resolve(ok(replyBacklinks))
···
if (hours > 0) return `${hours}h`;
if (minutes > 0) return `${minutes}m`;
if (seconds > 0) return `${seconds}s`;
-
return 'just now';
+
return 'now';
+
};
+
+
const findBacklink = async (source: BacklinksSource) => {
+
const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source);
+
if (!backlinks.ok) return null;
+
return backlinks.value.records.find((r) => r.did === $selectedDid) ?? null;
+
};
+
+
let findAllBacklinks = async (did: Did | null) => {
+
if (!did) return;
+
if (postActions.has(`${did}:${aturi}`)) return;
+
const backlinks = await Promise.all([
+
findBacklink('app.bsky.feed.like:subject.uri'),
+
findBacklink('app.bsky.feed.repost:subject.uri')
+
// findBacklink('app.bsky.feed.post:reply.parent.uri'),
+
// findBacklink('app.bsky.feed.post:embed.record.uri')
+
]);
+
const actions: PostActions = {
+
like: backlinks[0],
+
repost: backlinks[1]
+
// reply: backlinks[2],
+
// quote: backlinks[3]
+
};
+
console.log('findAllBacklinks', did, aturi, actions);
+
postActions.set(`${did}:${aturi}`, actions);
+
};
+
onMount(() => {
+
// findAllBacklinks($selectedDid);
+
selectedDid.subscribe(findAllBacklinks);
+
});
+
+
const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => {
+
// console.log('toggleLink', selectedDid, link, collection);
+
if (!$selectedDid) return null;
+
const _post = await post;
+
if (!_post.ok) return null;
+
if (!link) {
+
if (_post.value.cid) {
+
const record = {
+
$type: collection,
+
subject: {
+
cid: _post.value.cid,
+
uri: aturi
+
},
+
createdAt: new Date().toISOString()
+
};
+
const rkey = TID.now();
+
// todo: handle errors
+
client.atcute?.post('com.atproto.repo.createRecord', {
+
input: {
+
repo: $selectedDid,
+
collection,
+
record,
+
rkey
+
}
+
});
+
return {
+
collection,
+
did: $selectedDid,
+
rkey
+
};
+
}
+
} else {
+
// todo: handle errors
+
client.atcute?.post('com.atproto.repo.deleteRecord', {
+
input: {
+
repo: link.did,
+
collection: link.collection,
+
rkey: link.rkey
+
}
+
});
+
return null;
+
}
+
return link;
};
</script>
···
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
style="background: color-mix(in srgb, {mini
? 'var(--nucleus-fg)'
-
: color} 13%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};"
+
: color} 10%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};"
>
{getEmbedText(record.embed.$type)}
</span>
···
{/snippet}
{#if mini}
-
<div class="overflow-hidden text-sm text-nowrap overflow-ellipsis opacity-60">
+
<div class="text-sm opacity-60">
{#await post}
loading...
{:then post}
{#if post.ok}
-
{@const record = post.value}
+
{@const record = post.value.record}
<span style="color: {color};">@{handle}</span>: {@render embedBadge(record)}
<span title={record.text}>{record.text}</span>
{:else}
···
</div>
{:then post}
{#if post.ok}
-
{@const record = post.value}
+
{@const record = post.value.record}
<div
class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all"
-
style="background: {color}18; border-color: {color}66;"
+
style="background: {color}{isOnPostComposer
+
? '36'
+
: '18'}; border-color: {color}{isOnPostComposer ? '99' : '66'};"
>
<div
class="group mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1"
···
{@const profileValue = profile.value}
<span class="min-w-0 overflow-hidden text-nowrap overflow-ellipsis"
>{profileValue.displayName}</span
-
><span class="shrink-0 text-nowrap">(@{handle})</span>
+
><span class="shrink-0 text-nowrap opacity-70">(@{handle})</span>
{:else}
{handle}
{/if}
{/await}
</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 class="text-nowrap text-(--nucleus-fg)/67"
>{getRelativeTime(new Date(record.createdAt))}</span
>
</div>
-
<p class="leading-relaxed text-wrap">
+
<p class="leading-relaxed text-wrap break-words">
{record.text}
+
{#if isOnPostComposer}
+
{@render embedBadge(record)}
+
{/if}
</p>
-
{#if record.embed}
+
{#if !isOnPostComposer && record.embed}
{@const embed = record.embed}
<div class="mt-2">
{#snippet embedPost(uri: ResourceUri)}
{@const parsedUri = expect(parseCanonicalResourceUri(uri))}
<!-- reject recursive quotes -->
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
-
<BskyPost {client} did={parsedUri.repo} rkey={parsedUri.rkey} />
+
<BskyPost
+
{selectedDid}
+
{client}
+
did={parsedUri.repo}
+
rkey={parsedUri.rkey}
+
{isOnPostComposer}
+
{onQuote}
+
{onReply}
+
/>
{:else}
<span>you think you're funny with that recursive quote but i'm onto you</span>
{/if}
···
<!-- todo: implement external link embeds -->
</div>
{/if}
+
{#if !isOnPostComposer}
+
{@const backlinks = postActions.get(`${$selectedDid!}:${post.value.uri}`)}
+
{@render postControls(post.value, backlinks)}
+
{/if}
</div>
{:else}
<div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;">
···
{/if}
{/await}
{/if}
+
+
{#snippet postControls(post: PostWithUri, backlinks?: PostActions)}
+
<div
+
class="group mt-3 flex w-fit max-w-full items-center rounded-sm"
+
style="background: {color}1f;"
+
>
+
{#snippet label(
+
name: string,
+
icon: string,
+
onClick: (link: Backlink | null | undefined) => void,
+
backlink?: Backlink | null,
+
hasSolid?: boolean
+
)}
+
<button
+
class="px-2 py-1.5 text-(--nucleus-fg)/90 hover:[backdrop-filter:brightness(120%)]"
+
onclick={() => onClick(backlink)}
+
style="color: {backlink ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}"
+
title={name}
+
>
+
<Icon icon={hasSolid && backlink ? `${icon}-solid` : icon} width={20} />
+
</button>
+
{/snippet}
+
{@render label('reply', 'heroicons:chat-bubble-left', () => {
+
onReply?.(post);
+
})}
+
{@render label(
+
'repost',
+
'heroicons:arrow-path-rounded-square-20-solid',
+
async (link) => {
+
if (link === undefined) return;
+
postActions.set(`${$selectedDid!}:${aturi}`, {
+
...backlinks!,
+
repost: await toggleLink(link, 'app.bsky.feed.repost')
+
});
+
},
+
backlinks?.repost
+
)}
+
{@render label('quote', 'heroicons:paper-clip-20-solid', () => {
+
onQuote?.(post);
+
})}
+
{@render label(
+
'like',
+
'heroicons:star',
+
async (link) => {
+
if (link === undefined) return;
+
postActions.set(`${$selectedDid!}:${aturi}`, {
+
...backlinks!,
+
like: await toggleLink(link, 'app.bsky.feed.like')
+
});
+
},
+
backlinks?.like,
+
true
+
)}
+
</div>
+
{/snippet}
+1 -1
src/components/PfpPlaceholder.svelte
···
</script>
<svg
-
class="rounded-sm"
+
class="shrink-0 rounded-sm"
style="background: color-mix(in srgb, {color} 27%, transparent); color: {color}; width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});"
xmlns="http://www.w3.org/2000/svg"
width="24px"
+70 -13
src/components/PostComposer.svelte
···
<script lang="ts">
import type { AtpClient } from '$lib/at/client';
-
import { ok, err, type Result } from '$lib/result';
+
import { ok, err, type Result, expect } from '$lib/result';
import type { AppBskyFeedPost } from '@atcute/bluesky';
-
import type { ResourceUri } from '@atcute/lexicons';
import { generateColorForDid } from '$lib/accounts';
+
import type { PostWithUri } from '$lib/at/fetch';
+
import BskyPost from './BskyPost.svelte';
+
import { parseCanonicalResourceUri, type Did } from '@atcute/lexicons';
+
import type { ComAtprotoRepoStrongRef } from '@atcute/atproto';
+
import type { Writable } from 'svelte/store';
interface Props {
client: AtpClient;
-
onPostSent: (uri: ResourceUri, post: AppBskyFeedPost.Main) => void;
+
selectedDid: Writable<Did | null>;
+
onPostSent: (post: PostWithUri) => void;
+
quoting?: PostWithUri;
+
replying?: PostWithUri;
}
-
const { client, onPostSent }: Props = $props();
+
let {
+
client,
+
selectedDid,
+
onPostSent,
+
quoting = $bindable(undefined),
+
replying = $bindable(undefined)
+
}: Props = $props();
let color = $derived(
client.didDoc?.did ? generateColorForDid(client.didDoc?.did) : 'var(--nucleus-accent)'
);
-
const post = async (
-
text: string
-
): Promise<Result<{ uri: ResourceUri; record: AppBskyFeedPost.Main }, string>> => {
+
const post = async (text: string): Promise<Result<PostWithUri, string>> => {
+
const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({
+
$type: 'com.atproto.repo.strongRef',
+
cid: p.cid!,
+
uri: p.uri
+
});
const record: AppBskyFeedPost.Main = {
$type: 'app.bsky.feed.post',
text,
+
reply: replying
+
? {
+
root: replying.record.reply?.root ?? strongRef(replying),
+
parent: strongRef(replying)
+
}
+
: undefined,
+
embed: quoting
+
? {
+
$type: 'app.bsky.embed.record',
+
record: strongRef(quoting)
+
}
+
: undefined,
createdAt: new Date().toISOString()
};
···
return ok({
uri: res.data.uri,
+
cid: res.data.cid,
record
});
};
···
post(postText).then((res) => {
if (res.ok) {
-
onPostSent(res.value.uri, res.value.record);
+
onPostSent(res.value);
postText = '';
info = 'posted!';
-
setTimeout(() => (info = ''), 1000 * 3);
+
isFocused = false;
+
quoting = undefined;
+
replying = undefined;
+
setTimeout(() => (info = ''), 1000 * 0.8);
} else {
+
// todo: add a way to clear error
info = res.error;
}
});
···
$effect(() => {
if (isFocused && textareaEl) textareaEl.focus();
+
if (quoting || replying) isFocused = true;
});
</script>
···
{:else}
<div class="flex flex-col gap-2">
{#if isFocused}
+
{#if replying}
+
{@const parsedUri = expect(parseCanonicalResourceUri(replying.uri))}
+
<BskyPost
+
{client}
+
{selectedDid}
+
did={parsedUri.repo}
+
rkey={parsedUri.rkey}
+
data={replying}
+
isOnPostComposer={true}
+
/>
+
{/if}
<textarea
bind:this={textareaEl}
bind:value={postText}
onfocus={() => (isFocused = true)}
-
onblur={() => (isFocused = false)}
+
onblur={() => {
+
isFocused = false;
+
quoting = undefined;
+
replying = undefined;
+
}}
onkeydown={(event) => {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
}}
···
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}
+
{@const parsedUri = expect(parseCanonicalResourceUri(quoting.uri))}
+
<BskyPost
+
{client}
+
{selectedDid}
+
did={parsedUri.repo}
+
rkey={parsedUri.rkey}
+
data={quoting}
+
isOnPostComposer={true}
+
/>
+
{/if}
<div class="flex items-center gap-2">
<div class="grow"></div>
<span
···
<input
bind:value={postText}
onfocus={() => (isFocused = true)}
-
onkeydown={(event) => {
-
if (event.key === 'Enter') doPost();
-
}}
type="text"
placeholder="what's on your mind?"
class="single-line-input flex-1 bg-(--nucleus-bg)/40"
+26 -27
src/lib/accounts.ts
···
export const generateColorForDid = (did: string) => hashColor(did);
-
function hashColor(input: string | number): string {
-
let hash = typeof input === 'string' ? stringToHash(input) : input;
+
function hashColor(input: string): string {
+
let hash: number;
+
+
const id = input.split(':').pop() || input;
+
hash = 0;
+
for (let i = 0; i < Math.min(10, id.length); i++) {
+
hash = (hash << 4) + id.charCodeAt(i);
+
}
+
hash = hash >>> 0;
+
+
// magic mixing
hash ^= hash >>> 16;
-
hash = Math.imul(hash, 0x85ebca6b);
-
hash ^= hash >>> 13;
-
hash = Math.imul(hash, 0xb00b1355);
-
hash ^= hash >>> 16;
+
hash = Math.imul(hash, 0x21f0aaad);
+
hash ^= hash >>> 15;
hash = hash >>> 0;
const hue = hash % 360;
-
const saturation = 0.7 + ((hash >>> 8) % 30) * 0.01;
-
const value = 0.6 + ((hash >>> 16) % 40) * 0.01;
+
const saturation = 0.8 + ((hash >>> 10) % 20) * 0.01; // 80-100%
+
const lightness = 0.45 + ((hash >>> 20) % 35) * 0.01; // 50-75%
-
const rgb = hsvToRgb(hue, saturation, value);
+
const rgb = hslToRgb(hue, saturation, lightness);
const hex = rgb.map((value) => value.toString(16).padStart(2, '0')).join('');
return `#${hex}`;
}
-
function stringToHash(str: string): number {
-
let hash = 0;
-
for (let i = 0; i < str.length; i++) {
-
hash = (Math.imul(hash << 5, 1) - hash + str.charCodeAt(i)) | 0;
-
}
-
return hash >>> 0;
-
}
-
-
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
-
const c = v * s;
-
const hPrime = h * 0.016666667;
+
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
+
const c = (1 - Math.abs(2 * l - 1)) * s;
+
const hPrime = h / 60;
const x = c * (1 - Math.abs((hPrime % 2) - 1));
-
const m = v - c;
+
const m = l - c / 2;
let r: number, g: number, b: number;
-
if (h < 60) {
+
if (hPrime < 1) {
r = c;
g = x;
b = 0;
-
} else if (h < 120) {
+
} else if (hPrime < 2) {
r = x;
g = c;
b = 0;
-
} else if (h < 180) {
+
} else if (hPrime < 3) {
r = 0;
g = c;
b = x;
-
} else if (h < 240) {
+
} else if (hPrime < 4) {
r = 0;
g = x;
b = c;
-
} else if (h < 300) {
+
} else if (hPrime < 5) {
r = x;
g = 0;
b = c;
···
b = x;
}
-
return [((r + m) * 255) | 0, ((g + m) * 255) | 0, ((b + m) * 255) | 0];
+
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
}
+39 -15
src/lib/at/client.ts
···
type ActorIdentifier,
type AtprotoDid,
type CanonicalResourceUri,
+
type Cid,
type Did,
type Nsid,
type RecordKey,
···
export type NotificationsStream = WebSocket<NotificationsStreamEncoder>;
export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>;
+
export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output };
+
export class AtpClient {
public atcute: AtcuteClient | null = null;
public didDoc: MiniDoc | null = null;
···
TKey extends RecordKeySchema,
Schema extends RecordSchema<TObject, TKey>,
Output extends InferInput<Schema>
-
>(schema: Schema, uri: ResourceUri): Promise<Result<Output, string>> {
+
>(schema: Schema, uri: ResourceUri): Promise<Result<RecordOutput<Output>, string>> {
const parsedUri = expect(parseResourceUri(uri));
if (parsedUri.collection !== schema.object.shape.$type.expected)
return err(
···
TKey extends RecordKeySchema,
Schema extends RecordSchema<TObject, TKey>,
Output extends InferInput<Schema>
-
>(schema: Schema, repo: ActorIdentifier, rkey: RecordKey): Promise<Result<Output, string>> {
+
>(
+
schema: Schema,
+
repo: ActorIdentifier,
+
rkey: RecordKey
+
): Promise<Result<RecordOutput<Output>, string>> {
const collection = schema.object.shape.$type.expected;
const cacheKey = `${repo}:${collection}:${rkey}`;
const cached = recordCache.get(cacheKey);
-
if (cached) return ok(cached.value as Output);
+
if (cached) return ok({ uri: cached.uri, cid: cached.cid, record: cached.value as Output });
const cachedSignal = recordCache.getSignal(cacheKey);
const result = await Promise.race([
···
repo,
collection,
rkey
-
}).then((result): Result<Output, string> => {
+
}).then((result): Result<RecordOutput<Output>, string> => {
if (!result.ok) return result;
const parsed = safeParse(schema, result.value.value);
···
recordCache.set(cacheKey, result.value);
-
return ok(parsed.value as Output);
+
return ok({
+
uri: result.value.uri,
+
cid: result.value.cid,
+
record: parsed.value as Output
+
});
}),
-
cachedSignal.then((d): Result<Output, string> => ok(d.value as Output))
+
cachedSignal.then(
+
(d): Result<RecordOutput<Output>, string> =>
+
ok({ uri: d.uri, cid: d.cid, record: d.value as Output })
+
)
]);
if (!result.ok) return result;
-
return ok(result.value as Output);
+
return ok(result.value);
}
async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> {
repo = repo ?? this.didDoc?.did;
if (!repo) return err('not authenticated');
-
return await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self');
+
return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record);
}
async listRecords<Collection extends keyof Records>(
···
if (!did.ok) {
return err(`failed to resolve handle: ${did.error}`);
}
+
return await fetchMicrocosm(constellationUrl, BacklinksQuery, {
subject: `at://${did.value}/${collection}/${rkey}`,
source,
···
init?: RequestInit
): Promise<Result<Output, string>> => {
if (!schema.output || schema.output.type === 'blob') return err('schema must be blob');
+
api.pathname = `/xrpc/${schema.nsid}`;
+
api.search = params ? `?${new URLSearchParams(params)}` : '';
try {
-
api.pathname = `/xrpc/${schema.nsid}`;
-
api.search = params ? `?${new URLSearchParams(params)}` : '';
-
// console.info(`fetching:`, api.href);
-
const response = await fetch(api, init);
+
const body = await fetchJson(api, init);
+
if (!body.ok) return err(body.error);
+
const parsed = safeParse(schema.output.schema, body.value);
+
if (!parsed.ok) return err(parsed.message);
+
return ok(parsed.value as Output);
+
} catch (error) {
+
return err(`FetchError: ${error}`);
+
}
+
};
+
+
const fetchJson = async (url: URL, init?: RequestInit): Promise<Result<unknown, string>> => {
+
try {
+
const response = await fetch(url, init);
const body = await response.json();
if (response.status === 400) return err(`${body.error}: ${body.message}`);
if (response.status !== 200) return err(`UnexpectedStatusCode: ${response.status}`);
-
const parsed = safeParse(schema.output.schema, body);
-
if (!parsed.ok) return err(parsed.message);
-
return ok(parsed.value as Output);
+
return ok(body);
} catch (error) {
return err(`FetchError: ${error}`);
}
+27 -33
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 { ActorIdentifier, CanonicalResourceUri, Cid, ResourceUri } from '@atcute/lexicons';
+
import { recordCache, type AtpClient } from './client';
+
import { err, ok, type Result } from '$lib/result';
import type { Backlinks } from './constellation';
import { AppBskyFeedPost } from '@atcute/bluesky';
-
export type PostWithUri = { uri: CanonicalResourceUri; record: AppBskyFeedPost.Main };
+
export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main };
export type PostWithBacklinks = PostWithUri & {
replies: Result<Backlinks, string>;
};
···
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): PostWithBacklinks => ({
-
uri: r.uri as CanonicalResourceUri,
-
record: r.value as AppBskyFeedPost.Main,
-
replies: res
-
})
-
)
-
)
+
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 });
···
export const hydratePosts = async (
client: AtpClient,
data: PostsWithReplyBacklinks
-
): Promise<Map<CanonicalResourceUri, AppBskyFeedPost.Main>> => {
+
): Promise<Map<ResourceUri, PostWithUri>> => {
const allPosts = await Promise.all(
data.map(async (post) => {
-
const result: Result<PostWithUri, string>[] = [ok({ uri: post.uri, record: post.record })];
+
const result: Result<PostWithUri, string>[] = [ok(post)];
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
-
})
-
)
-
)
+
client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey)
)
);
result.push(...replies);
···
allPosts
.flat()
.flatMap((res) => (res.ok ? [res.value] : []))
-
.map((post) => [post.uri, post.record])
+
.map((post) => [post.uri, post])
);
// hydrate posts
const missingPosts = await Promise.all(
-
Array.from(posts).map(async ([uri, record]) => {
-
let result: PostWithUri[] = [{ uri, record }];
-
let parent = record.reply?.parent;
+
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 = [{ uri: parent.uri as CanonicalResourceUri, record: p.value }, ...result];
-
parent = p.value.reply?.parent;
+
result = [p.value, ...result];
+
parent = p.value.record.reply?.parent;
continue;
}
parent = undefined;
···
})
);
for (const post of missingPosts.flat()) {
-
posts.set(post.uri, post.record);
+
posts.set(post.uri, post);
}
return posts;
+4 -1
src/lib/cache.ts
···
set(key: K, value: V): void {
this.memory.set(key, value);
this.storage.set(this.prefixed(key), value);
-
for (const signal of this.signals.get(key) ?? []) {
+
const signals = this.signals.get(key);
+
let signal = signals?.pop();
+
while (signal) {
signal(value);
+
signal = signals?.pop();
}
this.storage.flush(); // TODO: uh evil and fucked up (this whole file is evil honestly)
}
+13
src/lib/index.ts
···
import { writable } from 'svelte/store';
import { type NotificationsStream } from './at/client';
+
import { SvelteMap } from 'svelte/reactivity';
+
import type { Did, ResourceUri } from '@atcute/lexicons';
+
import type { Backlink } from './at/constellation';
// import type { JetstreamSubscription } from '@atcute/jetstream';
+
export const selectedDid = writable<Did | null>(null);
+
export const notificationStream = writable<NotificationsStream | null>(null);
// export const jetstream = writable<JetstreamSubscription | null>(null);
+
+
export type PostActions = {
+
like: Backlink | null;
+
repost: Backlink | null;
+
// reply: Backlink | null;
+
// quote: Backlink | null;
+
};
+
export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>();
+89 -57
src/routes/+page.svelte
···
type ResourceUri
} from '@atcute/lexicons';
import { onMount } from 'svelte';
-
import { fetchPostsWithBacklinks, hydratePosts } from '$lib/at/fetch';
+
import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch';
import { expect, ok } from '$lib/result';
import { AppBskyFeedPost } from '@atcute/bluesky';
-
import { SvelteMap } from 'svelte/reactivity';
+
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { InfiniteLoader, LoaderState } from 'svelte-infinite';
-
import { notificationStream } from '$lib';
+
import { notificationStream, selectedDid } from '$lib';
import { get } from 'svelte/store';
+
import Icon from '@iconify/svelte';
let loaderState = new LoaderState();
let scrollContainer = $state<HTMLDivElement>();
-
let selectedDid = $state<Did | null>(null);
let clients = new SvelteMap<Did, AtpClient>();
-
let selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null);
+
let selectedClient = $derived($selectedDid ? clients.get($selectedDid) : null);
let viewClient = $state<AtpClient>(new AtpClient());
-
let posts = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyFeedPost.Main>>();
+
let posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();
let cursors = new SvelteMap<Did, { value?: string; end: boolean }>();
let isSettingsOpen = $state(false);
+
let reverseChronological = $state(true);
+
let viewOwnPosts = $state(true);
-
const addPosts = (did: Did, accTimeline: Map<ResourceUri, AppBskyFeedPost.Main>) => {
+
const addPosts = (did: Did, accTimeline: Map<ResourceUri, PostWithUri>) => {
if (!posts.has(did)) {
posts.set(did, new SvelteMap(accTimeline));
return;
···
const cursor = cursors.get(account.did);
if (cursor && cursor.end) return;
-
const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 12);
+
const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6);
if (!accPosts.ok)
throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`;
···
const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
const hydrated = await hydratePosts(viewClient, [
{
-
record: subjectPost.value,
+
record: subjectPost.value.record,
uri: event.data.link.subject,
+
cid: subjectPost.value.cid,
replies: ok({
cursor: null,
total: 1,
···
// });
if ($accounts.length > 0) {
loaderState.status = 'LOADING';
-
selectedDid = $accounts[0].did;
+
$selectedDid = $accounts[0].did;
Promise.all($accounts.map(loginAccount)).then(() => {
loadMore();
});
···
};
const handleAccountSelected = async (did: Did) => {
-
selectedDid = 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 = async (did: Did, handle: Handle, password: string) => {
const newAccount: Account = { did, handle, password };
addAccount(newAccount);
-
selectedDid = did;
+
$selectedDid = did;
loginAccount(newAccount).then(() => fetchTimeline(newAccount));
};
···
}
};
-
let reverseChronological = $state(true);
-
let viewOwnPosts = $state(true);
-
type ThreadPost = {
-
uri: ResourceUri;
+
data: PostWithUri;
did: Did;
rkey: string;
-
record: AppBskyFeedPost.Main;
parentUri: ResourceUri | null;
depth: number;
newestTime: number;
···
branchParentPost?: ThreadPost;
};
-
const buildThreads = (timelines: Map<Did, Map<ResourceUri, AppBskyFeedPost.Main>>): Thread[] => {
+
const buildThreads = (timelines: Map<Did, Map<ResourceUri, PostWithUri>>): 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) {
+
for (const [uri, data] 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 rootUri = (data.record.reply?.root.uri as ResourceUri) || uri;
+
const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null;
const post: ThreadPost = {
-
uri,
+
data,
did: parsedUri.repo,
rkey: parsedUri.rkey,
-
record,
parentUri,
depth: 0,
-
newestTime: new Date(record.createdAt).getTime()
+
newestTime: new Date(data.record.createdAt).getTime()
};
if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
···
const threads: Thread[] = [];
for (const [rootUri, posts] of threadMap) {
-
const uriToPost = new Map(posts.map((p) => [p.uri, p]));
+
const uriToPost = new Map(posts.map((p) => [p.data.uri, p]));
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const childrenMap = new Map<ResourceUri | null, ThreadPost[]>();
···
const result: ThreadPost[] = [];
const addWithChildren = (post: ThreadPost) => {
result.push(post);
-
const children = childrenMap.get(post.uri) || [];
+
const children = childrenMap.get(post.data.uri) || [];
children.forEach(addWithChildren);
};
addWithChildren(startPost);
···
threads.push(
createThread(
branchPosts,
-
branchRoot.uri,
+
branchRoot.data.uri,
isOldestBranch ? undefined : (branchParentUri ?? undefined)
)
);
···
});
let threads = $derived(filterThreads(buildThreads(posts), $accounts));
+
+
let quoting = $state<PostWithUri | undefined>(undefined);
+
let replying = $state<PostWithUri | undefined>(undefined);
+
+
let expandedThreads = new SvelteSet<ResourceUri>();
</script>
<div class="mx-auto flex h-screen max-w-2xl flex-col p-4">
···
</div>
<button
onclick={() => (isSettingsOpen = true)}
-
class="rounded-sm bg-(--nucleus-accent)/7 p-2.5 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg"
-
aria-label="Settings"
+
class="group rounded-sm bg-(--nucleus-accent)/7 p-2 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg"
+
aria-label="settings"
>
-
<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"
-
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
-
/>
-
<path
-
stroke-linecap="round"
-
stroke-linejoin="round"
-
stroke-width="2"
-
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
-
/>
-
</svg>
+
<Icon class="group-hover:hidden" icon="heroicons:cog-6-tooth" width={28} />
+
<Icon class="hidden group-hover:block" icon="heroicons:cog-6-tooth-solid" width={28} />
</button>
</div>
···
<AccountSelector
client={viewClient}
accounts={$accounts}
-
bind:selectedDid
+
bind:selectedDid={$selectedDid}
onAccountSelected={handleAccountSelected}
onLoginSucceed={handleLoginSucceed}
onLogout={handleLogout}
···
<div class="flex-1">
<PostComposer
client={selectedClient}
-
onPostSent={(uri, record) => posts.get(selectedDid!)?.set(uri, record)}
+
{selectedDid}
+
onPostSent={(post) => posts.get($selectedDid!)?.set(post.uri, post)}
+
bind:quoting
+
bind:replying
/>
</div>
{:else}
···
{/if}
</div>
-
<hr
+
<!-- <hr
class="h-[4px] w-full rounded-full border-0"
style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));"
-
/>
+
/> -->
</div>
<div
···
</InfiniteLoader>
{/snippet}
+
{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)}
+
<span
+
class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap break-words overflow-ellipsis"
+
>
+
<span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span>
+
<BskyPost mini {selectedDid} client={selectedClient ?? viewClient} {...post} />
+
</span>
+
{/snippet}
+
{#snippet threadsView()}
-
{#each threads as thread ([thread.rootUri, thread.branchParentPost, ...thread.posts.map((post) => post.uri)])}
+
{#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 text-nowrap opacity-60">{reverseChronological ? '↱' : '↳'}</span>
-
<BskyPost mini client={viewClient} {...post} />
-
</div>
+
{@render replyPost(thread.branchParentPost)}
{/if}
-
{#each thread.posts as post (post.uri)}
-
<div class="mb-1.5">
-
<BskyPost client={viewClient} {...post} />
-
</div>
+
{#each thread.posts as post, idx (post.data.uri)}
+
{@const mini =
+
!expandedThreads.has(thread.rootUri) &&
+
thread.posts.length > 4 &&
+
idx > 0 &&
+
idx < thread.posts.length - 2}
+
{#if !mini}
+
<div class="mb-1.5">
+
<BskyPost
+
{selectedDid}
+
client={selectedClient ?? viewClient}
+
onQuote={(post) => (quoting = post)}
+
onReply={(post) => (replying = post)}
+
{...post}
+
/>
+
</div>
+
{:else if mini}
+
{#if idx === 1}
+
{@render replyPost(post, !reverseChronological)}
+
<button
+
class="mx-1.5 mt-1.5 mb-2.5 flex items-center gap-1.5 text-[color-mix(in_srgb,_var(--nucleus-fg)_50%,_var(--nucleus-accent))]/70 transition-colors hover:text-(--nucleus-accent)"
+
onclick={() => expandedThreads.add(thread.rootUri)}
+
>
+
<div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div>
+
<Icon
+
class="shrink-0"
+
icon={reverseChronological
+
? 'heroicons:bars-arrow-up-solid'
+
: 'heroicons:bars-arrow-down-solid'}
+
width={32}
+
/><span class="shrink-0 pb-1">view full chain</span>
+
<div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div>
+
</button>
+
{:else if idx === thread.posts.length - 3}
+
{@render replyPost(post)}
+
{/if}
+
{/if}
{/each}
</div>
{/each}