+6
-47
src/components/BskyPost.svelte
+6
-47
src/components/BskyPost.svelte
···
42
import RichText from './RichText.svelte';
43
import { getRelativeTime } from '$lib/date';
44
import { likeSource, repostSource } from '$lib';
45
46
interface Props {
47
client: AtpClient;
···
72
const selectedDid = $derived(client.user?.did ?? null);
73
const actionClient = $derived(clients.get(did as AtprotoDid));
74
75
-
const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`;
76
-
const color = generateColorForDid(did);
77
78
-
let handle: ActorIdentifier = $state(did);
79
const didDoc = resolveDidDoc(did).then((res) => {
80
if (res.ok) handle = res.value.handle;
81
return res;
···
91
// console.log(profile.description);
92
});
93
94
-
const postId = `timeline-post-${aturi}-${quoteDepth}`;
95
const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId);
96
97
const scrollToAndPulse = (targetUri: ResourceUri) => {
···
169
};
170
171
let profileOpen = $state(false);
172
-
let profilePopoutShowDid = $state(false);
173
</script>
174
175
{#snippet embedBadge(embed: AppBskyEmbeds)}
···
207
208
<!-- eslint-disable svelte/no-navigation-without-resolve -->
209
{#snippet profilePopout()}
210
-
{@const profileDesc = profile?.description?.trim() ?? ''}
211
<Dropdown
212
class="post-dropdown max-w-xl gap-2! p-2.5! backdrop-blur-3xl! backdrop-brightness-25!"
213
style="background: {color}36; border-color: {color}99;"
214
bind:isOpen={profileOpen}
215
trigger={profileInline}
216
>
217
-
<div class="flex items-center gap-2">
218
-
<ProfilePicture {client} {did} size={20} />
219
-
220
-
<div class="flex flex-col items-start overflow-hidden overflow-ellipsis">
221
-
<span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis">
222
-
{profile?.displayName ?? handle}
223
-
{#if profile?.pronouns}
224
-
<span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span>
225
-
{/if}
226
-
</span>
227
-
<button
228
-
oncontextmenu={(e) => {
229
-
const node = e.target as Node;
230
-
const selection = window.getSelection() ?? new Selection();
231
-
const range = document.createRange();
232
-
range.selectNodeContents(node);
233
-
selection.removeAllRanges();
234
-
selection.addRange(range);
235
-
e.stopPropagation();
236
-
}}
237
-
onclick={() => (profilePopoutShowDid = !profilePopoutShowDid)}
238
-
class="mb-0.5 text-nowrap opacity-85 select-text hover:underline"
239
-
>
240
-
{profilePopoutShowDid ? did : `@${handle}`}
241
-
</button>
242
-
{#if profile?.website}
243
-
<a
244
-
target="_blank"
245
-
rel="noopener noreferrer"
246
-
href={profile.website}
247
-
class="text-sm text-nowrap opacity-60">{profile.website}</a
248
-
>
249
-
{/if}
250
-
</div>
251
-
</div>
252
-
253
-
{#if profileDesc.length > 0}
254
-
<p class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word">
255
-
<RichText text={profileDesc} />
256
-
</p>
257
-
{/if}
258
</Dropdown>
259
{/snippet}
260
···
42
import RichText from './RichText.svelte';
43
import { getRelativeTime } from '$lib/date';
44
import { likeSource, repostSource } from '$lib';
45
+
import ProfileInfo from './ProfileInfo.svelte';
46
47
interface Props {
48
client: AtpClient;
···
73
const selectedDid = $derived(client.user?.did ?? null);
74
const actionClient = $derived(clients.get(did as AtprotoDid));
75
76
+
const aturi = $derived(`at://${did}/app.bsky.feed.post/${rkey}` as CanonicalResourceUri);
77
+
const color = $derived(generateColorForDid(did));
78
79
+
let handle: ActorIdentifier = $state('handle.invalid');
80
const didDoc = resolveDidDoc(did).then((res) => {
81
if (res.ok) handle = res.value.handle;
82
return res;
···
92
// console.log(profile.description);
93
});
94
95
+
const postId = $derived(`timeline-post-${aturi}-${quoteDepth}`);
96
const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId);
97
98
const scrollToAndPulse = (targetUri: ResourceUri) => {
···
170
};
171
172
let profileOpen = $state(false);
173
</script>
174
175
{#snippet embedBadge(embed: AppBskyEmbeds)}
···
207
208
<!-- eslint-disable svelte/no-navigation-without-resolve -->
209
{#snippet profilePopout()}
210
<Dropdown
211
class="post-dropdown max-w-xl gap-2! p-2.5! backdrop-blur-3xl! backdrop-brightness-25!"
212
style="background: {color}36; border-color: {color}99;"
213
bind:isOpen={profileOpen}
214
trigger={profileInline}
215
>
216
+
<ProfileInfo {client} {did} {handle} {profile} />
217
</Dropdown>
218
{/snippet}
219
+6
-2
src/components/FollowingItem.svelte
+6
-2
src/components/FollowingItem.svelte
···
21
client: AtpClient;
22
sort: Sort;
23
currentTime: Date;
24
}
25
26
-
let { style, did, stats, client, sort, currentTime }: Props = $props();
27
28
// svelte-ignore state_referenced_locally
29
const cached = profileCache.get(did);
···
96
</script>
97
98
<div {style} class="box-border w-full pb-2">
99
<div
100
-
class="group flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20"
101
style={`--post-color: ${color};`}
102
>
103
<ProfilePicture {client} {did} size={10} />
···
21
client: AtpClient;
22
sort: Sort;
23
currentTime: Date;
24
+
onClick?: (did: AtprotoDid) => void;
25
}
26
27
+
let { style, did, stats, client, sort, currentTime, onClick }: Props = $props();
28
29
// svelte-ignore state_referenced_locally
30
const cached = profileCache.get(did);
···
97
</script>
98
99
<div {style} class="box-border w-full pb-2">
100
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
101
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
102
<div
103
+
onclick={() => onClick?.(did as AtprotoDid)}
104
+
class="group flex cursor-pointer items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20"
105
style={`--post-color: ${color};`}
106
>
107
<ProfilePicture {client} {did} size={10} />
+4
-1
src/components/FollowingView.svelte
+4
-1
src/components/FollowingView.svelte
···
1
<script lang="ts">
2
import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte';
3
import type { Did } from '@atcute/lexicons';
4
import { type AtpClient } from '$lib/at/client';
5
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list';
6
import {
···
14
interface Props {
15
selectedDid: Did;
16
selectedClient: AtpClient;
17
}
18
19
-
const { selectedDid, selectedClient }: Props = $props();
20
21
let followingSort: Sort = $state('active' as Sort);
22
const followsMap = $derived(follows.get(selectedDid));
···
155
client={selectedClient}
156
sort={followingSort}
157
{currentTime}
158
/>
159
{/snippet}
160
</VirtualList>
···
1
<script lang="ts">
2
import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte';
3
import type { Did } from '@atcute/lexicons';
4
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
5
import { type AtpClient } from '$lib/at/client';
6
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list';
7
import {
···
15
interface Props {
16
selectedDid: Did;
17
selectedClient: AtpClient;
18
+
onProfileClick: (did: AtprotoDid) => void;
19
}
20
21
+
const { selectedDid, selectedClient, onProfileClick }: Props = $props();
22
23
let followingSort: Sort = $state('active' as Sort);
24
const followsMap = $derived(follows.get(selectedDid));
···
157
client={selectedClient}
158
sort={followingSort}
159
{currentTime}
160
+
onClick={onProfileClick}
161
/>
162
{/snippet}
163
</VirtualList>
+88
src/components/ProfileInfo.svelte
+88
src/components/ProfileInfo.svelte
···
···
1
+
<script lang="ts">
2
+
import { AtpClient, resolveDidDoc } from '$lib/at/client';
3
+
import type { Did } from '@atcute/lexicons/syntax';
4
+
import type { AppBskyActorProfile } from '@atcute/bluesky';
5
+
import ProfilePicture from './ProfilePicture.svelte';
6
+
import RichText from './RichText.svelte';
7
+
import { onMount } from 'svelte';
8
+
9
+
interface Props {
10
+
client: AtpClient;
11
+
did: Did;
12
+
handle?: string;
13
+
profile?: AppBskyActorProfile.Main | null;
14
+
interactive?: boolean;
15
+
}
16
+
17
+
let { client, did, handle, profile = $bindable(null), interactive = true }: Props = $props();
18
+
19
+
onMount(async () => {
20
+
await Promise.all([
21
+
(async () => {
22
+
if (!profile) {
23
+
const res = await client.getProfile(did);
24
+
if (res.ok) profile = res.value;
25
+
}
26
+
})(),
27
+
(async () => {
28
+
if (!handle) {
29
+
const res = await resolveDidDoc(did);
30
+
if (res.ok) handle = res.value.handle;
31
+
}
32
+
})()
33
+
]);
34
+
});
35
+
36
+
let displayHandle = $derived(handle ?? 'handle.invalid');
37
+
let profileDesc = $derived(profile?.description?.trim() ?? '');
38
+
let showDid = $state(false);
39
+
</script>
40
+
41
+
<div class="flex flex-col gap-2">
42
+
<div class="flex items-center gap-2">
43
+
<ProfilePicture {client} {did} size={20} />
44
+
45
+
<div class="flex min-w-0 flex-col items-start overflow-hidden overflow-ellipsis">
46
+
<span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis">
47
+
{profile?.displayName ?? displayHandle}
48
+
{#if profile?.pronouns}
49
+
<span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span>
50
+
{/if}
51
+
</span>
52
+
<button
53
+
oncontextmenu={(e) => {
54
+
e.stopPropagation();
55
+
const node = e.target as Node;
56
+
const selection = window.getSelection() ?? new Selection();
57
+
const range = document.createRange();
58
+
range.selectNodeContents(node);
59
+
selection.removeAllRanges();
60
+
selection.addRange(range);
61
+
}}
62
+
onmousedown={(e) => {
63
+
// disable double clicks to disable "double click to select text"
64
+
// since it doesnt work with us toggling did vs handle
65
+
if (e.detail > 1) e.preventDefault();
66
+
}}
67
+
onclick={() => (showDid = !showDid)}
68
+
class="mb-0.5 text-nowrap opacity-85 select-text hover:underline"
69
+
>
70
+
{showDid ? did : `@${displayHandle}`}
71
+
</button>
72
+
{#if profile?.website}
73
+
<a
74
+
target="_blank"
75
+
rel="noopener noreferrer"
76
+
href={profile.website}
77
+
class="text-sm text-nowrap opacity-60 hover:underline">{profile.website}</a
78
+
>
79
+
{/if}
80
+
</div>
81
+
</div>
82
+
83
+
{#if profileDesc.length > 0}
84
+
<div class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word">
85
+
<RichText text={profileDesc} />
86
+
</div>
87
+
{/if}
88
+
</div>
+83
src/components/ProfileView.svelte
+83
src/components/ProfileView.svelte
···
···
1
+
<script lang="ts">
2
+
import { AtpClient } from '$lib/at/client';
3
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
4
+
import TimelineView from './TimelineView.svelte';
5
+
import ProfileInfo from './ProfileInfo.svelte';
6
+
import type { State as PostComposerState } from './PostComposer.svelte';
7
+
import Icon from '@iconify/svelte';
8
+
import { generateColorForDid } from '$lib/accounts';
9
+
import { img } from '$lib/cdn';
10
+
import { isBlob } from '@atcute/lexicons/interfaces';
11
+
import type { AppBskyActorProfile } from '@atcute/bluesky';
12
+
import { onMount } from 'svelte';
13
+
14
+
interface Props {
15
+
client: AtpClient;
16
+
did: AtprotoDid;
17
+
onBack: () => void;
18
+
postComposerState?: PostComposerState;
19
+
}
20
+
21
+
let { client, did, onBack, postComposerState = $bindable({ type: 'null' }) }: Props = $props();
22
+
23
+
let profile = $state<AppBskyActorProfile.Main | null>(null);
24
+
let loading = $state(true);
25
+
let error = $state<string | null>(null);
26
+
27
+
onMount(async () => {
28
+
const res = await client.getProfile(did);
29
+
if (res.ok) profile = res.value;
30
+
else error = res.error;
31
+
loading = false;
32
+
});
33
+
34
+
const color = $derived(generateColorForDid(did));
35
+
const bannerUrl = $derived(
36
+
profile && isBlob(profile.banner) ? img('feed_fullsize', did, profile.banner.ref.$link) : null
37
+
);
38
+
</script>
39
+
40
+
<div class="flex min-h-dvh flex-col">
41
+
<!-- Header -->
42
+
<div
43
+
class="sticky top-0 z-20 flex items-center gap-4 border-b-2 bg-(--nucleus-bg)/80 p-4 backdrop-blur-md"
44
+
style="border-color: {color}40;"
45
+
>
46
+
<button
47
+
onclick={onBack}
48
+
class="rounded-full p-1 text-(--nucleus-fg) transition-all hover:bg-(--nucleus-fg)/10"
49
+
>
50
+
<Icon icon="heroicons:arrow-left-20-solid" width={24} />
51
+
</button>
52
+
<h2 class="text-xl font-bold">
53
+
{profile?.displayName ?? (loading ? 'loading...' : 'profile')}
54
+
</h2>
55
+
</div>
56
+
57
+
{#if error}
58
+
<div class="p-8 text-center text-red-500">
59
+
<p>failed to load profile: {error}</p>
60
+
</div>
61
+
{:else}
62
+
<!-- Banner -->
63
+
<div class="relative h-32 w-full overflow-hidden bg-(--nucleus-fg)/5 md:h-48">
64
+
{#if bannerUrl}
65
+
<img src={bannerUrl} alt="banner" class="h-full w-full object-cover" />
66
+
{/if}
67
+
<div
68
+
class="absolute inset-0 bg-linear-to-b from-transparent to-(--nucleus-bg)"
69
+
style="opacity: 0.8;"
70
+
></div>
71
+
</div>
72
+
73
+
<div class="px-4 pb-4">
74
+
<div class="relative z-10 -mt-12 mb-4">
75
+
<ProfileInfo {client} {did} bind:profile />
76
+
</div>
77
+
78
+
<div class="my-4 h-px bg-white/10"></div>
79
+
80
+
<TimelineView {client} targetDid={did} bind:postComposerState class="min-h-[50vh]" />
81
+
</div>
82
+
{/if}
83
+
</div>
+28
-7
src/components/TimelineView.svelte
+28
-7
src/components/TimelineView.svelte
···
6
import { type ResourceUri } from '@atcute/lexicons';
7
import { SvelteSet } from 'svelte/reactivity';
8
import { InfiniteLoader, LoaderState } from 'svelte-infinite';
9
-
import { postCursors, fetchTimeline, allPosts, timelines } from '$lib/state.svelte';
10
import Icon from '@iconify/svelte';
11
import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread';
12
import type { AtprotoDid } from '@atcute/lexicons/syntax';
13
14
interface Props {
15
client?: AtpClient | null;
16
postComposerState: PostComposerState;
17
class?: string;
18
}
19
20
-
let { client = null, postComposerState = $bindable(), class: className = '' }: Props = $props();
21
22
let reverseChronological = $state(true);
23
let viewOwnPosts = $state(true);
24
const expandedThreads = new SvelteSet<ResourceUri>();
25
26
-
const did = $derived(client?.user?.did);
27
28
const threads = $derived(
29
filterThreads(
···
36
const loaderState = new LoaderState();
37
let scrollContainer = $state<HTMLDivElement>();
38
let loading = $state(false);
39
let loadError = $state('');
40
41
const loadMore = async () => {
42
-
if (loading || $accounts.length === 0 || !did) return;
43
44
loading = true;
45
loaderState.status = 'LOADING';
46
47
try {
48
await fetchTimeline(did as AtprotoDid);
49
loaderState.loaded();
50
} catch (error) {
51
loadError = `${error}`;
···
55
}
56
57
loading = false;
58
-
if (postCursors.values().every((cursor) => cursor.end)) loaderState.complete();
59
};
60
61
$effect(() => {
62
-
if (threads.length === 0 && !loading) loadMore();
63
});
64
</script>
65
···
128
class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}"
129
bind:this={scrollContainer}
130
>
131
-
{#if $accounts.length > 0}
132
<InfiniteLoader
133
{loaderState}
134
triggerLoad={loadMore}
···
6
import { type ResourceUri } from '@atcute/lexicons';
7
import { SvelteSet } from 'svelte/reactivity';
8
import { InfiniteLoader, LoaderState } from 'svelte-infinite';
9
+
import {
10
+
postCursors,
11
+
fetchTimeline,
12
+
allPosts,
13
+
timelines,
14
+
fetchInteractionsUntil
15
+
} from '$lib/state.svelte';
16
import Icon from '@iconify/svelte';
17
import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread';
18
import type { AtprotoDid } from '@atcute/lexicons/syntax';
19
20
interface Props {
21
client?: AtpClient | null;
22
+
targetDid?: AtprotoDid;
23
postComposerState: PostComposerState;
24
class?: string;
25
}
26
27
+
let {
28
+
client = null,
29
+
targetDid = undefined,
30
+
postComposerState = $bindable(),
31
+
class: className = ''
32
+
}: Props = $props();
33
34
let reverseChronological = $state(true);
35
let viewOwnPosts = $state(true);
36
const expandedThreads = new SvelteSet<ResourceUri>();
37
38
+
const did = $derived(targetDid ?? client?.user?.did);
39
40
const threads = $derived(
41
filterThreads(
···
48
const loaderState = new LoaderState();
49
let scrollContainer = $state<HTMLDivElement>();
50
let loading = $state(false);
51
+
let fetchMoreInteractions: boolean | undefined = $state(false);
52
let loadError = $state('');
53
54
const loadMore = async () => {
55
+
if (loading || !client || !did) return;
56
57
loading = true;
58
loaderState.status = 'LOADING';
59
60
try {
61
await fetchTimeline(did as AtprotoDid);
62
+
// interaction fetching is done lazily so we dont block loading posts
63
+
fetchMoreInteractions = true;
64
loaderState.loaded();
65
} catch (error) {
66
loadError = `${error}`;
···
70
}
71
72
loading = false;
73
+
const cursor = postCursors.get(did as AtprotoDid);
74
+
if (cursor && cursor.end) loaderState.complete();
75
};
76
77
$effect(() => {
78
+
if (threads.length === 0 && !loading && did) loadMore();
79
+
if (client && did && fetchMoreInteractions) {
80
+
// set to false so it doesnt attempt to fetch again while its already fetching
81
+
fetchMoreInteractions = false;
82
+
fetchInteractionsUntil(client, did).then(() => (fetchMoreInteractions = undefined));
83
+
}
84
});
85
</script>
86
···
149
class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}"
150
bind:this={scrollContainer}
151
>
152
+
{#if targetDid || $accounts.length > 0}
153
<InfiniteLoader
154
{loaderState}
155
triggerLoad={loadMore}
+39
-15
src/lib/at/client.ts
+39
-15
src/lib/at/client.ts
···
80
return res.value;
81
});
82
83
-
const cache = cacheWithRecords;
84
85
export class AtpClient {
86
public atcute: AtcuteClient | null = null;
···
162
Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string>
163
> {
164
if (!this.atcute || !this.user) return err('not authenticated');
165
-
const res = await this.atcute.get('com.atproto.repo.listRecords', {
166
-
params: {
167
-
repo: this.user.did,
168
collection,
169
cursor,
170
-
limit,
171
-
reverse: false
172
-
}
173
-
});
174
-
if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`);
175
-
176
-
for (const record of res.data.records)
177
-
await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24);
178
-
179
-
return ok(res.data);
180
}
181
182
async listRecordsUntil<Collection extends keyof Records>(
···
204
data.cursor
205
);
206
end = true;
207
-
} else if (cursorTimestamp < timestamp) {
208
end = true;
209
} else {
210
console.info(
···
80
return res.value;
81
});
82
83
+
type ListRecordsParams = {
84
+
atcute: AtcuteClient;
85
+
did: Did;
86
+
collection: Nsid;
87
+
cursor?: string;
88
+
limit?: number;
89
+
};
90
+
const cacheWithListRecords = cacheWithRecords.define(
91
+
'listRecords',
92
+
async (params: ListRecordsParams) => {
93
+
const res = await params.atcute.get('com.atproto.repo.listRecords', {
94
+
params: {
95
+
repo: params.did,
96
+
collection: params.collection,
97
+
cursor: params.cursor,
98
+
limit: params.limit ?? 100,
99
+
reverse: false
100
+
}
101
+
});
102
+
if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`);
103
+
104
+
for (const record of res.data.records)
105
+
await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24);
106
+
107
+
return ok(res.data);
108
+
}
109
+
);
110
+
111
+
const cache = cacheWithListRecords;
112
113
export class AtpClient {
114
public atcute: AtcuteClient | null = null;
···
190
Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string>
191
> {
192
if (!this.atcute || !this.user) return err('not authenticated');
193
+
try {
194
+
return (await cache.listRecords({
195
+
atcute: this.atcute,
196
+
did: this.user.did,
197
collection,
198
cursor,
199
+
limit
200
+
})) as Awaited<ReturnType<typeof this.listRecords>>;
201
+
} catch (e) {
202
+
return err(String(e));
203
+
}
204
}
205
206
async listRecordsUntil<Collection extends keyof Records>(
···
228
data.cursor
229
);
230
end = true;
231
+
} else if (cursorTimestamp <= timestamp) {
232
end = true;
233
} else {
234
console.info(
+1
src/lib/cache.ts
+1
src/lib/cache.ts
+19
-15
src/lib/state.svelte.ts
+19
-15
src/lib/state.svelte.ts
···
114
const [_collection, source] = backlinkSource.split(':');
115
const collection = _collection as keyof Records;
116
const cursor = cursorMap.get(backlinkSource);
117
console.log(`${did}: fetchLinksUntil`, backlinkSource, cursor, timestamp);
118
const result = await client.listRecordsUntil(collection, cursor, timestamp);
119
···
193
export const pulsingPostId = writable<string | null>(null);
194
195
export const viewClient = new AtpClient();
196
-
export const clients = new SvelteMap<AtprotoDid, AtpClient>();
197
-
export const getClient = async (did: AtprotoDid): Promise<AtpClient> => {
198
if (!clients.has(did)) clients.set(did, await newPublicClient(did));
199
return clients.get(did)!;
200
};
···
232
const cursorTimestamp = timestampFromCursor(res.value.cursor) ?? -1;
233
const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000;
234
const timestamp = Math.min(cursorTimestamp, threeDaysAgo);
235
-
console.log(`${did}: fetchFollowPosts`, res.value.cursor, timestamp);
236
await Promise.all([repostSource].map((s) => fetchLinksUntil(client, s, timestamp)));
237
};
238
···
249
{ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main } as PostWithUri
250
]);
251
addPosts(postsWithUri);
252
-
postCursors.set(did, { value: newPosts.cursor, end: newPosts.cursor === undefined });
253
};
254
255
export const addPosts = (newPosts: Iterable<[ResourceUri, PostWithUri]>) => {
···
296
};
297
298
export const fetchTimeline = async (did: AtprotoDid, limit: number = 6) => {
299
-
const client = await getClient(did);
300
301
const cursor = postCursors.get(did);
302
if (cursor && cursor.end) return;
303
304
-
const accPosts = await fetchPostsWithBacklinks(client, cursor?.value, limit);
305
if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`;
306
307
// if the cursor is undefined, we've reached the end of the timeline
308
-
if (!accPosts.value.cursor) {
309
-
postCursors.set(did, { ...cursor, end: true });
310
-
return;
311
-
}
312
-
313
-
postCursors.set(did, { value: accPosts.value.cursor, end: false });
314
-
const hydrated = await hydratePosts(client, did, accPosts.value.posts);
315
if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`;
316
317
addPosts(hydrated.value);
318
addTimeline(did, hydrated.value.keys());
319
320
-
const timestamp = timestampFromCursor(accPosts.value.cursor);
321
-
console.log(`${did}: fetchTimeline`, accPosts.value.cursor, timestamp);
322
await Promise.all([likeSource, repostSource].map((s) => fetchLinksUntil(client, s, timestamp)));
323
};
324
···
114
const [_collection, source] = backlinkSource.split(':');
115
const collection = _collection as keyof Records;
116
const cursor = cursorMap.get(backlinkSource);
117
+
118
+
// if already fetched we dont need to fetch again
119
+
const cursorTimestamp = timestampFromCursor(cursor);
120
+
if (cursorTimestamp && cursorTimestamp <= timestamp) return;
121
+
122
console.log(`${did}: fetchLinksUntil`, backlinkSource, cursor, timestamp);
123
const result = await client.listRecordsUntil(collection, cursor, timestamp);
124
···
198
export const pulsingPostId = writable<string | null>(null);
199
200
export const viewClient = new AtpClient();
201
+
export const clients = new SvelteMap<Did, AtpClient>();
202
+
export const getClient = async (did: Did): Promise<AtpClient> => {
203
if (!clients.has(did)) clients.set(did, await newPublicClient(did));
204
return clients.get(did)!;
205
};
···
237
const cursorTimestamp = timestampFromCursor(res.value.cursor) ?? -1;
238
const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000;
239
const timestamp = Math.min(cursorTimestamp, threeDaysAgo);
240
+
console.log(`${did}: fetchForInteractions`, res.value.cursor, timestamp);
241
await Promise.all([repostSource].map((s) => fetchLinksUntil(client, s, timestamp)));
242
};
243
···
254
{ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main } as PostWithUri
255
]);
256
addPosts(postsWithUri);
257
};
258
259
export const addPosts = (newPosts: Iterable<[ResourceUri, PostWithUri]>) => {
···
300
};
301
302
export const fetchTimeline = async (did: AtprotoDid, limit: number = 6) => {
303
+
const targetClient = await getClient(did);
304
305
const cursor = postCursors.get(did);
306
if (cursor && cursor.end) return;
307
308
+
const accPosts = await fetchPostsWithBacklinks(targetClient, cursor?.value, limit);
309
if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`;
310
311
// if the cursor is undefined, we've reached the end of the timeline
312
+
postCursors.set(did, { value: accPosts.value.cursor, end: !accPosts.value.cursor });
313
+
const hydrated = await hydratePosts(targetClient, did, accPosts.value.posts);
314
if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`;
315
316
addPosts(hydrated.value);
317
addTimeline(did, hydrated.value.keys());
318
319
+
console.log(`${did}: fetchTimeline`, accPosts.value.cursor);
320
+
};
321
+
322
+
export const fetchInteractionsUntil = async (client: AtpClient, did: Did) => {
323
+
const cursor = postCursors.get(did);
324
+
if (!cursor) return;
325
+
const timestamp = timestampFromCursor(cursor.value);
326
await Promise.all([likeSource, repostSource].map((s) => fetchLinksUntil(client, s, timestamp)));
327
};
328
+36
-5
src/routes/+page.svelte
+36
-5
src/routes/+page.svelte
···
5
import NotificationsView from '$components/NotificationsView.svelte';
6
import FollowingView from '$components/FollowingView.svelte';
7
import TimelineView from '$components/TimelineView.svelte';
8
import { AtpClient, streamNotifications } from '$lib/at/client';
9
import { accounts, type Account } from '$lib/accounts';
10
import { onMount, tick } from 'svelte';
···
68
handleAccountSelected(newAccounts[0]?.did);
69
};
70
71
-
type View = 'timeline' | 'notifications' | 'following' | 'settings';
72
let currentView = $state<View>('timeline');
73
let animClass = $state('animate-fade-in-scale');
74
let scrollPositions = new SvelteMap<View, number>();
75
76
const viewOrder: Record<View, number> = {
77
timeline: 0,
78
following: 1,
79
notifications: 2,
80
-
settings: 3
81
};
82
83
const switchView = async (newView: View) => {
···
85
scrollPositions.set(currentView, window.scrollY);
86
87
const direction = viewOrder[newView] > viewOrder[currentView] ? 'right' : 'left';
88
-
animClass = direction === 'right' ? 'animate-slide-in-right' : 'animate-slide-in-left';
89
currentView = newView;
90
91
await tick();
92
93
window.scrollTo({ top: scrollPositions.get(newView) || 0, behavior: 'instant' });
94
};
95
96
let postComposerState = $state<PostComposerState>({ type: 'null' });
···
216
{/if}
217
{#if currentView === 'following'}
218
<div class={animClass}>
219
-
<FollowingView selectedClient={selectedClient!} selectedDid={selectedDid!} />
220
</div>
221
{/if}
222
</div>
···
247
248
<div
249
class="
250
-
{currentView === 'timeline' || currentView === 'following' ? '' : 'hidden'}
251
z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all
252
"
253
>
···
5
import NotificationsView from '$components/NotificationsView.svelte';
6
import FollowingView from '$components/FollowingView.svelte';
7
import TimelineView from '$components/TimelineView.svelte';
8
+
import ProfileView from '$components/ProfileView.svelte';
9
import { AtpClient, streamNotifications } from '$lib/at/client';
10
import { accounts, type Account } from '$lib/accounts';
11
import { onMount, tick } from 'svelte';
···
69
handleAccountSelected(newAccounts[0]?.did);
70
};
71
72
+
type View = 'timeline' | 'notifications' | 'following' | 'settings' | 'profile';
73
let currentView = $state<View>('timeline');
74
let animClass = $state('animate-fade-in-scale');
75
let scrollPositions = new SvelteMap<View, number>();
76
+
let viewingProfileDid = $state<AtprotoDid | null>(null);
77
+
let previousView = $state<View>('timeline');
78
79
const viewOrder: Record<View, number> = {
80
timeline: 0,
81
following: 1,
82
notifications: 2,
83
+
settings: 3,
84
+
profile: 4
85
};
86
87
const switchView = async (newView: View) => {
···
89
scrollPositions.set(currentView, window.scrollY);
90
91
const direction = viewOrder[newView] > viewOrder[currentView] ? 'right' : 'left';
92
+
// Profile always slides in from right unless going back
93
+
if (newView === 'profile') animClass = 'animate-slide-in-right';
94
+
else if (currentView === 'profile') animClass = 'animate-slide-in-left';
95
+
else animClass = direction === 'right' ? 'animate-slide-in-right' : 'animate-slide-in-left';
96
+
// Don't overwrite previousView if we're just going to profile
97
+
if (newView !== 'profile' && currentView !== 'profile') previousView = currentView;
98
+
else if (newView === 'profile' && currentView !== 'profile') previousView = currentView;
99
currentView = newView;
100
101
await tick();
102
103
window.scrollTo({ top: scrollPositions.get(newView) || 0, behavior: 'instant' });
104
+
};
105
+
106
+
const goToProfile = (did: AtprotoDid) => {
107
+
viewingProfileDid = did;
108
+
switchView('profile');
109
};
110
111
let postComposerState = $state<PostComposerState>({ type: 'null' });
···
231
{/if}
232
{#if currentView === 'following'}
233
<div class={animClass}>
234
+
<FollowingView
235
+
selectedClient={selectedClient!}
236
+
selectedDid={selectedDid!}
237
+
onProfileClick={goToProfile}
238
+
/>
239
+
</div>
240
+
{/if}
241
+
{#if currentView === 'profile' && viewingProfileDid}
242
+
<div class={animClass}>
243
+
<ProfileView
244
+
client={selectedClient!}
245
+
did={viewingProfileDid}
246
+
onBack={() => switchView(previousView)}
247
+
bind:postComposerState
248
+
/>
249
</div>
250
{/if}
251
</div>
···
276
277
<div
278
class="
279
+
{currentView === 'timeline' || currentView === 'following' || currentView === 'profile'
280
+
? ''
281
+
: 'hidden'}
282
z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all
283
"
284
>