+6
-4
src/app.css
+6
-4
src/app.css
···
4
4
.grain:before {
5
5
content: '';
6
6
background-color: transparent;
7
-
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");
7
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='4' stitchTiles='stitch' /%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='2' intercept='-0.5' /%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)' /%3E%3C/svg%3E");
8
8
background-repeat: repeat;
9
-
background-size: 364px;
10
-
opacity: 0.05;
9
+
background-size: 40vmax;
10
+
opacity: 0.06;
11
11
top: 0;
12
12
left: 0;
13
-
position: absolute;
13
+
position: fixed;
14
14
width: 100%;
15
15
height: 100%;
16
+
pointer-events: none;
17
+
z-index: 1;
16
18
}
+53
-29
src/components/AccountSelector.svelte
+53
-29
src/components/AccountSelector.svelte
···
4
4
import type { Did, Handle } from '@atcute/lexicons';
5
5
import { theme } from '$lib/theme.svelte';
6
6
7
-
let {
8
-
accounts = [],
9
-
selectedDid = $bindable(null),
10
-
onAccountSelected,
11
-
onLoginSucceed
12
-
}: {
7
+
interface Props {
13
8
accounts: Array<Account>;
14
9
selectedDid?: Did | null;
15
10
onAccountSelected: (did: Did) => void;
16
11
onLoginSucceed: (did: Did, handle: Handle, password: string) => void;
17
-
} = $props();
12
+
onLogout: (did: Did) => void;
13
+
}
14
+
15
+
let {
16
+
accounts = [],
17
+
selectedDid = $bindable(null),
18
+
onAccountSelected,
19
+
onLoginSucceed,
20
+
onLogout
21
+
}: Props = $props();
18
22
19
23
let color = $derived(selectedDid ? (generateColorForDid(selectedDid) ?? theme.fg) : theme.fg);
20
24
···
104
108
<div class="relative">
105
109
<button
106
110
onclick={toggleDropdown}
107
-
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"
111
+
class="group flex h-full items-center gap-2 rounded-sm border-2 px-2 font-medium shadow-lg transition-all hover:scale-105 hover:shadow-xl"
108
112
style="border-color: {theme.accent}66; background: {theme.accent}18; color: {color}; backdrop-filter: blur(8px);"
109
113
>
110
-
<span class="text-sm">
114
+
<span class="font-bold">
111
115
{selectedAccount ? `@${selectedAccount.handle}` : 'select account'}
112
116
</span>
113
117
<svg
···
125
129
<!-- svelte-ignore a11y_click_events_have_key_events -->
126
130
<!-- svelte-ignore a11y_no_static_element_interactions -->
127
131
<div
128
-
class="absolute left-0 z-10 mt-3 min-w-52 overflow-hidden rounded-2xl border-2 shadow-2xl backdrop-blur-lg"
132
+
class="absolute left-0 z-10 mt-3 min-w-52 overflow-hidden rounded-sm border-2 shadow-2xl backdrop-blur-lg"
129
133
style="border-color: {theme.accent}; background: {theme.bg}f0;"
130
134
onclick={(e) => e.stopPropagation()}
131
135
>
···
135
139
{@const color = generateColorForDid(account.did)}
136
140
<button
137
141
onclick={() => selectAccount(account.did)}
138
-
class="flex w-full items-center gap-3 rounded-xl p-2 text-left text-sm font-medium transition-all {account.did ===
139
-
selectedDid
140
-
? 'shadow-lg'
141
-
: 'hover:scale-[1.02]'}"
142
+
class="
143
+
group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all
144
+
{account.did === selectedDid ? 'shadow-lg' : ''}
145
+
"
142
146
style="color: {color}; background: {account.did === selectedDid
143
147
? `linear-gradient(135deg, ${theme.accent}33, ${theme.accent2}33)`
144
148
: 'transparent'};"
145
149
>
146
150
<span>@{account.handle}</span>
151
+
<svg
152
+
xmlns="http://www.w3.org/2000/svg"
153
+
onclick={() => onLogout(account.did)}
154
+
class="ml-auto hidden h-5 w-5 transition-all group-hover:[display:block] hover:scale-[1.2] hover:shadow-md"
155
+
style="color: {theme.accent};"
156
+
width="24"
157
+
height="24"
158
+
viewBox="0 0 20 20"
159
+
><path
160
+
fill="currentColor"
161
+
fill-rule="evenodd"
162
+
d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443q-1.193.115-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022l.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52l.149.023a.75.75 0 0 0 .23-1.482A41 41 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1zM10 4q1.26 0 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325Q8.74 4 10 4M8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06z"
163
+
clip-rule="evenodd"
164
+
/></svg
165
+
>
166
+
147
167
{#if account.did === selectedDid}
148
168
<svg
149
-
class="ml-auto h-5 w-5"
169
+
xmlns="http://www.w3.org/2000/svg"
170
+
class="ml-auto h-5 w-5 group-hover:hidden"
150
171
style="color: {theme.accent};"
151
-
fill="currentColor"
152
-
viewBox="0 0 20 20"
153
-
>
154
-
<path
172
+
width="24"
173
+
height="24"
174
+
viewBox="0 0 24 24"
175
+
><path
176
+
fill="currentColor"
155
177
fill-rule="evenodd"
156
-
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"
178
+
d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353l8.493-12.74a.75.75 0 0 1 1.04-.207"
157
179
clip-rule="evenodd"
158
-
/>
159
-
</svg>
180
+
stroke-width="1.5"
181
+
stroke="currentColor"
182
+
/></svg
183
+
>
160
184
{/if}
161
185
</button>
162
186
{/each}
···
168
192
{/if}
169
193
<button
170
194
onclick={openLoginModal}
171
-
class="flex w-full items-center gap-3 p-3 text-left text-sm font-semibold transition-all hover:scale-[1.02]"
195
+
class="group flex w-full origin-left items-center gap-3 p-3 text-left text-sm font-semibold transition-all hover:scale-[1.1]"
172
196
style="color: {theme.accent};"
173
197
>
174
198
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
···
197
221
<!-- svelte-ignore a11y_interactive_supports_focus -->
198
222
<!-- svelte-ignore a11y_click_events_have_key_events -->
199
223
<div
200
-
class="w-full max-w-md rounded-3xl border-2 p-5 shadow-2xl"
224
+
class="w-full max-w-md rounded-sm border-2 p-5 shadow-2xl"
201
225
style="background: {theme.bg}; border-color: {theme.accent};"
202
226
onclick={(e) => e.stopPropagation()}
203
227
role="dialog"
···
237
261
type="text"
238
262
bind:value={loginHandle}
239
263
placeholder="example.bsky.social"
240
-
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"
264
+
class="placeholder-opacity-40 w-full rounded-sm border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none"
241
265
style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};"
242
266
disabled={isLoggingIn}
243
267
/>
···
256
280
type="password"
257
281
bind:value={loginPassword}
258
282
placeholder="xxxx-xxxx-xxxx-xxxx"
259
-
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"
283
+
class="placeholder-opacity-40 w-full rounded-sm border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none"
260
284
style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};"
261
285
disabled={isLoggingIn}
262
286
/>
···
264
288
265
289
{#if loginError}
266
290
<div
267
-
class="rounded-xl border-2 p-4"
291
+
class="rounded-sm border-2 p-4"
268
292
style="background: #ef444422; border-color: #ef4444;"
269
293
>
270
294
<p class="text-sm font-medium" style="color: #fca5a5;">{loginError}</p>
···
274
298
<div class="flex gap-3 pt-3">
275
299
<button
276
300
onclick={closeLoginModal}
277
-
class="flex-1 rounded-xl border-2 px-5 py-3 font-semibold transition-all hover:scale-105"
301
+
class="flex-1 rounded-sm border-2 px-5 py-3 font-semibold transition-all hover:scale-105"
278
302
style="background: {theme.bg}; border-color: {theme.fg}33; color: {theme.fg};"
279
303
disabled={isLoggingIn}
280
304
>
···
282
306
</button>
283
307
<button
284
308
onclick={handleLogin}
285
-
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"
309
+
class="flex-1 rounded-sm border-2 px-5 py-3 font-semibold transition-all hover:scale-105 hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-50"
286
310
style="background: linear-gradient(135deg, {theme.accent}, {theme.accent2}); border-color: transparent; color: {theme.fg};"
287
311
disabled={isLoggingIn}
288
312
>
+74
-50
src/components/BskyPost.svelte
+74
-50
src/components/BskyPost.svelte
···
4
4
import type { ActorIdentifier, RecordKey } from '@atcute/lexicons';
5
5
import { theme } from '$lib/theme.svelte';
6
6
import { map, ok } from '$lib/result';
7
-
import type { Backlinks } from '$lib/at/constellation';
8
7
import { generateColorForDid } from '$lib/accounts';
9
8
10
9
interface Props {
11
10
client: AtpClient;
12
11
identifier: ActorIdentifier;
13
12
rkey: RecordKey;
14
-
replyBacklinks?: Backlinks;
13
+
// replyBacklinks?: Backlinks;
15
14
record?: AppBskyFeedPost.Main;
15
+
mini?: boolean;
16
16
}
17
17
18
-
const { client, identifier, rkey, record, replyBacklinks }: Props = $props();
18
+
const { client, identifier, rkey, record, mini /* replyBacklinks */ }: Props = $props();
19
19
20
20
const color = generateColorForDid(identifier) ?? theme.accent2;
21
21
···
29
29
const post = record
30
30
? Promise.resolve(ok(record))
31
31
: client.getRecord(AppBskyFeedPost.mainSchema, identifier, rkey);
32
-
const replies = replyBacklinks
33
-
? Promise.resolve(ok(replyBacklinks))
34
-
: client.getBacklinks(
35
-
identifier,
36
-
'app.bsky.feed.post',
37
-
rkey,
38
-
'app.bsky.feed.post:reply.parent.uri'
39
-
);
32
+
// const replies = replyBacklinks
33
+
// ? Promise.resolve(ok(replyBacklinks))
34
+
// : client.getBacklinks(
35
+
// identifier,
36
+
// 'app.bsky.feed.post',
37
+
// rkey,
38
+
// 'app.bsky.feed.post:reply.parent.uri'
39
+
// );
40
40
41
41
const getEmbedText = (embedType: string) => {
42
42
switch (embedType) {
···
70
70
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
71
71
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
72
72
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
73
-
return `${seconds} second${seconds > 1 ? 's' : ''} ago`;
73
+
if (seconds > 0) return `${seconds} second${seconds > 1 ? 's' : ''} ago`;
74
+
return 'just now';
74
75
};
75
76
</script>
76
77
77
-
{#await post}
78
+
{#snippet embedBadge(record: AppBskyFeedPost.Main)}
79
+
{#if record.embed}
80
+
<span
81
+
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
82
+
style="background: {mini ? theme.fg : color}22; color: {mini ? theme.fg : color};"
83
+
>
84
+
{getEmbedText(record.embed.$type)}
85
+
</span>
86
+
{/if}
87
+
{/snippet}
88
+
89
+
{#if mini}
78
90
<div
79
-
class="rounded-xl border-2 p-3 text-center backdrop-blur-sm"
80
-
style="background: {color}18; border-color: {color}66;"
91
+
class="overflow-hidden text-sm text-nowrap overflow-ellipsis opacity-60"
92
+
style="color: {theme.fg};"
81
93
>
82
-
<div
83
-
class="inline-block h-6 w-6 animate-spin rounded-full border-3"
84
-
style="border-color: {theme.accent}; border-left-color: transparent;"
85
-
></div>
86
-
<p class="mt-3 text-sm font-medium opacity-60" style="color: {theme.fg};">loading post...</p>
94
+
{#await post}
95
+
loading...
96
+
{:then post}
97
+
{#if post.ok}
98
+
{@const record = post.value}
99
+
<span style="color: {color};">@{handle}</span>: {@render embedBadge(record)}
100
+
{record.text}
101
+
{:else}
102
+
{post.error}
103
+
{/if}
104
+
{/await}
87
105
</div>
88
-
{:then post}
89
-
{#if post.ok}
90
-
{@const record = post.value}
106
+
{:else}
107
+
{#await post}
91
108
<div
92
-
class="rounded-xl border-2 p-3 shadow-lg backdrop-blur-sm transition-all hover:scale-[1.01]"
109
+
class="rounded-sm border-2 p-3 text-center backdrop-blur-sm"
93
110
style="background: {color}18; border-color: {color}66;"
94
111
>
95
-
<div class="mb-3 flex items-center gap-1.5">
96
-
<span class="font-bold" style="color: {color};">
97
-
@{handle}
98
-
</span>
99
-
<span>·</span>
112
+
<div
113
+
class="inline-block h-6 w-6 animate-spin rounded-full border-3"
114
+
style="border-color: {theme.accent}; border-left-color: transparent;"
115
+
></div>
116
+
<p class="mt-3 text-sm font-medium opacity-60" style="color: {theme.fg};">loading post...</p>
117
+
</div>
118
+
{:then post}
119
+
{#if post.ok}
120
+
{@const record = post.value}
121
+
<div
122
+
class="rounded-sm border-2 p-3 shadow-lg backdrop-blur-sm transition-all hover:scale-[1.01]"
123
+
style="background: {color}18; border-color: {color}66;"
124
+
>
125
+
<div class="mb-3 flex items-center gap-1.5">
126
+
<span class="font-bold" style="color: {color};">
127
+
@{handle}
128
+
</span>
129
+
<!-- <span>·</span>
100
130
{#await replies}
101
131
<span style="color: {theme.fg}aa;">… replies</span>
102
132
{:then replies}
···
117
147
style="color: {theme.fg}aa;">{replies.error}</span
118
148
>
119
149
{/if}
120
-
{/await}
121
-
<span>·</span>
122
-
<span style="color: {theme.fg}aa;">{getRelativeTime(new Date(record.createdAt))}</span>
150
+
{/await} -->
151
+
<span>·</span>
152
+
<span style="color: {theme.fg}aa;">{getRelativeTime(new Date(record.createdAt))}</span>
153
+
</div>
154
+
<p class="leading-relaxed text-wrap" style="color: {theme.fg};">
155
+
{record.text}
156
+
{@render embedBadge(record)}
157
+
</p>
123
158
</div>
124
-
<p class="leading-relaxed text-wrap" style="color: {theme.fg};">
125
-
{record.text}
126
-
{#if record.embed}
127
-
<span
128
-
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
129
-
style="background: {color}22; color: {color};"
130
-
>
131
-
{getEmbedText(record.embed.$type)}
132
-
</span>
133
-
{/if}
134
-
</p>
135
-
</div>
136
-
{:else}
137
-
<div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;">
138
-
<p class="text-sm font-medium" style="color: #fca5a5;">error: {post.error}</p>
139
-
</div>
140
-
{/if}
141
-
{/await}
159
+
{:else}
160
+
<div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;">
161
+
<p class="text-sm font-medium" style="color: #fca5a5;">error: {post.error}</p>
162
+
</div>
163
+
{/if}
164
+
{/await}
165
+
{/if}
+29
-22
src/components/PostComposer.svelte
+29
-22
src/components/PostComposer.svelte
···
1
1
<script lang="ts">
2
2
import type { AtpClient } from '$lib/at/client';
3
3
import { ok, err, type Result } from '$lib/result';
4
-
import type { ComAtprotoRepoCreateRecord } from '@atcute/atproto';
5
4
import type { AppBskyFeedPost } from '@atcute/bluesky';
6
-
import type { InferOutput } from '@atcute/lexicons';
5
+
import type { ResourceUri } from '@atcute/lexicons';
7
6
import { theme } from '$lib/theme.svelte';
8
7
9
8
interface Props {
10
9
client: AtpClient;
10
+
onPostSent: (uri: ResourceUri, post: AppBskyFeedPost.Main) => void;
11
11
}
12
12
13
-
const { client }: Props = $props();
13
+
const { client, onPostSent }: Props = $props();
14
14
15
15
const post = async (
16
16
text: string
17
-
): Promise<
18
-
Result<InferOutput<(typeof ComAtprotoRepoCreateRecord.mainSchema)['output']['schema']>, string>
19
-
> => {
17
+
): Promise<Result<{ uri: ResourceUri; record: AppBskyFeedPost.Main }, string>> => {
20
18
const record: AppBskyFeedPost.Main = {
21
19
$type: 'app.bsky.feed.post',
22
20
text,
···
39
37
return err(`failed to post: ${res.data.error}: ${res.data.message ?? 'no details'}`);
40
38
}
41
39
42
-
return ok(res.data);
40
+
return ok({
41
+
uri: res.data.uri,
42
+
record
43
+
});
43
44
};
44
45
45
46
let postText = $state('');
46
47
let info = $state('');
48
+
49
+
const doPost = () => {
50
+
post(postText).then((res) => {
51
+
if (res.ok) {
52
+
onPostSent(res.value.uri, res.value.record);
53
+
postText = '';
54
+
info = 'posted!';
55
+
setTimeout(() => (info = ''), 1000 * 3);
56
+
} else {
57
+
info = res.error;
58
+
}
59
+
});
60
+
};
47
61
</script>
48
62
49
63
<div
50
-
class="flex min-h-16 max-w-full items-center rounded-xl border-2 px-1 shadow-lg backdrop-blur-sm"
64
+
class="flex min-h-16 max-w-full items-center rounded-sm border-2 px-1 shadow-lg backdrop-blur-sm"
51
65
style="background: {theme.accent}18; border-color: {theme.accent}66;"
52
66
>
53
67
<div class="w-full p-1">
54
68
{#if info.length > 0}
55
69
<div
56
-
class="rounded-lg px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis"
70
+
class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis"
57
71
style="background: {theme.accent}22; color: {theme.accent};"
58
72
>
59
73
{info}
···
62
76
<div class="flex gap-2">
63
77
<input
64
78
bind:value={postText}
79
+
onkeydown={(event) => {
80
+
if (event.key === 'Enter') doPost();
81
+
}}
65
82
type="text"
66
83
placeholder="what's on your mind?"
67
-
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"
84
+
class="placeholder-opacity-50 flex-1 rounded-sm border-2 px-3 py-2 text-sm font-medium transition-all focus:scale-[1.01] focus:shadow-lg focus:outline-none"
68
85
style="background: {theme.bg}66; border-color: {theme.accent}44; color: {theme.fg};"
69
86
/>
70
87
<button
71
-
onclick={() => {
72
-
post(postText).then((res) => {
73
-
if (res.ok) {
74
-
postText = '';
75
-
info = 'posted! aaaaaaaaaasdf asdlfkasl;df kjasdfjalsdkfjaskd fajksdhf';
76
-
setTimeout(() => (info = ''), 1000 * 3);
77
-
} else {
78
-
info = res.error;
79
-
}
80
-
});
81
-
}}
82
-
class="rounded-lg border-none px-5 py-2 text-sm font-bold transition-all hover:scale-105 hover:shadow-xl"
88
+
onclick={doPost}
89
+
class="rounded-sm border-none px-5 py-2 text-sm font-bold transition-all hover:scale-105 hover:shadow-xl"
83
90
style="background: linear-gradient(120deg, {theme.accent}c0, {theme.accent2}c0); color: {theme.fg}f0;"
84
91
>
85
92
post
+20
-2
src/lib/at/client.ts
+20
-2
src/lib/at/client.ts
···
9
9
import {
10
10
isHandle,
11
11
parseCanonicalResourceUri,
12
+
parseResourceUri,
12
13
type ActorIdentifier,
13
14
type AtprotoDid,
14
15
type CanonicalResourceUri,
15
16
type Nsid,
16
-
type RecordKey
17
+
type RecordKey,
18
+
type ResourceUri
17
19
} from '@atcute/lexicons/syntax';
18
20
import type {
21
+
InferInput,
19
22
InferXRPCBodyOutput,
20
23
ObjectSchema,
21
24
RecordKeySchema,
···
74
77
return ok(null);
75
78
}
76
79
80
+
async getRecordUri<
81
+
Collection extends Nsid,
82
+
TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } },
83
+
TKey extends RecordKeySchema,
84
+
Schema extends RecordSchema<TObject, TKey>,
85
+
Output extends InferInput<Schema>
86
+
>(schema: Schema, uri: ResourceUri): Promise<Result<Output, string>> {
87
+
const parsedUri = expect(parseResourceUri(uri));
88
+
if (parsedUri.collection !== schema.object.shape.$type.expected)
89
+
return err(
90
+
`collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}`
91
+
);
92
+
return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!);
93
+
}
94
+
77
95
async getRecord<
78
96
Collection extends Nsid,
79
97
TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } },
80
98
TKey extends RecordKeySchema,
81
99
Schema extends RecordSchema<TObject, TKey>,
82
-
Output extends InferOutput<Schema>
100
+
Output extends InferInput<Schema>
83
101
>(schema: Schema, repo: ActorIdentifier, rkey: RecordKey): Promise<Result<Output, string>> {
84
102
const collection = schema.object.shape.$type.expected;
85
103
const cacheKey = `${repo}:${collection}:${rkey}`;
+67
-30
src/lib/at/fetch.ts
+67
-30
src/lib/at/fetch.ts
···
4
4
import type { Backlinks } from './constellation';
5
5
import { AppBskyFeedPost } from '@atcute/bluesky';
6
6
7
-
export type PostWithBacklinks = {
8
-
post: AppBskyFeedPost.Main;
9
-
replies: Backlinks | string;
7
+
export type PostWithUri = { uri: CanonicalResourceUri; record: AppBskyFeedPost.Main };
8
+
export type PostWithBacklinks = PostWithUri & {
9
+
replies: Result<Backlinks, string>;
10
10
};
11
-
export type PostsWithReplyBacklinks = Map<CanonicalResourceUri, PostWithBacklinks>;
11
+
export type PostsWithReplyBacklinks = PostWithBacklinks[];
12
12
13
-
export const fetchPostsWithReplyBacklinks = async (
13
+
export const fetchPostsWithBacklinks = async (
14
14
client: AtpClient,
15
15
repo: ActorIdentifier,
16
16
cursor?: string,
···
25
25
records.map((r) =>
26
26
client
27
27
.getBacklinksUri(r.uri as CanonicalResourceUri, 'app.bsky.feed.post:reply.parent.uri')
28
-
.then((res) => ({
29
-
key: r.uri as CanonicalResourceUri,
30
-
value: {
31
-
post: r.value as AppBskyFeedPost.Main,
32
-
// filter out posts from the same repo
33
-
replies: res.ok
34
-
? { ...res.value, records: res.value.records.filter((r) => r.did !== repo) }
35
-
: res.error
36
-
}
37
-
}))
28
+
.then(
29
+
(res): PostWithBacklinks => ({
30
+
uri: r.uri as CanonicalResourceUri,
31
+
record: r.value as AppBskyFeedPost.Main,
32
+
replies: res
33
+
})
34
+
)
38
35
)
39
36
);
40
37
41
-
return ok({ posts: new Map(allBacklinks.map((b) => [b.key, b.value])), cursor });
38
+
return ok({ posts: allBacklinks, cursor });
42
39
};
43
40
44
-
export const fetchReplies = async (client: AtpClient, data: PostsWithReplyBacklinks) => {
45
-
const allReplies = await Promise.all(
46
-
Array.from(data.values()).map(async (d) => {
47
-
if (typeof d.replies === 'string') return [];
48
-
const replies = await Promise.all(
49
-
d.replies.records.map((r) =>
50
-
client
51
-
.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey)
52
-
.then((res) =>
53
-
map(res, (d) => ({ uri: `at://${r.did}/app.bsky.feed.post/${r.rkey}`, record: d }))
41
+
export const hydratePosts = async (
42
+
client: AtpClient,
43
+
data: PostsWithReplyBacklinks
44
+
): Promise<Map<CanonicalResourceUri, AppBskyFeedPost.Main>> => {
45
+
const allPosts = await Promise.all(
46
+
data.map(async (post) => {
47
+
const result: Result<PostWithUri, string>[] = [ok({ uri: post.uri, record: post.record })];
48
+
if (post.replies.ok) {
49
+
const replies = await Promise.all(
50
+
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
+
)
54
59
)
55
-
)
56
-
);
57
-
return replies;
60
+
)
61
+
);
62
+
result.push(...replies);
63
+
}
64
+
return result;
58
65
})
59
66
);
67
+
const posts = new Map(
68
+
allPosts
69
+
.flat()
70
+
.flatMap((res) => (res.ok ? [res.value] : []))
71
+
.map((post) => [post.uri, post.record])
72
+
);
60
73
61
-
return allReplies.flat();
74
+
// hydrate posts
75
+
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;
79
+
while (parent) {
80
+
if (posts.has(parent.uri as CanonicalResourceUri)) {
81
+
return result;
82
+
}
83
+
const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parent.uri);
84
+
if (p.ok) {
85
+
result = [{ uri: parent.uri as CanonicalResourceUri, record: p.value }, ...result];
86
+
parent = p.value.reply?.parent;
87
+
continue;
88
+
}
89
+
parent = undefined;
90
+
}
91
+
return result;
92
+
})
93
+
);
94
+
for (const post of missingPosts.flat()) {
95
+
posts.set(post.uri, post.record);
96
+
}
97
+
98
+
return posts;
62
99
};
+1
-1
src/lib/theme.svelte.ts
+1
-1
src/lib/theme.svelte.ts
···
1
1
export const theme = $state({
2
-
bg: '#0f172a', // slate-900 - deep blue-grey background
2
+
bg: '#11001c', // slate-900 - deep blue-grey background
3
3
fg: '#f8fafc', // slate-50 - crisp white foreground
4
4
accent: '#ec4899', // pink-500 - vibrant pink accent
5
5
accent2: '#8b5cf6' // violet-500 - purple secondary accent
+250
-51
src/routes/+page.svelte
+250
-51
src/routes/+page.svelte
···
4
4
import AccountSelector from '$components/AccountSelector.svelte';
5
5
import { AtpClient } from '$lib/at/client';
6
6
import { accounts, addAccount, type Account } from '$lib/accounts';
7
-
import { type Did, type Handle, parseCanonicalResourceUri } from '@atcute/lexicons';
7
+
import {
8
+
type Did,
9
+
type Handle,
10
+
parseCanonicalResourceUri,
11
+
type ResourceUri
12
+
} from '@atcute/lexicons';
8
13
import { onMount } from 'svelte';
9
14
import { theme } from '$lib/theme.svelte';
10
-
import { fetchPostsWithReplyBacklinks, fetchReplies } from '$lib/at/fetch';
15
+
import { fetchPostsWithBacklinks, hydratePosts } from '$lib/at/fetch';
11
16
import { expect } from '$lib/result';
12
-
import { writable } from 'svelte/store';
13
17
import type { AppBskyFeedPost } from '@atcute/bluesky';
18
+
import { SvelteMap } from 'svelte/reactivity';
14
19
15
20
let selectedDid = $state<Did | null>(null);
16
-
let clients = writable<Map<Did, AtpClient>>(new Map());
17
-
let selectedClient = $derived(selectedDid ? $clients.get(selectedDid) : null);
21
+
let clients = new SvelteMap<Did, AtpClient>();
22
+
let selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null);
18
23
19
24
let viewClient = $state<AtpClient>(new AtpClient());
20
25
21
26
onMount(async () => {
22
27
if ($accounts.length > 0) {
23
28
selectedDid = $accounts[0].did;
24
-
Promise.all($accounts.map(loginAccount)).then(() => fetchTimeline($accounts));
29
+
Promise.all($accounts.map(loginAccount)).then(() => fetchTimelines($accounts));
25
30
}
26
31
});
27
32
28
33
const loginAccount = async (account: Account) => {
29
34
const client = new AtpClient();
30
35
const result = await client.login(account.handle, account.password);
31
-
if (result.ok) {
32
-
clients.update((map) => map.set(account.did, client));
33
-
}
36
+
if (result.ok) clients.set(account.did, client);
34
37
};
35
38
36
39
const handleAccountSelected = async (did: Did) => {
37
40
selectedDid = did;
38
41
const account = $accounts.find((acc) => acc.did === did);
39
-
if (account && (!$clients.has(account.did) || !$clients.get(account.did)?.atcute))
42
+
if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute))
40
43
await loginAccount(account);
41
44
};
42
45
43
-
const handleLoginSucceed = (did: Did, handle: Handle, password: string) => {
46
+
const handleLogout = async (did: Did) => {
47
+
$accounts = $accounts.filter((acc) => acc.did !== did);
48
+
clients.delete(did);
49
+
posts.delete(did);
50
+
};
51
+
52
+
const handleLoginSucceed = async (did: Did, handle: Handle, password: string) => {
44
53
const newAccount: Account = { did, handle, password };
45
54
addAccount(newAccount);
46
55
selectedDid = did;
47
-
loginAccount(newAccount);
56
+
loginAccount(newAccount).then(() => fetchTimeline(newAccount));
48
57
};
49
58
50
-
let timeline = writable<Map<string, AppBskyFeedPost.Main>>(new Map());
51
-
const fetchTimeline = async (newAccounts: Account[]) => {
52
-
await Promise.all(
53
-
newAccounts.map(async (account) => {
54
-
const client = $clients.get(account.did);
55
-
if (!client) return;
56
-
const accPosts = await fetchPostsWithReplyBacklinks(client, account.did, undefined, 20);
57
-
if (!accPosts.ok) {
58
-
console.error(`failed to fetch posts for account ${account.handle}: ${accPosts.error}`);
59
-
return;
59
+
let posts = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyFeedPost.Main>>();
60
+
const fetchTimeline = async (account: Account) => {
61
+
const client = clients.get(account.did);
62
+
if (!client) return;
63
+
const accPosts = await fetchPostsWithBacklinks(client, account.did, undefined, 20);
64
+
if (!accPosts.ok) {
65
+
console.error(`failed to fetch posts for account ${account.handle}: ${accPosts.error}`);
66
+
return;
67
+
}
68
+
const accTimeline = await hydratePosts(client, accPosts.value.posts);
69
+
if (!posts.has(account.did)) {
70
+
posts.set(account.did, new SvelteMap(accTimeline));
71
+
return;
72
+
}
73
+
const map = posts.get(account.did)!;
74
+
for (const [uri, record] of accTimeline) map.set(uri, record);
75
+
};
76
+
const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline));
77
+
78
+
let reverseChronological = $state(true);
79
+
let viewOwnPosts = $state(true);
80
+
81
+
type ThreadPost = {
82
+
uri: ResourceUri;
83
+
did: Did;
84
+
rkey: string;
85
+
record: AppBskyFeedPost.Main;
86
+
parentUri: ResourceUri | null;
87
+
depth: number;
88
+
newestTime: number;
89
+
};
90
+
91
+
type Thread = {
92
+
rootUri: ResourceUri;
93
+
posts: ThreadPost[];
94
+
newestTime: number;
95
+
branchParentPost?: ThreadPost;
96
+
};
97
+
98
+
const buildThreads = (timelines: Map<Did, Map<ResourceUri, AppBskyFeedPost.Main>>): Thread[] => {
99
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
100
+
const threadMap = new Map<ResourceUri, ThreadPost[]>();
101
+
102
+
// Single pass: create posts and group by thread
103
+
for (const [, timeline] of timelines) {
104
+
for (const [uri, record] of timeline) {
105
+
const parsedUri = expect(parseCanonicalResourceUri(uri));
106
+
const rootUri = (record.reply?.root.uri as ResourceUri) || uri;
107
+
const parentUri = (record.reply?.parent.uri as ResourceUri) || null;
108
+
109
+
const post: ThreadPost = {
110
+
uri,
111
+
did: parsedUri.repo,
112
+
rkey: parsedUri.rkey,
113
+
record,
114
+
parentUri,
115
+
depth: 0,
116
+
newestTime: new Date(record.createdAt).getTime()
117
+
};
118
+
119
+
if (!threadMap.has(rootUri)) {
120
+
threadMap.set(rootUri, []);
121
+
}
122
+
threadMap.get(rootUri)!.push(post);
123
+
}
124
+
}
125
+
126
+
const threads: Thread[] = [];
127
+
128
+
for (const [rootUri, posts] of threadMap) {
129
+
const uriToPost = new Map(posts.map((p) => [p.uri, p]));
130
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
131
+
const childrenMap = new Map<ResourceUri | null, ThreadPost[]>();
132
+
133
+
// Calculate depth and group by parent
134
+
for (const post of posts) {
135
+
let depth = 0;
136
+
let currentUri = post.parentUri;
137
+
138
+
while (currentUri && uriToPost.has(currentUri)) {
139
+
depth++;
140
+
currentUri = uriToPost.get(currentUri)!.parentUri;
141
+
}
142
+
143
+
post.depth = depth;
144
+
145
+
if (!childrenMap.has(post.parentUri)) {
146
+
childrenMap.set(post.parentUri, []);
60
147
}
61
-
const accTimeline = await fetchReplies(client, accPosts.value.posts);
62
-
for (const reply of accTimeline) {
63
-
if (!reply.ok) {
64
-
console.error(`failed to fetch reply: ${reply.error}`);
65
-
return;
148
+
childrenMap.get(post.parentUri)!.push(post);
149
+
}
150
+
151
+
// Sort children by time (newest first)
152
+
for (const children of childrenMap.values()) {
153
+
children.sort((a, b) => b.newestTime - a.newestTime);
154
+
}
155
+
156
+
// Helper to create a thread from posts
157
+
const createThread = (
158
+
posts: ThreadPost[],
159
+
rootUri: ResourceUri,
160
+
branchParentUri?: ResourceUri
161
+
): Thread => {
162
+
return {
163
+
rootUri,
164
+
posts,
165
+
newestTime: Math.max(...posts.map((p) => p.newestTime)),
166
+
branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined
167
+
};
168
+
};
169
+
170
+
// Helper to collect all posts in a subtree
171
+
const collectSubtree = (startPost: ThreadPost): ThreadPost[] => {
172
+
const result: ThreadPost[] = [];
173
+
const addWithChildren = (post: ThreadPost) => {
174
+
result.push(post);
175
+
const children = childrenMap.get(post.uri) || [];
176
+
for (const child of children) {
177
+
addWithChildren(child);
66
178
}
67
-
timeline.update((map) => map.set(reply.value.uri, reply.value.record));
179
+
};
180
+
addWithChildren(startPost);
181
+
return result;
182
+
};
183
+
184
+
// Find branching points (posts with 2+ children)
185
+
const branchingPoints = Array.from(childrenMap.entries())
186
+
.filter(([, children]) => children.length > 1)
187
+
.map(([uri]) => uri);
188
+
189
+
if (branchingPoints.length === 0) {
190
+
// No branches - single thread
191
+
const roots = childrenMap.get(null) || [];
192
+
const allPosts = roots.flatMap((root) => collectSubtree(root));
193
+
threads.push(createThread(allPosts, rootUri));
194
+
} else {
195
+
// Has branches - split into separate threads
196
+
for (const branchParentUri of branchingPoints) {
197
+
const branches = childrenMap.get(branchParentUri) || [];
198
+
199
+
// Sort branches oldest to newest for processing
200
+
const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime);
201
+
202
+
sortedBranches.forEach((branchRoot, index) => {
203
+
const isOldestBranch = index === 0;
204
+
const branchPosts: ThreadPost[] = [];
205
+
206
+
// If oldest branch, include parent chain
207
+
if (isOldestBranch && branchParentUri !== null) {
208
+
const parentChain: ThreadPost[] = [];
209
+
let currentUri: ResourceUri | null = branchParentUri;
210
+
while (currentUri && uriToPost.has(currentUri)) {
211
+
parentChain.unshift(uriToPost.get(currentUri)!);
212
+
currentUri = uriToPost.get(currentUri)!.parentUri;
213
+
}
214
+
branchPosts.push(...parentChain);
215
+
}
216
+
217
+
// Add branch posts
218
+
branchPosts.push(...collectSubtree(branchRoot));
219
+
220
+
// Recalculate depths for display
221
+
const minDepth = Math.min(...branchPosts.map((p) => p.depth));
222
+
branchPosts.forEach((p) => (p.depth = p.depth - minDepth));
223
+
224
+
threads.push(
225
+
createThread(
226
+
branchPosts,
227
+
branchRoot.uri,
228
+
isOldestBranch ? undefined : (branchParentUri ?? undefined)
229
+
)
230
+
);
231
+
});
68
232
}
69
-
})
70
-
);
71
-
};
72
-
accounts.subscribe(fetchTimeline);
233
+
}
234
+
}
73
235
74
-
const getSortedTimeline = (_timeline: Map<string, AppBskyFeedPost.Main>) => {
75
-
const sortedTimeline = Array.from(_timeline).sort(
76
-
([_a, post], [_b, post2]) =>
77
-
new Date(post2.createdAt).getTime() - new Date(post.createdAt).getTime()
78
-
);
79
-
return sortedTimeline;
236
+
// Sort threads by newest time (descending) so older branches appear first
237
+
threads.sort((a, b) => b.newestTime - a.newestTime);
238
+
239
+
return threads;
80
240
};
81
-
let sortedTimeline = $derived(getSortedTimeline($timeline));
241
+
242
+
// Filtering functions (now much simpler!)
243
+
const isOwnPost = (post: ThreadPost, accounts: Account[]) =>
244
+
accounts.some((account) => account.did === post.did);
245
+
const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) =>
246
+
posts.some((post) => !isOwnPost(post, accounts));
247
+
const filterThreads = (threads: Thread[], accounts: Account[]) =>
248
+
threads.filter((thread) => {
249
+
if (!viewOwnPosts) {
250
+
return hasNonOwnPost(thread.posts, accounts);
251
+
}
252
+
return true;
253
+
});
254
+
255
+
// Usage
256
+
let threads = $derived(filterThreads(buildThreads(posts), $accounts));
82
257
</script>
83
258
84
259
<div class="mx-auto max-w-2xl p-4">
···
97
272
bind:selectedDid
98
273
onAccountSelected={handleAccountSelected}
99
274
onLoginSucceed={handleLoginSucceed}
275
+
onLogout={handleLogout}
100
276
/>
101
277
102
278
{#if selectedClient}
103
279
<div class="flex-1">
104
-
<PostComposer client={selectedClient} />
280
+
<PostComposer
281
+
client={selectedClient}
282
+
onPostSent={(uri, record) => posts.get(selectedDid!)?.set(uri, record)}
283
+
/>
105
284
</div>
106
285
{:else}
107
286
<div
108
-
class="flex flex-1 items-center justify-center rounded-xl border-2 px-4 py-2.5 backdrop-blur-sm"
287
+
class="flex flex-1 items-center justify-center rounded-sm border-2 px-4 py-2.5 backdrop-blur-sm"
109
288
style="border-color: {theme.accent}33; background: {theme.accent}0a;"
110
289
>
111
290
<p class="text-sm opacity-80" style="color: {theme.fg};">
···
116
295
</div>
117
296
118
297
<hr
119
-
class="h-[3px] w-full rounded-full border-0"
298
+
class="h-[4px] w-full rounded-full border-0"
120
299
style="background: linear-gradient(to right, {theme.accent}, {theme.accent2});"
121
300
/>
122
301
123
-
<div class="flex flex-col gap-3">
124
-
{#each sortedTimeline as [postUri, data] (postUri)}
125
-
{@const parsedUri = expect(parseCanonicalResourceUri(postUri))}
126
-
<BskyPost
127
-
client={viewClient}
128
-
identifier={parsedUri.repo}
129
-
rkey={parsedUri.rkey}
130
-
record={data}
131
-
/>
302
+
<div class="flex flex-col">
303
+
{#each threads as thread (thread.rootUri)}
304
+
<div class="flex {reverseChronological ? 'flex-col' : 'flex-col-reverse'} mb-6.5">
305
+
{#if thread.branchParentPost}
306
+
{@const post = thread.branchParentPost}
307
+
<div class="mb-1.5 flex items-center gap-1.5">
308
+
<span class="text-sm opacity-60" style="color: {theme.fg};"
309
+
>{reverseChronological ? '↱' : '↳'}</span
310
+
>
311
+
<BskyPost
312
+
mini
313
+
client={viewClient}
314
+
identifier={post.did}
315
+
rkey={post.rkey}
316
+
record={post.record}
317
+
/>
318
+
</div>
319
+
{/if}
320
+
{#each thread.posts as post (post.uri)}
321
+
<div class="mb-1.5">
322
+
<BskyPost
323
+
client={viewClient}
324
+
identifier={post.did}
325
+
rkey={post.rkey}
326
+
record={post.record}
327
+
/>
328
+
</div>
329
+
{/each}
330
+
</div>
132
331
{/each}
133
332
</div>
134
333
</div>