+13
.zed/settings.json
+13
.zed/settings.json
+2
-2
nix/modules.nix
+2
-2
nix/modules.nix
···
14
14
];
15
15
};
16
16
17
-
outputHash = "sha256-oZKRCeIxjkv8Iujo82FaumsEnpt8pSqoYDbibmPgmZA=";
17
+
outputHash = "sha256-1AkU6eV0uIUZohotHhd8E5eAwc4E4wwg2SjHVUdX8LE=";
18
18
outputHashAlgo = "sha256";
19
19
outputHashMode = "recursive";
20
20
21
-
nativeBuildInputs = [deno];
21
+
nativeBuildInputs = [ deno ];
22
22
23
23
dontConfigure = true;
24
24
dontCheck = true;
+4
src/app.css
+4
src/app.css
+1
-1
src/components/AccountSelector.svelte
+1
-1
src/components/AccountSelector.svelte
···
1
1
<script lang="ts">
2
2
import { generateColorForDid, loggingIn, type Account } from '$lib/accounts';
3
-
import { AtpClient, resolveHandle } from '$lib/at/client';
3
+
import { AtpClient, resolveHandle } from '$lib/at/client.svelte';
4
4
import type { Handle } from '@atcute/lexicons';
5
5
import ProfilePicture from './ProfilePicture.svelte';
6
6
import PfpPlaceholder from './PfpPlaceholder.svelte';
+36
src/components/BlockedUserIndicator.svelte
+36
src/components/BlockedUserIndicator.svelte
···
1
+
<script lang="ts">
2
+
import type { Did } from '@atcute/lexicons';
3
+
import ProfilePicture from './ProfilePicture.svelte';
4
+
import type { AtpClient } from '$lib/at/client.svelte';
5
+
import { generateColorForDid } from '$lib/accounts';
6
+
7
+
interface Props {
8
+
client: AtpClient;
9
+
did: Did;
10
+
reason: 'blocked' | 'blocks-you';
11
+
size?: 'small' | 'normal' | 'large';
12
+
}
13
+
14
+
let { client, did, reason, size = 'normal' }: Props = $props();
15
+
16
+
const color = $derived(generateColorForDid(did));
17
+
const text = $derived(reason === 'blocked' ? 'user blocked' : 'user blocks you');
18
+
const pfpSize = $derived(size === 'small' ? 8 : size === 'large' ? 16 : 10);
19
+
</script>
20
+
21
+
<div
22
+
class="flex items-center gap-2 rounded-sm border-2 p-2 {size === 'small' ? 'text-sm' : ''}"
23
+
style="background: {color}11; border-color: {color}44;"
24
+
>
25
+
<div class="blocked-pfp">
26
+
<ProfilePicture {client} {did} size={pfpSize} />
27
+
</div>
28
+
<span class="opacity-80">{text}</span>
29
+
</div>
30
+
31
+
<style>
32
+
.blocked-pfp {
33
+
filter: blur(8px) grayscale(100%);
34
+
opacity: 0.4;
35
+
}
36
+
</style>
+168
-249
src/components/BskyPost.svelte
+168
-249
src/components/BskyPost.svelte
···
1
1
<script lang="ts">
2
-
import { resolveDidDoc, type AtpClient } from '$lib/at/client';
3
-
import {
4
-
AppBskyActorProfile,
5
-
AppBskyEmbedExternal,
6
-
AppBskyEmbedImages,
7
-
AppBskyEmbedVideo,
8
-
AppBskyFeedPost
9
-
} from '@atcute/bluesky';
2
+
import { resolveDidDoc, type AtpClient } from '$lib/at/client.svelte';
3
+
import { AppBskyActorProfile, AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/bluesky';
10
4
import {
11
5
parseCanonicalResourceUri,
12
6
type Did,
···
17
11
import { expect, ok } from '$lib/result';
18
12
import { accounts, generateColorForDid } from '$lib/accounts';
19
13
import ProfilePicture from './ProfilePicture.svelte';
20
-
import { isBlob } from '@atcute/lexicons/interfaces';
21
-
import { blob, img } from '$lib/cdn';
22
14
import BskyPost from './BskyPost.svelte';
23
15
import Icon from '@iconify/svelte';
24
16
import {
25
17
allPosts,
26
18
pulsingPostId,
27
19
currentTime,
28
-
findBacklinksBy,
29
20
deletePostBacklink,
30
21
createPostBacklink,
31
22
router,
32
23
profiles,
33
-
handles
24
+
handles,
25
+
hasBacklink,
26
+
getBlockRelationship,
27
+
clients
34
28
} from '$lib/state.svelte';
35
29
import type { PostWithUri } from '$lib/at/fetch';
36
30
import { onMount, type Snippet } from 'svelte';
37
31
import { derived } from 'svelte/store';
38
-
import Device from 'svelte-device-info';
39
32
import Dropdown from './Dropdown.svelte';
40
-
import { type AppBskyEmbeds } from '$lib/at/types';
41
33
import { settings } from '$lib/settings';
42
34
import RichText from './RichText.svelte';
43
35
import { getRelativeTime } from '$lib/date';
44
36
import { likeSource, repostSource, toCanonicalUri } from '$lib';
45
37
import ProfileInfo from './ProfileInfo.svelte';
46
-
import PhotoSwipeGallery, { type GalleryItem } from './PhotoSwipeGallery.svelte';
38
+
import EmbedBadge from './EmbedBadge.svelte';
39
+
import EmbedMedia from './EmbedMedia.svelte';
47
40
48
41
interface Props {
49
42
client: AtpClient;
···
58
51
onQuote?: (quote: PostWithUri) => void;
59
52
onReply?: (reply: PostWithUri) => void;
60
53
cornerFragment?: Snippet;
54
+
isBlocked?: boolean;
61
55
}
62
56
63
57
const {
···
70
64
onQuote,
71
65
onReply,
72
66
isOnPostComposer = false /* replyBacklinks */,
73
-
cornerFragment
67
+
cornerFragment,
68
+
isBlocked = false
74
69
}: Props = $props();
75
70
76
-
const selectedDid = $derived(client.user?.did ?? null);
71
+
const user = $derived(client.user);
77
72
const isLoggedInUser = $derived($accounts.some((acc) => acc.did === did));
78
73
79
74
const aturi = $derived(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey }));
80
75
const color = $derived(generateColorForDid(did));
81
76
77
+
let expandBlocked = $state(false);
78
+
const blockRel = $derived(
79
+
user && !isOnPostComposer
80
+
? getBlockRelationship(user.did, did)
81
+
: { userBlocked: false, blockedByTarget: false }
82
+
);
83
+
const showAsBlocked = $derived(
84
+
(isBlocked || blockRel.userBlocked || blockRel.blockedByTarget) && !expandBlocked
85
+
);
86
+
82
87
let handle: Handle = $state(handles.get(did) ?? 'handle.invalid');
83
-
const didDoc = resolveDidDoc(did).then((res) => {
84
-
if (res.ok) {
85
-
handle = res.value.handle;
86
-
handles.set(did, handle);
87
-
}
88
-
return res;
88
+
onMount(() => {
89
+
resolveDidDoc(did).then((res) => {
90
+
if (res.ok) {
91
+
handle = res.value.handle;
92
+
handles.set(did, handle);
93
+
}
94
+
return res;
95
+
});
89
96
});
90
97
const post = data
91
98
? Promise.resolve(ok(data))
···
120
127
}, 400);
121
128
};
122
129
123
-
const getEmbedText = (embedType: string) => {
124
-
switch (embedType) {
125
-
case 'app.bsky.embed.external':
126
-
return '๐ has external link';
127
-
case 'app.bsky.embed.record':
128
-
return '๐ฌ has quote';
129
-
case 'app.bsky.embed.images':
130
-
return '๐ผ๏ธ has images';
131
-
case 'app.bsky.embed.video':
132
-
return '๐ฅ has video';
133
-
case 'app.bsky.embed.recordWithMedia':
134
-
return '๐ has quote with media';
135
-
default:
136
-
return 'โ has unknown embed';
137
-
}
138
-
};
139
-
140
130
let actionsOpen = $state(false);
141
131
let actionsPos = $state({ x: 0, y: 0 });
142
132
···
159
149
return;
160
150
}
161
151
162
-
client?.atcute
163
-
?.post('com.atproto.repo.deleteRecord', {
152
+
clients
153
+
.get(did)
154
+
?.user?.atcute.post('com.atproto.repo.deleteRecord', {
164
155
input: {
165
156
collection: 'app.bsky.feed.post',
166
157
repo: did,
···
178
169
let profileOpen = $state(false);
179
170
</script>
180
171
181
-
{#snippet embedBadge(embed: AppBskyEmbeds)}
182
-
<span
183
-
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
184
-
style="
185
-
background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' : color} 10%, transparent);
186
-
color: {mini ? 'var(--nucleus-fg)' : color};
187
-
"
188
-
>
189
-
{getEmbedText(embed.$type!)}
190
-
</span>
191
-
{/snippet}
192
-
193
172
{#snippet profileInline()}
194
173
<button
195
174
class="
···
197
176
rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10
198
177
"
199
178
style="color: {color};"
200
-
onclick={() => router.navigate(`/profile/${did}`)}
179
+
onclick={() => ((profileOpen = false), router.navigate(`/profile/${did}`))}
201
180
>
202
181
<ProfilePicture {client} {did} size={8} />
203
182
···
231
210
{:then post}
232
211
{#if post.ok}
233
212
{@const record = post.value.record}
234
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
235
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
236
-
<div
237
-
onclick={() => scrollToAndPulse(post.value.uri)}
238
-
class="select-none hover:cursor-pointer hover:underline"
239
-
>
240
-
<span style="color: {color};">@{handle}</span>:
241
-
{#if record.embed}
242
-
{@render embedBadge(record.embed)}
243
-
{/if}
244
-
<span title={record.text}>{record.text}</span>
245
-
</div>
213
+
{#if showAsBlocked}
214
+
<button
215
+
onclick={() => (expandBlocked = true)}
216
+
class="text-left hover:cursor-pointer hover:underline"
217
+
>
218
+
<span style="color: {color};">post from blocked user</span> (click to show)
219
+
</button>
220
+
{:else}
221
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
222
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
223
+
<div
224
+
onclick={() => scrollToAndPulse(post.value.uri)}
225
+
class="hover:cursor-pointer hover:underline"
226
+
>
227
+
<span style="color: {color};">@{handle}</span>:
228
+
{#if record.embed}
229
+
<EmbedBadge embed={record.embed} />
230
+
{/if}
231
+
<span title={record.text}>{record.text}</span>
232
+
</div>
233
+
{/if}
246
234
{:else}
247
235
{post.error}
248
236
{/if}
···
265
253
{:then post}
266
254
{#if post.ok}
267
255
{@const record = post.value.record}
268
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
269
-
<div
270
-
id="timeline-post-{post.value.uri}-{quoteDepth}"
271
-
oncontextmenu={handleRightClick}
272
-
class="
256
+
{#if showAsBlocked}
257
+
<button
258
+
onclick={() => (expandBlocked = true)}
259
+
class="
260
+
group w-full rounded-sm border-2 p-3 text-left shadow-lg
261
+
backdrop-blur-sm transition-all hover:border-(--nucleus-accent)
262
+
"
263
+
style="background: {color}18; border-color: {color}66;"
264
+
>
265
+
<div class="flex items-center gap-2">
266
+
<span class="opacity-80">post from blocked user</span>
267
+
<span class="text-sm opacity-60">(click to show)</span>
268
+
</div>
269
+
</button>
270
+
{:else}
271
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
272
+
<div
273
+
id="timeline-post-{post.value.uri}-{quoteDepth}"
274
+
oncontextmenu={handleRightClick}
275
+
class="
273
276
group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all
274
277
{$isPulsing ? 'animate-pulse-highlight' : ''}
275
278
{isOnPostComposer ? 'backdrop-brightness-20' : ''}
276
279
"
277
-
style="
280
+
style="
278
281
background: {color}{isOnPostComposer
279
-
? '36'
280
-
: Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)};
282
+
? '36'
283
+
: Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)};
281
284
border-color: {color}{isOnPostComposer ? '99' : '66'};
282
285
"
283
-
>
284
-
<div class="mb-3 flex max-w-full items-center justify-between">
285
-
<div class="flex items-center gap-1 rounded-sm pr-1" style="background: {color}33;">
286
-
{@render profilePopout()}
287
-
<span>ยท</span>
288
-
<span
289
-
title={new Date(record.createdAt).toLocaleString()}
290
-
class="pl-0.5 text-nowrap text-(--nucleus-fg)/67"
291
-
>
292
-
{getRelativeTime(new Date(record.createdAt), currentTime)}
293
-
</span>
286
+
>
287
+
<div class="mb-3 flex max-w-full items-center justify-between">
288
+
<div class="flex items-center gap-1 rounded-sm pr-1" style="background: {color}33;">
289
+
{@render profilePopout()}
290
+
<span>ยท</span>
291
+
<span
292
+
title={new Date(record.createdAt).toLocaleString()}
293
+
class="pl-0.5 text-nowrap text-(--nucleus-fg)/67"
294
+
>
295
+
{getRelativeTime(new Date(record.createdAt), currentTime)}
296
+
</span>
297
+
</div>
298
+
{@render cornerFragment?.()}
294
299
</div>
295
-
{@render cornerFragment?.()}
296
-
</div>
297
300
298
-
<p class="leading-normal text-wrap wrap-break-word">
299
-
<RichText text={record.text} facets={record.facets ?? []} />
300
-
{#if isOnPostComposer && record.embed}
301
-
{@render embedBadge(record.embed)}
301
+
<p class="leading-normal text-wrap wrap-break-word">
302
+
<RichText text={record.text} facets={record.facets ?? []} />
303
+
{#if isOnPostComposer && record.embed}
304
+
<EmbedBadge embed={record.embed} {color} />
305
+
{/if}
306
+
</p>
307
+
{#if !isOnPostComposer && record.embed}
308
+
{@const embed = record.embed}
309
+
<div class="mt-2">
310
+
{#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'}
311
+
<EmbedMedia {did} {embed} />
312
+
{:else if embed.$type === 'app.bsky.embed.record'}
313
+
{@render embedPost(embed.record.uri)}
314
+
{:else if embed.$type === 'app.bsky.embed.recordWithMedia'}
315
+
<div class="space-y-1.5">
316
+
<EmbedMedia {did} embed={embed.media} />
317
+
{@render embedPost(embed.record.record.uri)}
318
+
</div>
319
+
{/if}
320
+
</div>
302
321
{/if}
303
-
</p>
304
-
{#if !isOnPostComposer && record.embed}
305
-
{@const embed = record.embed}
306
-
<div class="mt-2">
307
-
{@render postEmbed(embed)}
308
-
</div>
309
-
{/if}
310
-
{#if !isOnPostComposer}
311
-
{@render postControls(post.value)}
312
-
{/if}
313
-
</div>
322
+
{#if !isOnPostComposer}
323
+
{@render postControls(post.value)}
324
+
{/if}
325
+
</div>
326
+
{/if}
314
327
{:else}
315
328
<div class="error-disclaimer">
316
329
<p class="text-sm font-medium">error: {post.error}</p>
···
319
332
{/await}
320
333
{/if}
321
334
322
-
{#snippet postEmbed(embed: AppBskyEmbeds)}
323
-
{#snippet embedMedia(
324
-
embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main
325
-
)}
326
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
327
-
<div oncontextmenu={(e) => e.stopPropagation()}>
328
-
{#if embed.$type === 'app.bsky.embed.images'}
329
-
{@const _images = embed.images.flatMap((img) =>
330
-
isBlob(img.image) ? [{ ...img, image: img.image }] : []
331
-
)}
332
-
{@const images = _images.map((i): GalleryItem => {
333
-
const sizeFactor = 200;
334
-
const size = {
335
-
width: (i.aspectRatio?.width ?? 4) * sizeFactor,
336
-
height: (i.aspectRatio?.height ?? 3) * sizeFactor
337
-
};
338
-
return {
339
-
...size,
340
-
src: img('feed_fullsize', did, i.image.ref.$link),
341
-
thumbnail: {
342
-
src: img('feed_thumbnail', did, i.image.ref.$link),
343
-
...size
344
-
}
345
-
};
346
-
})}
347
-
<PhotoSwipeGallery {images} />
348
-
{:else if embed.$type === 'app.bsky.embed.video'}
349
-
{#if isBlob(embed.video)}
350
-
{#await didDoc then didDoc}
351
-
{#if didDoc.ok}
352
-
<!-- svelte-ignore a11y_media_has_caption -->
353
-
<video
354
-
class="rounded-sm"
355
-
src={blob(didDoc.value.pds, did, embed.video.ref.$link)}
356
-
controls
357
-
></video>
358
-
{/if}
359
-
{/await}
360
-
{/if}
361
-
{/if}
362
-
</div>
363
-
{/snippet}
364
-
{#snippet embedPost(uri: ResourceUri)}
365
-
{#if quoteDepth < 2}
366
-
{@const parsedUri = expect(parseCanonicalResourceUri(uri))}
367
-
<!-- reject recursive quotes -->
368
-
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
335
+
{#snippet embedPost(uri: ResourceUri)}
336
+
{#if quoteDepth < 2}
337
+
{@const parsedUri = expect(parseCanonicalResourceUri(uri))}
338
+
{@const embedBlockRel =
339
+
user?.did && !isOnPostComposer
340
+
? getBlockRelationship(user.did, parsedUri.repo)
341
+
: { userBlocked: false, blockedByTarget: false }}
342
+
{@const embedIsBlocked = embedBlockRel.userBlocked || embedBlockRel.blockedByTarget}
343
+
344
+
<!-- reject recursive quotes -->
345
+
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
346
+
{#if embedIsBlocked}
347
+
<div
348
+
class="rounded-sm border-2 p-2 text-sm opacity-70"
349
+
style="background: {generateColorForDid(
350
+
parsedUri.repo
351
+
)}11; border-color: {generateColorForDid(parsedUri.repo)}44;"
352
+
>
353
+
quoted post from blocked user
354
+
</div>
355
+
{:else}
369
356
<BskyPost
370
357
{client}
371
358
quoteDepth={quoteDepth + 1}
···
375
362
{onQuote}
376
363
{onReply}
377
364
/>
378
-
{:else}
379
-
<span>you think you're funny with that recursive quote but i'm onto you</span>
380
365
{/if}
381
366
{:else}
382
-
{@render embedBadge(embed)}
367
+
<span>you think you're funny with that recursive quote but i'm onto you</span>
383
368
{/if}
384
-
{/snippet}
385
-
{#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'}
386
-
{@render embedMedia(embed)}
387
-
{:else if embed.$type === 'app.bsky.embed.record'}
388
-
{@render embedPost(embed.record.uri)}
389
-
{:else if embed.$type === 'app.bsky.embed.recordWithMedia'}
390
-
<div class="space-y-1.5">
391
-
{@render embedPost(embed.record.record.uri)}
392
-
{@render embedMedia(embed.media)}
393
-
</div>
369
+
{:else}
370
+
<EmbedBadge embed={{ $type: 'app.bsky.embed.record' } as AppBskyEmbedRecord.Main} />
394
371
{/if}
395
372
{/snippet}
396
373
397
374
{#snippet postControls(post: PostWithUri)}
398
-
{@const myRepost = findBacklinksBy(post.uri, repostSource, selectedDid!).length > 0}
399
-
{@const myLike = findBacklinksBy(post.uri, likeSource, selectedDid!).length > 0}
375
+
{@const myRepost = user ? hasBacklink(post.uri, repostSource, user.did) : false}
376
+
{@const myLike = user ? hasBacklink(post.uri, likeSource, user.did) : false}
400
377
{#snippet control({
401
378
name,
402
379
icon,
403
380
onClick,
404
381
isFull,
405
382
hasSolid,
406
-
canBeDisabled = true
383
+
canBeDisabled = true,
384
+
iconColor = color
407
385
}: {
408
386
name: string;
409
387
icon: string;
···
411
389
isFull?: boolean;
412
390
hasSolid?: boolean;
413
391
canBeDisabled?: boolean;
392
+
iconColor?: string;
414
393
})}
415
394
<button
416
395
class="
417
-
px-2 py-1.5 text-(--nucleus-fg)/90 transition-all
396
+
px-1.75 py-1.5 text-(--nucleus-fg)/90 transition-all
418
397
duration-100 not-disabled:hover:[backdrop-filter:brightness(120%)]
419
398
disabled:cursor-not-allowed!
420
399
"
421
400
onclick={(e) => onClick(e)}
422
-
style="color: {isFull ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}"
401
+
style="color: {isFull ? iconColor : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}"
423
402
title={name}
424
-
disabled={canBeDisabled ? selectedDid === null : false}
403
+
disabled={canBeDisabled ? user?.did === undefined : false}
425
404
>
426
405
<Icon icon={hasSolid && isFull ? `${icon}-solid` : icon} width={20} />
427
406
</button>
···
438
417
name: 'repost',
439
418
icon: 'heroicons:arrow-path-rounded-square-20-solid',
440
419
onClick: () => {
441
-
if (!selectedDid) return;
420
+
if (!user?.did) return;
442
421
if (myRepost) deletePostBacklink(client, post, repostSource);
443
422
else createPostBacklink(client, post, repostSource);
444
423
},
···
453
432
name: 'like',
454
433
icon: 'heroicons:star',
455
434
onClick: () => {
456
-
if (!selectedDid) return;
435
+
if (!user?.did) return;
457
436
if (myLike) deletePostBacklink(client, post, likeSource);
458
437
else createPostBacklink(client, post, likeSource);
459
438
},
···
471
450
{@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () =>
472
451
navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`)
473
452
)}
474
-
{@render dropdownItem('heroicons:link-20-solid', 'copy at uri', () =>
453
+
{@render dropdownItem(undefined, 'copy at uri', () =>
475
454
navigator.clipboard.writeText(post.uri)
476
455
)}
477
456
{@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () =>
···
489
468
{/if}
490
469
491
470
{#snippet trigger()}
492
-
<div
493
-
class="
494
-
w-fit items-center rounded-sm transition-opacity
495
-
duration-100 ease-in-out group-hover:opacity-100
496
-
{!actionsOpen && !Device.isMobile ? 'opacity-0' : ''}
497
-
"
498
-
style="background: {color}1f;"
499
-
>
500
-
{@render control({
501
-
name: 'actions',
502
-
icon: 'heroicons:ellipsis-horizontal-16-solid',
503
-
onClick: (e: MouseEvent) => {
504
-
e.stopPropagation();
505
-
actionsOpen = !actionsOpen;
506
-
actionsPos = { x: 0, y: 0 };
507
-
},
508
-
canBeDisabled: false
509
-
})}
510
-
</div>
471
+
{@render control({
472
+
name: 'actions',
473
+
icon: 'heroicons:ellipsis-horizontal-16-solid',
474
+
onClick: (e: MouseEvent) => {
475
+
e.stopPropagation();
476
+
actionsOpen = !actionsOpen;
477
+
actionsPos = { x: 0, y: 0 };
478
+
},
479
+
canBeDisabled: false,
480
+
isFull: true,
481
+
iconColor: 'color-mix(in srgb, var(--nucleus-fg) 70%, transparent)'
482
+
})}
511
483
{/snippet}
512
484
</Dropdown>
513
485
</div>
514
486
{/snippet}
515
487
516
488
{#snippet dropdownItem(
517
-
icon: string,
489
+
icon: string | undefined,
518
490
label: string,
519
491
onClick: () => void,
520
492
autoClose: boolean = true,
···
531
503
if (autoClose) actionsOpen = false;
532
504
}}
533
505
>
534
-
<span class="font-bold">{label}</span>
535
-
<Icon class="h-6 w-6" {icon} />
506
+
<span class="font-semibold opacity-85">{label}</span>
507
+
{#if icon}
508
+
<Icon class="h-6 w-6" {icon} />
509
+
{/if}
536
510
</button>
537
511
{/snippet}
538
-
539
-
<style>
540
-
@reference "../app.css";
541
-
542
-
:global(.post-dropdown) {
543
-
@apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60;
544
-
}
545
-
546
-
.image-grid {
547
-
display: grid;
548
-
gap: 2px;
549
-
border-radius: 0.375rem;
550
-
overflow: hidden;
551
-
max-height: 500px;
552
-
}
553
-
554
-
/* 1 image: full width */
555
-
.image-grid.count-1 {
556
-
grid-template-columns: 1fr;
557
-
}
558
-
559
-
/* 2 images: side by side */
560
-
.image-grid.count-2 {
561
-
grid-template-columns: repeat(2, 1fr);
562
-
}
563
-
564
-
/* 3 images: first spans left, two stack on right */
565
-
.image-grid.count-3 {
566
-
grid-template-columns: repeat(2, 1fr);
567
-
grid-template-rows: repeat(2, 1fr);
568
-
}
569
-
.image-grid.count-3 a:first-child {
570
-
grid-row: 1 / 3;
571
-
}
572
-
573
-
/* 4+ images: 2x2 grid */
574
-
.image-grid.count-4,
575
-
.image-grid.count-5 {
576
-
grid-template-columns: repeat(2, 1fr);
577
-
grid-template-rows: repeat(2, 1fr);
578
-
}
579
-
580
-
.image-item {
581
-
width: 100%;
582
-
height: 100%;
583
-
object-fit: cover;
584
-
display: block;
585
-
cursor: pointer;
586
-
transition: opacity 0.2s;
587
-
}
588
-
589
-
.image-item:hover {
590
-
opacity: 0.9;
591
-
}
592
-
</style>
+37
src/components/EmbedBadge.svelte
+37
src/components/EmbedBadge.svelte
···
1
+
<script lang="ts">
2
+
import type { AppBskyEmbeds } from '$lib/at/types';
3
+
4
+
interface Props {
5
+
embed: AppBskyEmbeds;
6
+
color?: string;
7
+
}
8
+
9
+
let { embed, color = 'var(--nucleus-fg)' }: Props = $props();
10
+
11
+
const embedText = $derived.by(() => {
12
+
switch (embed.$type) {
13
+
case 'app.bsky.embed.external':
14
+
return '๐ has external link';
15
+
case 'app.bsky.embed.record':
16
+
return '๐ฌ has quote';
17
+
case 'app.bsky.embed.images':
18
+
return '๐ผ๏ธ has images';
19
+
case 'app.bsky.embed.video':
20
+
return '๐ฅ has video';
21
+
case 'app.bsky.embed.recordWithMedia':
22
+
return '๐ has quote with media';
23
+
default:
24
+
return 'โ has unknown embed';
25
+
}
26
+
});
27
+
</script>
28
+
29
+
<span
30
+
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
31
+
style="
32
+
background: color-mix(in srgb, {color} 10%, transparent);
33
+
color: {color};
34
+
"
35
+
>
36
+
{embedText}
37
+
</span>
+53
src/components/EmbedMedia.svelte
+53
src/components/EmbedMedia.svelte
···
1
+
<script lang="ts">
2
+
import { isBlob } from '@atcute/lexicons/interfaces';
3
+
import PhotoSwipeGallery, { type GalleryItem } from './PhotoSwipeGallery.svelte';
4
+
import { blob, img } from '$lib/cdn';
5
+
import { type Did } from '@atcute/lexicons';
6
+
import { resolveDidDoc } from '$lib/at/client.svelte';
7
+
import type { AppBskyEmbedMedia } from '$lib/at/types';
8
+
9
+
interface Props {
10
+
did: Did;
11
+
embed: AppBskyEmbedMedia;
12
+
}
13
+
14
+
let { did, embed }: Props = $props();
15
+
</script>
16
+
17
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
18
+
<div oncontextmenu={(e) => e.stopPropagation()}>
19
+
{#if embed.$type === 'app.bsky.embed.images'}
20
+
{@const _images = embed.images.flatMap((img) =>
21
+
isBlob(img.image) ? [{ ...img, image: img.image }] : []
22
+
)}
23
+
{@const images = _images.map((i): GalleryItem => {
24
+
const size = i.aspectRatio;
25
+
const cid = i.image.ref.$link;
26
+
return {
27
+
...size,
28
+
src: img('feed_fullsize', did, cid),
29
+
thumbnail: {
30
+
src: img('feed_thumbnail', did, cid),
31
+
...size
32
+
},
33
+
alt: i.alt
34
+
};
35
+
})}
36
+
{#if images.length > 0}
37
+
<PhotoSwipeGallery {images} />
38
+
{/if}
39
+
{:else if embed.$type === 'app.bsky.embed.video'}
40
+
{#if isBlob(embed.video)}
41
+
{#await resolveDidDoc(did) then didDoc}
42
+
{#if didDoc.ok}
43
+
<!-- svelte-ignore a11y_media_has_caption -->
44
+
<video
45
+
class="rounded-sm"
46
+
src={blob(didDoc.value.pds, did, embed.video.ref.$link)}
47
+
controls
48
+
></video>
49
+
{/if}
50
+
{/await}
51
+
{/if}
52
+
{/if}
53
+
</div>
+77
-95
src/components/FollowingItem.svelte
+77
-95
src/components/FollowingItem.svelte
···
1
-
<script lang="ts" module>
2
-
// Cache for synchronous access during component recycling
3
-
const profileCache = new SvelteMap<string, { displayName?: string; handle: string }>();
4
-
</script>
5
-
6
1
<script lang="ts">
7
2
import ProfilePicture from './ProfilePicture.svelte';
3
+
import BlockedUserIndicator from './BlockedUserIndicator.svelte';
8
4
import { getRelativeTime } from '$lib/date';
9
5
import { generateColorForDid } from '$lib/accounts';
10
6
import type { Did } from '@atcute/lexicons';
11
-
import type { AtprotoDid } from '@atcute/lexicons/syntax';
12
7
import type { calculateFollowedUserStats, Sort } from '$lib/following';
13
-
import type { AtpClient } from '$lib/at/client';
14
-
import { SvelteMap } from 'svelte/reactivity';
15
-
import { clients, getClient, router } from '$lib/state.svelte';
8
+
import { resolveDidDoc, type AtpClient } from '$lib/at/client.svelte';
9
+
import { router, getBlockRelationship, profiles, handles } from '$lib/state.svelte';
10
+
import { map } from '$lib/result';
16
11
17
12
interface Props {
18
13
style: string;
···
25
20
26
21
let { style, did, stats, client, sort, currentTime }: Props = $props();
27
22
28
-
// svelte-ignore state_referenced_locally
29
-
const cached = profileCache.get(did);
30
-
let displayName = $state<string | undefined>(cached?.displayName);
31
-
let handle = $state<string>(cached?.handle ?? 'handle.invalid');
32
-
33
-
const loadProfile = async (targetDid: Did) => {
34
-
if (profileCache.has(targetDid)) {
35
-
const c = profileCache.get(targetDid)!;
36
-
displayName = c.displayName;
37
-
handle = c.handle;
38
-
} else {
39
-
const existingClient = clients.get(targetDid as AtprotoDid);
40
-
if (existingClient?.user?.handle) {
41
-
handle = existingClient.user.handle;
42
-
} else {
43
-
handle = 'handle.invalid';
44
-
displayName = undefined;
45
-
}
46
-
}
47
-
48
-
try {
49
-
// Optimization: Check clients map first to avoid async overhead if possible
50
-
// but we need to ensure we have the profile data, not just client existence.
51
-
const userClient = await getClient(targetDid as AtprotoDid);
52
-
53
-
// Check if the component has been recycled for a different user while we were awaiting
54
-
if (did !== targetDid) return;
23
+
const userDid = $derived(client.user?.did);
24
+
const blockRel = $derived(
25
+
userDid ? getBlockRelationship(userDid, did) : { userBlocked: false, blockedByTarget: false }
26
+
);
27
+
const isBlocked = $derived(blockRel.userBlocked || blockRel.blockedByTarget);
55
28
56
-
let newHandle = handle;
57
-
let newDisplayName = displayName;
29
+
const displayName = $derived(profiles.get(did)?.displayName);
30
+
const handle = $derived(handles.get(did) ?? 'loading...');
58
31
59
-
if (userClient.user?.handle) {
60
-
newHandle = userClient.user.handle;
61
-
handle = newHandle;
62
-
} else {
63
-
newHandle = targetDid;
64
-
handle = newHandle;
65
-
}
32
+
let error = $state('');
66
33
67
-
const profileRes = await userClient.getProfile();
34
+
const loadProfile = async (targetDid: Did) => {
35
+
if (profiles.has(targetDid) && handles.has(targetDid)) return;
68
36
37
+
try {
38
+
const [profileRes, handleRes] = await Promise.all([
39
+
client.getProfile(targetDid),
40
+
resolveDidDoc(targetDid).then((r) => map(r, (doc) => doc.handle))
41
+
]);
69
42
if (did !== targetDid) return;
70
43
71
-
if (profileRes.ok) {
72
-
newDisplayName = profileRes.value.displayName;
73
-
displayName = newDisplayName;
74
-
}
75
-
76
-
// Update cache
77
-
profileCache.set(targetDid, {
78
-
handle: newHandle,
79
-
displayName: newDisplayName
80
-
});
44
+
if (profileRes.ok) profiles.set(targetDid, profileRes.value);
45
+
if (handleRes.ok) handles.set(targetDid, handleRes.value);
46
+
else handles.set(targetDid, 'handle.invalid');
81
47
} catch (e) {
82
48
if (did !== targetDid) return;
83
49
console.error(`failed to load profile for ${targetDid}`, e);
84
-
handle = 'error';
50
+
error = String(e);
85
51
}
86
52
};
87
53
88
-
// Re-run whenever `did` changes
89
54
$effect(() => {
90
55
loadProfile(did);
91
56
});
···
94
59
const relTime = $derived(getRelativeTime(lastPostAt, currentTime));
95
60
const color = $derived(generateColorForDid(did));
96
61
97
-
const goToProfile = () => {
98
-
router.navigate(`/profile/${did}`);
99
-
};
62
+
const goToProfile = () => router.navigate(`/profile/${did}`);
100
63
</script>
101
64
102
65
<div {style} class="box-border w-full pb-2">
103
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
104
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
105
-
<div
106
-
onclick={goToProfile}
107
-
class="group flex cursor-pointer items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20"
108
-
style={`--post-color: ${color};`}
109
-
>
110
-
<ProfilePicture {client} {did} size={10} />
111
-
<div class="min-w-0 flex-1 space-y-1">
112
-
<div
113
-
class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)"
114
-
style={`--post-color: ${color};`}
115
-
>
116
-
<span class="truncate">{displayName || handle}</span>
117
-
<span class="truncate text-sm opacity-60">@{handle}</span>
118
-
</div>
119
-
<div class="flex gap-2 text-xs opacity-70">
120
-
<span
121
-
class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2
122
-
? 'text-(--nucleus-accent)'
123
-
: ''}
124
-
>
125
-
posted {relTime}
126
-
{relTime !== 'now' ? 'ago' : ''}
127
-
</span>
128
-
{#if stats?.recentPostCount && stats.recentPostCount > 0}
129
-
<span class="text-(--nucleus-accent2)">
130
-
{stats.recentPostCount} posts / 6h
131
-
</span>
66
+
{#if isBlocked}
67
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
68
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
69
+
<div onclick={goToProfile} class="cursor-pointer">
70
+
<BlockedUserIndicator
71
+
{client}
72
+
{did}
73
+
reason={blockRel.userBlocked ? 'blocked' : 'blocks-you'}
74
+
size="small"
75
+
/>
76
+
</div>
77
+
{:else}
78
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
79
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
80
+
<div
81
+
onclick={goToProfile}
82
+
class="group flex cursor-pointer items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20"
83
+
style={`--post-color: ${color};`}
84
+
>
85
+
<ProfilePicture {client} {did} size={10} />
86
+
<div class="min-w-0 flex-1 space-y-1">
87
+
{#if error.length === 0}
88
+
<div
89
+
class="flex items-baseline gap-2 truncate font-bold transition-colors group-hover:text-(--post-color)"
90
+
style={`--post-color: ${color};`}
91
+
>
92
+
<span class="truncate">{displayName || handle}</span>
93
+
<span class="truncate text-sm opacity-60">@{handle}</span>
94
+
</div>
95
+
{:else}
96
+
<div class="flex items-baseline truncate text-sm text-red-500">
97
+
error: {error}
98
+
</div>
132
99
{/if}
133
-
{#if sort === 'conversational' && stats?.conversationalScore && stats.conversationalScore > 0}
134
-
<span class="ml-auto font-bold text-(--nucleus-accent)">
135
-
โ
{stats.conversationalScore.toFixed(1)}
100
+
<div class="flex gap-2 text-xs opacity-70">
101
+
<span
102
+
class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2
103
+
? 'text-(--nucleus-accent)'
104
+
: ''}
105
+
>
106
+
posted {relTime}
107
+
{relTime !== 'now' ? 'ago' : ''}
136
108
</span>
137
-
{/if}
109
+
{#if stats?.recentPostCount && stats.recentPostCount > 0}
110
+
<span class="text-(--nucleus-accent2)">
111
+
{stats.recentPostCount} posts / 6h
112
+
</span>
113
+
{/if}
114
+
{#if sort === 'conversational' && stats?.conversationalScore && stats.conversationalScore > 0}
115
+
<span class="ml-auto font-bold text-(--nucleus-accent)">
116
+
โ
{stats.conversationalScore.toFixed(1)}
117
+
</span>
118
+
{/if}
119
+
</div>
138
120
</div>
139
121
</div>
140
-
</div>
122
+
{/if}
141
123
</div>
+2
-2
src/components/FollowingView.svelte
+2
-2
src/components/FollowingView.svelte
···
1
1
<script lang="ts">
2
2
import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte';
3
3
import type { Did } from '@atcute/lexicons';
4
-
import { type AtpClient } from '$lib/at/client';
4
+
import { type AtpClient } from '$lib/at/client.svelte';
5
5
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list';
6
6
import {
7
7
calculateFollowedUserStats,
···
139
139
</div>
140
140
141
141
<div class="min-h-0 flex-1" bind:this={listContainer}>
142
-
{#if !client}
142
+
{#if !client || !client.user}
143
143
<NotLoggedIn />
144
144
{:else if sortedFollowing.length === 0 || isLongCalculation}
145
145
<div class="flex justify-center py-8">
+9
-9
src/components/PhotoSwipeGallery.svelte
+9
-9
src/components/PhotoSwipeGallery.svelte
···
3
3
src: string;
4
4
thumbnail?: {
5
5
src: string;
6
-
width: number;
7
-
height: number;
6
+
width?: number;
7
+
height?: number;
8
8
};
9
-
width: number;
10
-
height: number;
11
-
cropped?: boolean;
9
+
width?: number;
10
+
height?: number;
12
11
alt?: string;
13
12
}
14
13
export type GalleryData = Array<GalleryItem>;
···
23
22
24
23
export let images: GalleryData;
25
24
let element: HTMLDivElement;
25
+
let imageElements: { [key: number]: HTMLImageElement } = {};
26
26
27
27
const options = writable<Partial<PreparedPhotoSwipeOptions> | undefined>(undefined);
28
28
$: {
···
67
67
{@const isHidden = i > 3}
68
68
{@const isOverlay = i === 3 && images.length > 4}
69
69
70
+
<!-- eslint-disable svelte/no-navigation-without-resolve -->
70
71
<a
71
72
href={img.src}
72
-
data-pswp-width={img.width}
73
-
data-pswp-height={img.height}
73
+
data-pswp-width={img.width ?? imageElements[i]?.width}
74
+
data-pswp-height={img.height ?? imageElements[i]?.height}
74
75
target="_blank"
75
76
class:hidden-in-grid={isHidden}
76
77
class:overlay-container={isOverlay}
77
78
>
78
-
<img src={thumb.src} alt={img.alt ?? ''} width={thumb.width} height={thumb.height} />
79
+
<img bind:this={imageElements[i]} src={thumb.src} title={img.alt ?? ''} alt={img.alt ?? ''} />
79
80
80
81
{#if isOverlay}
81
82
<div class="more-overlay">
···
100
101
gap: 2px;
101
102
border-radius: 4px;
102
103
overflow: hidden;
103
-
width: 100%;
104
104
}
105
105
106
106
.gallery.styling-twitter > a {
+483
-35
src/components/PostComposer.svelte
+483
-35
src/components/PostComposer.svelte
···
1
1
<script lang="ts">
2
-
import type { AtpClient } from '$lib/at/client';
2
+
import type { AtpClient } from '$lib/at/client.svelte';
3
3
import { ok, err, type Result, expect } from '$lib/result';
4
-
import type { AppBskyFeedPost } from '@atcute/bluesky';
4
+
import type { AppBskyEmbedRecordWithMedia, AppBskyFeedPost } from '@atcute/bluesky';
5
5
import { generateColorForDid } from '$lib/accounts';
6
6
import type { PostWithUri } from '$lib/at/fetch';
7
7
import BskyPost from './BskyPost.svelte';
8
-
import { parseCanonicalResourceUri } from '@atcute/lexicons';
8
+
import { parseCanonicalResourceUri, type Blob as AtpBlob } from '@atcute/lexicons';
9
9
import type { ComAtprotoRepoStrongRef } from '@atcute/atproto';
10
10
import { parseToRichText } from '$lib/richtext';
11
11
import { tokenize } from '$lib/richtext/parser';
12
12
import Icon from '@iconify/svelte';
13
13
import ProfilePicture from './ProfilePicture.svelte';
14
+
import type { AppBskyEmbedMedia } from '$lib/at/types';
15
+
import { SvelteMap } from 'svelte/reactivity';
16
+
import { handles } from '$lib/state.svelte';
14
17
18
+
type UploadState =
19
+
| { state: 'uploading'; progress: number }
20
+
| { state: 'uploaded'; blob: AtpBlob<string> }
21
+
| { state: 'error'; message: string };
15
22
export type FocusState = 'null' | 'focused';
16
23
export type State = {
17
24
focus: FocusState;
18
25
text: string;
19
26
quoting?: PostWithUri;
20
27
replying?: PostWithUri;
28
+
attachedMedia?: AppBskyEmbedMedia;
29
+
blobsState: SvelteMap<string, UploadState>;
21
30
};
22
31
23
32
interface Props {
···
26
35
_state: State;
27
36
}
28
37
29
-
let { client, onPostSent, _state = $bindable({ focus: 'null', text: '' }) }: Props = $props();
38
+
let { client, onPostSent, _state = $bindable() }: Props = $props();
30
39
31
40
const isFocused = $derived(_state.focus === 'focused');
32
41
···
34
43
client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)'
35
44
);
36
45
46
+
const getVideoDimensions = (
47
+
blobUrl: string
48
+
): Promise<Result<{ width: number; height: number }, string>> =>
49
+
new Promise((resolve) => {
50
+
const video = document.createElement('video');
51
+
video.onloadedmetadata = () => {
52
+
resolve(ok({ width: video.videoWidth, height: video.videoHeight }));
53
+
};
54
+
video.onerror = (e) => resolve(err(String(e)));
55
+
video.src = blobUrl;
56
+
});
57
+
58
+
const uploadVideo = async (blobUrl: string, mimeType: string) => {
59
+
const file = await (await fetch(blobUrl)).blob();
60
+
return await client.uploadVideo(file, mimeType, (status) => {
61
+
if (status.stage === 'uploading' && status.progress !== undefined) {
62
+
_state.blobsState.set(blobUrl, { state: 'uploading', progress: status.progress * 0.5 });
63
+
} else if (status.stage === 'processing' && status.progress !== undefined) {
64
+
_state.blobsState.set(blobUrl, {
65
+
state: 'uploading',
66
+
progress: 0.5 + status.progress * 0.5
67
+
});
68
+
}
69
+
});
70
+
};
71
+
72
+
const getImageDimensions = (
73
+
blobUrl: string
74
+
): Promise<Result<{ width: number; height: number }, string>> =>
75
+
new Promise((resolve) => {
76
+
const img = new Image();
77
+
img.onload = () => resolve(ok({ width: img.width, height: img.height }));
78
+
img.onerror = (e) => resolve(err(String(e)));
79
+
img.src = blobUrl;
80
+
});
81
+
82
+
const uploadImage = async (blobUrl: string) => {
83
+
const file = await (await fetch(blobUrl)).blob();
84
+
return await client.uploadBlob(file, (progress) => {
85
+
_state.blobsState.set(blobUrl, { state: 'uploading', progress });
86
+
});
87
+
};
88
+
37
89
const post = async (text: string): Promise<Result<PostWithUri, string>> => {
38
90
const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({
39
91
$type: 'com.atproto.repo.strongRef',
···
43
95
44
96
const rt = await parseToRichText(text);
45
97
98
+
let media: AppBskyEmbedMedia | undefined = _state.attachedMedia;
99
+
if (_state.attachedMedia?.$type === 'app.bsky.embed.images') {
100
+
const images = _state.attachedMedia.images;
101
+
let uploadedImages: typeof images = [];
102
+
for (const image of images) {
103
+
const blobUrl = (image.image as AtpBlob<string>).ref.$link;
104
+
const upload = _state.blobsState.get(blobUrl);
105
+
if (!upload || upload.state !== 'uploaded') continue;
106
+
const size = await getImageDimensions(blobUrl);
107
+
if (size.ok) image.aspectRatio = size.value;
108
+
uploadedImages.push({
109
+
...image,
110
+
image: upload.blob
111
+
});
112
+
}
113
+
if (uploadedImages.length > 0)
114
+
media = {
115
+
..._state.attachedMedia,
116
+
$type: 'app.bsky.embed.images',
117
+
images: uploadedImages
118
+
};
119
+
} else if (_state.attachedMedia?.$type === 'app.bsky.embed.video') {
120
+
const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link;
121
+
const upload = _state.blobsState.get(blobUrl);
122
+
if (upload && upload.state === 'uploaded') {
123
+
const size = await getVideoDimensions(blobUrl);
124
+
if (size.ok) _state.attachedMedia.aspectRatio = size.value;
125
+
media = {
126
+
..._state.attachedMedia,
127
+
$type: 'app.bsky.embed.video',
128
+
video: upload.blob
129
+
};
130
+
}
131
+
}
132
+
// console.log('media', media);
133
+
46
134
const record: AppBskyFeedPost.Main = {
47
135
$type: 'app.bsky.feed.post',
48
136
text: rt.text,
···
56
144
: undefined,
57
145
embed:
58
146
_state.focus === 'focused' && _state.quoting
59
-
? {
60
-
$type: 'app.bsky.embed.record',
61
-
record: strongRef(_state.quoting)
62
-
}
63
-
: undefined,
147
+
? media
148
+
? {
149
+
$type: 'app.bsky.embed.recordWithMedia',
150
+
record: { record: strongRef(_state.quoting) },
151
+
media: media as AppBskyEmbedRecordWithMedia.Main['media']
152
+
}
153
+
: {
154
+
$type: 'app.bsky.embed.record',
155
+
record: strongRef(_state.quoting)
156
+
}
157
+
: (media as AppBskyFeedPost.Main['embed']),
64
158
createdAt: new Date().toISOString()
65
159
};
66
160
67
-
const res = await client.atcute?.post('com.atproto.repo.createRecord', {
161
+
const res = await client.user?.atcute.post('com.atproto.repo.createRecord', {
68
162
input: {
69
163
collection: 'app.bsky.feed.post',
70
164
repo: client.user!.did,
···
84
178
});
85
179
};
86
180
87
-
let info = $state('');
181
+
let posting = $state(false);
182
+
let postError = $state('');
88
183
let textareaEl: HTMLTextAreaElement | undefined = $state();
184
+
let fileInputEl: HTMLInputElement | undefined = $state();
185
+
let selectingFile = $state(false);
186
+
187
+
const canUpload = $derived(
188
+
!(
189
+
_state.attachedMedia?.$type === 'app.bsky.embed.video' ||
190
+
(_state.attachedMedia?.$type === 'app.bsky.embed.images' &&
191
+
_state.attachedMedia.images.length >= 4)
192
+
)
193
+
);
89
194
90
195
const unfocus = () => (_state.focus = 'null');
91
196
197
+
const handleFiles = (files: File[]) => {
198
+
if (!canUpload || !files || files.length === 0) return;
199
+
200
+
const existingImages =
201
+
_state.attachedMedia?.$type === 'app.bsky.embed.images' ? _state.attachedMedia.images : [];
202
+
203
+
let newImages = [...existingImages];
204
+
let hasVideo = false;
205
+
206
+
for (let i = 0; i < files.length; i++) {
207
+
const file = files[i];
208
+
const isVideo = file.type.startsWith('video/');
209
+
const isImage = file.type.startsWith('image/');
210
+
211
+
if (!isVideo && !isImage) {
212
+
postError = 'unsupported file type';
213
+
continue;
214
+
}
215
+
216
+
if (isVideo) {
217
+
if (existingImages.length > 0 || newImages.length > 0) {
218
+
postError = 'cannot mix images and video';
219
+
continue;
220
+
}
221
+
const blobUrl = URL.createObjectURL(file);
222
+
_state.attachedMedia = {
223
+
$type: 'app.bsky.embed.video',
224
+
video: {
225
+
$type: 'blob',
226
+
ref: { $link: blobUrl },
227
+
mimeType: file.type,
228
+
size: file.size
229
+
}
230
+
};
231
+
hasVideo = true;
232
+
break;
233
+
} else if (isImage) {
234
+
if (newImages.length >= 4) {
235
+
postError = 'max 4 images allowed';
236
+
break;
237
+
}
238
+
const blobUrl = URL.createObjectURL(file);
239
+
newImages.push({
240
+
image: {
241
+
$type: 'blob',
242
+
ref: { $link: blobUrl },
243
+
mimeType: file.type,
244
+
size: file.size
245
+
},
246
+
alt: '',
247
+
aspectRatio: undefined
248
+
});
249
+
}
250
+
}
251
+
252
+
if (!hasVideo && newImages.length > 0) {
253
+
_state.attachedMedia = {
254
+
$type: 'app.bsky.embed.images',
255
+
images: newImages
256
+
};
257
+
}
258
+
259
+
const handleUpload = (blobUrl: string, res: Result<AtpBlob<string>, string>) => {
260
+
if (res.ok) _state.blobsState.set(blobUrl, { state: 'uploaded', blob: res.value });
261
+
else _state.blobsState.set(blobUrl, { state: 'error', message: res.error });
262
+
};
263
+
264
+
const media = _state.attachedMedia;
265
+
if (media?.$type == 'app.bsky.embed.images') {
266
+
for (const image of media.images) {
267
+
const blobUrl = (image.image as AtpBlob<string>).ref.$link;
268
+
uploadImage(blobUrl).then((r) => handleUpload(blobUrl, r));
269
+
}
270
+
} else if (media?.$type === 'app.bsky.embed.video') {
271
+
const blobUrl = (media.video as AtpBlob<string>).ref.$link;
272
+
uploadVideo(blobUrl, media.video.mimeType).then((r) => handleUpload(blobUrl, r));
273
+
}
274
+
};
275
+
276
+
const handlePaste = (e: ClipboardEvent) => {
277
+
const files = Array.from(e.clipboardData?.items ?? [])
278
+
.filter((item) => item.kind === 'file')
279
+
.map((item) => item.getAsFile())
280
+
.filter((file): file is File => file !== null);
281
+
282
+
if (files.length > 0) {
283
+
e.preventDefault();
284
+
handleFiles(files);
285
+
}
286
+
};
287
+
288
+
const handleDrop = (e: DragEvent) => {
289
+
e.preventDefault();
290
+
const files = Array.from(e.dataTransfer?.files ?? []);
291
+
if (files.length > 0) handleFiles(files);
292
+
};
293
+
294
+
const handleFileSelect = (e: Event) => {
295
+
e.preventDefault();
296
+
selectingFile = false;
297
+
298
+
const input = e.target as HTMLInputElement;
299
+
if (input.files) handleFiles(Array.from(input.files));
300
+
301
+
input.value = '';
302
+
};
303
+
304
+
const removeMedia = () => {
305
+
if (_state.attachedMedia?.$type === 'app.bsky.embed.video') {
306
+
const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link;
307
+
_state.blobsState.delete(blobUrl);
308
+
queueMicrotask(() => URL.revokeObjectURL(blobUrl));
309
+
}
310
+
_state.attachedMedia = undefined;
311
+
};
312
+
313
+
const removeMediaAtIndex = (index: number) => {
314
+
if (_state.attachedMedia?.$type !== 'app.bsky.embed.images') return;
315
+
const imageToRemove = _state.attachedMedia.images[index];
316
+
const blobUrl = (imageToRemove.image as AtpBlob<string>).ref.$link;
317
+
_state.blobsState.delete(blobUrl);
318
+
queueMicrotask(() => URL.revokeObjectURL(blobUrl));
319
+
320
+
const images = _state.attachedMedia.images.filter((_, i) => i !== index);
321
+
_state.attachedMedia = images.length > 0 ? { ..._state.attachedMedia, images } : undefined;
322
+
};
323
+
92
324
const doPost = () => {
93
325
if (_state.text.length === 0 || _state.text.length > 300) return;
94
326
95
-
post(_state.text).then((res) => {
96
-
if (res.ok) {
97
-
onPostSent(res.value);
98
-
_state.text = '';
99
-
info = 'posted!';
100
-
unfocus();
101
-
setTimeout(() => (info = ''), 800);
102
-
} else {
103
-
info = res.error;
104
-
setTimeout(() => (info = ''), 3000);
105
-
}
106
-
});
327
+
postError = '';
328
+
posting = true;
329
+
post(_state.text)
330
+
.then((res) => {
331
+
if (res.ok) {
332
+
onPostSent(res.value);
333
+
_state.text = '';
334
+
_state.quoting = undefined;
335
+
_state.replying = undefined;
336
+
if (_state.attachedMedia?.$type === 'app.bsky.embed.video')
337
+
URL.revokeObjectURL((_state.attachedMedia.video as AtpBlob<string>).ref.$link);
338
+
else if (_state.attachedMedia?.$type === 'app.bsky.embed.images')
339
+
_state.attachedMedia.images.forEach((image) =>
340
+
URL.revokeObjectURL((image.image as AtpBlob<string>).ref.$link)
341
+
);
342
+
_state.attachedMedia = undefined;
343
+
_state.blobsState.clear();
344
+
unfocus();
345
+
} else {
346
+
postError = res.error;
347
+
}
348
+
})
349
+
.finally(() => {
350
+
posting = false;
351
+
});
107
352
};
108
353
109
354
$effect(() => {
110
-
if (!client.atcute) info = 'not logged in';
111
355
document.documentElement.style.setProperty('--acc-color', color);
112
356
if (isFocused && textareaEl) textareaEl.focus();
113
357
});
···
130
374
{#snippet attachmentIndicator(post: PostWithUri, type: 'quoting' | 'replying')}
131
375
{@const parsedUri = expect(parseCanonicalResourceUri(post.uri))}
132
376
{@const color = generateColorForDid(parsedUri.repo)}
377
+
{@const id = handles.get(parsedUri.repo) ?? parsedUri.repo}
133
378
<div
134
379
class="flex shrink-0 items-center gap-1.5 rounded-sm border py-0.5 pr-0.5 pl-1 text-xs font-bold transition-all"
135
380
style="
···
137
382
border-color: {color};
138
383
color: {color};
139
384
"
140
-
title={type === 'replying' ? `replying to @${parsedUri.repo}` : `quoting @${parsedUri.repo}`}
385
+
title={type === 'replying' ? `replying to ${id}` : `quoting ${id}`}
141
386
>
142
387
<span class="truncate text-sm font-normal opacity-90">
143
388
{type === 'replying' ? 'replying to' : 'quoting'}
···
162
407
{/if}
163
408
{/snippet}
164
409
410
+
{#snippet uploadControls(blobUrl: string, remove: () => void)}
411
+
{@const upload = _state.blobsState.get(blobUrl)}
412
+
{#if upload !== undefined && upload.state === 'uploading'}
413
+
<div
414
+
class="absolute top-2 right-2 z-10 flex items-center gap-2 rounded-sm bg-black/70 p-1.5 text-sm backdrop-blur-sm"
415
+
>
416
+
<div class="flex justify-center">
417
+
<div
418
+
class="h-5 w-5 animate-spin rounded-full border-4 border-t-transparent"
419
+
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
420
+
></div>
421
+
</div>
422
+
<span class="font-medium">{Math.round(upload.progress * 100)}%</span>
423
+
</div>
424
+
{:else}
425
+
<div class="absolute top-2 right-2 z-10 flex items-center gap-1">
426
+
{#if upload !== undefined && upload.state === 'error'}
427
+
<span
428
+
class="rounded-sm bg-black/70 p-1.5 px-1 text-sm font-bold text-red-500 backdrop-blur-sm"
429
+
>{upload.message}</span
430
+
>
431
+
{/if}
432
+
<button
433
+
onclick={(e) => {
434
+
e.preventDefault();
435
+
e.stopPropagation();
436
+
remove();
437
+
}}
438
+
onmousedown={(e) => e.preventDefault()}
439
+
class="rounded-sm bg-black/70 p-1.5 backdrop-blur-sm {upload?.state !== 'error'
440
+
? 'opacity-0 transition-opacity group-hover:opacity-100'
441
+
: ''}"
442
+
>
443
+
{#if upload?.state === 'error'}
444
+
<Icon
445
+
class="text-red-500 group-hover:hidden"
446
+
icon="heroicons:exclamation-circle-16-solid"
447
+
width={20}
448
+
/>
449
+
{/if}
450
+
<Icon
451
+
class={upload?.state === 'error' ? 'hidden group-hover:block' : ''}
452
+
icon="heroicons:x-mark-16-solid"
453
+
width={20}
454
+
/>
455
+
</button>
456
+
</div>
457
+
{/if}
458
+
{/snippet}
459
+
460
+
{#snippet mediaPreview(embed: AppBskyEmbedMedia)}
461
+
{#if embed.$type === 'app.bsky.embed.images'}
462
+
<div class="image-preview-grid" data-total={embed.images.length}>
463
+
{#each embed.images as image, idx (idx)}
464
+
{@const blobUrl = (image.image as AtpBlob<string>).ref.$link}
465
+
<div class="image-preview-item group">
466
+
<img src={blobUrl} alt="" />
467
+
{@render uploadControls(blobUrl, () => removeMediaAtIndex(idx))}
468
+
</div>
469
+
{/each}
470
+
</div>
471
+
{:else if embed.$type === 'app.bsky.embed.video'}
472
+
{@const blobUrl = (embed.video as AtpBlob<string>).ref.$link}
473
+
<div
474
+
class="group relative max-h-[30vh] overflow-hidden rounded-sm"
475
+
style="aspect-ratio: 16/10;"
476
+
>
477
+
<!-- svelte-ignore a11y_media_has_caption -->
478
+
<video src={blobUrl} controls class="h-full w-full"></video>
479
+
{@render uploadControls(blobUrl, removeMedia)}
480
+
</div>
481
+
{/if}
482
+
{/snippet}
483
+
165
484
{#snippet composer(replying?: PostWithUri, quoting?: PostWithUri)}
485
+
{@const hasIncompleteUpload = _state.blobsState
486
+
.values()
487
+
.some((s) => s.state === 'uploading' || s.state === 'error')}
166
488
<div class="flex items-center gap-2">
489
+
<input
490
+
bind:this={fileInputEl}
491
+
type="file"
492
+
accept="image/*,video/*"
493
+
multiple
494
+
onchange={handleFileSelect}
495
+
oncancel={() => (selectingFile = false)}
496
+
class="hidden"
497
+
/>
498
+
<button
499
+
onclick={(e) => {
500
+
e.preventDefault();
501
+
e.stopPropagation();
502
+
selectingFile = true;
503
+
fileInputEl?.click();
504
+
}}
505
+
onmousedown={(e) => e.preventDefault()}
506
+
disabled={!canUpload}
507
+
class="rounded-sm p-1.5 transition-all duration-150 enabled:hover:scale-110 disabled:cursor-not-allowed disabled:opacity-50"
508
+
style="background: color-mix(in srgb, {color} 15%, transparent); color: {color};"
509
+
title="attach media"
510
+
>
511
+
<Icon icon="heroicons:photo-16-solid" width={20} />
512
+
</button>
513
+
{#if postError.length > 0}
514
+
<div class="group flex items-center gap-2 truncate rounded-sm bg-red-500 p-1.5">
515
+
<button onclick={() => (postError = '')}>
516
+
<Icon
517
+
class="group-hover:hidden"
518
+
icon="heroicons:exclamation-circle-16-solid"
519
+
width={20}
520
+
/>
521
+
<Icon class="hidden group-hover:block" icon="heroicons:x-mark-16-solid" width={20} />
522
+
</button>
523
+
<span title={postError} class="truncate text-sm font-bold">{postError}</span>
524
+
</div>
525
+
{/if}
167
526
<div class="grow"></div>
527
+
{#if posting}
528
+
<div
529
+
class="h-6 w-6 animate-spin rounded-full border-4 border-t-transparent"
530
+
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
531
+
></div>
532
+
{/if}
168
533
<span
169
-
class="text-sm font-medium"
534
+
class="text-sm font-medium text-nowrap"
170
535
style="color: color-mix(in srgb, {_state.text.length > 300
171
536
? '#ef4444'
172
537
: 'var(--nucleus-fg)'} 53%, transparent);"
···
174
539
{_state.text.length} / 300
175
540
</span>
176
541
<button
542
+
onmousedown={(e) => e.preventDefault()}
177
543
onclick={doPost}
178
-
disabled={_state.text.length === 0 || _state.text.length > 300}
179
-
class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed! disabled:opacity-50 disabled:hover:scale-100"
544
+
disabled={(!_state.attachedMedia && _state.text.length === 0) ||
545
+
_state.text.length > 300 ||
546
+
hasIncompleteUpload}
547
+
class="action-button border-none px-4 py-1.5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed! disabled:opacity-50 disabled:hover:scale-100"
180
548
style="background: color-mix(in srgb, {color} 87%, transparent);"
181
549
>
182
550
post
···
185
553
{#if replying}
186
554
{@render attachedPost(replying, 'replying')}
187
555
{/if}
188
-
<div class="composer space-y-2">
556
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
557
+
<div
558
+
class="composer space-y-2"
559
+
onpaste={handlePaste}
560
+
ondrop={handleDrop}
561
+
ondragover={(e) => e.preventDefault()}
562
+
>
189
563
<div class="relative grid">
190
564
<!-- todo: replace this with a proper rich text editor -->
191
565
<div
···
199
573
bind:this={textareaEl}
200
574
bind:value={_state.text}
201
575
onfocus={() => (_state.focus = 'focused')}
202
-
onblur={unfocus}
576
+
onblur={() => (!selectingFile ? unfocus() : null)}
203
577
onkeydown={(event) => {
204
578
if (event.key === 'Escape') unfocus();
205
579
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
···
209
583
class="col-start-1 row-start-1 field-sizing-content min-h-[5lh] w-full resize-none overflow-hidden bg-transparent text-wrap break-all whitespace-pre-wrap text-transparent caret-(--nucleus-fg) placeholder:text-(--nucleus-fg)/45"
210
584
></textarea>
211
585
</div>
586
+
{#if _state.attachedMedia}
587
+
{@render mediaPreview(_state.attachedMedia)}
588
+
{/if}
212
589
{#if quoting}
213
590
{@render attachedPost(quoting, 'quoting')}
214
591
{/if}
···
234
611
: `color-mix(in srgb, color-mix(in srgb, var(--nucleus-bg) 85%, ${color}) 70%, transparent)`};
235
612
border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);"
236
613
>
237
-
<div class="w-full p-1 px-2">
238
-
{#if info.length > 0}
614
+
<div class="w-full p-1">
615
+
{#if !client.user}
239
616
<div
240
617
class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis"
241
618
style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};"
242
619
>
243
-
{info}
620
+
not logged in
244
621
</div>
245
622
{:else}
246
-
<div class="flex flex-col gap-2">
623
+
<div class="flex flex-col gap-1">
247
624
{#if _state.focus === 'focused'}
248
625
{@render composer(_state.replying, _state.quoting)}
249
626
{:else}
···
281
658
282
659
input,
283
660
.composer {
284
-
@apply single-line-input bg-(--nucleus-bg)/35;
661
+
@apply single-line-input rounded-xs bg-(--nucleus-bg)/35;
285
662
border-color: color-mix(in srgb, var(--acc-color) 30%, transparent);
286
663
}
287
664
···
307
684
308
685
textarea:focus {
309
686
@apply border-none! [box-shadow:none]! outline-none!;
687
+
}
688
+
689
+
/* Image preview grid - based on PhotoSwipeGallery */
690
+
.image-preview-grid {
691
+
display: grid;
692
+
gap: 2px;
693
+
border-radius: 4px;
694
+
overflow: hidden;
695
+
width: 100%;
696
+
max-height: 30vh;
697
+
}
698
+
699
+
.image-preview-item {
700
+
width: 100%;
701
+
height: 100%;
702
+
display: block;
703
+
position: relative;
704
+
overflow: hidden;
705
+
border-radius: 4px;
706
+
}
707
+
708
+
.image-preview-item > img {
709
+
width: 100%;
710
+
height: 100%;
711
+
object-fit: cover;
712
+
}
713
+
714
+
/* Single image: natural aspect ratio */
715
+
.image-preview-grid[data-total='1'] {
716
+
display: block;
717
+
height: auto;
718
+
width: 100%;
719
+
border-radius: 0;
720
+
}
721
+
722
+
.image-preview-grid[data-total='1'] .image-preview-item {
723
+
width: 100%;
724
+
height: auto;
725
+
display: block;
726
+
border-radius: 4px;
727
+
}
728
+
729
+
.image-preview-grid[data-total='1'] .image-preview-item > img {
730
+
width: 100%;
731
+
height: auto;
732
+
max-height: 60vh;
733
+
object-fit: contain;
734
+
}
735
+
736
+
/* 2 Images: Split vertically */
737
+
.image-preview-grid[data-total='2'] {
738
+
grid-template-columns: 1fr 1fr;
739
+
grid-template-rows: 1fr;
740
+
aspect-ratio: 16/9;
741
+
}
742
+
743
+
/* 3 Images: 1 Big (left), 2 Small (stacked right) */
744
+
.image-preview-grid[data-total='3'] {
745
+
grid-template-columns: 1fr 1fr;
746
+
grid-template-rows: 1fr 1fr;
747
+
aspect-ratio: 16/9;
748
+
}
749
+
.image-preview-grid[data-total='3'] .image-preview-item:first-child {
750
+
grid-row: span 2;
751
+
}
752
+
753
+
/* 4 Images: 2x2 Grid */
754
+
.image-preview-grid[data-total='4'] {
755
+
grid-template-columns: 1fr 1fr;
756
+
grid-template-rows: 1fr 1fr;
757
+
aspect-ratio: 16/9;
310
758
}
311
759
</style>
+145
src/components/ProfileActions.svelte
+145
src/components/ProfileActions.svelte
···
1
+
<script lang="ts">
2
+
import type { AtpClient } from '$lib/at/client.svelte';
3
+
import { parseCanonicalResourceUri, type Did } from '@atcute/lexicons';
4
+
import Dropdown from './Dropdown.svelte';
5
+
import Icon from '@iconify/svelte';
6
+
import { createBlock, deleteBlock, follows } from '$lib/state.svelte';
7
+
import { generateColorForDid } from '$lib/accounts';
8
+
import { now as tidNow } from '@atcute/tid';
9
+
import type { AppBskyGraphFollow } from '@atcute/bluesky';
10
+
import { toCanonicalUri } from '$lib';
11
+
import { SvelteMap } from 'svelte/reactivity';
12
+
13
+
interface Props {
14
+
client: AtpClient;
15
+
targetDid: Did;
16
+
userBlocked: boolean;
17
+
blockedByTarget: boolean;
18
+
}
19
+
20
+
let { client, targetDid, userBlocked = $bindable(), blockedByTarget }: Props = $props();
21
+
22
+
const userDid = $derived(client.user?.did);
23
+
const color = $derived(generateColorForDid(targetDid));
24
+
25
+
let actionsOpen = $state(false);
26
+
let actionsPos = $state({ x: 0, y: 0 });
27
+
28
+
const followsMap = $derived(userDid ? follows.get(userDid) : undefined);
29
+
const follow = $derived(
30
+
followsMap
31
+
? Array.from(followsMap.entries()).find(([, follow]) => follow.subject === targetDid)
32
+
: undefined
33
+
);
34
+
35
+
const handleFollow = async () => {
36
+
if (!userDid || !client.user) return;
37
+
38
+
if (follow) {
39
+
const [uri] = follow;
40
+
followsMap?.delete(uri);
41
+
42
+
// extract rkey from uri
43
+
const parsedUri = parseCanonicalResourceUri(uri);
44
+
if (!parsedUri.ok) return;
45
+
const rkey = parsedUri.value.rkey;
46
+
47
+
await client.user.atcute.post('com.atproto.repo.deleteRecord', {
48
+
input: {
49
+
repo: userDid,
50
+
collection: 'app.bsky.graph.follow',
51
+
rkey
52
+
}
53
+
});
54
+
} else {
55
+
// follow
56
+
const rkey = tidNow();
57
+
const record: AppBskyGraphFollow.Main = {
58
+
$type: 'app.bsky.graph.follow',
59
+
subject: targetDid,
60
+
createdAt: new Date().toISOString()
61
+
};
62
+
63
+
const uri = toCanonicalUri({
64
+
did: userDid,
65
+
collection: 'app.bsky.graph.follow',
66
+
rkey
67
+
});
68
+
69
+
if (!followsMap) follows.set(userDid, new SvelteMap([[uri, record]]));
70
+
else followsMap.set(uri, record);
71
+
72
+
await client.user.atcute.post('com.atproto.repo.createRecord', {
73
+
input: {
74
+
repo: userDid,
75
+
collection: 'app.bsky.graph.follow',
76
+
rkey,
77
+
record
78
+
}
79
+
});
80
+
}
81
+
82
+
actionsOpen = false;
83
+
};
84
+
85
+
const handleBlock = async () => {
86
+
if (!userDid) return;
87
+
88
+
if (userBlocked) {
89
+
await deleteBlock(client, targetDid);
90
+
userBlocked = false;
91
+
} else {
92
+
await createBlock(client, targetDid);
93
+
userBlocked = true;
94
+
}
95
+
96
+
actionsOpen = false;
97
+
};
98
+
</script>
99
+
100
+
{#snippet dropdownItem(icon: string, label: string, onClick: () => void, disabled: boolean = false)}
101
+
<button
102
+
class="flex items-center justify-between rounded-sm px-2 py-1.5 transition-all duration-100
103
+
{disabled ? 'cursor-not-allowed opacity-50' : 'hover:[backdrop-filter:brightness(120%)]'}"
104
+
onclick={onClick}
105
+
{disabled}
106
+
>
107
+
<span class="font-semibold opacity-85">{label}</span>
108
+
<Icon class="h-6 w-6" {icon} />
109
+
</button>
110
+
{/snippet}
111
+
112
+
<Dropdown
113
+
class="post-dropdown"
114
+
style="background: {color}36; border-color: {color}99;"
115
+
bind:isOpen={actionsOpen}
116
+
bind:position={actionsPos}
117
+
placement="bottom-end"
118
+
>
119
+
{#if !blockedByTarget}
120
+
{@render dropdownItem(
121
+
follow ? 'heroicons:user-minus-20-solid' : 'heroicons:user-plus-20-solid',
122
+
follow ? 'unfollow' : 'follow',
123
+
handleFollow
124
+
)}
125
+
{/if}
126
+
{@render dropdownItem(
127
+
userBlocked ? 'heroicons:eye-20-solid' : 'heroicons:eye-slash-20-solid',
128
+
userBlocked ? 'unblock' : 'block',
129
+
handleBlock
130
+
)}
131
+
132
+
{#snippet trigger()}
133
+
<button
134
+
class="rounded-sm p-1.5 transition-all hover:bg-white/10"
135
+
onclick={(e: MouseEvent) => {
136
+
e.stopPropagation();
137
+
actionsOpen = !actionsOpen;
138
+
actionsPos = { x: 0, y: 0 };
139
+
}}
140
+
title="profile actions"
141
+
>
142
+
<Icon icon="heroicons:ellipsis-horizontal-16-solid" width={24} />
143
+
</button>
144
+
{/snippet}
145
+
</Dropdown>
+66
-46
src/components/ProfileInfo.svelte
+66
-46
src/components/ProfileInfo.svelte
···
1
1
<script lang="ts">
2
-
import { AtpClient, resolveDidDoc } from '$lib/at/client';
2
+
import { AtpClient, resolveDidDoc } from '$lib/at/client.svelte';
3
3
import type { Did, Handle } from '@atcute/lexicons/syntax';
4
4
import type { AppBskyActorProfile } from '@atcute/bluesky';
5
5
import ProfilePicture from './ProfilePicture.svelte';
6
6
import RichText from './RichText.svelte';
7
7
import { onMount } from 'svelte';
8
-
import { handles, profiles } from '$lib/state.svelte';
8
+
import { getBlockRelationship, handles, profiles } from '$lib/state.svelte';
9
+
import BlockedUserIndicator from './BlockedUserIndicator.svelte';
9
10
10
11
interface Props {
11
12
client: AtpClient;
···
21
22
profile = $bindable(profiles.get(did) ?? null)
22
23
}: Props = $props();
23
24
25
+
const userDid = $derived(client.user?.did);
26
+
const blockRel = $derived(
27
+
userDid ? getBlockRelationship(userDid, did) : { userBlocked: false, blockedByTarget: false }
28
+
);
29
+
const isBlocked = $derived(blockRel.userBlocked || blockRel.blockedByTarget);
30
+
24
31
onMount(async () => {
32
+
// don't load profile info if blocked
33
+
if (isBlocked) return;
34
+
25
35
await Promise.all([
26
36
(async () => {
27
37
if (profile) return;
···
46
56
let showDid = $state(false);
47
57
</script>
48
58
49
-
<div class="flex flex-col gap-2">
50
-
<div class="flex items-center gap-2">
51
-
<ProfilePicture {client} {did} size={20} />
59
+
{#if isBlocked}
60
+
<BlockedUserIndicator
61
+
{client}
62
+
{did}
63
+
reason={blockRel.userBlocked ? 'blocked' : 'blocks-you'}
64
+
size="normal"
65
+
/>
66
+
{:else}
67
+
<div class="flex flex-col gap-2">
68
+
<div class="flex items-center gap-2">
69
+
<ProfilePicture {client} {did} size={20} />
52
70
53
-
<div class="flex min-w-0 flex-col items-start overflow-hidden overflow-ellipsis">
54
-
<span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis">
55
-
{profileDisplayName.length > 0 ? profileDisplayName : displayHandle}
56
-
{#if profile?.pronouns}
57
-
<span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span>
58
-
{/if}
59
-
</span>
60
-
<button
61
-
oncontextmenu={(e) => {
62
-
e.stopPropagation();
63
-
const node = e.target as Node;
64
-
const selection = window.getSelection() ?? new Selection();
65
-
const range = document.createRange();
66
-
range.selectNodeContents(node);
67
-
selection.removeAllRanges();
68
-
selection.addRange(range);
69
-
}}
70
-
onmousedown={(e) => {
71
-
// disable double clicks to disable "double click to select text"
72
-
// since it doesnt work with us toggling did vs handle
73
-
if (e.detail > 1) e.preventDefault();
74
-
}}
75
-
onclick={() => (showDid = !showDid)}
76
-
class="mb-0.5 text-nowrap opacity-85 select-text hover:underline"
77
-
>
78
-
{showDid ? did : `@${displayHandle}`}
79
-
</button>
80
-
{#if profile?.website}
81
-
<a
82
-
target="_blank"
83
-
rel="noopener noreferrer"
84
-
href={profile.website}
85
-
class="text-sm text-nowrap opacity-60 hover:underline">{profile.website}</a
71
+
<div class="flex min-w-0 flex-col items-start overflow-hidden overflow-ellipsis">
72
+
<span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis">
73
+
{profileDisplayName.length > 0 ? profileDisplayName : displayHandle}
74
+
{#if profile?.pronouns}
75
+
<span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span>
76
+
{/if}
77
+
</span>
78
+
<button
79
+
oncontextmenu={(e) => {
80
+
e.stopPropagation();
81
+
const node = e.target as Node;
82
+
const selection = window.getSelection() ?? new Selection();
83
+
const range = document.createRange();
84
+
range.selectNodeContents(node);
85
+
selection.removeAllRanges();
86
+
selection.addRange(range);
87
+
}}
88
+
onmousedown={(e) => {
89
+
// disable double clicks to disable "double click to select text"
90
+
// since it doesnt work with us toggling did vs handle
91
+
if (e.detail > 1) e.preventDefault();
92
+
}}
93
+
onclick={() => (showDid = !showDid)}
94
+
class="mb-0.5 text-nowrap opacity-85 select-text hover:underline"
86
95
>
87
-
{/if}
96
+
{showDid ? did : `@${displayHandle}`}
97
+
</button>
98
+
{#if profile?.website}
99
+
<!-- eslint-disable svelte/no-navigation-without-resolve -->
100
+
<a
101
+
target="_blank"
102
+
rel="noopener noreferrer"
103
+
href={profile.website}
104
+
class="text-sm text-nowrap opacity-60 hover:underline">{profile.website}</a
105
+
>
106
+
{/if}
107
+
</div>
88
108
</div>
89
-
</div>
90
109
91
-
{#if profileDesc.length > 0}
92
-
<div class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word">
93
-
<RichText text={profileDesc} />
94
-
</div>
95
-
{/if}
96
-
</div>
110
+
{#if profileDesc.length > 0}
111
+
<div class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word">
112
+
<RichText text={profileDesc} />
113
+
</div>
114
+
{/if}
115
+
</div>
116
+
{/if}
+1
-1
src/components/ProfilePicture.svelte
+1
-1
src/components/ProfilePicture.svelte
···
1
1
<script lang="ts">
2
2
import { generateColorForDid } from '$lib/accounts';
3
-
import type { AtpClient } from '$lib/at/client';
3
+
import type { AtpClient } from '$lib/at/client.svelte';
4
4
import { isBlob } from '@atcute/lexicons/interfaces';
5
5
import PfpPlaceholder from './PfpPlaceholder.svelte';
6
6
import { img } from '$lib/cdn';
+95
-75
src/components/ProfileView.svelte
+95
-75
src/components/ProfileView.svelte
···
1
1
<script lang="ts">
2
-
import { AtpClient, resolveDidDoc, resolveHandle } from '$lib/at/client';
3
-
import {
4
-
isHandle,
5
-
type ActorIdentifier,
6
-
type AtprotoDid,
7
-
type Did,
8
-
type Handle
9
-
} from '@atcute/lexicons/syntax';
2
+
import { AtpClient, resolveDidDoc } from '$lib/at/client.svelte';
3
+
import { isDid, isHandle, type ActorIdentifier, type Did } from '@atcute/lexicons/syntax';
10
4
import TimelineView from './TimelineView.svelte';
11
5
import ProfileInfo from './ProfileInfo.svelte';
12
6
import type { State as PostComposerState } from './PostComposer.svelte';
13
7
import Icon from '@iconify/svelte';
14
-
import { generateColorForDid } from '$lib/accounts';
8
+
import { accounts, generateColorForDid } from '$lib/accounts';
15
9
import { img } from '$lib/cdn';
16
10
import { isBlob } from '@atcute/lexicons/interfaces';
17
-
import type { AppBskyActorProfile } from '@atcute/bluesky';
18
-
import { onMount } from 'svelte';
19
-
import { handles, profiles } from '$lib/state.svelte';
11
+
import {
12
+
handles,
13
+
profiles,
14
+
getBlockRelationship,
15
+
fetchBlocked,
16
+
blockFlags
17
+
} from '$lib/state.svelte';
18
+
import BlockedUserIndicator from './BlockedUserIndicator.svelte';
19
+
import ProfileActions from './ProfileActions.svelte';
20
20
21
21
interface Props {
22
22
client: AtpClient;
···
27
27
28
28
let { client, actor, onBack, postComposerState = $bindable() }: Props = $props();
29
29
30
-
let profile = $state<AppBskyActorProfile.Main | null>(profiles.get(actor as Did) ?? null);
30
+
const profile = $derived(profiles.get(actor as Did));
31
31
const displayName = $derived(profile?.displayName ?? '');
32
+
const handle = $derived(isHandle(actor) ? actor : handles.get(actor as Did));
32
33
let loading = $state(true);
33
34
let error = $state<string | null>(null);
34
-
let did = $state<AtprotoDid | null>(null);
35
-
let handle = $state<Handle | null>(handles.get(actor as Did) ?? null);
35
+
let did = $state(isDid(actor) ? actor : null);
36
+
37
+
let userBlocked = $state(false);
38
+
let blockedByTarget = $state(false);
36
39
37
40
const loadProfile = async (identifier: ActorIdentifier) => {
38
41
loading = true;
39
42
error = null;
40
-
profile = null;
41
-
handle = isHandle(identifier) ? identifier : null;
42
43
43
-
const resDid = await resolveHandle(identifier);
44
-
if (resDid.ok) did = resDid.value;
45
-
else {
46
-
error = resDid.error;
47
-
loading = false;
44
+
const docRes = await resolveDidDoc(identifier);
45
+
if (docRes.ok) {
46
+
did = docRes.value.did;
47
+
handles.set(did, docRes.value.handle);
48
+
} else {
49
+
error = docRes.error;
48
50
return;
49
51
}
50
52
51
-
if (!handle) handle = handles.get(did) ?? null;
53
+
// check block relationship
54
+
if (client.user?.did) {
55
+
let blockRel = getBlockRelationship(client.user.did, did);
56
+
blockRel = blockFlags.get(client.user.did)?.has(did)
57
+
? blockRel
58
+
: await (async () => {
59
+
const [userBlocked, blockedByTarget] = await Promise.all([
60
+
await fetchBlocked(client, did, client.user!.did),
61
+
await fetchBlocked(client, client.user!.did, did)
62
+
]);
63
+
return { userBlocked, blockedByTarget };
64
+
})();
65
+
userBlocked = blockRel.userBlocked;
66
+
blockedByTarget = blockRel.blockedByTarget;
67
+
}
52
68
53
-
if (!handle) {
54
-
const resHandle = await resolveDidDoc(did);
55
-
if (resHandle.ok) {
56
-
handle = resHandle.value.handle;
57
-
handles.set(did, resHandle.value.handle);
58
-
}
69
+
// don't load profile if blocked
70
+
if (userBlocked || blockedByTarget) {
71
+
loading = false;
72
+
return;
59
73
}
60
74
61
-
const res = await client.getProfile(did);
62
-
if (res.ok) {
63
-
profile = res.value;
64
-
profiles.set(did, res.value);
65
-
} else error = res.error;
75
+
const res = await client.getProfile(did, true);
76
+
if (res.ok) profiles.set(did, res.value);
77
+
else error = res.error;
66
78
67
79
loading = false;
68
80
};
69
81
70
-
onMount(async () => {
71
-
await loadProfile(actor as ActorIdentifier);
82
+
$effect(() => {
83
+
// if we have accounts, wait until we are logged in to load the profile
84
+
if (!($accounts.length > 0 && !client.user?.did)) loadProfile(actor as ActorIdentifier);
72
85
});
73
86
74
-
const color = $derived(did ? generateColorForDid(did) : 'var(--nucleus-fg)');
87
+
const color = $derived(did ? generateColorForDid(did) : 'var(--nucleus-accent)');
75
88
const bannerUrl = $derived(
76
89
did && profile && isBlob(profile.banner)
77
90
? img('feed_fullsize', did, profile.banner.ref.$link)
···
82
95
<div class="flex min-h-dvh flex-col">
83
96
<!-- header -->
84
97
<div
85
-
class="sticky top-0 z-20 flex items-center gap-4 border-b-2 bg-(--nucleus-bg)/80 p-4 backdrop-blur-md"
86
-
style="border-color: {color}40;"
98
+
class="sticky top-0 z-20 flex items-center gap-4 border-b-2 bg-(--nucleus-bg)/80 p-2 backdrop-blur-md"
99
+
style="border-color: {color};"
87
100
>
88
101
<button
89
102
onclick={onBack}
90
-
class="rounded-full p-1 text-(--nucleus-fg) transition-all hover:bg-(--nucleus-fg)/10"
103
+
class="rounded-sm p-1 text-(--nucleus-fg) transition-all hover:bg-(--nucleus-fg)/10"
91
104
>
92
105
<Icon icon="heroicons:arrow-left-20-solid" width={24} />
93
106
</button>
94
107
<h2 class="text-xl font-bold">
95
-
{displayName.length > 0
96
-
? displayName
97
-
: loading
98
-
? 'loading...'
99
-
: (handle ?? actor ?? 'profile')}
108
+
{displayName.length > 0 ? displayName : loading ? 'loading...' : (handle ?? 'handle.invalid')}
100
109
</h2>
110
+
<div class="grow"></div>
111
+
{#if did && client.user && client.user.did !== did}
112
+
<ProfileActions {client} targetDid={did} bind:userBlocked {blockedByTarget} />
113
+
{/if}
101
114
</div>
102
115
103
-
{#if error}
104
-
<div class="p-8 text-center text-red-500">
105
-
<p>failed to load profile: {error}</p>
106
-
</div>
107
-
{:else}
108
-
<!-- banner -->
109
-
<div class="relative h-32 w-full overflow-hidden bg-(--nucleus-fg)/5 md:h-48">
116
+
{#if !loading}
117
+
{#if error}
118
+
<div class="p-8 text-center text-red-500">
119
+
<p>failed to load profile: {error}</p>
120
+
</div>
121
+
{:else if userBlocked || blockedByTarget}
122
+
<div class="p-8">
123
+
<BlockedUserIndicator
124
+
{client}
125
+
did={did!}
126
+
reason={userBlocked ? 'blocked' : 'blocks-you'}
127
+
size="large"
128
+
/>
129
+
</div>
130
+
{:else}
131
+
<!-- banner -->
110
132
{#if bannerUrl}
111
-
<img src={bannerUrl} alt="banner" class="h-full w-full object-cover" />
133
+
<div class="relative h-32 w-full overflow-hidden bg-(--nucleus-fg)/5 md:h-48">
134
+
<img src={bannerUrl} alt="banner" class="h-full w-full object-cover" />
135
+
<div
136
+
class="absolute inset-0 bg-linear-to-b from-transparent to-(--nucleus-bg)"
137
+
style="opacity: 0.8;"
138
+
></div>
139
+
</div>
112
140
{/if}
113
-
<div
114
-
class="absolute inset-0 bg-linear-to-b from-transparent to-(--nucleus-bg)"
115
-
style="opacity: 0.8;"
116
-
></div>
117
-
</div>
118
-
119
-
<div class="px-4 pb-4">
120
-
<div class="relative z-10 -mt-12 mb-4">
121
-
{#if did}
122
-
<ProfileInfo {client} {did} bind:profile />
123
-
{/if}
124
-
</div>
125
-
126
-
<div class="my-4 h-px bg-white/10"></div>
127
141
128
142
{#if did}
129
-
<TimelineView
130
-
showReplies={false}
131
-
{client}
132
-
targetDid={did}
133
-
bind:postComposerState
134
-
class="min-h-[50vh]"
135
-
/>
143
+
<div class="px-4 pb-4">
144
+
<div class="relative z-10 {bannerUrl ? '-mt-12' : 'mt-4'} mb-4">
145
+
<ProfileInfo {client} {did} {profile} />
146
+
</div>
147
+
148
+
<TimelineView
149
+
showReplies={false}
150
+
{client}
151
+
targetDid={did}
152
+
bind:postComposerState
153
+
class="min-h-[50vh]"
154
+
/>
155
+
</div>
136
156
{/if}
137
-
</div>
157
+
{/if}
138
158
{/if}
139
159
</div>
+1
src/components/RichText.svelte
+1
src/components/RichText.svelte
···
37
37
{@const { text, features: _features } = segment}
38
38
{@const features = _features ?? []}
39
39
{#if features.length > 0}
40
+
<!-- eslint-disable svelte/no-navigation-without-resolve -->
40
41
{#each features as feature, idx (idx)}
41
42
{#if feature.$type === 'app.bsky.richtext.facet#mention'}
42
43
<a
+41
-16
src/components/TimelineView.svelte
+41
-16
src/components/TimelineView.svelte
···
1
1
<script lang="ts">
2
2
import BskyPost from './BskyPost.svelte';
3
3
import { type State as PostComposerState } from './PostComposer.svelte';
4
-
import { AtpClient } from '$lib/at/client';
4
+
import { AtpClient } from '$lib/at/client.svelte';
5
5
import { accounts } from '$lib/accounts';
6
6
import { type ResourceUri } from '@atcute/lexicons';
7
7
import { SvelteSet } from 'svelte/reactivity';
···
11
11
fetchTimeline,
12
12
allPosts,
13
13
timelines,
14
-
fetchInteractionsUntil
14
+
fetchInteractionsToTimelineEnd
15
15
} from '$lib/state.svelte';
16
16
import Icon from '@iconify/svelte';
17
17
import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread';
18
-
import type { AtprotoDid } from '@atcute/lexicons/syntax';
18
+
import type { Did } from '@atcute/lexicons/syntax';
19
19
import NotLoggedIn from './NotLoggedIn.svelte';
20
20
21
21
interface Props {
22
22
client?: AtpClient | null;
23
-
targetDid?: AtprotoDid;
23
+
targetDid?: Did;
24
24
postComposerState: PostComposerState;
25
25
class?: string;
26
26
// whether to show replies that are not the user's own posts
···
39
39
let viewOwnPosts = $state(true);
40
40
const expandedThreads = new SvelteSet<ResourceUri>();
41
41
42
-
const did = $derived(targetDid ?? client?.user?.did);
42
+
const userDid = $derived(client?.user?.did);
43
+
const did = $derived(targetDid ?? userDid);
43
44
44
45
const threads = $derived(
45
46
// todo: apply showReplies here
···
53
54
const loaderState = new LoaderState();
54
55
let scrollContainer = $state<HTMLDivElement>();
55
56
let loading = $state(false);
56
-
let fetchMoreInteractions: boolean | undefined = $state(false);
57
57
let loadError = $state('');
58
58
59
59
const loadMore = async () => {
···
63
63
loaderState.status = 'LOADING';
64
64
65
65
try {
66
-
await fetchTimeline(did as AtprotoDid, 7, showReplies);
67
-
// interaction fetching is done lazily so we dont block loading posts
68
-
fetchMoreInteractions = true;
66
+
await fetchTimeline(client, did, 7, showReplies, {
67
+
downwards: userDid === did ? 'sameAuthor' : 'none'
68
+
});
69
+
// only fetch interactions if logged in (because if not who is the interactor)
70
+
if (client.user && userDid) {
71
+
if (!fetchingInteractions) {
72
+
scheduledFetchInteractions = false;
73
+
fetchingInteractions = true;
74
+
await fetchInteractionsToTimelineEnd(client, userDid, did);
75
+
fetchingInteractions = false;
76
+
} else {
77
+
scheduledFetchInteractions = true;
78
+
}
79
+
}
69
80
loaderState.loaded();
70
81
} catch (error) {
71
82
loadError = `${error}`;
···
75
86
}
76
87
77
88
loading = false;
78
-
const cursor = postCursors.get(did as AtprotoDid);
89
+
const cursor = postCursors.get(did);
79
90
if (cursor && cursor.end) loaderState.complete();
80
91
};
81
92
82
93
$effect(() => {
83
-
if (threads.length === 0 && !loading && did) {
94
+
if (threads.length === 0 && !loading && userDid && did) {
84
95
// if we saw all posts dont try to load more.
85
96
// this only really happens if the user has no posts at all
86
97
// but we do have to handle it to not cause an infinite loop
87
-
const cursor = did ? postCursors.get(did as AtprotoDid) : undefined;
98
+
const cursor = did ? postCursors.get(did) : undefined;
88
99
if (!cursor?.end) loadMore();
89
100
}
90
-
if (client && did && fetchMoreInteractions) {
91
-
// set to false so it doesnt attempt to fetch again while its already fetching
92
-
fetchMoreInteractions = false;
93
-
fetchInteractionsUntil(client, did).then(() => (fetchMoreInteractions = undefined));
101
+
});
102
+
103
+
let fetchingInteractions = $state(false);
104
+
let scheduledFetchInteractions = $state(false);
105
+
// we want to load interactions when changing logged in user
106
+
// only on timelines that arent logged in users, because those are already
107
+
// loaded by loadMore
108
+
$effect(() => {
109
+
if (client && scheduledFetchInteractions && userDid && did && did !== userDid) {
110
+
if (!fetchingInteractions) {
111
+
scheduledFetchInteractions = false;
112
+
fetchingInteractions = true;
113
+
fetchInteractionsToTimelineEnd(client, userDid, did).finally(
114
+
() => (fetchingInteractions = false)
115
+
);
116
+
} else {
117
+
scheduledFetchInteractions = true;
118
+
}
94
119
}
95
120
});
96
121
</script>
+506
src/lib/at/client.svelte.ts
+506
src/lib/at/client.svelte.ts
···
1
+
/* eslint-disable svelte/prefer-svelte-reactivity */
2
+
import { err, expect, map, ok, type OkType, type Result } from '$lib/result';
3
+
import {
4
+
ComAtprotoIdentityResolveHandle,
5
+
ComAtprotoRepoGetRecord,
6
+
ComAtprotoRepoListRecords
7
+
} from '@atcute/atproto';
8
+
import { Client as AtcuteClient, simpleFetchHandler } from '@atcute/client';
9
+
import { safeParse, type Blob as AtpBlob, type Handle, type InferOutput } from '@atcute/lexicons';
10
+
import {
11
+
isDid,
12
+
parseResourceUri,
13
+
type ActorIdentifier,
14
+
type AtprotoDid,
15
+
type Cid,
16
+
type Did,
17
+
type Nsid,
18
+
type RecordKey,
19
+
type ResourceUri
20
+
} from '@atcute/lexicons/syntax';
21
+
import type {
22
+
InferInput,
23
+
InferXRPCBodyOutput,
24
+
ObjectSchema,
25
+
RecordKeySchema,
26
+
RecordSchema,
27
+
XRPCQueryMetadata
28
+
} from '@atcute/lexicons/validations';
29
+
import * as v from '@atcute/lexicons/validations';
30
+
import { MiniDocQuery, type MiniDoc } from './slingshot';
31
+
import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation';
32
+
import type { Records, XRPCProcedures } from '@atcute/lexicons/ambient';
33
+
import { cache as rawCache, ttl } from '$lib/cache';
34
+
import { AppBskyActorProfile } from '@atcute/bluesky';
35
+
import { WebSocket } from '@soffinal/websocket';
36
+
import type { Notification } from './stardust';
37
+
import type { OAuthUserAgent } from '@atcute/oauth-browser-client';
38
+
import { timestampFromCursor, toCanonicalUri, toResourceUri } from '$lib';
39
+
import { constellationUrl, httpToDidWeb, slingshotUrl, spacedustUrl } from '.';
40
+
41
+
export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output };
42
+
43
+
const cacheWithHandles = rawCache.define(
44
+
'resolveHandle',
45
+
async (handle: Handle): Promise<AtprotoDid> => {
46
+
const res = await fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, {
47
+
handle
48
+
});
49
+
if (!res.ok) throw new Error(res.error);
50
+
return res.value.did as AtprotoDid;
51
+
}
52
+
);
53
+
54
+
const cacheWithDidDocs = cacheWithHandles.define(
55
+
'resolveDidDoc',
56
+
async (identifier: ActorIdentifier): Promise<MiniDoc> => {
57
+
const res = await fetchMicrocosm(slingshotUrl, MiniDocQuery, {
58
+
identifier
59
+
});
60
+
if (!res.ok) throw new Error(res.error);
61
+
return res.value;
62
+
}
63
+
);
64
+
65
+
const cacheWithRecords = cacheWithDidDocs.define('fetchRecord', async (uri: ResourceUri) => {
66
+
const parsedUri = parseResourceUri(uri);
67
+
if (!parsedUri.ok) throw new Error(`can't parse resource uri: ${parsedUri.error}`);
68
+
const { repo, collection, rkey } = parsedUri.value;
69
+
const res = await fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, {
70
+
repo,
71
+
collection: collection!,
72
+
rkey: rkey!
73
+
});
74
+
if (!res.ok) throw new Error(res.error);
75
+
return res.value;
76
+
});
77
+
78
+
const cache = cacheWithRecords;
79
+
80
+
export const invalidateRecordCache = async (uri: ResourceUri) => {
81
+
console.log(`invalidating cached for ${uri}`);
82
+
await cache.invalidate('fetchRecord', `fetchRecord~${uri}`);
83
+
};
84
+
export const setRecordCache = (uri: ResourceUri, record: unknown) =>
85
+
cache.set('fetchRecord', `fetchRecord~${uri}`, record, ttl);
86
+
87
+
export const xhrPost = (
88
+
url: string,
89
+
body: Blob | File,
90
+
headers: Record<string, string> = {},
91
+
onProgress?: (uploaded: number, total: number) => void
92
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
93
+
): Promise<Result<any, { error: string; message: string }>> => {
94
+
return new Promise((resolve) => {
95
+
const xhr = new XMLHttpRequest();
96
+
xhr.open('POST', url);
97
+
98
+
if (onProgress && xhr.upload)
99
+
xhr.upload.onprogress = (event: ProgressEvent) => {
100
+
if (event.lengthComputable) onProgress(event.loaded, event.total);
101
+
};
102
+
103
+
Object.keys(headers).forEach((key) => xhr.setRequestHeader(key, headers[key]));
104
+
105
+
xhr.onload = () => {
106
+
if (xhr.status >= 200 && xhr.status < 300) resolve(ok(JSON.parse(xhr.responseText)));
107
+
else resolve(err(JSON.parse(xhr.responseText)));
108
+
};
109
+
110
+
xhr.onerror = () => resolve(err({ error: 'xhr_error', message: 'network error' }));
111
+
xhr.onabort = () => resolve(err({ error: 'xhr_error', message: 'upload aborted' }));
112
+
xhr.send(body);
113
+
});
114
+
};
115
+
116
+
export type UploadStatus =
117
+
| { stage: 'auth' }
118
+
| { stage: 'uploading'; progress?: number }
119
+
| { stage: 'processing'; progress?: number }
120
+
| { stage: 'complete' };
121
+
122
+
export type Auth = {
123
+
atcute: AtcuteClient;
124
+
} & MiniDoc;
125
+
126
+
export class AtpClient {
127
+
public user: Auth | null = $state(null);
128
+
129
+
async login(agent: OAuthUserAgent): Promise<Result<null, string>> {
130
+
try {
131
+
const rpc = new AtcuteClient({ handler: agent });
132
+
const res = await rpc.get('com.atproto.server.getSession');
133
+
if (!res.ok) throw res.data.error;
134
+
this.user = {
135
+
atcute: rpc,
136
+
did: res.data.did,
137
+
handle: res.data.handle,
138
+
pds: agent.session.info.aud as `${string}:${string}`,
139
+
signing_key: ''
140
+
};
141
+
} catch (error) {
142
+
return err(`failed to login: ${error}`);
143
+
}
144
+
145
+
return ok(null);
146
+
}
147
+
148
+
async getRecordUri<
149
+
Collection extends Nsid,
150
+
TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } },
151
+
TKey extends RecordKeySchema,
152
+
Schema extends RecordSchema<TObject, TKey>,
153
+
Output extends InferInput<Schema>
154
+
>(
155
+
schema: Schema,
156
+
uri: ResourceUri,
157
+
noCache?: boolean
158
+
): Promise<Result<RecordOutput<Output>, string>> {
159
+
const parsedUri = expect(parseResourceUri(uri));
160
+
if (parsedUri.collection !== schema.object.shape.$type.expected)
161
+
return err(
162
+
`collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}`
163
+
);
164
+
return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!, noCache);
165
+
}
166
+
167
+
async getRecord<
168
+
Collection extends Nsid,
169
+
TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } },
170
+
TKey extends RecordKeySchema,
171
+
Schema extends RecordSchema<TObject, TKey>,
172
+
Output extends InferInput<Schema>
173
+
>(
174
+
schema: Schema,
175
+
repo: ActorIdentifier,
176
+
rkey: RecordKey,
177
+
noCache?: boolean
178
+
): Promise<Result<RecordOutput<Output>, string>> {
179
+
const collection = schema.object.shape.$type.expected;
180
+
181
+
try {
182
+
const uri = toResourceUri({ repo, collection, rkey, fragment: undefined });
183
+
if (noCache) await invalidateRecordCache(uri);
184
+
const rawValue = await cache.fetchRecord(uri);
185
+
186
+
const parsed = safeParse(schema, rawValue.value);
187
+
if (!parsed.ok) return err(parsed.message);
188
+
189
+
return ok({
190
+
uri: rawValue.uri,
191
+
cid: rawValue.cid,
192
+
record: parsed.value as Output
193
+
});
194
+
} catch (e) {
195
+
return err(String(e));
196
+
}
197
+
}
198
+
199
+
async getProfile(
200
+
repo?: ActorIdentifier,
201
+
noCache?: boolean
202
+
): Promise<Result<AppBskyActorProfile.Main, string>> {
203
+
repo = repo ?? this.user?.did;
204
+
if (!repo) return err('not authenticated');
205
+
return map(
206
+
await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self', noCache),
207
+
(d) => d.record
208
+
);
209
+
}
210
+
211
+
async listRecords<Collection extends keyof Records>(
212
+
ident: ActorIdentifier,
213
+
collection: Collection,
214
+
cursor?: string,
215
+
limit: number = 100
216
+
): Promise<
217
+
Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string>
218
+
> {
219
+
const auth = this.user;
220
+
if (!auth) return err('not authenticated');
221
+
const docRes = await resolveDidDoc(ident);
222
+
if (!docRes.ok) return docRes;
223
+
const atp =
224
+
auth.did === docRes.value.did
225
+
? auth.atcute
226
+
: new AtcuteClient({ handler: simpleFetchHandler({ service: docRes.value.pds }) });
227
+
const res = await atp.get('com.atproto.repo.listRecords', {
228
+
params: {
229
+
repo: docRes.value.did,
230
+
collection,
231
+
cursor,
232
+
limit,
233
+
reverse: false
234
+
}
235
+
});
236
+
if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`);
237
+
238
+
for (const record of res.data.records) setRecordCache(record.uri, record);
239
+
240
+
return ok(res.data);
241
+
}
242
+
243
+
async listRecordsUntil<Collection extends keyof Records>(
244
+
ident: ActorIdentifier,
245
+
collection: Collection,
246
+
cursor?: string,
247
+
timestamp: number = -1
248
+
): Promise<ReturnType<typeof this.listRecords>> {
249
+
const data: OkType<Awaited<ReturnType<typeof this.listRecords>>> = {
250
+
records: [],
251
+
cursor
252
+
};
253
+
254
+
let end = false;
255
+
while (!end) {
256
+
const res = await this.listRecords(ident, collection, data.cursor);
257
+
if (!res.ok) return res;
258
+
data.cursor = res.value.cursor;
259
+
data.records.push(...res.value.records);
260
+
end = data.records.length === 0 || !data.cursor;
261
+
if (!end && timestamp > 0) {
262
+
const cursorTimestamp = timestampFromCursor(data.cursor);
263
+
if (cursorTimestamp === undefined) {
264
+
console.warn(
265
+
'could not parse timestamp from cursor, stopping fetch to prevent infinite loop:',
266
+
data.cursor
267
+
);
268
+
end = true;
269
+
} else if (cursorTimestamp <= timestamp) {
270
+
end = true;
271
+
} else {
272
+
console.info(
273
+
`${ident}: continuing to fetch ${collection}, on ${cursorTimestamp} until ${timestamp}`
274
+
);
275
+
}
276
+
}
277
+
}
278
+
279
+
return ok(data);
280
+
}
281
+
282
+
async getBacklinks(
283
+
subject: ResourceUri,
284
+
source: BacklinksSource,
285
+
filterBy?: Did[],
286
+
limit?: number
287
+
): Promise<Result<Backlinks, string>> {
288
+
const { repo, collection, rkey } = expect(parseResourceUri(subject));
289
+
const did = await resolveHandle(repo);
290
+
if (!did.ok) return err(`cant resolve handle: ${did.error}`);
291
+
292
+
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000));
293
+
const query = fetchMicrocosm(constellationUrl, BacklinksQuery, {
294
+
subject: collection ? toCanonicalUri({ did: did.value, collection, rkey: rkey! }) : did.value,
295
+
source,
296
+
limit: limit || 100,
297
+
did: filterBy
298
+
});
299
+
300
+
const results = await Promise.race([query, timeout]);
301
+
if (!results) return err('cant fetch backlinks: timeout');
302
+
303
+
return results;
304
+
}
305
+
306
+
async getServiceAuth(lxm: keyof XRPCProcedures, exp: number): Promise<Result<string, string>> {
307
+
const auth = this.user;
308
+
if (!auth) return err('not authenticated');
309
+
const serviceAuthUrl = new URL(`${auth.pds}xrpc/com.atproto.server.getServiceAuth`);
310
+
serviceAuthUrl.searchParams.append('aud', httpToDidWeb(auth.pds));
311
+
serviceAuthUrl.searchParams.append('lxm', 'com.atproto.repo.uploadBlob');
312
+
serviceAuthUrl.searchParams.append('exp', exp.toString()); // 30 minutes
313
+
314
+
const serviceAuthResponse = await auth.atcute.handler(
315
+
`${serviceAuthUrl.pathname}${serviceAuthUrl.search}`,
316
+
{
317
+
method: 'GET'
318
+
}
319
+
);
320
+
if (!serviceAuthResponse.ok) {
321
+
const error = await serviceAuthResponse.text();
322
+
return err(`failed to get service auth: ${error}`);
323
+
}
324
+
const serviceAuth = await serviceAuthResponse.json();
325
+
return ok(serviceAuth.token);
326
+
}
327
+
328
+
async uploadBlob(
329
+
blob: Blob,
330
+
onProgress?: (progress: number) => void
331
+
): Promise<Result<AtpBlob<string>, string>> {
332
+
const auth = this.user;
333
+
if (!auth) return err('not authenticated');
334
+
const tokenResult = await this.getServiceAuth(
335
+
'com.atproto.repo.uploadBlob',
336
+
Math.floor(Date.now() / 1000) + 60
337
+
);
338
+
if (!tokenResult.ok) return tokenResult;
339
+
const result = await xhrPost(
340
+
`${auth.pds}xrpc/com.atproto.repo.uploadBlob`,
341
+
blob,
342
+
{ authorization: `Bearer ${tokenResult.value}` },
343
+
(uploaded, total) => onProgress?.(uploaded / total)
344
+
);
345
+
if (!result.ok) return err(`upload failed: ${result.error.message}`);
346
+
return ok(result.value.blob);
347
+
}
348
+
349
+
async uploadVideo(
350
+
blob: Blob,
351
+
mimeType: string,
352
+
onStatus?: (status: UploadStatus) => void
353
+
): Promise<Result<AtpBlob<string>, string>> {
354
+
const auth = this.user;
355
+
if (!auth) return err('not authenticated');
356
+
357
+
onStatus?.({ stage: 'auth' });
358
+
const tokenResult = await this.getServiceAuth(
359
+
'com.atproto.repo.uploadBlob',
360
+
Math.floor(Date.now() / 1000) + 60 * 30
361
+
);
362
+
if (!tokenResult.ok) return tokenResult;
363
+
364
+
onStatus?.({ stage: 'uploading' });
365
+
const uploadUrl = new URL('https://video.bsky.app/xrpc/app.bsky.video.uploadVideo');
366
+
uploadUrl.searchParams.append('did', auth.did);
367
+
uploadUrl.searchParams.append('name', 'video');
368
+
369
+
const uploadResult = await xhrPost(
370
+
uploadUrl.toString(),
371
+
blob,
372
+
{
373
+
Authorization: `Bearer ${tokenResult.value}`,
374
+
'Content-Type': mimeType
375
+
},
376
+
(uploaded, total) => onStatus?.({ stage: 'uploading', progress: uploaded / total })
377
+
);
378
+
if (!uploadResult.ok) return err(`failed to upload video: ${uploadResult.error}`);
379
+
const jobStatus = uploadResult.value;
380
+
let videoBlobRef: AtpBlob<string> = jobStatus.blob;
381
+
382
+
onStatus?.({ stage: 'processing' });
383
+
while (!videoBlobRef) {
384
+
await new Promise((resolve) => setTimeout(resolve, 1000));
385
+
386
+
const statusResponse = await fetch(
387
+
`https://video.bsky.app/xrpc/app.bsky.video.getJobStatus?jobId=${jobStatus.jobId}`
388
+
);
389
+
390
+
if (!statusResponse.ok) {
391
+
const error = await statusResponse.json();
392
+
// reuse blob
393
+
if (error.error === 'already_exists' && error.blob) {
394
+
videoBlobRef = error.blob;
395
+
break;
396
+
}
397
+
return err(`failed to get job status: ${error.message || error.error}`);
398
+
}
399
+
400
+
const status = await statusResponse.json();
401
+
if (status.jobStatus.blob) {
402
+
videoBlobRef = status.jobStatus.blob;
403
+
} else if (status.jobStatus.state === 'JOB_STATE_FAILED') {
404
+
return err(`video processing failed: ${status.jobStatus.error || 'unknown error'}`);
405
+
} else if (status.jobStatus.progress !== undefined) {
406
+
onStatus?.({
407
+
stage: 'processing',
408
+
progress: status.jobStatus.progress / 100
409
+
});
410
+
}
411
+
}
412
+
413
+
onStatus?.({ stage: 'complete' });
414
+
return ok(videoBlobRef);
415
+
}
416
+
}
417
+
418
+
// export const newPublicClient = async (ident: ActorIdentifier) => {
419
+
// const atp = new AtpClient();
420
+
// const didDoc = await resolveDidDoc(ident);
421
+
// if (!didDoc.ok) {
422
+
// console.error('failed to resolve did doc', didDoc.error);
423
+
// return atp;
424
+
// }
425
+
// atp.atcute = new AtcuteClient({ handler: simpleFetchHandler({ service: didDoc.value.pds }) });
426
+
// atp.user = didDoc.value;
427
+
// return atp;
428
+
// };
429
+
430
+
export const resolveHandle = (identifier: ActorIdentifier) => {
431
+
if (isDid(identifier)) return Promise.resolve(ok(identifier as AtprotoDid));
432
+
433
+
return cache
434
+
.resolveHandle(identifier)
435
+
.then((did) => ok(did))
436
+
.catch((e) => err(String(e)));
437
+
};
438
+
439
+
export const resolveDidDoc = (ident: ActorIdentifier) =>
440
+
cache
441
+
.resolveDidDoc(ident)
442
+
.then((doc) => ok(doc))
443
+
.catch((e) => err(String(e)));
444
+
445
+
type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>;
446
+
export type NotificationsStream = WebSocket<NotificationsStreamEncoder>;
447
+
export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>;
448
+
449
+
export const streamNotifications = (
450
+
subjects: Did[],
451
+
...sources: BacklinksSource[]
452
+
): NotificationsStream => {
453
+
const url = new URL(spacedustUrl);
454
+
url.protocol = 'wss:';
455
+
url.pathname = '/subscribe';
456
+
const searchParams = [];
457
+
sources.every((source) => searchParams.push(['wantedSources', source]));
458
+
subjects.every((subject) => searchParams.push(['wantedSubjectDids', subject]));
459
+
subjects.every((subject) => searchParams.push(['wantedSubjects', `at://${subject}`]));
460
+
searchParams.push(['instant', 'true']);
461
+
url.search = `?${new URLSearchParams(searchParams)}`;
462
+
// console.log(`streaming notifications: ${url}`);
463
+
const encoder = WebSocket.getDefaultEncoder<undefined, Notification>();
464
+
const ws = new WebSocket<typeof encoder>(url.toString(), {
465
+
encoder
466
+
});
467
+
return ws;
468
+
};
469
+
470
+
const fetchMicrocosm = async <
471
+
Schema extends XRPCQueryMetadata,
472
+
Input extends Schema['params'] extends ObjectSchema ? InferOutput<Schema['params']> : undefined,
473
+
Output extends InferXRPCBodyOutput<Schema['output']>
474
+
>(
475
+
api: URL,
476
+
schema: Schema,
477
+
params: Input,
478
+
init?: RequestInit
479
+
): Promise<Result<Output, string>> => {
480
+
if (!schema.output || schema.output.type === 'blob') return err('schema must be blob');
481
+
api.pathname = `/xrpc/${schema.nsid}`;
482
+
api.search = params
483
+
? `?${new URLSearchParams(Object.entries(params).flatMap(([k, v]) => (v === undefined ? [] : [[k, String(v)]])))}`
484
+
: '';
485
+
try {
486
+
const body = await fetchJson(api, init);
487
+
if (!body.ok) return err(body.error);
488
+
const parsed = safeParse(schema.output.schema, body.value);
489
+
if (!parsed.ok) return err(parsed.message);
490
+
return ok(parsed.value as Output);
491
+
} catch (error) {
492
+
return err(`FetchError: ${error}`);
493
+
}
494
+
};
495
+
496
+
const fetchJson = async (url: URL, init?: RequestInit): Promise<Result<unknown, string>> => {
497
+
try {
498
+
const response = await fetch(url, init);
499
+
const body = await response.json();
500
+
if (response.status === 400) return err(`${body.error}: ${body.message}`);
501
+
if (response.status !== 200) return err(`UnexpectedStatusCode: ${response.status}`);
502
+
return ok(body);
503
+
} catch (error) {
504
+
return err(`FetchError: ${error}`);
505
+
}
506
+
};
-353
src/lib/at/client.ts
-353
src/lib/at/client.ts
···
1
-
import { err, expect, map, ok, type OkType, type Result } from '$lib/result';
2
-
import {
3
-
ComAtprotoIdentityResolveHandle,
4
-
ComAtprotoRepoGetRecord,
5
-
ComAtprotoRepoListRecords
6
-
} from '@atcute/atproto';
7
-
import { Client as AtcuteClient, simpleFetchHandler } from '@atcute/client';
8
-
import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons';
9
-
import {
10
-
isDid,
11
-
parseCanonicalResourceUri,
12
-
parseResourceUri,
13
-
type ActorIdentifier,
14
-
type AtprotoDid,
15
-
type Cid,
16
-
type Did,
17
-
type Nsid,
18
-
type RecordKey,
19
-
type ResourceUri
20
-
} from '@atcute/lexicons/syntax';
21
-
import type {
22
-
InferInput,
23
-
InferXRPCBodyOutput,
24
-
ObjectSchema,
25
-
RecordKeySchema,
26
-
RecordSchema,
27
-
XRPCQueryMetadata
28
-
} from '@atcute/lexicons/validations';
29
-
import * as v from '@atcute/lexicons/validations';
30
-
import { MiniDocQuery, type MiniDoc } from './slingshot';
31
-
import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation';
32
-
import type { Records } from '@atcute/lexicons/ambient';
33
-
import { cache as rawCache } from '$lib/cache';
34
-
import { AppBskyActorProfile } from '@atcute/bluesky';
35
-
import { WebSocket } from '@soffinal/websocket';
36
-
import type { Notification } from './stardust';
37
-
import { get } from 'svelte/store';
38
-
import { settings } from '$lib/settings';
39
-
import type { OAuthUserAgent } from '@atcute/oauth-browser-client';
40
-
import { timestampFromCursor, toCanonicalUri, toResourceUri } from '$lib';
41
-
42
-
export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot);
43
-
export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust);
44
-
export const constellationUrl: URL = new URL(get(settings).endpoints.constellation);
45
-
46
-
export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output };
47
-
48
-
const cacheWithHandles = rawCache.define(
49
-
'resolveHandle',
50
-
async (handle: Handle): Promise<AtprotoDid> => {
51
-
const res = await fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, {
52
-
handle
53
-
});
54
-
if (!res.ok) throw new Error(res.error);
55
-
return res.value.did as AtprotoDid;
56
-
}
57
-
);
58
-
59
-
const cacheWithDidDocs = cacheWithHandles.define(
60
-
'resolveDidDoc',
61
-
async (identifier: ActorIdentifier): Promise<MiniDoc> => {
62
-
const res = await fetchMicrocosm(slingshotUrl, MiniDocQuery, {
63
-
identifier
64
-
});
65
-
if (!res.ok) throw new Error(res.error);
66
-
return res.value;
67
-
}
68
-
);
69
-
70
-
const cacheWithRecords = cacheWithDidDocs.define('fetchRecord', async (uri: ResourceUri) => {
71
-
const parsedUri = parseResourceUri(uri);
72
-
if (!parsedUri.ok) throw new Error(`can't parse resource uri: ${parsedUri.error}`);
73
-
const { repo, collection, rkey } = parsedUri.value;
74
-
const res = await fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, {
75
-
repo,
76
-
collection: collection!,
77
-
rkey: rkey!
78
-
});
79
-
if (!res.ok) throw new Error(res.error);
80
-
return res.value;
81
-
});
82
-
83
-
const cache = cacheWithRecords;
84
-
85
-
export class AtpClient {
86
-
public atcute: AtcuteClient | null = null;
87
-
public user: { did: Did; handle: Handle } | null = null;
88
-
89
-
async login(agent: OAuthUserAgent): Promise<Result<null, string>> {
90
-
try {
91
-
const rpc = new AtcuteClient({ handler: agent });
92
-
const res = await rpc.get('com.atproto.server.getSession');
93
-
if (!res.ok) throw res.data.error;
94
-
this.user = {
95
-
did: res.data.did,
96
-
handle: res.data.handle
97
-
};
98
-
this.atcute = rpc;
99
-
} catch (error) {
100
-
return err(`failed to login: ${error}`);
101
-
}
102
-
103
-
return ok(null);
104
-
}
105
-
106
-
async getRecordUri<
107
-
Collection extends Nsid,
108
-
TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } },
109
-
TKey extends RecordKeySchema,
110
-
Schema extends RecordSchema<TObject, TKey>,
111
-
Output extends InferInput<Schema>
112
-
>(schema: Schema, uri: ResourceUri): Promise<Result<RecordOutput<Output>, string>> {
113
-
const parsedUri = expect(parseResourceUri(uri));
114
-
if (parsedUri.collection !== schema.object.shape.$type.expected)
115
-
return err(
116
-
`collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}`
117
-
);
118
-
return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!);
119
-
}
120
-
121
-
async getRecord<
122
-
Collection extends Nsid,
123
-
TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } },
124
-
TKey extends RecordKeySchema,
125
-
Schema extends RecordSchema<TObject, TKey>,
126
-
Output extends InferInput<Schema>
127
-
>(
128
-
schema: Schema,
129
-
repo: ActorIdentifier,
130
-
rkey: RecordKey
131
-
): Promise<Result<RecordOutput<Output>, string>> {
132
-
const collection = schema.object.shape.$type.expected;
133
-
134
-
try {
135
-
const rawValue = await cache.fetchRecord(
136
-
toResourceUri({ repo, collection, rkey, fragment: undefined })
137
-
);
138
-
139
-
const parsed = safeParse(schema, rawValue.value);
140
-
if (!parsed.ok) return err(parsed.message);
141
-
142
-
return ok({
143
-
uri: rawValue.uri,
144
-
cid: rawValue.cid,
145
-
record: parsed.value as Output
146
-
});
147
-
} catch (e) {
148
-
return err(String(e));
149
-
}
150
-
}
151
-
152
-
async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> {
153
-
repo = repo ?? this.user?.did;
154
-
if (!repo) return err('not authenticated');
155
-
return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record);
156
-
}
157
-
158
-
async listRecords<Collection extends keyof Records>(
159
-
collection: Collection,
160
-
cursor?: string,
161
-
limit: number = 100
162
-
): Promise<
163
-
Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string>
164
-
> {
165
-
if (!this.atcute || !this.user) return err('not authenticated');
166
-
const res = await this.atcute.get('com.atproto.repo.listRecords', {
167
-
params: {
168
-
repo: this.user.did,
169
-
collection,
170
-
cursor,
171
-
limit,
172
-
reverse: false
173
-
}
174
-
});
175
-
if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`);
176
-
177
-
for (const record of res.data.records)
178
-
await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24);
179
-
180
-
return ok(res.data);
181
-
}
182
-
183
-
async listRecordsUntil<Collection extends keyof Records>(
184
-
collection: Collection,
185
-
cursor?: string,
186
-
timestamp: number = -1
187
-
): Promise<ReturnType<typeof this.listRecords>> {
188
-
const data: OkType<Awaited<ReturnType<typeof this.listRecords>>> = {
189
-
records: [],
190
-
cursor
191
-
};
192
-
193
-
let end = false;
194
-
while (!end) {
195
-
const res = await this.listRecords(collection, data.cursor);
196
-
if (!res.ok) return res;
197
-
data.cursor = res.value.cursor;
198
-
data.records.push(...res.value.records);
199
-
end = data.records.length === 0 || !data.cursor;
200
-
if (!end && timestamp > 0) {
201
-
const cursorTimestamp = timestampFromCursor(data.cursor);
202
-
if (cursorTimestamp === undefined) {
203
-
console.warn(
204
-
'could not parse timestamp from cursor, stopping fetch to prevent infinite loop:',
205
-
data.cursor
206
-
);
207
-
end = true;
208
-
} else if (cursorTimestamp <= timestamp) {
209
-
end = true;
210
-
} else {
211
-
console.info(
212
-
`${this.user?.did}: continuing to fetch ${collection}, on ${cursorTimestamp} until ${timestamp}`
213
-
);
214
-
}
215
-
}
216
-
}
217
-
218
-
return ok(data);
219
-
}
220
-
221
-
async getBacklinksUri(
222
-
uri: ResourceUri,
223
-
source: BacklinksSource
224
-
): Promise<Result<Backlinks, string>> {
225
-
const parsedResourceUri = expect(parseCanonicalResourceUri(uri));
226
-
return await this.getBacklinks(
227
-
parsedResourceUri.repo,
228
-
parsedResourceUri.collection,
229
-
parsedResourceUri.rkey,
230
-
source
231
-
);
232
-
}
233
-
234
-
async getBacklinks(
235
-
repo: ActorIdentifier,
236
-
collection: Nsid,
237
-
rkey: RecordKey,
238
-
source: BacklinksSource,
239
-
limit?: number
240
-
): Promise<Result<Backlinks, string>> {
241
-
const did = await resolveHandle(repo);
242
-
if (!did.ok) return err(`cant resolve handle: ${did.error}`);
243
-
244
-
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000));
245
-
const query = fetchMicrocosm(constellationUrl, BacklinksQuery, {
246
-
subject: toCanonicalUri({ did: did.value, collection, rkey }),
247
-
source,
248
-
limit: limit || 100
249
-
});
250
-
251
-
const results = await Promise.race([query, timeout]);
252
-
if (!results) return err('cant fetch backlinks: timeout');
253
-
254
-
return results;
255
-
}
256
-
}
257
-
258
-
export const newPublicClient = async (ident: ActorIdentifier): Promise<AtpClient> => {
259
-
const atp = new AtpClient();
260
-
const didDoc = await resolveDidDoc(ident);
261
-
if (!didDoc.ok) {
262
-
console.error('failed to resolve did doc', didDoc.error);
263
-
return atp;
264
-
}
265
-
atp.atcute = new AtcuteClient({ handler: simpleFetchHandler({ service: didDoc.value.pds }) });
266
-
atp.user = { did: didDoc.value.did, handle: didDoc.value.handle };
267
-
return atp;
268
-
};
269
-
270
-
// Wrappers that use the cache
271
-
272
-
export const resolveHandle = async (
273
-
identifier: ActorIdentifier
274
-
): Promise<Result<AtprotoDid, string>> => {
275
-
if (isDid(identifier)) return ok(identifier as AtprotoDid);
276
-
277
-
try {
278
-
const did = await cache.resolveHandle(identifier);
279
-
return ok(did);
280
-
} catch (e) {
281
-
return err(String(e));
282
-
}
283
-
};
284
-
285
-
export const resolveDidDoc = async (ident: ActorIdentifier): Promise<Result<MiniDoc, string>> => {
286
-
try {
287
-
const doc = await cache.resolveDidDoc(ident);
288
-
return ok(doc);
289
-
} catch (e) {
290
-
return err(String(e));
291
-
}
292
-
};
293
-
294
-
type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>;
295
-
export type NotificationsStream = WebSocket<NotificationsStreamEncoder>;
296
-
export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>;
297
-
298
-
export const streamNotifications = (
299
-
subjects: Did[],
300
-
...sources: BacklinksSource[]
301
-
): NotificationsStream => {
302
-
const url = new URL(spacedustUrl);
303
-
url.protocol = 'wss:';
304
-
url.pathname = '/subscribe';
305
-
const searchParams = [];
306
-
sources.every((source) => searchParams.push(['wantedSources', source]));
307
-
subjects.every((subject) => searchParams.push(['wantedSubjectDids', subject]));
308
-
subjects.every((subject) => searchParams.push(['wantedSubjects', `at://${subject}`]));
309
-
searchParams.push(['instant', 'true']);
310
-
url.search = `?${new URLSearchParams(searchParams)}`;
311
-
// console.log(`streaming notifications: ${url}`);
312
-
const encoder = WebSocket.getDefaultEncoder<undefined, Notification>();
313
-
const ws = new WebSocket<typeof encoder>(url.toString(), {
314
-
encoder
315
-
});
316
-
return ws;
317
-
};
318
-
319
-
const fetchMicrocosm = async <
320
-
Schema extends XRPCQueryMetadata,
321
-
Input extends Schema['params'] extends ObjectSchema ? InferOutput<Schema['params']> : undefined,
322
-
Output extends InferXRPCBodyOutput<Schema['output']>
323
-
>(
324
-
api: URL,
325
-
schema: Schema,
326
-
params: Input,
327
-
init?: RequestInit
328
-
): Promise<Result<Output, string>> => {
329
-
if (!schema.output || schema.output.type === 'blob') return err('schema must be blob');
330
-
api.pathname = `/xrpc/${schema.nsid}`;
331
-
api.search = params ? `?${new URLSearchParams(params)}` : '';
332
-
try {
333
-
const body = await fetchJson(api, init);
334
-
if (!body.ok) return err(body.error);
335
-
const parsed = safeParse(schema.output.schema, body.value);
336
-
if (!parsed.ok) return err(parsed.message);
337
-
return ok(parsed.value as Output);
338
-
} catch (error) {
339
-
return err(`FetchError: ${error}`);
340
-
}
341
-
};
342
-
343
-
const fetchJson = async (url: URL, init?: RequestInit): Promise<Result<unknown, string>> => {
344
-
try {
345
-
const response = await fetch(url, init);
346
-
const body = await response.json();
347
-
if (response.status === 400) return err(`${body.error}: ${body.message}`);
348
-
if (response.status !== 200) return err(`UnexpectedStatusCode: ${response.status}`);
349
-
return ok(body);
350
-
} catch (error) {
351
-
return err(`FetchError: ${error}`);
352
-
}
353
-
};
+1
-1
src/lib/at/constellation.ts
+1
-1
src/lib/at/constellation.ts
+36
-28
src/lib/at/fetch.ts
+36
-28
src/lib/at/fetch.ts
···
4
4
type Cid,
5
5
type ResourceUri
6
6
} from '@atcute/lexicons';
7
-
import { type AtpClient } from './client';
7
+
import { type AtpClient } from './client.svelte';
8
8
import { err, expect, ok, type Ok, type Result } from '$lib/result';
9
9
import type { Backlinks } from './constellation';
10
10
import { AppBskyFeedPost } from '@atcute/bluesky';
···
17
17
};
18
18
19
19
export const fetchPosts = async (
20
+
subject: Did,
20
21
client: AtpClient,
21
22
cursor?: string,
22
23
limit?: number,
23
24
withBacklinks: boolean = true
24
25
): Promise<Result<{ posts: PostWithBacklinks[]; cursor?: string }, string>> => {
25
-
const recordsList = await client.listRecords('app.bsky.feed.post', cursor, limit);
26
+
const recordsList = await client.listRecords(subject, 'app.bsky.feed.post', cursor, limit);
26
27
if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`);
27
28
cursor = recordsList.value.cursor;
28
29
const records = recordsList.value.records;
···
41
42
try {
42
43
const allBacklinks = await Promise.all(
43
44
records.map(async (r): Promise<PostWithBacklinks> => {
44
-
const result = await client.getBacklinksUri(r.uri, replySource);
45
+
const result = await client.getBacklinks(r.uri, replySource);
45
46
if (!result.ok) throw `cant fetch replies: ${result.error}`;
46
47
const replies = result.value;
47
48
return {
···
58
59
}
59
60
};
60
61
62
+
export type HydrateOptions = {
63
+
downwards: 'sameAuthor' | 'none';
64
+
};
65
+
61
66
export const hydratePosts = async (
62
67
client: AtpClient,
63
68
repo: Did,
64
69
data: PostWithBacklinks[],
65
-
cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined
70
+
cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined,
71
+
options?: Partial<HydrateOptions>
66
72
): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => {
67
73
let posts: Map<ResourceUri, PostWithUri> = new Map();
68
74
try {
···
114
120
};
115
121
await Promise.all(posts.values().map(fetchUpwardsChain));
116
122
117
-
try {
118
-
const fetchDownwardsChain = async (post: PostWithUri) => {
119
-
const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri));
120
-
if (repo === postRepo) return;
123
+
if (options?.downwards !== 'none') {
124
+
try {
125
+
const fetchDownwardsChain = async (post: PostWithUri) => {
126
+
const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri));
127
+
if (repo === postRepo) return;
121
128
122
-
// get chains that are the same author until we exhaust them
123
-
const backlinks = await client.getBacklinksUri(post.uri, replySource);
124
-
if (!backlinks.ok) return;
129
+
// get chains that are the same author until we exhaust them
130
+
const backlinks = await client.getBacklinks(post.uri, replySource);
131
+
if (!backlinks.ok) return;
125
132
126
-
const promises = [];
127
-
for (const reply of backlinks.value.records) {
128
-
if (reply.did !== postRepo) continue;
129
-
// if we already have this reply, then we already fetched this chain / are fetching it
130
-
if (posts.has(toCanonicalUri(reply))) continue;
131
-
const record =
132
-
cacheFn(reply.did, reply.rkey) ??
133
-
(await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey));
134
-
if (!record.ok) break; // TODO: this doesnt handle deleted posts in between
135
-
posts.set(record.value.uri, record.value);
136
-
promises.push(fetchDownwardsChain(record.value));
137
-
}
133
+
const promises = [];
134
+
for (const reply of backlinks.value.records) {
135
+
if (reply.did !== postRepo) continue;
136
+
// if we already have this reply, then we already fetched this chain / are fetching it
137
+
if (posts.has(toCanonicalUri(reply))) continue;
138
+
const record =
139
+
cacheFn(reply.did, reply.rkey) ??
140
+
(await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey));
141
+
if (!record.ok) break; // TODO: this doesnt handle deleted posts in between
142
+
posts.set(record.value.uri, record.value);
143
+
promises.push(fetchDownwardsChain(record.value));
144
+
}
138
145
139
-
await Promise.all(promises);
140
-
};
141
-
await Promise.all(posts.values().map(fetchDownwardsChain));
142
-
} catch (error) {
143
-
return err(`cant fetch post reply chain: ${error}`);
146
+
await Promise.all(promises);
147
+
};
148
+
await Promise.all(posts.values().map(fetchDownwardsChain));
149
+
} catch (error) {
150
+
return err(`cant fetch post reply chain: ${error}`);
151
+
}
144
152
}
145
153
146
154
return ok(posts);
+9
src/lib/at/index.ts
+9
src/lib/at/index.ts
···
1
+
import { settings } from '$lib/settings';
2
+
import type { Did } from '@atcute/lexicons';
3
+
import { get } from 'svelte/store';
4
+
5
+
export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot);
6
+
export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust);
7
+
export const constellationUrl: URL = new URL(get(settings).endpoints.constellation);
8
+
9
+
export const httpToDidWeb = (url: string): Did => `did:web:${new URL(url).hostname}`;
+1
-1
src/lib/at/oauth.ts
+1
-1
src/lib/at/oauth.ts
···
14
14
WebDidDocumentResolver,
15
15
XrpcHandleResolver
16
16
} from '@atcute/identity-resolver';
17
-
import { slingshotUrl } from './client';
18
17
import type { ActorIdentifier } from '@atcute/lexicons';
19
18
import { err, ok, type Result } from '$lib/result';
20
19
import type { AtprotoDid } from '@atcute/lexicons/syntax';
21
20
import { clientId, oauthMetadata, redirectUri } from '$lib/oauth';
21
+
import { slingshotUrl } from '.';
22
22
23
23
configureOAuth({
24
24
metadata: {
+5
src/lib/at/types.ts
+5
src/lib/at/types.ts
+3
-1
src/lib/cache.ts
+3
-1
src/lib/cache.ts
···
210
210
}
211
211
}
212
212
213
+
export const ttl = 60 * 60 * 3; // 3 hours
214
+
213
215
export const cache = createCache({
214
216
storage: {
215
217
type: 'custom',
···
217
219
storage: new IDBStorage()
218
220
}
219
221
},
220
-
ttl: 60 * 60 * 24, // 24 hours
222
+
ttl,
221
223
onError: (err) => console.error(err)
222
224
});
+32
-48
src/lib/following.ts
+32
-48
src/lib/following.ts
···
1
1
import { type ActorIdentifier, type Did, type ResourceUri } from '@atcute/lexicons';
2
2
import type { PostWithUri } from './at/fetch';
3
-
import type { Backlink, BacklinksSource } from './at/constellation';
3
+
import type { BacklinksSource } from './at/constellation';
4
4
import { extractDidFromUri, repostSource } from '$lib';
5
5
import type { AppBskyGraphFollow } from '@atcute/bluesky';
6
6
···
13
13
) => {
14
14
if (sort === 'conversational') {
15
15
if (Math.abs(statsB.conversationalScore! - statsA.conversationalScore!) > 0.1)
16
-
// sort based on conversational score
17
16
return statsB.conversationalScore! - statsA.conversationalScore!;
18
17
} else {
19
18
if (sort === 'active')
20
19
if (Math.abs(statsB.activeScore! - statsA.activeScore!) > 0.0001)
21
-
// sort based on activity
22
20
return statsB.activeScore! - statsA.activeScore!;
23
21
}
24
-
// use recent if scores are similar / we are using recent mode
25
22
return statsB.lastPostAt!.getTime() - statsA.lastPostAt!.getTime();
26
23
};
27
24
28
-
// Caching to prevent re-calculating stats for every render frame if data is stable
29
25
const userStatsCache = new Map<
30
26
Did,
31
27
{ timestamp: number; stats: ReturnType<typeof _calculateStats> }
32
28
>();
33
-
const STATS_CACHE_TTL = 60 * 1000; // 1 minute
29
+
const STATS_CACHE_TTL = 60 * 1000;
34
30
35
31
export const calculateFollowedUserStats = (
36
32
sort: Sort,
···
39
35
interactionScores: Map<ActorIdentifier, number> | null,
40
36
now: number
41
37
) => {
42
-
// For 'active' sort which is computationally heavy, use cache
43
38
if (sort === 'active') {
44
39
const cached = userStatsCache.get(did);
45
40
if (cached && now - cached.timestamp < STATS_CACHE_TTL) {
46
41
const postsMap = posts.get(did);
47
-
// Simple invalidation check: if post count matches, assume cache is valid enough
48
-
// This avoids iterating the map just to check contents.
49
-
// Ideally we'd have a version/hash on the map.
50
42
if (postsMap && postsMap.size > 0) return { ...cached.stats, did };
51
43
}
52
44
}
···
81
73
if (ageMs < quarterPosts) recentPostCount++;
82
74
if (sort === 'active') {
83
75
const ageHours = ageMs / (1000 * 60 * 60);
84
-
// score = 1 / t^G
85
76
activeScore += 1 / Math.pow(ageHours + 1, gravity);
86
77
}
87
78
}
···
99
90
};
100
91
};
101
92
102
-
// weights
103
93
const quoteWeight = 4;
104
94
const replyWeight = 6;
105
95
const repostWeight = 2;
···
108
98
const halfLifeMs = 3 * oneDay;
109
99
const decayLambda = 0.693 / halfLifeMs;
110
100
111
-
// normalization constants
112
101
const rateBaseline = 1;
113
102
const ratePower = 0.5;
114
103
const windowSize = 7 * oneDay;
115
104
116
-
// Cache for post rates to avoid iterating every user's timeline every time
117
105
const rateCache = new Map<Did, { rate: number; calculatedAt: number; postCount: number }>();
118
106
119
107
const getPostRate = (did: Did, posts: Map<ResourceUri, PostWithUri>, now: number): number => {
120
108
const cached = rateCache.get(did);
121
-
// If cached and number of posts hasn't changed, return cached rate
122
109
if (cached && cached.postCount === posts.size && now - cached.calculatedAt < 5 * 60 * 1000)
123
110
return cached.rate;
124
111
···
151
138
user: Did,
152
139
followsMap: Map<ResourceUri, AppBskyGraphFollow.Main>,
153
140
allPosts: Map<Did, Map<ResourceUri, PostWithUri>>,
154
-
backlinks_: Map<ResourceUri, Map<BacklinksSource, Set<Backlink>>>,
155
-
replyIndex: Map<Did, Set<ResourceUri>>, // NEW: Inverted Index
141
+
allBacklinks: Map<BacklinksSource, Map<ResourceUri, Map<Did, Set<string>>>>,
142
+
replyIndex: Map<Did, Set<ResourceUri>>,
156
143
now: number
157
144
) => {
158
145
const scores = new Map<Did, number>();
···
162
149
return Math.exp(-decayLambda * age);
163
150
};
164
151
165
-
// Helper to add score
166
152
const addScore = (did: Did, weight: number, time: number) => {
167
153
const current = scores.get(did) ?? 0;
168
154
scores.set(did, current + weight * decay(time));
169
155
};
170
156
171
-
// 1. Process MY posts (Me -> Others)
172
-
// This is relatively cheap as "my posts" are few compared to "everyone's posts"
157
+
// 1. process my posts (me -> others)
173
158
const myPosts = allPosts.get(user);
174
159
if (myPosts) {
175
160
const seenRoots = new Set<ResourceUri>();
176
161
for (const post of myPosts.values()) {
177
162
const t = new Date(post.record.createdAt).getTime();
178
163
179
-
// If I replied to someone
180
164
if (post.record.reply) {
181
165
const parentUri = post.record.reply.parent.uri;
182
166
const rootUri = post.record.reply.root.uri;
···
191
175
}
192
176
}
193
177
194
-
// If I quoted someone
195
178
if (post.record.embed?.$type === 'app.bsky.embed.record') {
196
179
const targetDid = extractDidFromUri(post.record.embed.record.uri);
197
180
if (targetDid && targetDid !== user) addScore(targetDid, quoteWeight, t);
···
199
182
}
200
183
}
201
184
202
-
// 2. Process OTHERS -> ME (using Index)
203
-
// Optimized: Use replyIndex instead of iterating all follows
185
+
// 2. process others -> me (using reply index)
204
186
const repliesToMe = replyIndex.get(user);
205
187
if (repliesToMe) {
206
188
for (const uri of repliesToMe) {
207
189
const authorDid = extractDidFromUri(uri);
208
-
if (!authorDid || authorDid === user) continue; // Self-reply
190
+
if (!authorDid || authorDid === user) continue;
209
191
210
192
const postsMap = allPosts.get(authorDid);
211
193
const post = postsMap?.get(uri);
212
-
if (!post) continue; // Post data not loaded?
194
+
if (!post) continue;
213
195
214
196
const t = new Date(post.record.createdAt).getTime();
215
197
addScore(authorDid, replyWeight, t);
216
198
}
217
199
}
218
200
219
-
for (const [uri, backlinks] of backlinks_) {
220
-
const targetDid = extractDidFromUri(uri);
221
-
if (!targetDid || targetDid !== user) continue; // Only care about interactions on MY posts
201
+
// 3. process reposts on my posts
202
+
const repostBacklinks = allBacklinks.get(repostSource);
203
+
if (repostBacklinks && myPosts) {
204
+
for (const [uri, myPost] of myPosts) {
205
+
const didMap = repostBacklinks.get(uri);
206
+
if (!didMap) continue;
222
207
223
-
const reposts = backlinks.get(repostSource);
224
-
if (reposts) {
208
+
const t = new Date(myPost.record.createdAt).getTime();
225
209
const adds = new Map<Did, { score: number; repostCount: number }>();
226
-
for (const repost of reposts) {
227
-
if (repost.did === user) continue;
228
-
const add = adds.get(repost.did) ?? { score: 0, repostCount: 0 };
210
+
211
+
for (const [did, rkeys] of didMap) {
212
+
if (did === user) continue;
213
+
214
+
let add = adds.get(did) ?? { score: 0, repostCount: 0 };
229
215
const diminishFactor = 9;
230
-
const weight = repostWeight * (diminishFactor / (add.repostCount + diminishFactor));
231
-
adds.set(repost.did, {
232
-
score: add.score + weight,
233
-
repostCount: add.repostCount + 1
234
-
});
235
-
}
236
216
237
-
// Get the timestamp of the post being reposted to calculate decay
238
-
// (Interaction timestamp is unknown for backlinks usually, so we use post timestamp as proxy or 'now'?
239
-
// Original code used `post.record.createdAt`.
240
-
const myPost = myPosts?.get(uri);
241
-
if (myPost) {
242
-
const t = new Date(myPost.record.createdAt).getTime();
243
-
for (const [did, add] of adds.entries()) addScore(did, add.score, t);
217
+
// each rkey is a separate repost record, apply diminishing returns
218
+
for (let i = 0; i < rkeys.size; i++) {
219
+
const weight = repostWeight * (diminishFactor / (add.repostCount + diminishFactor));
220
+
add = {
221
+
score: add.score + weight,
222
+
repostCount: add.repostCount + 1
223
+
};
224
+
}
225
+
adds.set(did, add);
244
226
}
227
+
228
+
for (const [did, add] of adds.entries()) addScore(did, add.score, t);
245
229
}
246
230
}
247
231
248
-
// Apply normalization
232
+
// normalize by posting rate
249
233
for (const [did, score] of scores) {
250
234
const posts = allPosts.get(did);
251
235
const rate = posts ? getPostRate(did, posts, now) : 0;
+2
src/lib/index.ts
+2
src/lib/index.ts
···
28
28
export const likeSource: BacklinksSource = 'app.bsky.feed.like:subject.uri';
29
29
export const repostSource: BacklinksSource = 'app.bsky.feed.repost:subject.uri';
30
30
export const replySource: BacklinksSource = 'app.bsky.feed.post:reply.parent.uri';
31
+
export const replyRootSource: BacklinksSource = 'app.bsky.feed.post:reply.root.uri';
32
+
export const blockSource: BacklinksSource = 'app.bsky.graph.block:subject';
31
33
32
34
export const timestampFromCursor = (cursor: string | undefined) => {
33
35
if (!cursor) return undefined;
+2
-1
src/lib/oauth.ts
+2
-1
src/lib/oauth.ts
···
7
7
client_uri: domain,
8
8
logo_uri: `${domain}/favicon.png`,
9
9
redirect_uris: [`${domain}/`],
10
-
scope: 'atproto repo:*?action=create&action=update&action=delete blob:*/*',
10
+
scope:
11
+
'atproto repo:*?action=create&action=update&action=delete rpc:com.atproto.repo.uploadBlob?aud=* blob:*/*',
11
12
grant_types: ['authorization_code', 'refresh_token'],
12
13
response_types: ['code'],
13
14
token_endpoint_auth_method: 'none',
+1
src/lib/result.ts
+1
src/lib/result.ts
+1
-1
src/lib/richtext/index.ts
+1
-1
src/lib/richtext/index.ts
···
1
1
import RichtextBuilder, { type BakedRichtext } from '@atcute/bluesky-richtext-builder';
2
2
import { tokenize, type Token } from '$lib/richtext/parser';
3
3
import type { Did, GenericUri, Handle } from '@atcute/lexicons';
4
-
import { resolveHandle } from '$lib/at/client';
4
+
import { resolveHandle } from '$lib/at/client.svelte';
5
5
6
6
export const parseToRichText = (text: string): ReturnType<typeof processTokens> =>
7
7
processTokens(tokenize(text));
+305
-99
src/lib/state.svelte.ts
+305
-99
src/lib/state.svelte.ts
···
1
1
import { writable } from 'svelte/store';
2
2
import {
3
3
AtpClient,
4
-
newPublicClient,
4
+
setRecordCache,
5
5
type NotificationsStream,
6
6
type NotificationsStreamEvent
7
-
} from './at/client';
7
+
} from './at/client.svelte';
8
8
import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity';
9
-
import type { Did, Handle, InferOutput, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons';
10
-
import { fetchPosts, hydratePosts, type PostWithUri } from './at/fetch';
9
+
import type { Did, Handle, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons';
10
+
import { fetchPosts, hydratePosts, type HydrateOptions, type PostWithUri } from './at/fetch';
11
11
import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax';
12
-
import { AppBskyActorProfile, AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky';
13
-
import type { ComAtprotoRepoListRecords } from '@atcute/atproto';
12
+
import {
13
+
AppBskyActorProfile,
14
+
AppBskyFeedPost,
15
+
AppBskyGraphBlock,
16
+
type AppBskyGraphFollow
17
+
} from '@atcute/bluesky';
14
18
import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream';
15
19
import { expect, ok } from './result';
16
20
import type { Backlink, BacklinksSource } from './at/constellation';
17
21
import { now as tidNow } from '@atcute/tid';
18
22
import type { Records } from '@atcute/lexicons/ambient';
19
23
import {
24
+
blockSource,
20
25
extractDidFromUri,
21
26
likeSource,
27
+
replyRootSource,
22
28
replySource,
23
29
repostSource,
24
30
timestampFromCursor,
25
31
toCanonicalUri
26
32
} from '$lib';
27
33
import { Router } from './router.svelte';
34
+
import type { Account } from './accounts';
28
35
29
36
export const notificationStream = writable<NotificationsStream | null>(null);
30
37
export const jetstream = writable<JetstreamSubscription | null>(null);
···
32
39
export const profiles = new SvelteMap<Did, AppBskyActorProfile.Main>();
33
40
export const handles = new SvelteMap<Did, Handle>();
34
41
35
-
export type BacklinksMap = SvelteMap<BacklinksSource, SvelteSet<Backlink>>;
36
-
export const allBacklinks = new SvelteMap<ResourceUri, BacklinksMap>();
42
+
// source -> subject -> did (who did the interaction) -> rkey
43
+
export type BacklinksMap = SvelteMap<
44
+
BacklinksSource,
45
+
SvelteMap<ResourceUri, SvelteMap<Did, SvelteSet<RecordKey>>>
46
+
>;
47
+
export const allBacklinks: BacklinksMap = new SvelteMap();
37
48
38
49
export const addBacklinks = (
39
50
subject: ResourceUri,
40
51
source: BacklinksSource,
41
52
links: Iterable<Backlink>
42
53
) => {
43
-
let postsMap = allBacklinks.get(subject);
44
-
if (!postsMap) {
45
-
postsMap = new SvelteMap();
46
-
allBacklinks.set(subject, postsMap);
54
+
let subjectMap = allBacklinks.get(source);
55
+
if (!subjectMap) {
56
+
subjectMap = new SvelteMap();
57
+
allBacklinks.set(source, subjectMap);
47
58
}
48
-
let backlinksSet = postsMap.get(source);
49
-
if (!backlinksSet) {
50
-
backlinksSet = new SvelteSet();
51
-
postsMap.set(source, backlinksSet);
59
+
60
+
let didMap = subjectMap.get(subject);
61
+
if (!didMap) {
62
+
didMap = new SvelteMap();
63
+
subjectMap.set(subject, didMap);
52
64
}
65
+
53
66
for (const link of links) {
54
-
backlinksSet.add(link);
55
-
// console.log(
56
-
// `added backlink at://${link.did}/${link.collection}/${link.rkey} to ${subject} from ${source}`
57
-
// );
67
+
let rkeys = didMap.get(link.did);
68
+
if (!rkeys) {
69
+
rkeys = new SvelteSet();
70
+
didMap.set(link.did, rkeys);
71
+
}
72
+
rkeys.add(link.rkey);
58
73
}
59
74
};
60
75
···
63
78
source: BacklinksSource,
64
79
links: Iterable<Backlink>
65
80
) => {
66
-
const postsMap = allBacklinks.get(subject);
67
-
if (!postsMap) return;
68
-
const backlinksSet = postsMap.get(source);
69
-
if (!backlinksSet) return;
70
-
for (const link of links) backlinksSet.delete(link);
81
+
const didMap = allBacklinks.get(source)?.get(subject);
82
+
if (!didMap) return;
83
+
84
+
for (const link of links) {
85
+
const rkeys = didMap.get(link.did);
86
+
if (!rkeys) continue;
87
+
rkeys.delete(link.rkey);
88
+
if (rkeys.size === 0) didMap.delete(link.did);
89
+
}
71
90
};
72
91
73
-
export const findBacklinksBy = (
74
-
subject: ResourceUri,
75
-
source: BacklinksSource,
76
-
did: Did
77
-
): Backlink[] => {
78
-
const postsMap = allBacklinks.get(subject);
79
-
if (!postsMap) return [];
80
-
const backlinksSet = postsMap.get(source);
81
-
if (!backlinksSet) return [];
82
-
return Array.from(backlinksSet.values().filter((link) => link.did === did));
92
+
export const findBacklinksBy = (subject: ResourceUri, source: BacklinksSource, did: Did) => {
93
+
const rkeys = allBacklinks.get(source)?.get(subject)?.get(did) ?? [];
94
+
// reconstruct the collection from the source
95
+
const collection = source.split(':')[0] as Nsid;
96
+
return rkeys.values().map((rkey) => ({ did, collection, rkey }));
97
+
};
98
+
99
+
export const hasBacklink = (subject: ResourceUri, source: BacklinksSource, did: Did): boolean => {
100
+
return allBacklinks.get(source)?.get(subject)?.has(did) ?? false;
101
+
};
102
+
103
+
export const getAllBacklinksFor = (subject: ResourceUri, source: BacklinksSource): Backlink[] => {
104
+
const subjectMap = allBacklinks.get(source);
105
+
if (!subjectMap) return [];
106
+
107
+
const didMap = subjectMap.get(subject);
108
+
if (!didMap) return [];
109
+
110
+
const collection = source.split(':')[0] as Nsid;
111
+
const result: Backlink[] = [];
112
+
113
+
for (const [did, rkeys] of didMap)
114
+
for (const rkey of rkeys) result.push({ did, collection, rkey });
115
+
116
+
return result;
117
+
};
118
+
119
+
export const isBlockedBy = (subject: Did, blocker: Did): boolean => {
120
+
return hasBacklink(`at://${subject}`, 'app.bsky.graph.block:subject', blocker);
83
121
};
84
122
85
123
// eslint-disable-next-line @typescript-eslint/no-explicit-any
···
103
141
>();
104
142
105
143
export const fetchLinksUntil = async (
144
+
subject: Did,
106
145
client: AtpClient,
107
146
backlinkSource: BacklinksSource,
108
147
timestamp: number = -1
109
148
) => {
110
-
const did = client.user?.did;
111
-
if (!did) return;
112
-
113
-
let cursorMap = backlinksCursors.get(did);
149
+
let cursorMap = backlinksCursors.get(subject);
114
150
if (!cursorMap) {
115
151
cursorMap = new SvelteMap<BacklinksSource, string | undefined>();
116
-
backlinksCursors.set(did, cursorMap);
152
+
backlinksCursors.set(subject, cursorMap);
117
153
}
118
154
119
155
const [_collection, source] = backlinkSource.split(':');
···
124
160
const cursorTimestamp = timestampFromCursor(cursor);
125
161
if (cursorTimestamp && cursorTimestamp <= timestamp) return;
126
162
127
-
console.log(`${did}: fetchLinksUntil`, backlinkSource, cursor, timestamp);
128
-
const result = await client.listRecordsUntil(collection, cursor, timestamp);
163
+
console.log(`${subject}: fetchLinksUntil`, backlinkSource, cursor, timestamp);
164
+
const result = await client.listRecordsUntil(subject, collection, cursor, timestamp);
129
165
130
166
if (!result.ok) {
131
167
console.error('failed to fetch links until', result.error);
···
160
196
removeBacklinks(post.uri, source, links);
161
197
await Promise.allSettled(
162
198
links.map((link) =>
163
-
client.atcute?.post('com.atproto.repo.deleteRecord', {
199
+
client.user?.atcute.post('com.atproto.repo.deleteRecord', {
164
200
input: { repo: did, collection, rkey: link.rkey! }
165
201
})
166
202
)
···
192
228
const subjectPath = subject.split('.');
193
229
setNestedValue(record, subjectPath, post.uri);
194
230
setNestedValue(record, [...subjectPath.slice(0, -1), 'cid'], post.cid);
195
-
await client.atcute?.post('com.atproto.repo.createRecord', {
231
+
await client.user?.atcute.post('com.atproto.repo.createRecord', {
196
232
input: {
197
233
repo: did,
198
234
collection,
···
206
242
207
243
export const viewClient = new AtpClient();
208
244
export const clients = new SvelteMap<Did, AtpClient>();
209
-
export const getClient = async (did: Did): Promise<AtpClient> => {
210
-
if (!clients.has(did)) clients.set(did, await newPublicClient(did));
211
-
return clients.get(did)!;
212
-
};
213
245
214
246
export const follows = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyGraphFollow.Main>>();
215
247
···
217
249
did: Did,
218
250
followMap: Iterable<[ResourceUri, AppBskyGraphFollow.Main]>
219
251
) => {
220
-
if (!follows.has(did)) {
221
-
follows.set(did, new SvelteMap(followMap));
252
+
let map = follows.get(did)!;
253
+
if (!map) {
254
+
map = new SvelteMap(followMap);
255
+
follows.set(did, map);
222
256
return;
223
257
}
224
-
const map = follows.get(did)!;
225
258
for (const [uri, record] of followMap) map.set(uri, record);
226
259
};
227
260
228
-
export const fetchFollows = async (did: AtprotoDid) => {
229
-
const client = await getClient(did);
230
-
const res = await client.listRecordsUntil('app.bsky.graph.follow');
231
-
if (!res.ok) return;
261
+
export const fetchFollows = async (
262
+
account: Account
263
+
): Promise<IteratorObject<AppBskyGraphFollow.Main>> => {
264
+
const client = clients.get(account.did)!;
265
+
const res = await client.listRecordsUntil(account.did, 'app.bsky.graph.follow');
266
+
if (!res.ok) {
267
+
console.error("can't fetch follows:", res.error);
268
+
return [].values();
269
+
}
232
270
addFollows(
233
-
did,
271
+
account.did,
234
272
res.value.records.map((follow) => [follow.uri, follow.value as AppBskyGraphFollow.Main])
235
273
);
274
+
return res.value.records.values().map((follow) => follow.value as AppBskyGraphFollow.Main);
236
275
};
237
276
238
277
// this fetches up to three days of posts and interactions for using in following list
239
-
export const fetchForInteractions = async (did: AtprotoDid) => {
278
+
export const fetchForInteractions = async (client: AtpClient, subject: Did) => {
240
279
const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000;
241
280
242
-
const client = await getClient(did);
243
-
const res = await client.listRecordsUntil('app.bsky.feed.post', undefined, threeDaysAgo);
281
+
const res = await client.listRecordsUntil(subject, 'app.bsky.feed.post', undefined, threeDaysAgo);
244
282
if (!res.ok) return;
245
-
addPostsRaw(did, res.value);
283
+
const postsWithUri = res.value.records.map(
284
+
(post) =>
285
+
({ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main }) as PostWithUri
286
+
);
287
+
addPosts(postsWithUri);
246
288
247
289
const cursorTimestamp = timestampFromCursor(res.value.cursor) ?? -1;
248
290
const timestamp = Math.min(cursorTimestamp, threeDaysAgo);
249
-
console.log(`${did}: fetchForInteractions`, res.value.cursor, timestamp);
250
-
await Promise.all([repostSource].map((s) => fetchLinksUntil(client, s, timestamp)));
291
+
console.log(`${subject}: fetchForInteractions`, res.value.cursor, timestamp);
292
+
await Promise.all([repostSource].map((s) => fetchLinksUntil(subject, client, s, timestamp)));
293
+
};
294
+
295
+
// if did is in set, we have fetched blocks for them already (against logged in users)
296
+
export const blockFlags = new SvelteMap<Did, SvelteSet<Did>>();
297
+
298
+
export const fetchBlocked = async (client: AtpClient, subject: Did, blocker: Did) => {
299
+
const subjectUri = `at://${subject}` as ResourceUri;
300
+
const res = await client.getBacklinks(subjectUri, blockSource, [blocker], 1);
301
+
if (!res.ok) return false;
302
+
if (res.value.total > 0) addBacklinks(subjectUri, blockSource, res.value.records);
303
+
304
+
// mark as fetched
305
+
let flags = blockFlags.get(subject);
306
+
if (!flags) {
307
+
flags = new SvelteSet();
308
+
blockFlags.set(subject, flags);
309
+
}
310
+
flags.add(blocker);
311
+
312
+
return res.value.total > 0;
313
+
};
314
+
315
+
export const fetchBlocks = async (account: Account) => {
316
+
const client = clients.get(account.did)!;
317
+
const res = await client.listRecordsUntil(account.did, 'app.bsky.graph.block');
318
+
if (!res.ok) return;
319
+
for (const block of res.value.records) {
320
+
const record = block.value as AppBskyGraphBlock.Main;
321
+
const parsedUri = expect(parseCanonicalResourceUri(block.uri));
322
+
addBacklinks(`at://${record.subject}`, blockSource, [
323
+
{
324
+
did: parsedUri.repo,
325
+
collection: parsedUri.collection,
326
+
rkey: parsedUri.rkey
327
+
}
328
+
]);
329
+
}
330
+
};
331
+
332
+
export const createBlock = async (client: AtpClient, targetDid: Did) => {
333
+
const userDid = client.user?.did;
334
+
if (!userDid) return;
335
+
336
+
const rkey = tidNow();
337
+
const targetUri = `at://${targetDid}` as ResourceUri;
338
+
339
+
addBacklinks(targetUri, blockSource, [
340
+
{
341
+
did: userDid,
342
+
collection: 'app.bsky.graph.block',
343
+
rkey
344
+
}
345
+
]);
346
+
347
+
const record: AppBskyGraphBlock.Main = {
348
+
$type: 'app.bsky.graph.block',
349
+
subject: targetDid,
350
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
351
+
createdAt: new Date().toISOString()
352
+
};
353
+
354
+
await client.user?.atcute.post('com.atproto.repo.createRecord', {
355
+
input: {
356
+
repo: userDid,
357
+
collection: 'app.bsky.graph.block',
358
+
rkey,
359
+
record
360
+
}
361
+
});
362
+
};
363
+
364
+
export const deleteBlock = async (client: AtpClient, targetDid: Did) => {
365
+
const userDid = client.user?.did;
366
+
if (!userDid) return;
367
+
368
+
const targetUri = `at://${targetDid}` as ResourceUri;
369
+
const links = findBacklinksBy(targetUri, blockSource, userDid);
370
+
371
+
removeBacklinks(targetUri, blockSource, links);
372
+
373
+
await Promise.allSettled(
374
+
links.map((link) =>
375
+
client.user?.atcute.post('com.atproto.repo.deleteRecord', {
376
+
input: {
377
+
repo: userDid,
378
+
collection: 'app.bsky.graph.block',
379
+
rkey: link.rkey
380
+
}
381
+
})
382
+
)
383
+
);
384
+
};
385
+
386
+
export const isBlockedByUser = (targetDid: Did, userDid: Did): boolean => {
387
+
return isBlockedBy(targetDid, userDid);
388
+
};
389
+
390
+
export const isUserBlockedBy = (userDid: Did, targetDid: Did): boolean => {
391
+
return isBlockedBy(userDid, targetDid);
392
+
};
393
+
394
+
export const hasBlockRelationship = (did1: Did, did2: Did): boolean => {
395
+
return isBlockedBy(did1, did2) || isBlockedBy(did2, did1);
396
+
};
397
+
398
+
export const getBlockRelationship = (
399
+
userDid: Did,
400
+
targetDid: Did
401
+
): { userBlocked: boolean; blockedByTarget: boolean } => {
402
+
return {
403
+
userBlocked: isBlockedBy(targetDid, userDid),
404
+
blockedByTarget: isBlockedBy(userDid, targetDid)
405
+
};
251
406
};
252
407
253
408
export const allPosts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();
409
+
export type DeletedPostInfo = { reply?: PostWithUri['record']['reply'] };
410
+
export const deletedPosts = new SvelteMap<ResourceUri, DeletedPostInfo>();
254
411
// did -> post uris that are replies to that did
255
412
export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>();
256
413
···
261
418
return cached ? ok(cached) : undefined;
262
419
};
263
420
264
-
export const addPostsRaw = (
265
-
did: AtprotoDid,
266
-
newPosts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']>
267
-
) => {
268
-
const postsWithUri = newPosts.records.map(
269
-
(post) =>
270
-
({ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main }) as PostWithUri
271
-
);
272
-
addPosts(postsWithUri);
273
-
};
274
-
275
421
export const addPosts = (newPosts: Iterable<PostWithUri>) => {
276
422
for (const post of newPosts) {
277
423
const parsedUri = expect(parseCanonicalResourceUri(post.uri));
···
282
428
}
283
429
posts.set(post.uri, post);
284
430
if (post.record.reply) {
285
-
addBacklinks(post.record.reply.parent.uri, replySource, [
286
-
{
287
-
did: parsedUri.repo,
288
-
collection: parsedUri.collection,
289
-
rkey: parsedUri.rkey
290
-
}
291
-
]);
431
+
const link = {
432
+
did: parsedUri.repo,
433
+
collection: parsedUri.collection,
434
+
rkey: parsedUri.rkey
435
+
};
436
+
addBacklinks(post.record.reply.parent.uri, replySource, [link]);
437
+
addBacklinks(post.record.reply.root.uri, replyRootSource, [link]);
292
438
293
439
// update reply index
294
440
const parentDid = extractDidFromUri(post.record.reply.parent.uri);
···
304
450
}
305
451
};
306
452
453
+
export const deletePost = (uri: ResourceUri) => {
454
+
const did = extractDidFromUri(uri)!;
455
+
const post = allPosts.get(did)?.get(uri);
456
+
if (!post) return;
457
+
allPosts.get(did)?.delete(uri);
458
+
// remove reply from index
459
+
const subjectDid = extractDidFromUri(post.record.reply?.parent.uri ?? '');
460
+
if (subjectDid) replyIndex.get(subjectDid)?.delete(uri);
461
+
deletedPosts.set(uri, { reply: post.record.reply });
462
+
};
463
+
307
464
export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>();
308
465
export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>();
309
466
···
332
489
};
333
490
334
491
export const fetchTimeline = async (
335
-
did: AtprotoDid,
492
+
client: AtpClient,
493
+
subject: Did,
336
494
limit: number = 6,
337
-
withBacklinks: boolean = true
495
+
withBacklinks: boolean = true,
496
+
hydrateOptions?: Partial<HydrateOptions>
338
497
) => {
339
-
const targetClient = await getClient(did);
340
-
341
-
const cursor = postCursors.get(did);
498
+
const cursor = postCursors.get(subject);
342
499
if (cursor && cursor.end) return;
343
500
344
-
const accPosts = await fetchPosts(targetClient, cursor?.value, limit, withBacklinks);
345
-
if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`;
501
+
const accPosts = await fetchPosts(subject, client, cursor?.value, limit, withBacklinks);
502
+
if (!accPosts.ok) throw `cant fetch posts ${subject}: ${accPosts.error}`;
346
503
347
504
// if the cursor is undefined, we've reached the end of the timeline
348
-
postCursors.set(did, { value: accPosts.value.cursor, end: !accPosts.value.cursor });
349
-
const hydrated = await hydratePosts(targetClient, did, accPosts.value.posts, hydrateCacheFn);
350
-
if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`;
505
+
const newCursor = { value: accPosts.value.cursor, end: !accPosts.value.cursor };
506
+
postCursors.set(subject, newCursor);
507
+
const hydrated = await hydratePosts(
508
+
client,
509
+
subject,
510
+
accPosts.value.posts,
511
+
hydrateCacheFn,
512
+
hydrateOptions
513
+
);
514
+
if (!hydrated.ok) throw `cant hydrate posts ${subject}: ${hydrated.error}`;
351
515
352
516
addPosts(hydrated.value.values());
353
-
addTimeline(did, hydrated.value.keys());
517
+
addTimeline(subject, hydrated.value.keys());
518
+
519
+
if (client.user?.did) {
520
+
const userDid = client.user.did;
521
+
// check if any of the post authors block the user
522
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
523
+
let distinctDids = new Set(hydrated.value.keys().map((uri) => extractDidFromUri(uri)!));
524
+
distinctDids.delete(userDid); // dont need to check if user blocks themselves
525
+
const alreadyFetched = blockFlags.get(userDid);
526
+
if (alreadyFetched) distinctDids = distinctDids.difference(alreadyFetched);
527
+
if (distinctDids.size > 0)
528
+
await Promise.all(distinctDids.values().map((did) => fetchBlocked(client, userDid, did)));
529
+
}
354
530
355
-
console.log(`${did}: fetchTimeline`, accPosts.value.cursor);
531
+
console.log(`${subject}: fetchTimeline`, accPosts.value.cursor);
532
+
return newCursor;
356
533
};
357
534
358
-
export const fetchInteractionsUntil = async (client: AtpClient, did: Did) => {
359
-
const cursor = postCursors.get(did);
535
+
export const fetchInteractionsToTimelineEnd = async (
536
+
client: AtpClient,
537
+
interactor: Did,
538
+
subject: Did
539
+
) => {
540
+
const cursor = postCursors.get(subject);
360
541
if (!cursor) return;
361
542
const timestamp = timestampFromCursor(cursor.value);
362
-
await Promise.all([likeSource, repostSource].map((s) => fetchLinksUntil(client, s, timestamp)));
543
+
await Promise.all(
544
+
[likeSource, repostSource].map((s) => fetchLinksUntil(interactor, client, s, timestamp))
545
+
);
546
+
};
547
+
548
+
export const fetchInitial = async (account: Account) => {
549
+
const client = clients.get(account.did)!;
550
+
await Promise.all([
551
+
fetchBlocks(account),
552
+
fetchForInteractions(client, account.did),
553
+
fetchFollows(account).then((follows) =>
554
+
Promise.all(follows.map((follow) => fetchForInteractions(client, follow.subject)) ?? [])
555
+
)
556
+
]);
363
557
};
364
558
365
559
export const handleJetstreamEvent = async (event: JetstreamEvent) => {
···
369
563
const uri: ResourceUri = toCanonicalUri({ did, ...commit });
370
564
if (commit.collection === 'app.bsky.feed.post') {
371
565
if (commit.operation === 'create') {
566
+
const record = commit.record as AppBskyFeedPost.Main;
372
567
const posts = [
373
568
{
374
-
record: commit.record as AppBskyFeedPost.Main,
569
+
record,
375
570
uri,
376
571
cid: commit.cid
377
572
}
378
573
];
379
-
const client = await getClient(did);
574
+
await setRecordCache(uri, record);
575
+
const client = clients.get(did) ?? viewClient;
380
576
const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn);
381
577
if (!hydrated.ok) {
382
578
console.error(`cant hydrate posts ${did}: ${hydrated.error}`);
···
384
580
}
385
581
addPosts(hydrated.value.values());
386
582
addTimeline(did, hydrated.value.keys());
583
+
if (record.reply) {
584
+
const parentDid = extractDidFromUri(record.reply.parent.uri)!;
585
+
addTimeline(parentDid, [uri]);
586
+
// const rootDid = extractDidFromUri(record.reply.root.uri)!;
587
+
// addTimeline(rootDid, [uri]);
588
+
}
387
589
} else if (commit.operation === 'delete') {
388
-
allPosts.get(did)?.delete(uri);
590
+
deletePost(uri);
389
591
}
390
592
}
391
593
};
···
393
595
const handlePostNotification = async (event: NotificationsStreamEvent & { type: 'message' }) => {
394
596
const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject));
395
597
const did = parsedSubjectUri.repo as AtprotoDid;
396
-
const client = await getClient(did);
598
+
const client = clients.get(did);
599
+
if (!client) {
600
+
console.error(`${did}: cant handle post notification, client not found !?`);
601
+
return;
602
+
}
397
603
const subjectPost = await client.getRecord(
398
604
AppBskyFeedPost.mainSchema,
399
605
did,
+6
-3
src/lib/thread.ts
+6
-3
src/lib/thread.ts
···
1
+
// updated src/lib/thread.ts
2
+
1
3
import { parseCanonicalResourceUri, type Did, type ResourceUri } from '@atcute/lexicons';
2
4
import type { Account } from './accounts';
3
5
import { expect } from './result';
4
6
import type { PostWithUri } from './at/fetch';
7
+
import { isBlockedBy } from './state.svelte';
5
8
6
9
export type ThreadPost = {
7
10
data: PostWithUri;
···
11
14
parentUri: ResourceUri | null;
12
15
depth: number;
13
16
newestTime: number;
17
+
isBlocked?: boolean;
14
18
};
15
19
16
20
export type Thread = {
···
43
47
rkey: parsedUri.rkey,
44
48
parentUri,
45
49
depth: 0,
46
-
newestTime: new Date(data.record.createdAt).getTime()
50
+
newestTime: new Date(data.record.createdAt).getTime(),
51
+
isBlocked: isBlockedBy(parsedUri.repo, account)
47
52
};
48
53
49
54
if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
···
150
155
}
151
156
152
157
threads.sort((a, b) => b.newestTime - a.newestTime);
153
-
154
-
// console.log(threads);
155
158
156
159
return threads;
157
160
};
+22
-26
src/routes/[...catchall]/+page.svelte
+22
-26
src/routes/[...catchall]/+page.svelte
···
6
6
import FollowingView from '$components/FollowingView.svelte';
7
7
import TimelineView from '$components/TimelineView.svelte';
8
8
import ProfileView from '$components/ProfileView.svelte';
9
-
import { AtpClient, streamNotifications } from '$lib/at/client';
9
+
import { AtpClient, streamNotifications } from '$lib/at/client.svelte';
10
10
import { accounts, type Account } from '$lib/accounts';
11
11
import { onMount } from 'svelte';
12
12
import {
13
13
clients,
14
14
postCursors,
15
-
fetchForInteractions,
16
-
fetchFollows,
17
15
follows,
18
16
notificationStream,
19
17
viewClient,
···
22
20
handleNotification,
23
21
addPosts,
24
22
addTimeline,
25
-
router
23
+
router,
24
+
fetchInitial
26
25
} from '$lib/state.svelte';
27
26
import { get } from 'svelte/store';
28
27
import Icon from '@iconify/svelte';
···
32
31
import { JetstreamSubscription } from '@atcute/jetstream';
33
32
import { settings } from '$lib/settings';
34
33
import type { Sort } from '$lib/following';
34
+
import { SvelteMap } from 'svelte/reactivity';
35
35
36
36
const { data: loadData }: PageProps = $props();
37
37
···
61
61
const handleAccountSelected = async (did: AtprotoDid) => {
62
62
selectedDid = did;
63
63
const account = $accounts.find((acc) => acc.did === did);
64
-
if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute))
64
+
if (account && (!clients.has(account.did) || !clients.get(account.did)?.user))
65
65
await loginAccount(account);
66
66
};
67
67
const handleLogout = async (did: AtprotoDid) => {
···
83
83
else animClass = 'animate-fade-in-scale';
84
84
});
85
85
86
-
let postComposerState = $state<PostComposerState>({ focus: 'null', text: '' });
86
+
let postComposerState = $state<PostComposerState>({
87
+
focus: 'null',
88
+
text: '',
89
+
blobsState: new SvelteMap()
90
+
});
87
91
let showScrollToTop = $state(false);
88
92
const handleScroll = () => {
89
93
if (currentRoute.path === '/' || currentRoute.path === '/profile/:actor')
···
108
112
'app.bsky.feed.post:embed.record.uri',
109
113
'app.bsky.feed.repost:subject.uri',
110
114
'app.bsky.feed.like:subject.uri',
111
-
'app.bsky.graph.follow:subject'
115
+
'app.bsky.graph.follow:subject',
116
+
'app.bsky.graph.block:subject'
112
117
)
113
118
);
114
119
});
···
139
144
}
140
145
if (!$accounts.some((account) => account.did === selectedDid)) selectedDid = $accounts[0].did;
141
146
// console.log('onMount selectedDid', selectedDid);
142
-
Promise.all($accounts.map(loginAccount)).then(() => {
143
-
$accounts.forEach((account) => {
144
-
fetchFollows(account.did).then(() =>
145
-
follows
146
-
.get(account.did)
147
-
?.forEach((follow) => fetchForInteractions(follow.subject as AtprotoDid))
148
-
);
149
-
fetchForInteractions(account.did);
150
-
});
151
-
});
147
+
Promise.all($accounts.map(loginAccount)).then(() => $accounts.forEach(fetchInitial));
152
148
} else {
153
149
selectedDid = null;
154
150
}
···
158
154
159
155
$effect(() => {
160
156
const wantedDids: Did[] = ['did:web:guestbook.gaze.systems'];
161
-
162
-
for (const followMap of follows.values())
163
-
for (const follow of followMap.values()) wantedDids.push(follow.subject);
164
-
for (const account of $accounts) wantedDids.push(account.did);
165
-
157
+
const followDids = follows
158
+
.values()
159
+
.flatMap((followMap) => followMap.values().map((follow) => follow.subject));
160
+
const accountDids = $accounts.values().map((account) => account.did);
161
+
wantedDids.push(...followDids, ...accountDids);
166
162
// console.log('updating jetstream options:', wantedDids);
167
163
$jetstream?.updateOptions({ wantedDids });
168
164
});
···
273
269
<div
274
270
class="
275
271
{['/', '/following', '/profile/:actor'].includes(router.current.path) ? '' : 'hidden'}
276
-
z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all
272
+
z-20 w-full max-w-2xl p-2.5 px-4 pb-1.25 transition-all
277
273
"
278
274
>
279
275
<!-- composer and error disclaimer (above thread list, not scrollable) -->
280
-
<div class="footer-border-bg rounded-sm px-0.5 py-0.5">
281
-
<div class="footer-bg flex gap-2 rounded-sm p-1.5 shadow-2xl">
276
+
<div class="footer-border-bg rounded-sm p-0.5">
277
+
<div class="footer-bg flex gap-2 rounded-sm p-1.5">
282
278
<AccountSelector
283
279
client={viewClient}
284
280
accounts={$accounts}
···
315
311
316
312
<div id="footer-portal" class="contents"></div>
317
313
318
-
<div class="footer-border-bg rounded-t-sm px-0.5 pt-0.5">
314
+
<div class="footer-border-bg rounded-t-sm px-0.75 pt-0.75">
319
315
<div class="footer-bg rounded-t-sm">
320
316
<div class="flex items-center gap-1.5 px-2 py-1">
321
317
<div class="mb-2">
+2
-3
src/routes/[...catchall]/+page.ts
+2
-3
src/routes/[...catchall]/+page.ts
···
1
-
import { replaceState } from '$app/navigation';
2
1
import { addAccount, loggingIn } from '$lib/accounts';
3
-
import { AtpClient } from '$lib/at/client';
2
+
import { AtpClient } from '$lib/at/client.svelte';
4
3
import { flow, sessions } from '$lib/at/oauth';
5
4
import { err, ok, type Result } from '$lib/result';
6
5
import type { PageLoad } from './$types';
···
24
23
const currentUrl = new URL(window.location.href);
25
24
// scrub history so auth state cant be replayed
26
25
try {
27
-
replaceState('', '/');
26
+
history.replaceState(null, '', '/');
28
27
} catch {
29
28
// if router was unitialized then we probably dont need to scrub anyway
30
29
// so its fine