+3
-2
src/components/EmbedMedia.svelte
+3
-2
src/components/EmbedMedia.svelte
···
21
21
isBlob(img.image) ? [{ ...img, image: img.image }] : []
22
22
)}
23
23
{@const images = _images.map((i): GalleryItem => {
24
-
const size = i.aspectRatio ?? { width: 400, height: 300 };
24
+
const size = i.aspectRatio;
25
25
const cid = i.image.ref.$link;
26
26
return {
27
27
...size,
···
29
29
thumbnail: {
30
30
src: img('feed_thumbnail', did, cid),
31
31
...size
32
-
}
32
+
},
33
+
alt: i.alt
33
34
};
34
35
})}
35
36
{#if images.length > 0}
+7
-14
src/components/FollowingItem.svelte
+7
-14
src/components/FollowingItem.svelte
···
1
-
<script lang="ts" module>
2
-
const profileCache = new SvelteMap<string, { displayName?: string; handle: string }>();
3
-
</script>
4
-
5
1
<script lang="ts">
6
2
import ProfilePicture from './ProfilePicture.svelte';
7
3
import BlockedUserIndicator from './BlockedUserIndicator.svelte';
···
10
6
import type { Did } from '@atcute/lexicons';
11
7
import type { calculateFollowedUserStats, Sort } from '$lib/following';
12
8
import { resolveDidDoc, type AtpClient } from '$lib/at/client.svelte';
13
-
import { SvelteMap } from 'svelte/reactivity';
14
-
import { router, getBlockRelationship } from '$lib/state.svelte';
9
+
import { router, getBlockRelationship, profiles, handles } from '$lib/state.svelte';
15
10
import { map } from '$lib/result';
16
11
17
12
interface Props {
···
31
26
);
32
27
const isBlocked = $derived(blockRel.userBlocked || blockRel.blockedByTarget);
33
28
34
-
const cached = $derived(profileCache.get(did));
35
-
const displayName = $derived(cached?.displayName);
36
-
const handle = $derived(cached?.handle ?? 'handle.invalid');
29
+
const displayName = $derived(profiles.get(did)?.displayName);
30
+
const handle = $derived(handles.get(did) ?? 'loading...');
37
31
38
32
let error = $state('');
39
33
40
34
const loadProfile = async (targetDid: Did) => {
41
-
if (profileCache.has(targetDid)) return;
35
+
if (profiles.has(targetDid) && handles.has(targetDid)) return;
42
36
43
37
try {
44
38
const [profileRes, handleRes] = await Promise.all([
···
47
41
]);
48
42
if (did !== targetDid) return;
49
43
50
-
profileCache.set(targetDid, {
51
-
handle: handleRes.ok ? handleRes.value : handle,
52
-
displayName: profileRes.ok ? profileRes.value.displayName : displayName
53
-
});
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');
54
47
} catch (e) {
55
48
if (did !== targetDid) return;
56
49
console.error(`failed to load profile for ${targetDid}`, e);
+8
-8
src/components/PhotoSwipeGallery.svelte
+8
-8
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
$: {
···
70
70
<!-- eslint-disable svelte/no-navigation-without-resolve -->
71
71
<a
72
72
href={img.src}
73
-
data-pswp-width={img.width}
74
-
data-pswp-height={img.height}
73
+
data-pswp-width={img.width ?? imageElements[i]?.width}
74
+
data-pswp-height={img.height ?? imageElements[i]?.height}
75
75
target="_blank"
76
76
class:hidden-in-grid={isHidden}
77
77
class:overlay-container={isOverlay}
78
78
>
79
-
<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 ?? ''} />
80
80
81
81
{#if isOverlay}
82
82
<div class="more-overlay">
+59
-17
src/components/PostComposer.svelte
+59
-17
src/components/PostComposer.svelte
···
43
43
client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)'
44
44
);
45
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
+
46
58
const uploadVideo = async (blobUrl: string, mimeType: string) => {
47
-
const blob = await (await fetch(blobUrl)).blob();
48
-
return await client.uploadVideo(blob, mimeType, (status) => {
59
+
const file = await (await fetch(blobUrl)).blob();
60
+
return await client.uploadVideo(file, mimeType, (status) => {
49
61
if (status.stage === 'uploading' && status.progress !== undefined) {
50
62
_state.blobsState.set(blobUrl, { state: 'uploading', progress: status.progress * 0.5 });
51
63
} else if (status.stage === 'processing' && status.progress !== undefined) {
···
56
68
}
57
69
});
58
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
+
59
82
const uploadImage = async (blobUrl: string) => {
60
-
const blob = await (await fetch(blobUrl)).blob();
61
-
return await client.uploadBlob(blob, (progress) => {
83
+
const file = await (await fetch(blobUrl)).blob();
84
+
return await client.uploadBlob(file, (progress) => {
62
85
_state.blobsState.set(blobUrl, { state: 'uploading', progress });
63
86
});
64
87
};
···
77
100
const images = _state.attachedMedia.images;
78
101
let uploadedImages: typeof images = [];
79
102
for (const image of images) {
80
-
const upload = _state.blobsState.get((image.image as AtpBlob<string>).ref.$link);
103
+
const blobUrl = (image.image as AtpBlob<string>).ref.$link;
104
+
const upload = _state.blobsState.get(blobUrl);
81
105
if (!upload || upload.state !== 'uploaded') continue;
106
+
const size = await getImageDimensions(blobUrl);
107
+
if (size.ok) image.aspectRatio = size.value;
82
108
uploadedImages.push({
83
109
...image,
84
110
image: upload.blob
···
91
117
images: uploadedImages
92
118
};
93
119
} else if (_state.attachedMedia?.$type === 'app.bsky.embed.video') {
94
-
const upload = _state.blobsState.get(
95
-
(_state.attachedMedia.video as AtpBlob<string>).ref.$link
96
-
);
97
-
if (upload && upload.state === 'uploaded')
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;
98
125
media = {
99
126
..._state.attachedMedia,
100
127
$type: 'app.bsky.embed.video',
101
128
video: upload.blob
102
129
};
130
+
}
103
131
}
104
-
console.log('media', media);
132
+
// console.log('media', media);
105
133
106
134
const record: AppBskyFeedPost.Main = {
107
135
$type: 'app.bsky.feed.post',
···
156
184
let fileInputEl: HTMLInputElement | undefined = $state();
157
185
let selectingFile = $state(false);
158
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
+
);
194
+
159
195
const unfocus = () => (_state.focus = 'null');
160
196
161
197
const handleFiles = (files: File[]) => {
162
-
if (!files || files.length === 0) return;
198
+
if (!canUpload || !files || files.length === 0) return;
163
199
164
200
const existingImages =
165
201
_state.attachedMedia?.$type === 'app.bsky.embed.images' ? _state.attachedMedia.images : [];
···
220
256
};
221
257
}
222
258
223
-
const handleUpload = (blobUrl: string, blob: Result<AtpBlob<string>, string>) => {
224
-
if (blob.ok) _state.blobsState.set(blobUrl, { state: 'uploaded', blob: blob.value });
225
-
else _state.blobsState.set(blobUrl, { state: 'error', message: blob.error });
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 });
226
262
};
227
263
228
264
const media = _state.attachedMedia;
···
269
305
if (_state.attachedMedia?.$type === 'app.bsky.embed.video') {
270
306
const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link;
271
307
_state.blobsState.delete(blobUrl);
308
+
queueMicrotask(() => URL.revokeObjectURL(blobUrl));
272
309
}
273
310
_state.attachedMedia = undefined;
274
311
};
···
278
315
const imageToRemove = _state.attachedMedia.images[index];
279
316
const blobUrl = (imageToRemove.image as AtpBlob<string>).ref.$link;
280
317
_state.blobsState.delete(blobUrl);
318
+
queueMicrotask(() => URL.revokeObjectURL(blobUrl));
281
319
282
320
const images = _state.attachedMedia.images.filter((_, i) => i !== index);
283
321
_state.attachedMedia = images.length > 0 ? { ..._state.attachedMedia, images } : undefined;
···
295
333
_state.text = '';
296
334
_state.quoting = undefined;
297
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
+
);
298
342
_state.attachedMedia = undefined;
299
343
_state.blobsState.clear();
300
344
unfocus();
···
459
503
fileInputEl?.click();
460
504
}}
461
505
onmousedown={(e) => e.preventDefault()}
462
-
disabled={_state.attachedMedia?.$type === 'app.bsky.embed.video' ||
463
-
(_state.attachedMedia?.$type === 'app.bsky.embed.images' &&
464
-
_state.attachedMedia.images.length >= 4)}
506
+
disabled={!canUpload}
465
507
class="rounded-sm p-1.5 transition-all duration-150 enabled:hover:scale-110 disabled:cursor-not-allowed disabled:opacity-50"
466
508
style="background: color-mix(in srgb, {color} 15%, transparent); color: {color};"
467
509
title="attach media"
+32
-53
src/components/ProfileView.svelte
+32
-53
src/components/ProfileView.svelte
···
1
1
<script lang="ts">
2
-
import { AtpClient, resolveDidDoc, resolveHandle } from '$lib/at/client.svelte';
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';
···
14
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
11
import {
19
12
handles,
20
13
profiles,
···
34
27
35
28
let { client, actor, onBack, postComposerState = $bindable() }: Props = $props();
36
29
37
-
let profile = $state<AppBskyActorProfile.Main | null>(profiles.get(actor as Did) ?? null);
30
+
const profile = $derived(profiles.get(actor as Did));
38
31
const displayName = $derived(profile?.displayName ?? '');
32
+
const handle = $derived(isHandle(actor) ? actor : handles.get(actor as Did));
39
33
let loading = $state(true);
40
34
let error = $state<string | null>(null);
41
-
let did = $state<AtprotoDid | null>(null);
42
-
let handle = $state<Handle | null>(handles.get(actor as Did) ?? null);
35
+
let did = $state(isDid(actor) ? actor : null);
43
36
44
37
let userBlocked = $state(false);
45
38
let blockedByTarget = $state(false);
···
47
40
const loadProfile = async (identifier: ActorIdentifier) => {
48
41
loading = true;
49
42
error = null;
50
-
profile = null;
51
-
handle = isHandle(identifier) ? identifier : null;
52
43
53
-
const resDid = await resolveHandle(identifier);
54
-
if (resDid.ok) did = resDid.value;
55
-
else {
56
-
error = resDid.error;
57
-
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;
58
50
return;
59
51
}
60
52
61
-
if (!handle) handle = handles.get(did) ?? null;
62
-
63
-
if (!handle) {
64
-
const resHandle = await resolveDidDoc(did);
65
-
if (resHandle.ok) {
66
-
handle = resHandle.value.handle;
67
-
handles.set(did, resHandle.value.handle);
68
-
}
69
-
}
70
-
71
53
// check block relationship
72
54
if (client.user?.did) {
73
55
let blockRel = getBlockRelationship(client.user.did, did);
74
56
blockRel = blockFlags.get(client.user.did)?.has(did)
75
57
? blockRel
76
-
: {
77
-
userBlocked: await fetchBlocked(client, did, client.user.did),
78
-
blockedByTarget: await fetchBlocked(client, client.user.did, did)
79
-
};
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
+
})();
80
65
userBlocked = blockRel.userBlocked;
81
66
blockedByTarget = blockRel.blockedByTarget;
82
67
}
···
87
72
return;
88
73
}
89
74
90
-
const res = await client.getProfile(did);
91
-
if (res.ok) {
92
-
profile = res.value;
93
-
profiles.set(did, res.value);
94
-
} 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;
95
78
96
79
loading = false;
97
80
};
···
122
105
<Icon icon="heroicons:arrow-left-20-solid" width={24} />
123
106
</button>
124
107
<h2 class="text-xl font-bold">
125
-
{displayName.length > 0
126
-
? displayName
127
-
: loading
128
-
? 'loading...'
129
-
: (handle ?? actor ?? 'profile')}
108
+
{displayName.length > 0 ? displayName : loading ? 'loading...' : (handle ?? 'handle.invalid')}
130
109
</h2>
131
110
<div class="grow"></div>
132
111
{#if did && client.user && client.user.did !== did}
···
150
129
</div>
151
130
{:else}
152
131
<!-- banner -->
153
-
<div class="relative h-32 w-full overflow-hidden bg-(--nucleus-fg)/5 md:h-48">
154
-
{#if bannerUrl}
132
+
{#if bannerUrl}
133
+
<div class="relative h-32 w-full overflow-hidden bg-(--nucleus-fg)/5 md:h-48">
155
134
<img src={bannerUrl} alt="banner" class="h-full w-full object-cover" />
156
-
{/if}
157
-
<div
158
-
class="absolute inset-0 bg-linear-to-b from-transparent to-(--nucleus-bg)"
159
-
style="opacity: 0.8;"
160
-
></div>
161
-
</div>
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>
140
+
{/if}
162
141
163
142
{#if did}
164
143
<div class="px-4 pb-4">
165
-
<div class="relative z-10 -mt-12 mb-4">
166
-
<ProfileInfo {client} {did} bind:profile />
144
+
<div class="relative z-10 {bannerUrl ? '-mt-12' : 'mt-4'} mb-4">
145
+
<ProfileInfo {client} {did} {profile} />
167
146
</div>
168
147
169
148
<TimelineView
+15
-10
src/components/TimelineView.svelte
+15
-10
src/components/TimelineView.svelte
···
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
···
63
63
loaderState.status = 'LOADING';
64
64
65
65
try {
66
-
await fetchTimeline(client, did as AtprotoDid, 7, showReplies);
66
+
await fetchTimeline(client, did, 7, showReplies, {
67
+
downwards: userDid === did ? 'sameAuthor' : 'none'
68
+
});
67
69
// only fetch interactions if logged in (because if not who is the interactor)
68
-
if (client.user) {
70
+
if (client.user && userDid) {
69
71
if (!fetchingInteractions) {
70
72
scheduledFetchInteractions = false;
71
73
fetchingInteractions = true;
72
-
fetchInteractionsToTimelineEnd(client, did).finally(() => (fetchingInteractions = false));
74
+
await fetchInteractionsToTimelineEnd(client, userDid, did);
75
+
fetchingInteractions = false;
73
76
} else {
74
77
scheduledFetchInteractions = true;
75
78
}
···
83
86
}
84
87
85
88
loading = false;
86
-
const cursor = postCursors.get(did as AtprotoDid);
89
+
const cursor = postCursors.get(did);
87
90
if (cursor && cursor.end) loaderState.complete();
88
91
};
89
92
···
92
95
// if we saw all posts dont try to load more.
93
96
// this only really happens if the user has no posts at all
94
97
// but we do have to handle it to not cause an infinite loop
95
-
const cursor = did ? postCursors.get(did as AtprotoDid) : undefined;
98
+
const cursor = did ? postCursors.get(did) : undefined;
96
99
if (!cursor?.end) loadMore();
97
100
}
98
101
});
99
102
100
103
let fetchingInteractions = $state(false);
101
104
let scheduledFetchInteractions = $state(false);
102
-
// we want to load interactions when changing logged in user on timelines
105
+
// we want to load interactions when changing logged in user
103
106
// only on timelines that arent logged in users, because those are already
104
107
// loaded by loadMore
105
108
$effect(() => {
106
-
if (client && did && scheduledFetchInteractions && userDid !== did) {
109
+
if (client && scheduledFetchInteractions && userDid && did && did !== userDid) {
107
110
if (!fetchingInteractions) {
108
111
scheduledFetchInteractions = false;
109
112
fetchingInteractions = true;
110
-
fetchInteractionsToTimelineEnd(client, did).finally(() => (fetchingInteractions = false));
113
+
fetchInteractionsToTimelineEnd(client, userDid, did).finally(
114
+
() => (fetchingInteractions = false)
115
+
);
111
116
} else {
112
117
scheduledFetchInteractions = true;
113
118
}
+31
-15
src/lib/at/client.svelte.ts
+31
-15
src/lib/at/client.svelte.ts
···
30
30
import { MiniDocQuery, type MiniDoc } from './slingshot';
31
31
import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation';
32
32
import type { Records, XRPCProcedures } from '@atcute/lexicons/ambient';
33
-
import { cache as rawCache } from '$lib/cache';
33
+
import { cache as rawCache, ttl } from '$lib/cache';
34
34
import { AppBskyActorProfile } from '@atcute/bluesky';
35
35
import { WebSocket } from '@soffinal/websocket';
36
36
import type { Notification } from './stardust';
···
77
77
78
78
const cache = cacheWithRecords;
79
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
+
80
87
export const xhrPost = (
81
88
url: string,
82
89
body: Blob | File,
···
88
95
const xhr = new XMLHttpRequest();
89
96
xhr.open('POST', url);
90
97
91
-
if (onProgress && xhr.upload) {
98
+
if (onProgress && xhr.upload)
92
99
xhr.upload.onprogress = (event: ProgressEvent) => {
93
100
if (event.lengthComputable) onProgress(event.loaded, event.total);
94
101
};
95
-
}
96
102
97
103
Object.keys(headers).forEach((key) => xhr.setRequestHeader(key, headers[key]));
98
104
···
145
151
TKey extends RecordKeySchema,
146
152
Schema extends RecordSchema<TObject, TKey>,
147
153
Output extends InferInput<Schema>
148
-
>(schema: Schema, uri: ResourceUri): Promise<Result<RecordOutput<Output>, string>> {
154
+
>(
155
+
schema: Schema,
156
+
uri: ResourceUri,
157
+
noCache?: boolean
158
+
): Promise<Result<RecordOutput<Output>, string>> {
149
159
const parsedUri = expect(parseResourceUri(uri));
150
160
if (parsedUri.collection !== schema.object.shape.$type.expected)
151
161
return err(
152
162
`collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}`
153
163
);
154
-
return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!);
164
+
return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!, noCache);
155
165
}
156
166
157
167
async getRecord<
···
163
173
>(
164
174
schema: Schema,
165
175
repo: ActorIdentifier,
166
-
rkey: RecordKey
176
+
rkey: RecordKey,
177
+
noCache?: boolean
167
178
): Promise<Result<RecordOutput<Output>, string>> {
168
179
const collection = schema.object.shape.$type.expected;
169
180
170
181
try {
171
-
const rawValue = await cache.fetchRecord(
172
-
toResourceUri({ repo, collection, rkey, fragment: undefined })
173
-
);
182
+
const uri = toResourceUri({ repo, collection, rkey, fragment: undefined });
183
+
if (noCache) await invalidateRecordCache(uri);
184
+
const rawValue = await cache.fetchRecord(uri);
174
185
175
186
const parsed = safeParse(schema, rawValue.value);
176
187
if (!parsed.ok) return err(parsed.message);
···
185
196
}
186
197
}
187
198
188
-
async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> {
199
+
async getProfile(
200
+
repo?: ActorIdentifier,
201
+
noCache?: boolean
202
+
): Promise<Result<AppBskyActorProfile.Main, string>> {
189
203
repo = repo ?? this.user?.did;
190
204
if (!repo) return err('not authenticated');
191
-
return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record);
205
+
return map(
206
+
await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self', noCache),
207
+
(d) => d.record
208
+
);
192
209
}
193
210
194
211
async listRecords<Collection extends keyof Records>(
···
218
235
});
219
236
if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`);
220
237
221
-
for (const record of res.data.records)
222
-
await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24);
238
+
for (const record of res.data.records) setRecordCache(record.uri, record);
223
239
224
240
return ok(res.data);
225
241
}
···
327
343
(uploaded, total) => onProgress?.(uploaded / total)
328
344
);
329
345
if (!result.ok) return err(`upload failed: ${result.error.message}`);
330
-
return ok(result.value);
346
+
return ok(result.value.blob);
331
347
}
332
348
333
349
async uploadVideo(
···
359
375
},
360
376
(uploaded, total) => onStatus?.({ stage: 'uploading', progress: uploaded / total })
361
377
);
362
-
if (!uploadResult.ok) return err(`failed to upload video: ${uploadResult.error.message}`);
378
+
if (!uploadResult.ok) return err(`failed to upload video: ${uploadResult.error}`);
363
379
const jobStatus = uploadResult.value;
364
380
let videoBlobRef: AtpBlob<string> = jobStatus.blob;
365
381
+32
-25
src/lib/at/fetch.ts
+32
-25
src/lib/at/fetch.ts
···
59
59
}
60
60
};
61
61
62
+
export type HydrateOptions = {
63
+
downwards: 'sameAuthor' | 'none';
64
+
};
65
+
62
66
export const hydratePosts = async (
63
67
client: AtpClient,
64
68
repo: Did,
65
69
data: PostWithBacklinks[],
66
-
cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined
70
+
cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined,
71
+
options?: Partial<HydrateOptions>
67
72
): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => {
68
73
let posts: Map<ResourceUri, PostWithUri> = new Map();
69
74
try {
···
115
120
};
116
121
await Promise.all(posts.values().map(fetchUpwardsChain));
117
122
118
-
try {
119
-
const fetchDownwardsChain = async (post: PostWithUri) => {
120
-
const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri));
121
-
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;
122
128
123
-
// get chains that are the same author until we exhaust them
124
-
const backlinks = await client.getBacklinks(post.uri, replySource);
125
-
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;
126
132
127
-
const promises = [];
128
-
for (const reply of backlinks.value.records) {
129
-
if (reply.did !== postRepo) continue;
130
-
// if we already have this reply, then we already fetched this chain / are fetching it
131
-
if (posts.has(toCanonicalUri(reply))) continue;
132
-
const record =
133
-
cacheFn(reply.did, reply.rkey) ??
134
-
(await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey));
135
-
if (!record.ok) break; // TODO: this doesnt handle deleted posts in between
136
-
posts.set(record.value.uri, record.value);
137
-
promises.push(fetchDownwardsChain(record.value));
138
-
}
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
+
}
139
145
140
-
await Promise.all(promises);
141
-
};
142
-
await Promise.all(posts.values().map(fetchDownwardsChain));
143
-
} catch (error) {
144
-
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
+
}
145
152
}
146
153
147
154
return ok(posts);
+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
});
+1
-1
src/lib/result.ts
+1
-1
src/lib/result.ts
···
12
12
return { ok: true, value };
13
13
};
14
14
export const err = <E>(error: E): Err<E> => {
15
-
console.error(error);
15
+
// console.error(error);
16
16
return { ok: false, error };
17
17
};
18
18
export const expect = <T, E>(v: Result<T, E>, msg: string = 'expected result to not be error:') => {
+42
-17
src/lib/state.svelte.ts
+42
-17
src/lib/state.svelte.ts
···
1
1
import { writable } from 'svelte/store';
2
2
import {
3
3
AtpClient,
4
+
setRecordCache,
4
5
type NotificationsStream,
5
6
type NotificationsStreamEvent
6
7
} from './at/client.svelte';
7
8
import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity';
8
9
import type { Did, Handle, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons';
9
-
import { fetchPosts, hydratePosts, type PostWithUri } from './at/fetch';
10
+
import { fetchPosts, hydratePosts, type HydrateOptions, type PostWithUri } from './at/fetch';
10
11
import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax';
11
12
import {
12
13
AppBskyActorProfile,
···
405
406
};
406
407
407
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>();
408
411
// did -> post uris that are replies to that did
409
412
export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>();
410
413
···
447
450
}
448
451
};
449
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
+
450
464
export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>();
451
465
export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>();
452
466
···
476
490
477
491
export const fetchTimeline = async (
478
492
client: AtpClient,
479
-
subject: AtprotoDid,
493
+
subject: Did,
480
494
limit: number = 6,
481
-
withBacklinks: boolean = true
495
+
withBacklinks: boolean = true,
496
+
hydrateOptions?: Partial<HydrateOptions>
482
497
) => {
483
498
const cursor = postCursors.get(subject);
484
499
if (cursor && cursor.end) return;
···
489
504
// if the cursor is undefined, we've reached the end of the timeline
490
505
const newCursor = { value: accPosts.value.cursor, end: !accPosts.value.cursor };
491
506
postCursors.set(subject, newCursor);
492
-
const hydrated = await hydratePosts(client, subject, accPosts.value.posts, hydrateCacheFn);
507
+
const hydrated = await hydratePosts(
508
+
client,
509
+
subject,
510
+
accPosts.value.posts,
511
+
hydrateCacheFn,
512
+
hydrateOptions
513
+
);
493
514
if (!hydrated.ok) throw `cant hydrate posts ${subject}: ${hydrated.error}`;
494
515
495
516
addPosts(hydrated.value.values());
···
511
532
return newCursor;
512
533
};
513
534
514
-
export const fetchInteractionsToTimelineEnd = async (client: AtpClient, did: Did) => {
515
-
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);
516
541
if (!cursor) return;
517
542
const timestamp = timestampFromCursor(cursor.value);
518
543
await Promise.all(
519
-
[likeSource, repostSource].map((s) => fetchLinksUntil(did, client, s, timestamp))
544
+
[likeSource, repostSource].map((s) => fetchLinksUntil(interactor, client, s, timestamp))
520
545
);
521
546
};
522
547
···
538
563
const uri: ResourceUri = toCanonicalUri({ did, ...commit });
539
564
if (commit.collection === 'app.bsky.feed.post') {
540
565
if (commit.operation === 'create') {
566
+
const record = commit.record as AppBskyFeedPost.Main;
541
567
const posts = [
542
568
{
543
-
record: commit.record as AppBskyFeedPost.Main,
569
+
record,
544
570
uri,
545
571
cid: commit.cid
546
572
}
547
573
];
574
+
await setRecordCache(uri, record);
548
575
const client = clients.get(did) ?? viewClient;
549
576
const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn);
550
577
if (!hydrated.ok) {
···
553
580
}
554
581
addPosts(hydrated.value.values());
555
582
addTimeline(did, hydrated.value.keys());
556
-
} else if (commit.operation === 'delete') {
557
-
const post = allPosts.get(did)?.get(uri);
558
-
if (post) {
559
-
allPosts.get(did)?.delete(uri);
560
-
// remove from timeline
561
-
timelines.get(did)?.delete(uri);
562
-
// remove reply from index
563
-
const subjectDid = extractDidFromUri(post.record.reply?.parent.uri ?? '');
564
-
if (subjectDid) replyIndex.get(subjectDid)?.delete(uri);
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]);
565
588
}
589
+
} else if (commit.operation === 'delete') {
590
+
deletePost(uri);
566
591
}
567
592
}
568
593
};