+14
src/app.css
+14
src/app.css
···
71
71
--picker-height: 8rem;
72
72
--picker-width: 8rem;
73
73
}
74
+
75
+
.animate-pulse-highlight {
76
+
animation: pulse-highlight 0.6s ease-in-out 3;
77
+
}
78
+
79
+
@keyframes pulse-highlight {
80
+
0%,
81
+
100% {
82
+
box-shadow: 0 0 0 0 var(--nucleus-selected-post);
83
+
}
84
+
50% {
85
+
box-shadow: 0 0 20px 5px var(--nucleus-selected-post);
86
+
}
87
+
}
+45
-10
src/components/BskyPost.svelte
+45
-10
src/components/BskyPost.svelte
···
18
18
import BskyPost from './BskyPost.svelte';
19
19
import Icon from '@iconify/svelte';
20
20
import { type Backlink, type BacklinksSource } from '$lib/at/constellation';
21
-
import { postActions, type PostActions } from '$lib/state.svelte';
21
+
import { postActions, pulsingPostId, type PostActions } from '$lib/state.svelte';
22
22
import * as TID from '@atcute/tid';
23
23
import type { PostWithUri } from '$lib/at/fetch';
24
24
import { onMount } from 'svelte';
25
25
import type { AtprotoDid } from '@atcute/lexicons/syntax';
26
+
import { derived } from 'svelte/store';
26
27
27
28
interface Props {
28
29
client: AtpClient;
···
30
31
did: Did;
31
32
rkey: RecordKey;
32
33
// replyBacklinks?: Backlinks;
33
-
depth?: number;
34
+
quoteDepth?: number;
34
35
data?: PostWithUri;
35
36
mini?: boolean;
36
37
isOnPostComposer?: boolean;
···
42
43
client,
43
44
did,
44
45
rkey,
45
-
depth = 0,
46
+
quoteDepth = 0,
46
47
data,
47
48
mini,
48
49
onQuote,
···
72
73
// 'app.bsky.feed.post:reply.parent.uri'
73
74
// );
74
75
76
+
const postId = `timeline-post-${aturi}-${quoteDepth}`;
77
+
const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId);
78
+
79
+
const scrollToAndPulse = (targetUri: ResourceUri) => {
80
+
const targetId = `timeline-post-${targetUri}-0`;
81
+
console.log(`Scrolling to ${targetId}`);
82
+
const element = document.getElementById(targetId);
83
+
if (!element) return;
84
+
85
+
// Smooth scroll to the target
86
+
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
87
+
88
+
// Trigger pulse after scroll completes
89
+
setTimeout(() => {
90
+
document.documentElement.style.setProperty(
91
+
'--nucleus-selected-post',
92
+
generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo)
93
+
);
94
+
pulsingPostId.set(targetId);
95
+
// Clear pulse after animation
96
+
setTimeout(() => pulsingPostId.set(null), 2000);
97
+
}, 500);
98
+
};
99
+
75
100
const getEmbedText = (embedType: string) => {
76
101
switch (embedType) {
77
102
case 'app.bsky.embed.external':
···
205
230
{:then post}
206
231
{#if post.ok}
207
232
{@const record = post.value.record}
208
-
<span style="color: {color};">@{handle}</span>: {@render embedBadge(record)}
209
-
<span title={record.text}>{record.text}</span>
233
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
234
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
235
+
<div
236
+
onclick={() => scrollToAndPulse(post.value.uri)}
237
+
class="select-none hover:cursor-pointer hover:underline"
238
+
>
239
+
<span style="color: {color};">@{handle}</span>: {@render embedBadge(record)}
240
+
<span title={record.text}>{record.text}</span>
241
+
</div>
210
242
{:else}
211
243
{post.error}
212
244
{/if}
···
227
259
{#if post.ok}
228
260
{@const record = post.value.record}
229
261
<div
230
-
class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all"
262
+
id="timeline-post-{post.value.uri}-{quoteDepth}"
263
+
class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all {$isPulsing
264
+
? 'animate-pulse-highlight'
265
+
: ''}"
231
266
style="background: {color}{isOnPostComposer
232
267
? '36'
233
268
: '18'}; border-color: {color}{isOnPostComposer ? '99' : '66'};"
···
267
302
{@const embed = record.embed}
268
303
<div class="mt-2">
269
304
{#snippet embedPost(uri: ResourceUri)}
270
-
{#if depth < 2}
305
+
{#if quoteDepth < 2}
271
306
{@const parsedUri = expect(parseCanonicalResourceUri(uri))}
272
307
<!-- reject recursive quotes -->
273
308
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
274
309
<BskyPost
275
310
{client}
276
-
depth={depth + 1}
311
+
quoteDepth={quoteDepth + 1}
277
312
did={parsedUri.repo}
278
313
rkey={parsedUri.rkey}
279
314
{isOnPostComposer}
···
325
360
{/if}
326
361
</div>
327
362
{:else}
328
-
<div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;">
329
-
<p class="text-sm font-medium" style="color: #fca5a5;">error: {post.error}</p>
363
+
<div class="error-disclaimer">
364
+
<p class="text-sm font-medium">error: {post.error}</p>
330
365
</div>
331
366
{/if}
332
367
{/await}
+60
-61
src/components/PostComposer.svelte
+60
-61
src/components/PostComposer.svelte
···
107
107
});
108
108
</script>
109
109
110
+
{#snippet renderPost(post: PostWithUri)}
111
+
{@const parsedUri = expect(parseCanonicalResourceUri(post.uri))}
112
+
<BskyPost
113
+
{client}
114
+
did={parsedUri.repo}
115
+
rkey={parsedUri.rkey}
116
+
data={post}
117
+
isOnPostComposer={true}
118
+
/>
119
+
{/snippet}
120
+
121
+
{#snippet composer()}
122
+
{#if replying}
123
+
{@render renderPost(replying)}
124
+
{/if}
125
+
<textarea
126
+
bind:this={textareaEl}
127
+
bind:value={postText}
128
+
onfocus={() => (isFocused = true)}
129
+
onblur={unfocus}
130
+
onkeydown={(event) => {
131
+
if (event.key === 'Escape') unfocus();
132
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
133
+
}}
134
+
placeholder="what's on your mind?"
135
+
rows="4"
136
+
class="field-sizing-content single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100"
137
+
style="border-color: color-mix(in srgb, {color} 27%, transparent);"
138
+
></textarea>
139
+
{#if quoting}
140
+
{@render renderPost(quoting)}
141
+
{/if}
142
+
<div class="flex items-center gap-2">
143
+
<div class="grow"></div>
144
+
<span
145
+
class="text-sm font-medium"
146
+
style="color: color-mix(in srgb, {postText.length > 300
147
+
? '#ef4444'
148
+
: 'var(--nucleus-fg)'} 53%, transparent);"
149
+
>
150
+
{postText.length} / 300
151
+
</span>
152
+
<button
153
+
onmousedown={(e) => {
154
+
e.preventDefault();
155
+
doPost();
156
+
}}
157
+
disabled={postText.length === 0 || postText.length > 300}
158
+
class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
159
+
style="background: color-mix(in srgb, {color} 87%, transparent);"
160
+
>
161
+
post
162
+
</button>
163
+
</div>
164
+
{/snippet}
165
+
110
166
<div class="relative min-h-16">
111
167
<!-- Spacer to maintain layout when focused -->
112
168
{#if isFocused}
···
120
176
e.preventDefault();
121
177
}
122
178
}}
123
-
class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300"
124
-
class:min-h-16={!isFocused}
125
-
class:items-center={!isFocused}
126
-
class:shadow-2xl={isFocused}
127
-
class:absolute={isFocused}
128
-
class:top-0={isFocused}
129
-
class:left-0={isFocused}
130
-
class:right-0={isFocused}
131
-
class:z-50={isFocused}
179
+
class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300
180
+
{!isFocused ? 'min-h-16 items-center' : ''}
181
+
{isFocused ? 'absolute top-0 right-0 left-0 z-50 shadow-2xl' : ''}"
132
182
style="background: {isFocused
133
183
? `color-mix(in srgb, var(--nucleus-bg) 80%, ${color} 20%)`
134
184
: `color-mix(in srgb, ${color} 9%, transparent)`};
···
144
194
</div>
145
195
{:else}
146
196
<div class="flex flex-col gap-2">
147
-
{#snippet renderPost(post: PostWithUri)}
148
-
{@const parsedUri = expect(parseCanonicalResourceUri(post.uri))}
149
-
<BskyPost
150
-
{client}
151
-
did={parsedUri.repo}
152
-
rkey={parsedUri.rkey}
153
-
data={post}
154
-
isOnPostComposer={true}
155
-
/>
156
-
{/snippet}
157
197
{#if isFocused}
158
-
{#if replying}
159
-
{@render renderPost(replying)}
160
-
{/if}
161
-
<textarea
162
-
bind:this={textareaEl}
163
-
bind:value={postText}
164
-
onfocus={() => (isFocused = true)}
165
-
onblur={unfocus}
166
-
onkeydown={(event) => {
167
-
if (event.key === 'Escape') unfocus();
168
-
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
169
-
}}
170
-
placeholder="what's on your mind?"
171
-
rows="4"
172
-
class="field-sizing-content single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100"
173
-
style="border-color: color-mix(in srgb, {color} 27%, transparent);"
174
-
></textarea>
175
-
{#if quoting}
176
-
{@render renderPost(quoting)}
177
-
{/if}
178
-
<div class="flex items-center gap-2">
179
-
<div class="grow"></div>
180
-
<span
181
-
class="text-sm font-medium"
182
-
style="color: color-mix(in srgb, {postText.length > 300
183
-
? '#ef4444'
184
-
: 'var(--nucleus-fg)'} 53%, transparent);"
185
-
>
186
-
{postText.length} / 300
187
-
</span>
188
-
<button
189
-
onmousedown={(e) => {
190
-
e.preventDefault();
191
-
doPost();
192
-
}}
193
-
disabled={postText.length === 0 || postText.length > 300}
194
-
class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
195
-
style="background: color-mix(in srgb, {color} 87%, transparent);"
196
-
>
197
-
post
198
-
</button>
199
-
</div>
198
+
{@render composer()}
200
199
{:else}
201
200
<input
202
201
bind:value={postText}
+11
-12
src/lib/at/client.ts
+11
-12
src/lib/at/client.ts
···
12
12
parseResourceUri,
13
13
type ActorIdentifier,
14
14
type AtprotoDid,
15
-
type CanonicalResourceUri,
16
15
type Cid,
17
16
type Did,
18
17
type Nsid,
···
199
198
200
199
const mapped = map(res, (data) => data.did as AtprotoDid);
201
200
202
-
if (mapped.ok) {
203
-
handleCache.set(identifier, mapped.value);
204
-
}
201
+
if (mapped.ok) handleCache.set(identifier, mapped.value);
205
202
206
203
return mapped;
207
204
}
···
218
215
cachedSignal.then((d): Result<MiniDoc, string> => ok(d))
219
216
]);
220
217
221
-
if (result.ok) {
222
-
didDocCache.set(handleOrDid, result.value);
223
-
}
218
+
if (result.ok) didDocCache.set(handleOrDid, result.value);
224
219
225
220
return result;
226
221
}
227
222
228
223
async getBacklinksUri(
229
-
uri: CanonicalResourceUri,
224
+
uri: ResourceUri,
230
225
source: BacklinksSource
231
226
): Promise<Result<Backlinks, string>> {
232
227
const parsedResourceUri = expect(parseCanonicalResourceUri(uri));
···
245
240
source: BacklinksSource
246
241
): Promise<Result<Backlinks, string>> {
247
242
const did = await this.resolveHandle(repo);
248
-
if (!did.ok) {
249
-
return err(`failed to resolve handle: ${did.error}`);
250
-
}
243
+
if (!did.ok) return err(`cant resolve handle: ${did.error}`);
251
244
252
-
return await fetchMicrocosm(constellationUrl, BacklinksQuery, {
245
+
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000));
246
+
const query = fetchMicrocosm(constellationUrl, BacklinksQuery, {
253
247
subject: `at://${did.value}/${collection}/${rkey}`,
254
248
source,
255
249
limit: 100
256
250
});
251
+
252
+
const results = await Promise.race([query, timeout]);
253
+
if (!results) return err('cant fetch backlinks: timeout');
254
+
255
+
return results;
257
256
}
258
257
259
258
streamNotifications(subjects: Did[], ...sources: BacklinksSource[]): NotificationsStream {
+90
-60
src/lib/at/fetch.ts
+90
-60
src/lib/at/fetch.ts
···
1
-
import type { ActorIdentifier, CanonicalResourceUri, Cid, ResourceUri } from '@atcute/lexicons';
1
+
import {
2
+
parseCanonicalResourceUri,
3
+
type CanonicalResourceUri,
4
+
type Cid,
5
+
type ResourceUri
6
+
} from '@atcute/lexicons';
2
7
import { recordCache, type AtpClient } from './client';
3
-
import { err, ok, type Result } from '$lib/result';
8
+
import { err, expect, ok, type Result } from '$lib/result';
4
9
import type { Backlinks } from './constellation';
5
10
import { AppBskyFeedPost } from '@atcute/bluesky';
11
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
6
12
7
13
export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main };
8
14
export type PostWithBacklinks = PostWithUri & {
9
-
replies: Result<Backlinks, string>;
15
+
replies: Backlinks;
10
16
};
11
17
export type PostsWithReplyBacklinks = PostWithBacklinks[];
12
18
19
+
const replySource = 'app.bsky.feed.post:reply.parent.uri';
20
+
13
21
export const fetchPostsWithBacklinks = async (
14
22
client: AtpClient,
15
-
repo: ActorIdentifier,
23
+
repo: AtprotoDid,
16
24
cursor?: string,
17
25
limit?: number
18
26
): Promise<Result<{ posts: PostsWithReplyBacklinks; cursor?: string }, string>> => {
···
21
29
cursor = recordsList.value.cursor;
22
30
const records = recordsList.value.records;
23
31
24
-
const allBacklinks = await Promise.all(
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
-
})
38
-
);
39
-
40
-
return ok({ posts: allBacklinks, cursor });
32
+
try {
33
+
const allBacklinks = await Promise.all(
34
+
records.map(async (r): Promise<PostWithBacklinks> => {
35
+
recordCache.set(r.uri, r);
36
+
const replies = await client.getBacklinksUri(r.uri, replySource);
37
+
if (!replies.ok) throw `cant fetch replies: ${replies.error}`;
38
+
return {
39
+
uri: r.uri,
40
+
cid: r.cid,
41
+
record: r.value as AppBskyFeedPost.Main,
42
+
replies: replies.value
43
+
};
44
+
})
45
+
);
46
+
return ok({ posts: allBacklinks, cursor });
47
+
} catch (error) {
48
+
return err(`cant fetch posts backlinks: ${error}`);
49
+
}
41
50
};
42
51
43
52
export const hydratePosts = async (
44
53
client: AtpClient,
54
+
repo: AtprotoDid,
45
55
data: PostsWithReplyBacklinks
46
-
): Promise<Map<ResourceUri, PostWithUri>> => {
47
-
const allPosts = await Promise.all(
48
-
data.map(async (post) => {
49
-
const result: Result<PostWithUri, string>[] = [ok(post)];
50
-
if (post.replies.ok) {
56
+
): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => {
57
+
let posts: Map<ResourceUri, PostWithUri> = new Map();
58
+
try {
59
+
const allPosts = await Promise.all(
60
+
data.map(async (post) => {
61
+
const result: PostWithUri[] = [post];
51
62
const replies = await Promise.all(
52
-
post.replies.value.records.map((r) =>
53
-
client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey)
54
-
)
63
+
post.replies.records.map(async (r) => {
64
+
const reply = await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey);
65
+
if (!reply.ok) throw `cant fetch reply: ${reply.error}`;
66
+
return reply.value;
67
+
})
55
68
);
56
69
result.push(...replies);
70
+
return result;
71
+
})
72
+
);
73
+
posts = new Map(allPosts.flat().map((post) => [post.uri, post]));
74
+
} catch (error) {
75
+
return err(`cant hydrate immediate replies: ${error}`);
76
+
}
77
+
78
+
const fetchUpwardsChain = async (post: PostWithUri) => {
79
+
let parent = post.record.reply?.parent;
80
+
while (parent) {
81
+
// if we already have this parent, then we already fetched this chain / are fetching it
82
+
if (posts.has(parent.uri as CanonicalResourceUri)) return;
83
+
const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parent.uri);
84
+
if (p.ok) {
85
+
posts.set(p.value.uri, p.value);
86
+
parent = p.value.record.reply?.parent;
87
+
continue;
57
88
}
58
-
return result;
59
-
})
60
-
);
61
-
const posts = new Map(
62
-
allPosts
63
-
.flat()
64
-
.flatMap((res) => (res.ok ? [res.value] : []))
65
-
.map((post) => [post.uri, post])
66
-
);
89
+
// TODO: handle deleted parent posts
90
+
parent = undefined;
91
+
}
92
+
};
93
+
await Promise.all(posts.values().map(fetchUpwardsChain));
94
+
95
+
try {
96
+
const fetchDownwardsChain = async (post: PostWithUri) => {
97
+
const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri));
98
+
if (repo === postRepo) return;
99
+
100
+
// get chains that are the same author until we exhaust them
101
+
const backlinks = await client.getBacklinksUri(post.uri, replySource);
102
+
if (!backlinks.ok) return;
67
103
68
-
// hydrate posts
69
-
const missingPosts = await Promise.all(
70
-
Array.from(posts).map(async ([, post]) => {
71
-
let result: PostWithUri[] = [post];
72
-
let parent = post.record.reply?.parent;
73
-
while (parent) {
74
-
if (posts.has(parent.uri as CanonicalResourceUri)) {
75
-
return result;
76
-
}
77
-
const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parent.uri);
78
-
if (p.ok) {
79
-
result = [p.value, ...result];
80
-
parent = p.value.record.reply?.parent;
81
-
continue;
82
-
}
83
-
parent = undefined;
104
+
const promises = [];
105
+
for (const reply of backlinks.value.records) {
106
+
if (reply.did !== postRepo) continue;
107
+
// if we already have this reply, then we already fetched this chain / are fetching it
108
+
if (posts.has(`at://${reply.did}/${reply.collection}/${reply.rkey}`)) continue;
109
+
const record = await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey);
110
+
if (!record.ok) break; // TODO: this doesnt handle deleted posts in between
111
+
posts.set(record.value.uri, record.value);
112
+
promises.push(fetchDownwardsChain(record.value));
84
113
}
85
-
return result;
86
-
})
87
-
);
88
-
for (const post of missingPosts.flat()) {
89
-
posts.set(post.uri, post);
114
+
115
+
await Promise.all(promises);
116
+
};
117
+
await Promise.all(posts.values().map(fetchDownwardsChain));
118
+
} catch (error) {
119
+
return err(`cant fetch post reply chain: ${error}`);
90
120
}
91
121
92
-
return posts;
122
+
return ok(posts);
93
123
};
+2
src/lib/state.svelte.ts
+2
src/lib/state.svelte.ts
+3
-1
src/lib/thread.ts
+3
-1
src/lib/thread.ts
···
5
5
6
6
export type ThreadPost = {
7
7
data: PostWithUri;
8
+
account: Did;
8
9
did: Did;
9
10
rkey: string;
10
11
parentUri: ResourceUri | null;
···
23
24
const threadMap = new Map<ResourceUri, ThreadPost[]>();
24
25
25
26
// group posts by root uri into "thread" chains
26
-
for (const [, timeline] of timelines) {
27
+
for (const [account, timeline] of timelines) {
27
28
for (const [uri, data] of timeline) {
28
29
const parsedUri = expect(parseCanonicalResourceUri(uri));
29
30
const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri;
···
31
32
32
33
const post: ThreadPost = {
33
34
data,
35
+
account,
34
36
did: parsedUri.repo,
35
37
rkey: parsedUri.rkey,
36
38
parentUri,
+27
-14
src/routes/+page.svelte
+27
-14
src/routes/+page.svelte
···
8
8
import { type Did, parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons';
9
9
import { onMount } from 'svelte';
10
10
import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch';
11
-
import { expect, ok } from '$lib/result';
11
+
import { expect } from '$lib/result';
12
12
import { AppBskyFeedPost } from '@atcute/bluesky';
13
13
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
14
14
import { InfiniteLoader, LoaderState } from 'svelte-infinite';
···
98
98
if (cursor && cursor.end) return;
99
99
100
100
const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6);
101
-
if (!accPosts.ok)
102
-
throw `failed to fetch posts for account ${account.handle}: ${accPosts.error}`;
101
+
if (!accPosts.ok) throw `cant fetch posts @${account.handle}: ${accPosts.error}`;
103
102
104
103
// if the cursor is undefined, we've reached the end of the timeline
105
104
if (!accPosts.value.cursor) {
···
108
107
}
109
108
110
109
cursors.set(account.did, { value: accPosts.value.cursor, end: false });
111
-
addPosts(account.did, await hydratePosts(client, accPosts.value.posts));
110
+
const hydrated = await hydratePosts(client, account.did, accPosts.value.posts);
111
+
if (!hydrated.ok) throw `cant hydrate posts @${account.handle}: ${hydrated.error}`;
112
+
113
+
addPosts(account.did, hydrated.value);
112
114
};
113
115
114
116
const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline));
···
125
127
if (!subjectPost.ok) return;
126
128
127
129
const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
128
-
const hydrated = await hydratePosts(viewClient, [
130
+
const hydrated = await hydratePosts(viewClient, parsedSubjectUri.repo as AtprotoDid, [
129
131
{
130
132
record: subjectPost.value.record,
131
133
uri: event.data.link.subject,
132
134
cid: subjectPost.value.cid,
133
-
replies: ok({
135
+
replies: {
134
136
cursor: null,
135
137
total: 1,
136
138
records: [
···
140
142
rkey: parsedSourceUri.rkey
141
143
}
142
144
]
143
-
})
145
+
}
144
146
}
145
147
]);
148
+
149
+
if (!hydrated.ok) {
150
+
errors.push(`cant hydrate posts @${parsedSubjectUri.repo}: ${hydrated.error}`);
151
+
return;
152
+
}
146
153
147
154
// console.log(hydrated);
148
-
addPosts(parsedSubjectUri.repo, hydrated);
155
+
addPosts(parsedSubjectUri.repo, hydrated.value);
149
156
}
150
157
};
151
158
···
181
188
loaderState.error();
182
189
} finally {
183
190
loading = false;
184
-
if (cursors.values().every((cursor) => cursor.end)) loaderState.complete();
191
+
// if (cursors.values().every((cursor) => cursor.end)) loaderState.complete();
185
192
}
186
193
};
187
194
···
230
237
</script>
231
238
232
239
<div class="mx-auto max-w-2xl">
233
-
<!-- Sticky header -->
240
+
<!-- header -->
234
241
<div class="sticky top-0 z-10 bg-(--nucleus-bg) pb-2">
235
242
<div class="mb-6 flex items-center justify-between">
236
243
<div>
···
250
257
</button>
251
258
</div>
252
259
253
-
<!-- Composer and error disclaimer (above thread list, not scrollable) -->
260
+
<!-- composer and error disclaimer (above thread list, not scrollable) -->
254
261
<div class="space-y-4">
255
262
<div class="flex min-h-16 items-stretch gap-2">
256
263
<AccountSelector
···
308
315
</div>
309
316
</div>
310
317
311
-
<!-- Thread list (page scrolls as a whole) -->
318
+
<!-- thread list (page scrolls as a whole) -->
312
319
<div class="mt-4 [scrollbar-color:var(--nucleus-accent)_transparent]" bind:this={scrollContainer}>
313
320
{#if $accounts.length > 0}
314
321
{@render renderThreads()}
···
407
414
</div>
408
415
{/snippet}
409
416
{#snippet error()}
410
-
<div class="flex justify-center py-4">
417
+
<div class="flex flex-col gap-4 py-4">
411
418
<p class="text-xl opacity-80">
412
-
<span class="text-4xl">:(</span> <br /> an error occurred while loading posts: {loadError}
419
+
<span class="text-4xl">x_x</span> <br />
420
+
{loadError}
413
421
</p>
422
+
<div>
423
+
<button class="flex action-button items-center gap-2" onclick={loadMore}>
424
+
<Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again
425
+
</button>
426
+
</div>
414
427
</div>
415
428
{/snippet}
416
429
</InfiniteLoader>
+4
-7
src/routes/+page.ts
+4
-7
src/routes/+page.ts
···
1
1
import { replaceState } from '$app/navigation';
2
2
import { addAccount, loggingIn } from '$lib/accounts';
3
3
import { AtpClient } from '$lib/at/client';
4
-
import { flow } from '$lib/at/oauth';
4
+
import { flow, sessions } from '$lib/at/oauth';
5
5
import { err, ok, type Result } from '$lib/result';
6
6
import type { PageLoad } from './$types';
7
7
···
29
29
}
30
30
31
31
loggingIn.set(null);
32
+
await sessions.remove(account.did);
32
33
const agent = await flow.finalize(currentUrl);
33
34
if (!agent.ok || !agent.value) {
34
-
if (!agent.ok) {
35
-
return err(agent.error);
36
-
}
35
+
if (!agent.ok) return err(agent.error);
37
36
return err('no session was logged into?!');
38
37
}
39
38
40
39
const client = new AtpClient();
41
40
const result = await client.login(account.did, agent.value);
42
-
if (!result.ok) {
43
-
return err(result.error);
44
-
}
41
+
if (!result.ok) return err(result.error);
45
42
46
43
addAccount(account);
47
44
return ok(client);