+17
deno.lock
+17
deno.lock
···
6
6
"npm:@atcute/client@^4.0.5": "4.0.5",
7
7
"npm:@atcute/identity@^1.1.1": "1.1.1",
8
8
"npm:@atcute/lexicons@^1.2.2": "1.2.2",
9
+
"npm:@atcute/tid@^1.0.3": "1.0.3",
9
10
"npm:@eslint/compat@^1.4.0": "1.4.0_eslint@9.37.0",
10
11
"npm:@eslint/js@^9.36.0": "9.37.0",
12
+
"npm:@iconify/svelte@^5.0.2": "5.0.2_svelte@5.40.1__acorn@8.15.0",
11
13
"npm:@soffinal/websocket@~0.2.1": "0.2.1_typescript@5.9.3",
12
14
"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",
13
15
"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",
···
68
70
"@standard-schema/spec",
69
71
"esm-env"
70
72
]
73
+
},
74
+
"@atcute/tid@1.0.3": {
75
+
"integrity": "sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w=="
71
76
},
72
77
"@badrap/valita@0.4.6": {
73
78
"integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="
···
284
289
},
285
290
"@humanwhocodes/retry@0.4.3": {
286
291
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="
292
+
},
293
+
"@iconify/svelte@5.0.2_svelte@5.40.1__acorn@8.15.0": {
294
+
"integrity": "sha512-1iWUT+1veS/QOAzKDG0NPgBtJYGoJqEPwF97voTm8jw6PQ6yU0hL73lEwFoTGMrZmatLvh9cjRBmeSHHaltmrg==",
295
+
"dependencies": [
296
+
"@iconify/types",
297
+
"svelte"
298
+
]
299
+
},
300
+
"@iconify/types@2.0.0": {
301
+
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="
287
302
},
288
303
"@isaacs/fs-minipass@4.0.1": {
289
304
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
···
1752
1767
"npm:@atcute/client@^4.0.5",
1753
1768
"npm:@atcute/identity@^1.1.1",
1754
1769
"npm:@atcute/lexicons@^1.2.2",
1770
+
"npm:@atcute/tid@^1.0.3",
1755
1771
"npm:@eslint/compat@^1.4.0",
1756
1772
"npm:@eslint/js@^9.36.0",
1773
+
"npm:@iconify/svelte@^5.0.2",
1757
1774
"npm:@soffinal/websocket@~0.2.1",
1758
1775
"npm:@sveltejs/adapter-auto@^6.1.0",
1759
1776
"npm:@sveltejs/kit@^2.43.2",
+2
package.json
+2
package.json
···
19
19
"@atcute/client": "^4.0.5",
20
20
"@atcute/identity": "^1.1.1",
21
21
"@atcute/lexicons": "^1.2.2",
22
+
"@atcute/tid": "^1.0.3",
22
23
"@soffinal/websocket": "^0.2.1",
23
24
"@wora/cache-persist": "^2.2.1",
24
25
"hash-wasm": "^4.12.0",
···
28
29
"devDependencies": {
29
30
"@eslint/compat": "^1.4.0",
30
31
"@eslint/js": "^9.36.0",
32
+
"@iconify/svelte": "^5.0.2",
31
33
"@sveltejs/adapter-auto": "^6.1.0",
32
34
"@sveltejs/kit": "^2.43.2",
33
35
"@sveltejs/vite-plugin-svelte": "^6.2.0",
+186
-38
src/components/BskyPost.svelte
+186
-38
src/components/BskyPost.svelte
···
1
1
<script lang="ts">
2
-
import type { AtpClient } from '$lib/at/client';
2
+
import { type AtpClient } from '$lib/at/client';
3
3
import { AppBskyFeedPost } from '@atcute/bluesky';
4
4
import {
5
5
parseCanonicalResourceUri,
6
6
type ActorIdentifier,
7
+
type CanonicalResourceUri,
7
8
type Did,
9
+
type Nsid,
8
10
type RecordKey,
9
11
type ResourceUri
10
12
} from '@atcute/lexicons';
···
14
16
import { isBlob } from '@atcute/lexicons/interfaces';
15
17
import { blob, img } from '$lib/cdn';
16
18
import BskyPost from './BskyPost.svelte';
19
+
import Icon from '@iconify/svelte';
20
+
import { type Backlink, type BacklinksSource } from '$lib/at/constellation';
21
+
import { postActions, type PostActions } from '$lib';
22
+
import * as TID from '@atcute/tid';
23
+
import type { PostWithUri } from '$lib/at/fetch';
24
+
import type { Writable } from 'svelte/store';
25
+
import { onMount } from 'svelte';
17
26
18
27
interface Props {
19
28
client: AtpClient;
29
+
selectedDid: Writable<Did | null>;
30
+
// post
20
31
did: Did;
21
32
rkey: RecordKey;
22
33
// replyBacklinks?: Backlinks;
23
-
record?: AppBskyFeedPost.Main;
34
+
data?: PostWithUri;
24
35
mini?: boolean;
36
+
isOnPostComposer?: boolean;
37
+
onQuote?: (quote: PostWithUri) => void;
38
+
onReply?: (reply: PostWithUri) => void;
25
39
}
26
40
27
-
const { client, did, rkey, record, mini /* replyBacklinks */ }: Props = $props();
41
+
const {
42
+
client,
43
+
selectedDid,
44
+
did,
45
+
rkey,
46
+
data,
47
+
mini,
48
+
onQuote,
49
+
onReply,
50
+
isOnPostComposer = false /* replyBacklinks */
51
+
}: Props = $props();
28
52
53
+
const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`;
29
54
const color = generateColorForDid(did);
30
55
31
56
let handle: ActorIdentifier = $state(did);
···
33
58
if (res.ok) handle = res.value.handle;
34
59
return res;
35
60
});
36
-
const post = record
37
-
? Promise.resolve(ok(record))
61
+
const post = data
62
+
? Promise.resolve(ok(data))
38
63
: client.getRecord(AppBskyFeedPost.mainSchema, did, rkey);
39
64
// const replies = replyBacklinks
40
65
// ? Promise.resolve(ok(replyBacklinks))
···
78
103
if (hours > 0) return `${hours}h`;
79
104
if (minutes > 0) return `${minutes}m`;
80
105
if (seconds > 0) return `${seconds}s`;
81
-
return 'just now';
106
+
return 'now';
107
+
};
108
+
109
+
const findBacklink = async (source: BacklinksSource) => {
110
+
const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source);
111
+
if (!backlinks.ok) return null;
112
+
return backlinks.value.records.find((r) => r.did === $selectedDid) ?? null;
113
+
};
114
+
115
+
let findAllBacklinks = async (did: Did | null) => {
116
+
if (!did) return;
117
+
if (postActions.has(`${did}:${aturi}`)) return;
118
+
const backlinks = await Promise.all([
119
+
findBacklink('app.bsky.feed.like:subject.uri'),
120
+
findBacklink('app.bsky.feed.repost:subject.uri')
121
+
// findBacklink('app.bsky.feed.post:reply.parent.uri'),
122
+
// findBacklink('app.bsky.feed.post:embed.record.uri')
123
+
]);
124
+
const actions: PostActions = {
125
+
like: backlinks[0],
126
+
repost: backlinks[1]
127
+
// reply: backlinks[2],
128
+
// quote: backlinks[3]
129
+
};
130
+
console.log('findAllBacklinks', did, aturi, actions);
131
+
postActions.set(`${did}:${aturi}`, actions);
132
+
};
133
+
onMount(() => {
134
+
// findAllBacklinks($selectedDid);
135
+
selectedDid.subscribe(findAllBacklinks);
136
+
});
137
+
138
+
const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => {
139
+
// console.log('toggleLink', selectedDid, link, collection);
140
+
if (!$selectedDid) return null;
141
+
const _post = await post;
142
+
if (!_post.ok) return null;
143
+
if (!link) {
144
+
if (_post.value.cid) {
145
+
const record = {
146
+
$type: collection,
147
+
subject: {
148
+
cid: _post.value.cid,
149
+
uri: aturi
150
+
},
151
+
createdAt: new Date().toISOString()
152
+
};
153
+
const rkey = TID.now();
154
+
// todo: handle errors
155
+
client.atcute?.post('com.atproto.repo.createRecord', {
156
+
input: {
157
+
repo: $selectedDid,
158
+
collection,
159
+
record,
160
+
rkey
161
+
}
162
+
});
163
+
return {
164
+
collection,
165
+
did: $selectedDid,
166
+
rkey
167
+
};
168
+
}
169
+
} else {
170
+
// todo: handle errors
171
+
client.atcute?.post('com.atproto.repo.deleteRecord', {
172
+
input: {
173
+
repo: link.did,
174
+
collection: link.collection,
175
+
rkey: link.rkey
176
+
}
177
+
});
178
+
return null;
179
+
}
180
+
return link;
82
181
};
83
182
</script>
84
183
···
88
187
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
89
188
style="background: color-mix(in srgb, {mini
90
189
? 'var(--nucleus-fg)'
91
-
: color} 13%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};"
190
+
: color} 10%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};"
92
191
>
93
192
{getEmbedText(record.embed.$type)}
94
193
</span>
···
96
195
{/snippet}
97
196
98
197
{#if mini}
99
-
<div class="overflow-hidden text-sm text-nowrap overflow-ellipsis opacity-60">
198
+
<div class="text-sm opacity-60">
100
199
{#await post}
101
200
loading...
102
201
{:then post}
103
202
{#if post.ok}
104
-
{@const record = post.value}
203
+
{@const record = post.value.record}
105
204
<span style="color: {color};">@{handle}</span>: {@render embedBadge(record)}
106
205
<span title={record.text}>{record.text}</span>
107
206
{:else}
···
122
221
</div>
123
222
{:then post}
124
223
{#if post.ok}
125
-
{@const record = post.value}
224
+
{@const record = post.value.record}
126
225
<div
127
226
class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all"
128
-
style="background: {color}18; border-color: {color}66;"
227
+
style="background: {color}{isOnPostComposer
228
+
? '36'
229
+
: '18'}; border-color: {color}{isOnPostComposer ? '99' : '66'};"
129
230
>
130
231
<div
131
232
class="group mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1"
···
141
242
{@const profileValue = profile.value}
142
243
<span class="min-w-0 overflow-hidden text-nowrap overflow-ellipsis"
143
244
>{profileValue.displayName}</span
144
-
><span class="shrink-0 text-nowrap">(@{handle})</span>
245
+
><span class="shrink-0 text-nowrap opacity-70">(@{handle})</span>
145
246
{:else}
146
247
{handle}
147
248
{/if}
148
249
{/await}
149
250
</span>
150
-
151
-
<!-- <span>·</span>
152
-
{#await replies}
153
-
<span style="color: {theme.fg}aa;">… replies</span>
154
-
{:then replies}
155
-
{#if replies.ok}
156
-
{@const repliesValue = replies.value}
157
-
<span style="color: {theme.fg}aa;">
158
-
{#if repliesValue.total > 0}
159
-
{repliesValue.total}
160
-
{repliesValue.total > 1 ? 'replies' : 'reply'}
161
-
{:else}
162
-
no replies
163
-
{/if}
164
-
</span>
165
-
{:else}
166
-
<span
167
-
title={`${replies.error}`}
168
-
class="max-w-[32ch] overflow-hidden text-nowrap"
169
-
style="color: {theme.fg}aa;">{replies.error}</span
170
-
>
171
-
{/if}
172
-
{/await} -->
173
251
<span>·</span>
174
252
<span class="text-nowrap text-(--nucleus-fg)/67"
175
253
>{getRelativeTime(new Date(record.createdAt))}</span
176
254
>
177
255
</div>
178
-
<p class="leading-relaxed text-wrap">
256
+
<p class="leading-relaxed text-wrap break-words">
179
257
{record.text}
258
+
{#if isOnPostComposer}
259
+
{@render embedBadge(record)}
260
+
{/if}
180
261
</p>
181
-
{#if record.embed}
262
+
{#if !isOnPostComposer && record.embed}
182
263
{@const embed = record.embed}
183
264
<div class="mt-2">
184
265
{#snippet embedPost(uri: ResourceUri)}
185
266
{@const parsedUri = expect(parseCanonicalResourceUri(uri))}
186
267
<!-- reject recursive quotes -->
187
268
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
188
-
<BskyPost {client} did={parsedUri.repo} rkey={parsedUri.rkey} />
269
+
<BskyPost
270
+
{selectedDid}
271
+
{client}
272
+
did={parsedUri.repo}
273
+
rkey={parsedUri.rkey}
274
+
{isOnPostComposer}
275
+
{onQuote}
276
+
{onReply}
277
+
/>
189
278
{:else}
190
279
<span>you think you're funny with that recursive quote but i'm onto you</span>
191
280
{/if}
···
222
311
<!-- todo: implement external link embeds -->
223
312
</div>
224
313
{/if}
314
+
{#if !isOnPostComposer}
315
+
{@const backlinks = postActions.get(`${$selectedDid!}:${post.value.uri}`)}
316
+
{@render postControls(post.value, backlinks)}
317
+
{/if}
225
318
</div>
226
319
{:else}
227
320
<div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;">
···
230
323
{/if}
231
324
{/await}
232
325
{/if}
326
+
327
+
{#snippet postControls(post: PostWithUri, backlinks?: PostActions)}
328
+
<div
329
+
class="group mt-3 flex w-fit max-w-full items-center rounded-sm"
330
+
style="background: {color}1f;"
331
+
>
332
+
{#snippet label(
333
+
name: string,
334
+
icon: string,
335
+
onClick: (link: Backlink | null | undefined) => void,
336
+
backlink?: Backlink | null,
337
+
hasSolid?: boolean
338
+
)}
339
+
<button
340
+
class="px-2 py-1.5 text-(--nucleus-fg)/90 hover:[backdrop-filter:brightness(120%)]"
341
+
onclick={() => onClick(backlink)}
342
+
style="color: {backlink ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}"
343
+
title={name}
344
+
>
345
+
<Icon icon={hasSolid && backlink ? `${icon}-solid` : icon} width={20} />
346
+
</button>
347
+
{/snippet}
348
+
{@render label('reply', 'heroicons:chat-bubble-left', () => {
349
+
onReply?.(post);
350
+
})}
351
+
{@render label(
352
+
'repost',
353
+
'heroicons:arrow-path-rounded-square-20-solid',
354
+
async (link) => {
355
+
if (link === undefined) return;
356
+
postActions.set(`${$selectedDid!}:${aturi}`, {
357
+
...backlinks!,
358
+
repost: await toggleLink(link, 'app.bsky.feed.repost')
359
+
});
360
+
},
361
+
backlinks?.repost
362
+
)}
363
+
{@render label('quote', 'heroicons:paper-clip-20-solid', () => {
364
+
onQuote?.(post);
365
+
})}
366
+
{@render label(
367
+
'like',
368
+
'heroicons:star',
369
+
async (link) => {
370
+
if (link === undefined) return;
371
+
postActions.set(`${$selectedDid!}:${aturi}`, {
372
+
...backlinks!,
373
+
like: await toggleLink(link, 'app.bsky.feed.like')
374
+
});
375
+
},
376
+
backlinks?.like,
377
+
true
378
+
)}
379
+
</div>
380
+
{/snippet}
+1
-1
src/components/PfpPlaceholder.svelte
+1
-1
src/components/PfpPlaceholder.svelte
···
8
8
</script>
9
9
10
10
<svg
11
-
class="rounded-sm"
11
+
class="shrink-0 rounded-sm"
12
12
style="background: color-mix(in srgb, {color} 27%, transparent); color: {color}; width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});"
13
13
xmlns="http://www.w3.org/2000/svg"
14
14
width="24px"
+70
-13
src/components/PostComposer.svelte
+70
-13
src/components/PostComposer.svelte
···
1
1
<script lang="ts">
2
2
import type { AtpClient } from '$lib/at/client';
3
-
import { ok, err, type Result } from '$lib/result';
3
+
import { ok, err, type Result, expect } from '$lib/result';
4
4
import type { AppBskyFeedPost } from '@atcute/bluesky';
5
-
import type { ResourceUri } from '@atcute/lexicons';
6
5
import { generateColorForDid } from '$lib/accounts';
6
+
import type { PostWithUri } from '$lib/at/fetch';
7
+
import BskyPost from './BskyPost.svelte';
8
+
import { parseCanonicalResourceUri, type Did } from '@atcute/lexicons';
9
+
import type { ComAtprotoRepoStrongRef } from '@atcute/atproto';
10
+
import type { Writable } from 'svelte/store';
7
11
8
12
interface Props {
9
13
client: AtpClient;
10
-
onPostSent: (uri: ResourceUri, post: AppBskyFeedPost.Main) => void;
14
+
selectedDid: Writable<Did | null>;
15
+
onPostSent: (post: PostWithUri) => void;
16
+
quoting?: PostWithUri;
17
+
replying?: PostWithUri;
11
18
}
12
19
13
-
const { client, onPostSent }: Props = $props();
20
+
let {
21
+
client,
22
+
selectedDid,
23
+
onPostSent,
24
+
quoting = $bindable(undefined),
25
+
replying = $bindable(undefined)
26
+
}: Props = $props();
14
27
15
28
let color = $derived(
16
29
client.didDoc?.did ? generateColorForDid(client.didDoc?.did) : 'var(--nucleus-accent)'
17
30
);
18
31
19
-
const post = async (
20
-
text: string
21
-
): Promise<Result<{ uri: ResourceUri; record: AppBskyFeedPost.Main }, string>> => {
32
+
const post = async (text: string): Promise<Result<PostWithUri, string>> => {
33
+
const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({
34
+
$type: 'com.atproto.repo.strongRef',
35
+
cid: p.cid!,
36
+
uri: p.uri
37
+
});
22
38
const record: AppBskyFeedPost.Main = {
23
39
$type: 'app.bsky.feed.post',
24
40
text,
41
+
reply: replying
42
+
? {
43
+
root: replying.record.reply?.root ?? strongRef(replying),
44
+
parent: strongRef(replying)
45
+
}
46
+
: undefined,
47
+
embed: quoting
48
+
? {
49
+
$type: 'app.bsky.embed.record',
50
+
record: strongRef(quoting)
51
+
}
52
+
: undefined,
25
53
createdAt: new Date().toISOString()
26
54
};
27
55
···
43
71
44
72
return ok({
45
73
uri: res.data.uri,
74
+
cid: res.data.cid,
46
75
record
47
76
});
48
77
};
···
57
86
58
87
post(postText).then((res) => {
59
88
if (res.ok) {
60
-
onPostSent(res.value.uri, res.value.record);
89
+
onPostSent(res.value);
61
90
postText = '';
62
91
info = 'posted!';
63
-
setTimeout(() => (info = ''), 1000 * 3);
92
+
isFocused = false;
93
+
quoting = undefined;
94
+
replying = undefined;
95
+
setTimeout(() => (info = ''), 1000 * 0.8);
64
96
} else {
97
+
// todo: add a way to clear error
65
98
info = res.error;
66
99
}
67
100
});
···
69
102
70
103
$effect(() => {
71
104
if (isFocused && textareaEl) textareaEl.focus();
105
+
if (quoting || replying) isFocused = true;
72
106
});
73
107
</script>
74
108
···
104
138
{:else}
105
139
<div class="flex flex-col gap-2">
106
140
{#if isFocused}
141
+
{#if replying}
142
+
{@const parsedUri = expect(parseCanonicalResourceUri(replying.uri))}
143
+
<BskyPost
144
+
{client}
145
+
{selectedDid}
146
+
did={parsedUri.repo}
147
+
rkey={parsedUri.rkey}
148
+
data={replying}
149
+
isOnPostComposer={true}
150
+
/>
151
+
{/if}
107
152
<textarea
108
153
bind:this={textareaEl}
109
154
bind:value={postText}
110
155
onfocus={() => (isFocused = true)}
111
-
onblur={() => (isFocused = false)}
156
+
onblur={() => {
157
+
isFocused = false;
158
+
quoting = undefined;
159
+
replying = undefined;
160
+
}}
112
161
onkeydown={(event) => {
113
162
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
114
163
}}
···
117
166
class="[field-sizing:content] single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100"
118
167
style="border-color: color-mix(in srgb, {color} 27%, transparent);"
119
168
></textarea>
169
+
{#if quoting}
170
+
{@const parsedUri = expect(parseCanonicalResourceUri(quoting.uri))}
171
+
<BskyPost
172
+
{client}
173
+
{selectedDid}
174
+
did={parsedUri.repo}
175
+
rkey={parsedUri.rkey}
176
+
data={quoting}
177
+
isOnPostComposer={true}
178
+
/>
179
+
{/if}
120
180
<div class="flex items-center gap-2">
121
181
<div class="grow"></div>
122
182
<span
···
140
200
<input
141
201
bind:value={postText}
142
202
onfocus={() => (isFocused = true)}
143
-
onkeydown={(event) => {
144
-
if (event.key === 'Enter') doPost();
145
-
}}
146
203
type="text"
147
204
placeholder="what's on your mind?"
148
205
class="single-line-input flex-1 bg-(--nucleus-bg)/40"
+26
-27
src/lib/accounts.ts
+26
-27
src/lib/accounts.ts
···
25
25
26
26
export const generateColorForDid = (did: string) => hashColor(did);
27
27
28
-
function hashColor(input: string | number): string {
29
-
let hash = typeof input === 'string' ? stringToHash(input) : input;
28
+
function hashColor(input: string): string {
29
+
let hash: number;
30
+
31
+
const id = input.split(':').pop() || input;
30
32
33
+
hash = 0;
34
+
for (let i = 0; i < Math.min(10, id.length); i++) {
35
+
hash = (hash << 4) + id.charCodeAt(i);
36
+
}
37
+
hash = hash >>> 0;
38
+
39
+
// magic mixing
31
40
hash ^= hash >>> 16;
32
-
hash = Math.imul(hash, 0x85ebca6b);
33
-
hash ^= hash >>> 13;
34
-
hash = Math.imul(hash, 0xb00b1355);
35
-
hash ^= hash >>> 16;
41
+
hash = Math.imul(hash, 0x21f0aaad);
42
+
hash ^= hash >>> 15;
36
43
hash = hash >>> 0;
37
44
38
45
const hue = hash % 360;
39
-
const saturation = 0.7 + ((hash >>> 8) % 30) * 0.01;
40
-
const value = 0.6 + ((hash >>> 16) % 40) * 0.01;
46
+
const saturation = 0.8 + ((hash >>> 10) % 20) * 0.01; // 80-100%
47
+
const lightness = 0.45 + ((hash >>> 20) % 35) * 0.01; // 50-75%
41
48
42
-
const rgb = hsvToRgb(hue, saturation, value);
49
+
const rgb = hslToRgb(hue, saturation, lightness);
43
50
const hex = rgb.map((value) => value.toString(16).padStart(2, '0')).join('');
44
51
45
52
return `#${hex}`;
46
53
}
47
54
48
-
function stringToHash(str: string): number {
49
-
let hash = 0;
50
-
for (let i = 0; i < str.length; i++) {
51
-
hash = (Math.imul(hash << 5, 1) - hash + str.charCodeAt(i)) | 0;
52
-
}
53
-
return hash >>> 0;
54
-
}
55
-
56
-
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
57
-
const c = v * s;
58
-
const hPrime = h * 0.016666667;
55
+
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
56
+
const c = (1 - Math.abs(2 * l - 1)) * s;
57
+
const hPrime = h / 60;
59
58
const x = c * (1 - Math.abs((hPrime % 2) - 1));
60
-
const m = v - c;
59
+
const m = l - c / 2;
61
60
62
61
let r: number, g: number, b: number;
63
62
64
-
if (h < 60) {
63
+
if (hPrime < 1) {
65
64
r = c;
66
65
g = x;
67
66
b = 0;
68
-
} else if (h < 120) {
67
+
} else if (hPrime < 2) {
69
68
r = x;
70
69
g = c;
71
70
b = 0;
72
-
} else if (h < 180) {
71
+
} else if (hPrime < 3) {
73
72
r = 0;
74
73
g = c;
75
74
b = x;
76
-
} else if (h < 240) {
75
+
} else if (hPrime < 4) {
77
76
r = 0;
78
77
g = x;
79
78
b = c;
80
-
} else if (h < 300) {
79
+
} else if (hPrime < 5) {
81
80
r = x;
82
81
g = 0;
83
82
b = c;
···
87
86
b = x;
88
87
}
89
88
90
-
return [((r + m) * 255) | 0, ((g + m) * 255) | 0, ((b + m) * 255) | 0];
89
+
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
91
90
}
+39
-15
src/lib/at/client.ts
+39
-15
src/lib/at/client.ts
···
13
13
type ActorIdentifier,
14
14
type AtprotoDid,
15
15
type CanonicalResourceUri,
16
+
type Cid,
16
17
type Did,
17
18
type Nsid,
18
19
type RecordKey,
···
66
67
export type NotificationsStream = WebSocket<NotificationsStreamEncoder>;
67
68
export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>;
68
69
70
+
export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output };
71
+
69
72
export class AtpClient {
70
73
public atcute: AtcuteClient | null = null;
71
74
public didDoc: MiniDoc | null = null;
···
94
97
TKey extends RecordKeySchema,
95
98
Schema extends RecordSchema<TObject, TKey>,
96
99
Output extends InferInput<Schema>
97
-
>(schema: Schema, uri: ResourceUri): Promise<Result<Output, string>> {
100
+
>(schema: Schema, uri: ResourceUri): Promise<Result<RecordOutput<Output>, string>> {
98
101
const parsedUri = expect(parseResourceUri(uri));
99
102
if (parsedUri.collection !== schema.object.shape.$type.expected)
100
103
return err(
···
109
112
TKey extends RecordKeySchema,
110
113
Schema extends RecordSchema<TObject, TKey>,
111
114
Output extends InferInput<Schema>
112
-
>(schema: Schema, repo: ActorIdentifier, rkey: RecordKey): Promise<Result<Output, string>> {
115
+
>(
116
+
schema: Schema,
117
+
repo: ActorIdentifier,
118
+
rkey: RecordKey
119
+
): Promise<Result<RecordOutput<Output>, string>> {
113
120
const collection = schema.object.shape.$type.expected;
114
121
const cacheKey = `${repo}:${collection}:${rkey}`;
115
122
116
123
const cached = recordCache.get(cacheKey);
117
-
if (cached) return ok(cached.value as Output);
124
+
if (cached) return ok({ uri: cached.uri, cid: cached.cid, record: cached.value as Output });
118
125
const cachedSignal = recordCache.getSignal(cacheKey);
119
126
120
127
const result = await Promise.race([
···
122
129
repo,
123
130
collection,
124
131
rkey
125
-
}).then((result): Result<Output, string> => {
132
+
}).then((result): Result<RecordOutput<Output>, string> => {
126
133
if (!result.ok) return result;
127
134
128
135
const parsed = safeParse(schema, result.value.value);
···
130
137
131
138
recordCache.set(cacheKey, result.value);
132
139
133
-
return ok(parsed.value as Output);
140
+
return ok({
141
+
uri: result.value.uri,
142
+
cid: result.value.cid,
143
+
record: parsed.value as Output
144
+
});
134
145
}),
135
-
cachedSignal.then((d): Result<Output, string> => ok(d.value as Output))
146
+
cachedSignal.then(
147
+
(d): Result<RecordOutput<Output>, string> =>
148
+
ok({ uri: d.uri, cid: d.cid, record: d.value as Output })
149
+
)
136
150
]);
137
151
138
152
if (!result.ok) return result;
139
153
140
-
return ok(result.value as Output);
154
+
return ok(result.value);
141
155
}
142
156
143
157
async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> {
144
158
repo = repo ?? this.didDoc?.did;
145
159
if (!repo) return err('not authenticated');
146
-
return await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self');
160
+
return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record);
147
161
}
148
162
149
163
async listRecords<Collection extends keyof Records>(
···
232
246
if (!did.ok) {
233
247
return err(`failed to resolve handle: ${did.error}`);
234
248
}
249
+
235
250
return await fetchMicrocosm(constellationUrl, BacklinksQuery, {
236
251
subject: `at://${did.value}/${collection}/${rkey}`,
237
252
source,
···
277
292
init?: RequestInit
278
293
): Promise<Result<Output, string>> => {
279
294
if (!schema.output || schema.output.type === 'blob') return err('schema must be blob');
295
+
api.pathname = `/xrpc/${schema.nsid}`;
296
+
api.search = params ? `?${new URLSearchParams(params)}` : '';
280
297
try {
281
-
api.pathname = `/xrpc/${schema.nsid}`;
282
-
api.search = params ? `?${new URLSearchParams(params)}` : '';
283
-
// console.info(`fetching:`, api.href);
284
-
const response = await fetch(api, init);
298
+
const body = await fetchJson(api, init);
299
+
if (!body.ok) return err(body.error);
300
+
const parsed = safeParse(schema.output.schema, body.value);
301
+
if (!parsed.ok) return err(parsed.message);
302
+
return ok(parsed.value as Output);
303
+
} catch (error) {
304
+
return err(`FetchError: ${error}`);
305
+
}
306
+
};
307
+
308
+
const fetchJson = async (url: URL, init?: RequestInit): Promise<Result<unknown, string>> => {
309
+
try {
310
+
const response = await fetch(url, init);
285
311
const body = await response.json();
286
312
if (response.status === 400) return err(`${body.error}: ${body.message}`);
287
313
if (response.status !== 200) return err(`UnexpectedStatusCode: ${response.status}`);
288
-
const parsed = safeParse(schema.output.schema, body);
289
-
if (!parsed.ok) return err(parsed.message);
290
-
return ok(parsed.value as Output);
314
+
return ok(body);
291
315
} catch (error) {
292
316
return err(`FetchError: ${error}`);
293
317
}
+27
-33
src/lib/at/fetch.ts
+27
-33
src/lib/at/fetch.ts
···
1
-
import type { ActorIdentifier, CanonicalResourceUri } from '@atcute/lexicons';
2
-
import type { AtpClient } from './client';
3
-
import { err, map, ok, type Result } from '$lib/result';
1
+
import type { ActorIdentifier, CanonicalResourceUri, Cid, ResourceUri } from '@atcute/lexicons';
2
+
import { recordCache, type AtpClient } from './client';
3
+
import { err, ok, type Result } from '$lib/result';
4
4
import type { Backlinks } from './constellation';
5
5
import { AppBskyFeedPost } from '@atcute/bluesky';
6
6
7
-
export type PostWithUri = { uri: CanonicalResourceUri; record: AppBskyFeedPost.Main };
7
+
export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main };
8
8
export type PostWithBacklinks = PostWithUri & {
9
9
replies: Result<Backlinks, string>;
10
10
};
···
22
22
const records = recordsList.value.records;
23
23
24
24
const allBacklinks = await Promise.all(
25
-
records.map((r) =>
26
-
client
27
-
.getBacklinksUri(r.uri as CanonicalResourceUri, 'app.bsky.feed.post:reply.parent.uri')
28
-
.then(
29
-
(res): PostWithBacklinks => ({
30
-
uri: r.uri as CanonicalResourceUri,
31
-
record: r.value as AppBskyFeedPost.Main,
32
-
replies: res
33
-
})
34
-
)
35
-
)
25
+
records.map(async (r) => {
26
+
recordCache.set(r.uri, r);
27
+
const res = await client.getBacklinksUri(
28
+
r.uri as CanonicalResourceUri,
29
+
'app.bsky.feed.post:reply.parent.uri'
30
+
);
31
+
return {
32
+
uri: r.uri,
33
+
cid: r.cid,
34
+
record: r.value as AppBskyFeedPost.Main,
35
+
replies: res
36
+
};
37
+
})
36
38
);
37
39
38
40
return ok({ posts: allBacklinks, cursor });
···
41
43
export const hydratePosts = async (
42
44
client: AtpClient,
43
45
data: PostsWithReplyBacklinks
44
-
): Promise<Map<CanonicalResourceUri, AppBskyFeedPost.Main>> => {
46
+
): Promise<Map<ResourceUri, PostWithUri>> => {
45
47
const allPosts = await Promise.all(
46
48
data.map(async (post) => {
47
-
const result: Result<PostWithUri, string>[] = [ok({ uri: post.uri, record: post.record })];
49
+
const result: Result<PostWithUri, string>[] = [ok(post)];
48
50
if (post.replies.ok) {
49
51
const replies = await Promise.all(
50
52
post.replies.value.records.map((r) =>
51
-
client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey).then((res) =>
52
-
map(
53
-
res,
54
-
(d): PostWithUri => ({
55
-
uri: `at://${r.did}/app.bsky.feed.post/${r.rkey}` as CanonicalResourceUri,
56
-
record: d
57
-
})
58
-
)
59
-
)
53
+
client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey)
60
54
)
61
55
);
62
56
result.push(...replies);
···
68
62
allPosts
69
63
.flat()
70
64
.flatMap((res) => (res.ok ? [res.value] : []))
71
-
.map((post) => [post.uri, post.record])
65
+
.map((post) => [post.uri, post])
72
66
);
73
67
74
68
// hydrate posts
75
69
const missingPosts = await Promise.all(
76
-
Array.from(posts).map(async ([uri, record]) => {
77
-
let result: PostWithUri[] = [{ uri, record }];
78
-
let parent = record.reply?.parent;
70
+
Array.from(posts).map(async ([, post]) => {
71
+
let result: PostWithUri[] = [post];
72
+
let parent = post.record.reply?.parent;
79
73
while (parent) {
80
74
if (posts.has(parent.uri as CanonicalResourceUri)) {
81
75
return result;
82
76
}
83
77
const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parent.uri);
84
78
if (p.ok) {
85
-
result = [{ uri: parent.uri as CanonicalResourceUri, record: p.value }, ...result];
86
-
parent = p.value.reply?.parent;
79
+
result = [p.value, ...result];
80
+
parent = p.value.record.reply?.parent;
87
81
continue;
88
82
}
89
83
parent = undefined;
···
92
86
})
93
87
);
94
88
for (const post of missingPosts.flat()) {
95
-
posts.set(post.uri, post.record);
89
+
posts.set(post.uri, post);
96
90
}
97
91
98
92
return posts;
+4
-1
src/lib/cache.ts
+4
-1
src/lib/cache.ts
···
61
61
set(key: K, value: V): void {
62
62
this.memory.set(key, value);
63
63
this.storage.set(this.prefixed(key), value);
64
-
for (const signal of this.signals.get(key) ?? []) {
64
+
const signals = this.signals.get(key);
65
+
let signal = signals?.pop();
66
+
while (signal) {
65
67
signal(value);
68
+
signal = signals?.pop();
66
69
}
67
70
this.storage.flush(); // TODO: uh evil and fucked up (this whole file is evil honestly)
68
71
}
+13
src/lib/index.ts
+13
src/lib/index.ts
···
1
1
import { writable } from 'svelte/store';
2
2
import { type NotificationsStream } from './at/client';
3
+
import { SvelteMap } from 'svelte/reactivity';
4
+
import type { Did, ResourceUri } from '@atcute/lexicons';
5
+
import type { Backlink } from './at/constellation';
3
6
// import type { JetstreamSubscription } from '@atcute/jetstream';
4
7
8
+
export const selectedDid = writable<Did | null>(null);
9
+
5
10
export const notificationStream = writable<NotificationsStream | null>(null);
6
11
// export const jetstream = writable<JetstreamSubscription | null>(null);
12
+
13
+
export type PostActions = {
14
+
like: Backlink | null;
15
+
repost: Backlink | null;
16
+
// reply: Backlink | null;
17
+
// quote: Backlink | null;
18
+
};
19
+
export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>();
+89
-57
src/routes/+page.svelte
+89
-57
src/routes/+page.svelte
···
12
12
type ResourceUri
13
13
} from '@atcute/lexicons';
14
14
import { onMount } from 'svelte';
15
-
import { fetchPostsWithBacklinks, hydratePosts } from '$lib/at/fetch';
15
+
import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch';
16
16
import { expect, ok } from '$lib/result';
17
17
import { AppBskyFeedPost } from '@atcute/bluesky';
18
-
import { SvelteMap } from 'svelte/reactivity';
18
+
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
19
19
import { InfiniteLoader, LoaderState } from 'svelte-infinite';
20
-
import { notificationStream } from '$lib';
20
+
import { notificationStream, selectedDid } from '$lib';
21
21
import { get } from 'svelte/store';
22
+
import Icon from '@iconify/svelte';
22
23
23
24
let loaderState = new LoaderState();
24
25
let scrollContainer = $state<HTMLDivElement>();
25
26
26
-
let selectedDid = $state<Did | null>(null);
27
27
let clients = new SvelteMap<Did, AtpClient>();
28
-
let selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null);
28
+
let selectedClient = $derived($selectedDid ? clients.get($selectedDid) : null);
29
29
30
30
let viewClient = $state<AtpClient>(new AtpClient());
31
31
32
-
let posts = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyFeedPost.Main>>();
32
+
let posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();
33
33
let cursors = new SvelteMap<Did, { value?: string; end: boolean }>();
34
34
35
35
let isSettingsOpen = $state(false);
36
+
let reverseChronological = $state(true);
37
+
let viewOwnPosts = $state(true);
36
38
37
-
const addPosts = (did: Did, accTimeline: Map<ResourceUri, AppBskyFeedPost.Main>) => {
39
+
const addPosts = (did: Did, accTimeline: Map<ResourceUri, PostWithUri>) => {
38
40
if (!posts.has(did)) {
39
41
posts.set(did, new SvelteMap(accTimeline));
40
42
return;
···
50
52
const cursor = cursors.get(account.did);
51
53
if (cursor && cursor.end) return;
52
54
53
-
const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 12);
55
+
const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6);
54
56
if (!accPosts.ok)
55
57
throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`;
56
58
···
80
82
const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
81
83
const hydrated = await hydratePosts(viewClient, [
82
84
{
83
-
record: subjectPost.value,
85
+
record: subjectPost.value.record,
84
86
uri: event.data.link.subject,
87
+
cid: subjectPost.value.cid,
85
88
replies: ok({
86
89
cursor: null,
87
90
total: 1,
···
144
147
// });
145
148
if ($accounts.length > 0) {
146
149
loaderState.status = 'LOADING';
147
-
selectedDid = $accounts[0].did;
150
+
$selectedDid = $accounts[0].did;
148
151
Promise.all($accounts.map(loginAccount)).then(() => {
149
152
loadMore();
150
153
});
···
158
161
};
159
162
160
163
const handleAccountSelected = async (did: Did) => {
161
-
selectedDid = did;
164
+
$selectedDid = did;
162
165
const account = $accounts.find((acc) => acc.did === did);
163
166
if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute))
164
167
await loginAccount(account);
···
176
179
const handleLoginSucceed = async (did: Did, handle: Handle, password: string) => {
177
180
const newAccount: Account = { did, handle, password };
178
181
addAccount(newAccount);
179
-
selectedDid = did;
182
+
$selectedDid = did;
180
183
loginAccount(newAccount).then(() => fetchTimeline(newAccount));
181
184
};
182
185
···
198
201
}
199
202
};
200
203
201
-
let reverseChronological = $state(true);
202
-
let viewOwnPosts = $state(true);
203
-
204
204
type ThreadPost = {
205
-
uri: ResourceUri;
205
+
data: PostWithUri;
206
206
did: Did;
207
207
rkey: string;
208
-
record: AppBskyFeedPost.Main;
209
208
parentUri: ResourceUri | null;
210
209
depth: number;
211
210
newestTime: number;
···
218
217
branchParentPost?: ThreadPost;
219
218
};
220
219
221
-
const buildThreads = (timelines: Map<Did, Map<ResourceUri, AppBskyFeedPost.Main>>): Thread[] => {
220
+
const buildThreads = (timelines: Map<Did, Map<ResourceUri, PostWithUri>>): Thread[] => {
222
221
// eslint-disable-next-line svelte/prefer-svelte-reactivity
223
222
const threadMap = new Map<ResourceUri, ThreadPost[]>();
224
223
225
224
// Single pass: create posts and group by thread
226
225
for (const [, timeline] of timelines) {
227
-
for (const [uri, record] of timeline) {
226
+
for (const [uri, data] of timeline) {
228
227
const parsedUri = expect(parseCanonicalResourceUri(uri));
229
-
const rootUri = (record.reply?.root.uri as ResourceUri) || uri;
230
-
const parentUri = (record.reply?.parent.uri as ResourceUri) || null;
228
+
const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri;
229
+
const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null;
231
230
232
231
const post: ThreadPost = {
233
-
uri,
232
+
data,
234
233
did: parsedUri.repo,
235
234
rkey: parsedUri.rkey,
236
-
record,
237
235
parentUri,
238
236
depth: 0,
239
-
newestTime: new Date(record.createdAt).getTime()
237
+
newestTime: new Date(data.record.createdAt).getTime()
240
238
};
241
239
242
240
if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
···
248
246
const threads: Thread[] = [];
249
247
250
248
for (const [rootUri, posts] of threadMap) {
251
-
const uriToPost = new Map(posts.map((p) => [p.uri, p]));
249
+
const uriToPost = new Map(posts.map((p) => [p.data.uri, p]));
252
250
// eslint-disable-next-line svelte/prefer-svelte-reactivity
253
251
const childrenMap = new Map<ResourceUri | null, ThreadPost[]>();
254
252
···
292
290
const result: ThreadPost[] = [];
293
291
const addWithChildren = (post: ThreadPost) => {
294
292
result.push(post);
295
-
const children = childrenMap.get(post.uri) || [];
293
+
const children = childrenMap.get(post.data.uri) || [];
296
294
children.forEach(addWithChildren);
297
295
};
298
296
addWithChildren(startPost);
···
342
340
threads.push(
343
341
createThread(
344
342
branchPosts,
345
-
branchRoot.uri,
343
+
branchRoot.data.uri,
346
344
isOldestBranch ? undefined : (branchParentUri ?? undefined)
347
345
)
348
346
);
···
371
369
});
372
370
373
371
let threads = $derived(filterThreads(buildThreads(posts), $accounts));
372
+
373
+
let quoting = $state<PostWithUri | undefined>(undefined);
374
+
let replying = $state<PostWithUri | undefined>(undefined);
375
+
376
+
let expandedThreads = new SvelteSet<ResourceUri>();
374
377
</script>
375
378
376
379
<div class="mx-auto flex h-screen max-w-2xl flex-col p-4">
···
384
387
</div>
385
388
<button
386
389
onclick={() => (isSettingsOpen = true)}
387
-
class="rounded-sm bg-(--nucleus-accent)/7 p-2.5 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg"
388
-
aria-label="Settings"
390
+
class="group rounded-sm bg-(--nucleus-accent)/7 p-2 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg"
391
+
aria-label="settings"
389
392
>
390
-
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
391
-
<path
392
-
stroke-linecap="round"
393
-
stroke-linejoin="round"
394
-
stroke-width="2"
395
-
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"
396
-
/>
397
-
<path
398
-
stroke-linecap="round"
399
-
stroke-linejoin="round"
400
-
stroke-width="2"
401
-
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
402
-
/>
403
-
</svg>
393
+
<Icon class="group-hover:hidden" icon="heroicons:cog-6-tooth" width={28} />
394
+
<Icon class="hidden group-hover:block" icon="heroicons:cog-6-tooth-solid" width={28} />
404
395
</button>
405
396
</div>
406
397
···
409
400
<AccountSelector
410
401
client={viewClient}
411
402
accounts={$accounts}
412
-
bind:selectedDid
403
+
bind:selectedDid={$selectedDid}
413
404
onAccountSelected={handleAccountSelected}
414
405
onLoginSucceed={handleLoginSucceed}
415
406
onLogout={handleLogout}
···
419
410
<div class="flex-1">
420
411
<PostComposer
421
412
client={selectedClient}
422
-
onPostSent={(uri, record) => posts.get(selectedDid!)?.set(uri, record)}
413
+
{selectedDid}
414
+
onPostSent={(post) => posts.get($selectedDid!)?.set(post.uri, post)}
415
+
bind:quoting
416
+
bind:replying
423
417
/>
424
418
</div>
425
419
{:else}
···
431
425
{/if}
432
426
</div>
433
427
434
-
<hr
428
+
<!-- <hr
435
429
class="h-[4px] w-full rounded-full border-0"
436
430
style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));"
437
-
/>
431
+
/> -->
438
432
</div>
439
433
440
434
<div
···
488
482
</InfiniteLoader>
489
483
{/snippet}
490
484
485
+
{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)}
486
+
<span
487
+
class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap break-words overflow-ellipsis"
488
+
>
489
+
<span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span>
490
+
<BskyPost mini {selectedDid} client={selectedClient ?? viewClient} {...post} />
491
+
</span>
492
+
{/snippet}
493
+
491
494
{#snippet threadsView()}
492
-
{#each threads as thread ([thread.rootUri, thread.branchParentPost, ...thread.posts.map((post) => post.uri)])}
495
+
{#each threads as thread (thread.rootUri)}
493
496
<div class="flex {reverseChronological ? 'flex-col' : 'flex-col-reverse'} mb-6.5">
494
497
{#if thread.branchParentPost}
495
-
{@const post = thread.branchParentPost}
496
-
<div class="mb-1.5 flex items-center gap-1.5">
497
-
<span class="text-sm text-nowrap opacity-60">{reverseChronological ? '↱' : '↳'}</span>
498
-
<BskyPost mini client={viewClient} {...post} />
499
-
</div>
498
+
{@render replyPost(thread.branchParentPost)}
500
499
{/if}
501
-
{#each thread.posts as post (post.uri)}
502
-
<div class="mb-1.5">
503
-
<BskyPost client={viewClient} {...post} />
504
-
</div>
500
+
{#each thread.posts as post, idx (post.data.uri)}
501
+
{@const mini =
502
+
!expandedThreads.has(thread.rootUri) &&
503
+
thread.posts.length > 4 &&
504
+
idx > 0 &&
505
+
idx < thread.posts.length - 2}
506
+
{#if !mini}
507
+
<div class="mb-1.5">
508
+
<BskyPost
509
+
{selectedDid}
510
+
client={selectedClient ?? viewClient}
511
+
onQuote={(post) => (quoting = post)}
512
+
onReply={(post) => (replying = post)}
513
+
{...post}
514
+
/>
515
+
</div>
516
+
{:else if mini}
517
+
{#if idx === 1}
518
+
{@render replyPost(post, !reverseChronological)}
519
+
<button
520
+
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)"
521
+
onclick={() => expandedThreads.add(thread.rootUri)}
522
+
>
523
+
<div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div>
524
+
<Icon
525
+
class="shrink-0"
526
+
icon={reverseChronological
527
+
? 'heroicons:bars-arrow-up-solid'
528
+
: 'heroicons:bars-arrow-down-solid'}
529
+
width={32}
530
+
/><span class="shrink-0 pb-1">view full chain</span>
531
+
<div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div>
532
+
</button>
533
+
{:else if idx === thread.posts.length - 3}
534
+
{@render replyPost(post)}
535
+
{/if}
536
+
{/if}
505
537
{/each}
506
538
</div>
507
539
{/each}