+6
-6
src/components/BskyPost.svelte
+6
-6
src/components/BskyPost.svelte
···
16
type ResourceUri
17
} from '@atcute/lexicons';
18
import { expect, ok } from '$lib/result';
19
-
import { generateColorForDid } from '$lib/accounts';
20
import ProfilePicture from './ProfilePicture.svelte';
21
import { isBlob } from '@atcute/lexicons/interfaces';
22
import { blob, img } from '$lib/cdn';
23
import BskyPost from './BskyPost.svelte';
24
import Icon from '@iconify/svelte';
25
import {
26
-
clients,
27
allPosts,
28
pulsingPostId,
29
currentTime,
···
33
} from '$lib/state.svelte';
34
import type { PostWithUri } from '$lib/at/fetch';
35
import { onMount } from 'svelte';
36
-
import { type AtprotoDid } from '@atcute/lexicons/syntax';
37
import { derived } from 'svelte/store';
38
import Device from 'svelte-device-info';
39
import Dropdown from './Dropdown.svelte';
···
71
}: Props = $props();
72
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));
···
153
return;
154
}
155
156
-
actionClient?.atcute
157
?.post('com.atproto.repo.deleteRecord', {
158
input: {
159
collection: 'app.bsky.feed.post',
···
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>
···
444
{@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () =>
445
navigator.clipboard.writeText(post.record.text)
446
)}
447
-
{#if actionClient}
448
<div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div>
449
{@render dropdownItem(
450
deleteState === 'confirm' ? 'heroicons:check-20-solid' : 'heroicons:trash-20-solid',
···
16
type ResourceUri
17
} from '@atcute/lexicons';
18
import { expect, ok } from '$lib/result';
19
+
import { accounts, generateColorForDid } from '$lib/accounts';
20
import ProfilePicture from './ProfilePicture.svelte';
21
import { isBlob } from '@atcute/lexicons/interfaces';
22
import { blob, img } from '$lib/cdn';
23
import BskyPost from './BskyPost.svelte';
24
import Icon from '@iconify/svelte';
25
import {
26
allPosts,
27
pulsingPostId,
28
currentTime,
···
32
} from '$lib/state.svelte';
33
import type { PostWithUri } from '$lib/at/fetch';
34
import { onMount } from 'svelte';
35
import { derived } from 'svelte/store';
36
import Device from 'svelte-device-info';
37
import Dropdown from './Dropdown.svelte';
···
69
}: Props = $props();
70
71
const selectedDid = $derived(client.user?.did ?? null);
72
+
const isLoggedInUser = $derived($accounts.some((acc) => acc.did === did));
73
74
const aturi = $derived(`at://${did}/app.bsky.feed.post/${rkey}` as CanonicalResourceUri);
75
const color = $derived(generateColorForDid(did));
···
151
return;
152
}
153
154
+
client?.atcute
155
?.post('com.atproto.repo.deleteRecord', {
156
input: {
157
collection: 'app.bsky.feed.post',
···
210
style="background: {color}36; border-color: {color}99;"
211
bind:isOpen={profileOpen}
212
trigger={profileInline}
213
+
onMouseEnter={() => (profileOpen = true)}
214
+
onMouseLeave={() => (profileOpen = false)}
215
>
216
<ProfileInfo {client} {did} {handle} {profile} />
217
</Dropdown>
···
444
{@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () =>
445
navigator.clipboard.writeText(post.record.text)
446
)}
447
+
{#if isLoggedInUser}
448
<div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div>
449
{@render dropdownItem(
450
deleteState === 'confirm' ? 'heroicons:check-20-solid' : 'heroicons:trash-20-solid',
+59
-5
src/components/Dropdown.svelte
+59
-5
src/components/Dropdown.svelte
···
20
placement?: Placement;
21
offsetDistance?: number;
22
position?: { x: number; y: number };
23
}
24
25
let {
···
29
placement = 'bottom-start',
30
offsetDistance = 2,
31
position = $bindable(),
32
...restProps
33
}: Props = $props();
34
35
let triggerRef: HTMLElement | undefined = $state();
36
let contentRef: HTMLElement | undefined = $state();
37
let cleanup: (() => void) | null = null;
38
39
const updatePosition = async () => {
40
const { x, y } = await computePosition(triggerRef!, contentRef!, {
···
70
71
const handleScroll = handleClose;
72
73
$effect(() => {
74
if (isOpen) {
75
cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition);
···
79
}
80
});
81
82
-
onMount(() => {
83
-
return () => {
84
-
if (cleanup) cleanup();
85
-
};
86
});
87
</script>
88
89
<svelte:window onkeydown={handleEscape} onmousedown={handleClickOutside} onscroll={handleScroll} />
90
91
-
<div role="button" tabindex="0" bind:this={triggerRef}>
92
{@render trigger?.()}
93
</div>
94
···
100
style={restProps.style}
101
role="menu"
102
tabindex="-1"
103
>
104
{@render children?.()}
105
</div>
···
20
placement?: Placement;
21
offsetDistance?: number;
22
position?: { x: number; y: number };
23
+
onMouseEnter?: () => void;
24
+
onMouseLeave?: () => void;
25
}
26
27
let {
···
31
placement = 'bottom-start',
32
offsetDistance = 2,
33
position = $bindable(),
34
+
onMouseEnter,
35
+
onMouseLeave,
36
...restProps
37
}: Props = $props();
38
39
let triggerRef: HTMLElement | undefined = $state();
40
let contentRef: HTMLElement | undefined = $state();
41
let cleanup: (() => void) | null = null;
42
+
43
+
// State-based tracking for hover logic
44
+
let isTriggerHovered = false;
45
+
let isContentHovered = false;
46
+
let closeTimer: ReturnType<typeof setTimeout>;
47
48
const updatePosition = async () => {
49
const { x, y } = await computePosition(triggerRef!, contentRef!, {
···
79
80
const handleScroll = handleClose;
81
82
+
// The central check: "Should we close now?"
83
+
const scheduleCloseCheck = () => {
84
+
clearTimeout(closeTimer);
85
+
closeTimer = setTimeout(() => {
86
+
// Only close if we are NOT on the trigger AND NOT on the content
87
+
if (!isTriggerHovered && !isContentHovered) if (isOpen && onMouseLeave) onMouseLeave();
88
+
}, 30); // Small buffer to handle the physical gap between elements
89
+
};
90
+
91
+
const handleTriggerEnter = () => {
92
+
isTriggerHovered = true;
93
+
clearTimeout(closeTimer); // We are safe, cancel any pending close
94
+
if (!isOpen && onMouseEnter) onMouseEnter();
95
+
};
96
+
97
+
const handleTriggerLeave = () => {
98
+
isTriggerHovered = false;
99
+
scheduleCloseCheck(); // We left the trigger, check if we should close
100
+
};
101
+
102
+
const handleContentEnter = () => {
103
+
isContentHovered = true;
104
+
clearTimeout(closeTimer); // We made it to the content, cancel close
105
+
};
106
+
107
+
const handleContentLeave = () => {
108
+
isContentHovered = false;
109
+
scheduleCloseCheck(); // We left the content, check if we should close
110
+
};
111
+
112
+
// Reset state if the menu is closed externally
113
+
$effect(() => {
114
+
if (!isOpen) {
115
+
isContentHovered = false;
116
+
clearTimeout(closeTimer);
117
+
}
118
+
});
119
+
120
$effect(() => {
121
if (isOpen) {
122
cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition);
···
126
}
127
});
128
129
+
onMount(() => () => {
130
+
if (cleanup) cleanup();
131
+
clearTimeout(closeTimer);
132
});
133
</script>
134
135
<svelte:window onkeydown={handleEscape} onmousedown={handleClickOutside} onscroll={handleScroll} />
136
137
+
<div
138
+
role="button"
139
+
tabindex="0"
140
+
bind:this={triggerRef}
141
+
onmouseenter={handleTriggerEnter}
142
+
onmouseleave={handleTriggerLeave}
143
+
>
144
{@render trigger?.()}
145
</div>
146
···
152
style={restProps.style}
153
role="menu"
154
tabindex="-1"
155
+
onmouseenter={handleContentEnter}
156
+
onmouseleave={handleContentLeave}
157
>
158
{@render children?.()}
159
</div>
+7
-2
src/components/FollowingView.svelte
+7
-2
src/components/FollowingView.svelte
···
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));
25
26
// eslint-disable-next-line @typescript-eslint/no-explicit-any
···
16
selectedDid: Did;
17
selectedClient: AtpClient;
18
onProfileClick: (did: AtprotoDid) => void;
19
+
followingSort: Sort;
20
}
21
22
+
let {
23
+
selectedDid,
24
+
selectedClient,
25
+
onProfileClick,
26
+
followingSort = $bindable('active')
27
+
}: Props = $props();
28
29
const followsMap = $derived(follows.get(selectedDid));
30
31
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+1
-2
src/components/ProfileInfo.svelte
+1
-2
src/components/ProfileInfo.svelte
+7
-1
src/components/ProfileView.svelte
+7
-1
src/components/ProfileView.svelte
+5
-1
src/components/TimelineView.svelte
+5
-1
src/components/TimelineView.svelte
···
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();
···
38
const did = $derived(targetDid ?? client?.user?.did);
39
40
const threads = $derived(
41
filterThreads(
42
did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts) : [],
43
$accounts,
···
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();
···
22
targetDid?: AtprotoDid;
23
postComposerState: PostComposerState;
24
class?: string;
25
+
// whether to show replies that are not the user's own posts
26
+
showReplies?: boolean;
27
}
28
29
let {
30
client = null,
31
targetDid = undefined,
32
+
showReplies = true,
33
postComposerState = $bindable(),
34
class: className = ''
35
}: Props = $props();
···
41
const did = $derived(targetDid ?? client?.user?.did);
42
43
const threads = $derived(
44
+
// todo: apply showReplies here
45
filterThreads(
46
did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts) : [],
47
$accounts,
···
62
loaderState.status = 'LOADING';
63
64
try {
65
+
await fetchTimeline(did as AtprotoDid, 7, showReplies);
66
// interaction fetching is done lazily so we dont block loading posts
67
fetchMoreInteractions = true;
68
loaderState.loaded();
+14
-38
src/lib/at/client.ts
+14
-38
src/lib/at/client.ts
···
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>(
···
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>(
+43
-18
src/lib/at/fetch.ts
+43
-18
src/lib/at/fetch.ts
···
5
type ResourceUri
6
} from '@atcute/lexicons';
7
import { type AtpClient } from './client';
8
-
import { err, expect, ok, type Result } from '$lib/result';
9
import type { Backlinks } from './constellation';
10
import { AppBskyFeedPost } from '@atcute/bluesky';
11
-
import type { AtprotoDid } from '@atcute/lexicons/syntax';
12
import { replySource } from '$lib';
13
14
export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main };
15
export type PostWithBacklinks = PostWithUri & {
16
-
replies: Backlinks;
17
};
18
-
export type PostsWithReplyBacklinks = PostWithBacklinks[];
19
20
-
export const fetchPostsWithBacklinks = async (
21
client: AtpClient,
22
cursor?: string,
23
-
limit?: number
24
-
): Promise<Result<{ posts: PostsWithReplyBacklinks; cursor?: string }, string>> => {
25
const recordsList = await client.listRecords('app.bsky.feed.post', cursor, limit);
26
if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`);
27
cursor = recordsList.value.cursor;
28
const records = recordsList.value.records;
29
30
try {
31
const allBacklinks = await Promise.all(
32
records.map(async (r): Promise<PostWithBacklinks> => {
···
50
export const hydratePosts = async (
51
client: AtpClient,
52
repo: AtprotoDid,
53
-
data: PostsWithReplyBacklinks
54
): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => {
55
let posts: Map<ResourceUri, PostWithUri> = new Map();
56
try {
57
const allPosts = await Promise.all(
58
data.map(async (post) => {
59
const result: PostWithUri[] = [post];
60
-
const replies = await Promise.all(
61
-
post.replies.records.map(async (r) => {
62
-
const reply = await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey);
63
-
if (!reply.ok) throw `cant fetch reply: ${reply.error}`;
64
-
return reply.value;
65
-
})
66
-
);
67
-
result.push(...replies);
68
return result;
69
})
70
);
···
79
const parentUri = parent.uri as CanonicalResourceUri;
80
// if we already have this parent, then we already fetched this chain / are fetching it
81
if (posts.has(parentUri)) return;
82
-
const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parentUri);
83
if (p.ok) {
84
posts.set(p.value.uri, p.value);
85
parent = p.value.record.reply?.parent;
···
105
if (reply.did !== postRepo) continue;
106
// if we already have this reply, then we already fetched this chain / are fetching it
107
if (posts.has(`at://${reply.did}/${reply.collection}/${reply.rkey}`)) continue;
108
-
const record = await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey);
109
if (!record.ok) break; // TODO: this doesnt handle deleted posts in between
110
posts.set(record.value.uri, record.value);
111
promises.push(fetchDownwardsChain(record.value));
···
5
type ResourceUri
6
} from '@atcute/lexicons';
7
import { type AtpClient } from './client';
8
+
import { err, expect, ok, type Ok, type Result } from '$lib/result';
9
import type { Backlinks } from './constellation';
10
import { AppBskyFeedPost } from '@atcute/bluesky';
11
+
import type { AtprotoDid, Did, RecordKey } from '@atcute/lexicons/syntax';
12
import { replySource } from '$lib';
13
14
export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main };
15
export type PostWithBacklinks = PostWithUri & {
16
+
replies?: Backlinks;
17
};
18
19
+
export const fetchPosts = async (
20
client: AtpClient,
21
cursor?: string,
22
+
limit?: number,
23
+
withBacklinks: boolean = true
24
+
): Promise<Result<{ posts: PostWithBacklinks[]; cursor?: string }, string>> => {
25
const recordsList = await client.listRecords('app.bsky.feed.post', cursor, limit);
26
if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`);
27
cursor = recordsList.value.cursor;
28
const records = recordsList.value.records;
29
30
+
if (!withBacklinks) {
31
+
return ok({
32
+
posts: records.map((r) => ({
33
+
uri: r.uri,
34
+
cid: r.cid,
35
+
record: r.value as AppBskyFeedPost.Main
36
+
})),
37
+
cursor
38
+
});
39
+
}
40
+
41
try {
42
const allBacklinks = await Promise.all(
43
records.map(async (r): Promise<PostWithBacklinks> => {
···
61
export const hydratePosts = async (
62
client: AtpClient,
63
repo: AtprotoDid,
64
+
data: PostWithBacklinks[],
65
+
cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined
66
): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => {
67
let posts: Map<ResourceUri, PostWithUri> = new Map();
68
try {
69
const allPosts = await Promise.all(
70
data.map(async (post) => {
71
const result: PostWithUri[] = [post];
72
+
if (post.replies) {
73
+
const replies = await Promise.all(
74
+
post.replies.records.map(async (r) => {
75
+
const reply =
76
+
cacheFn(r.did, r.rkey) ??
77
+
(await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey));
78
+
if (!reply.ok) throw `cant fetch reply: ${reply.error}`;
79
+
return reply.value;
80
+
})
81
+
);
82
+
result.push(...replies);
83
+
}
84
return result;
85
})
86
);
···
95
const parentUri = parent.uri as CanonicalResourceUri;
96
// if we already have this parent, then we already fetched this chain / are fetching it
97
if (posts.has(parentUri)) return;
98
+
const parsedParentUri = expect(parseCanonicalResourceUri(parentUri));
99
+
const p =
100
+
cacheFn(parsedParentUri.repo, parsedParentUri.rkey) ??
101
+
(await client.getRecord(
102
+
AppBskyFeedPost.mainSchema,
103
+
parsedParentUri.repo,
104
+
parsedParentUri.rkey
105
+
));
106
if (p.ok) {
107
posts.set(p.value.uri, p.value);
108
parent = p.value.record.reply?.parent;
···
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(`at://${reply.did}/${reply.collection}/${reply.rkey}`)) 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));
+11
-11
src/lib/result.ts
+11
-11
src/lib/result.ts
···
1
-
export type Result<T, E> =
2
-
| {
3
-
ok: true;
4
-
value: T;
5
-
}
6
-
| {
7
-
ok: false;
8
-
error: E;
9
-
};
10
11
-
export const ok = <T, E>(value: T): Result<T, E> => {
12
return { ok: true, value };
13
};
14
-
export const err = <T, E>(error: E): Result<T, E> => {
15
return { ok: false, error };
16
};
17
export const expect = <T, E>(v: Result<T, E>, msg: string = 'expected result to not be error:') => {
···
1
+
export type Ok<T> = {
2
+
ok: true;
3
+
value: T;
4
+
};
5
+
export type Err<E> = {
6
+
ok: false;
7
+
error: E;
8
+
};
9
+
export type Result<T, E> = Ok<T> | Err<E>;
10
11
+
export const ok = <T>(value: T): Ok<T> => {
12
return { ok: true, value };
13
};
14
+
export const err = <E>(error: E): Err<E> => {
15
return { ok: false, error };
16
};
17
export const expect = <T, E>(v: Result<T, E>, msg: string = 'expected result to not be error:') => {
+57
-22
src/lib/state.svelte.ts
+57
-22
src/lib/state.svelte.ts
···
1
-
import { writable } from 'svelte/store';
2
import {
3
AtpClient,
4
newPublicClient,
···
6
type NotificationsStreamEvent
7
} from './at/client';
8
import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity';
9
-
import type { Did, InferOutput, Nsid, ResourceUri } from '@atcute/lexicons';
10
-
import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from './at/fetch';
11
import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax';
12
import { AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky';
13
import type { ComAtprotoRepoListRecords } from '@atcute/atproto';
14
import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream';
15
-
import { expect } from './result';
16
import type { Backlink, BacklinksSource } from './at/constellation';
17
import { now as tidNow } from '@atcute/tid';
18
import type { Records } from '@atcute/lexicons/ambient';
···
247
// did -> post uris that are replies to that did
248
export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>();
249
250
export const addPostsRaw = (
251
did: AtprotoDid,
252
newPosts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']>
253
) => {
254
-
const postsWithUri = newPosts.records.map((post): [ResourceUri, PostWithUri] => [
255
-
post.uri,
256
-
{ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main } as PostWithUri
257
-
]);
258
addPosts(postsWithUri);
259
};
260
261
-
export const addPosts = (newPosts: Iterable<[ResourceUri, PostWithUri]>) => {
262
-
for (const [uri, post] of newPosts) {
263
-
const parsedUri = expect(parseCanonicalResourceUri(uri));
264
let posts = allPosts.get(parsedUri.repo);
265
if (!posts) {
266
posts = new SvelteMap();
267
allPosts.set(parsedUri.repo, posts);
268
}
269
-
posts.set(uri, post);
270
const link: Backlink = {
271
did: parsedUri.repo,
272
collection: parsedUri.collection,
···
283
set = new SvelteSet();
284
replyIndex.set(parentDid, set);
285
}
286
-
set.add(uri);
287
}
288
}
289
}
···
292
export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>();
293
export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>();
294
295
export const addTimeline = (did: Did, uris: Iterable<ResourceUri>) => {
296
let timeline = timelines.get(did);
297
if (!timeline) {
298
timeline = new SvelteSet();
299
timelines.set(did, timeline);
300
}
301
-
for (const uri of uris) timeline.add(uri);
302
};
303
304
-
export const fetchTimeline = async (did: AtprotoDid, limit: number = 6) => {
305
const targetClient = await getClient(did);
306
307
const cursor = postCursors.get(did);
308
if (cursor && cursor.end) return;
309
310
-
const accPosts = await fetchPostsWithBacklinks(targetClient, cursor?.value, limit);
311
if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`;
312
313
// if the cursor is undefined, we've reached the end of the timeline
314
postCursors.set(did, { value: accPosts.value.cursor, end: !accPosts.value.cursor });
315
-
const hydrated = await hydratePosts(targetClient, did, accPosts.value.posts);
316
if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`;
317
318
-
addPosts(hydrated.value);
319
addTimeline(did, hydrated.value.keys());
320
321
console.log(`${did}: fetchTimeline`, accPosts.value.cursor);
···
342
// assume record is valid, we trust the jetstream
343
record: record as AppBskyFeedPost.Main
344
};
345
-
addPosts([[uri, post]]);
346
addTimeline(did, [uri]);
347
} else if (commit.operation === 'delete') {
348
allPosts.get(did)?.delete(uri);
···
362
if (!subjectPost.ok) return;
363
364
const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
365
-
const hydrated = await hydratePosts(client, did, [
366
{
367
record: subjectPost.value.record,
368
uri: event.data.link.subject,
···
379
]
380
}
381
}
382
-
]);
383
if (!hydrated.ok) {
384
console.error(`cant hydrate posts ${did}: ${hydrated.error}`);
385
return;
386
}
387
388
// console.log(hydrated);
389
-
addPosts(hydrated.value);
390
addTimeline(did, hydrated.value.keys());
391
};
392
···
414
setInterval(() => {
415
currentTime.setTime(Date.now());
416
}, 1000);
···
1
+
import { get, writable } from 'svelte/store';
2
import {
3
AtpClient,
4
newPublicClient,
···
6
type NotificationsStreamEvent
7
} from './at/client';
8
import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity';
9
+
import type { Did, InferOutput, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons';
10
+
import { fetchPosts, hydratePosts, type PostWithUri } from './at/fetch';
11
import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax';
12
import { AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky';
13
import type { ComAtprotoRepoListRecords } from '@atcute/atproto';
14
import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream';
15
+
import { expect, ok } from './result';
16
import type { Backlink, BacklinksSource } from './at/constellation';
17
import { now as tidNow } from '@atcute/tid';
18
import type { Records } from '@atcute/lexicons/ambient';
···
247
// did -> post uris that are replies to that did
248
export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>();
249
250
+
export const getPost = (did: Did, rkey: RecordKey) =>
251
+
allPosts.get(did)?.get(`at://${did}/app.bsky.feed.post/${rkey}`);
252
+
const hydrateCacheFn: Parameters<typeof hydratePosts>[3] = (did, rkey) => {
253
+
const cached = getPost(did, rkey);
254
+
return cached ? ok(cached) : undefined;
255
+
};
256
+
257
export const addPostsRaw = (
258
did: AtprotoDid,
259
newPosts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']>
260
) => {
261
+
const postsWithUri = newPosts.records.map(
262
+
(post) =>
263
+
({ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main }) as PostWithUri
264
+
);
265
addPosts(postsWithUri);
266
};
267
268
+
export const addPosts = (newPosts: Iterable<PostWithUri>) => {
269
+
for (const post of newPosts) {
270
+
const parsedUri = expect(parseCanonicalResourceUri(post.uri));
271
let posts = allPosts.get(parsedUri.repo);
272
if (!posts) {
273
posts = new SvelteMap();
274
allPosts.set(parsedUri.repo, posts);
275
}
276
+
posts.set(post.uri, post);
277
const link: Backlink = {
278
did: parsedUri.repo,
279
collection: parsedUri.collection,
···
290
set = new SvelteSet();
291
replyIndex.set(parentDid, set);
292
}
293
+
set.add(post.uri);
294
}
295
}
296
}
···
299
export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>();
300
export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>();
301
302
+
const traversePostChain = (post: PostWithUri) => {
303
+
const result = [post.uri];
304
+
const parentUri = post.record.reply?.parent.uri;
305
+
if (parentUri) {
306
+
const parentPost = allPosts.get(extractDidFromUri(parentUri)!)?.get(parentUri);
307
+
if (parentPost) result.push(...traversePostChain(parentPost));
308
+
}
309
+
return result;
310
+
};
311
export const addTimeline = (did: Did, uris: Iterable<ResourceUri>) => {
312
let timeline = timelines.get(did);
313
if (!timeline) {
314
timeline = new SvelteSet();
315
timelines.set(did, timeline);
316
}
317
+
for (const uri of uris) {
318
+
const post = allPosts.get(did)?.get(uri);
319
+
// we need to traverse the post chain to add all posts in the chain to the timeline
320
+
// because the parent posts might not be in the timeline yet
321
+
const chain = post ? traversePostChain(post) : [];
322
+
for (const uri of chain) timeline.add(uri);
323
+
}
324
};
325
326
+
export const fetchTimeline = async (
327
+
did: AtprotoDid,
328
+
limit: number = 6,
329
+
withBacklinks: boolean = true
330
+
) => {
331
const targetClient = await getClient(did);
332
333
const cursor = postCursors.get(did);
334
if (cursor && cursor.end) return;
335
336
+
const accPosts = await fetchPosts(targetClient, cursor?.value, limit, withBacklinks);
337
if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`;
338
339
// if the cursor is undefined, we've reached the end of the timeline
340
postCursors.set(did, { value: accPosts.value.cursor, end: !accPosts.value.cursor });
341
+
const hydrated = await hydratePosts(targetClient, did, accPosts.value.posts, hydrateCacheFn);
342
if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`;
343
344
+
addPosts(hydrated.value.values());
345
addTimeline(did, hydrated.value.keys());
346
347
console.log(`${did}: fetchTimeline`, accPosts.value.cursor);
···
368
// assume record is valid, we trust the jetstream
369
record: record as AppBskyFeedPost.Main
370
};
371
+
addPosts([post]);
372
addTimeline(did, [uri]);
373
} else if (commit.operation === 'delete') {
374
allPosts.get(did)?.delete(uri);
···
388
if (!subjectPost.ok) return;
389
390
const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
391
+
const posts = [
392
{
393
record: subjectPost.value.record,
394
uri: event.data.link.subject,
···
405
]
406
}
407
}
408
+
];
409
+
const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn);
410
if (!hydrated.ok) {
411
console.error(`cant hydrate posts ${did}: ${hydrated.error}`);
412
return;
413
}
414
415
// console.log(hydrated);
416
+
addPosts(hydrated.value.values());
417
addTimeline(did, hydrated.value.keys());
418
};
419
···
441
setInterval(() => {
442
currentTime.setTime(Date.now());
443
}, 1000);
444
+
445
+
export type View = 'timeline' | 'notifications' | 'following' | 'settings' | 'profile';
446
+
export const currentView = writable<View>('timeline');
447
+
export const previousView = writable<View>('timeline');
448
+
export const setView = (view: View) => {
449
+
previousView.set(get(currentView));
450
+
currentView.set(view);
451
+
};
+1
-3
src/lib/theme.ts
+1
-3
src/lib/theme.ts
+44
-39
src/routes/+page.svelte
+44
-39
src/routes/+page.svelte
···
17
fetchFollows,
18
follows,
19
notificationStream,
20
-
allPosts,
21
viewClient,
22
jetstream,
23
handleJetstreamEvent,
24
-
handleNotification
25
} from '$lib/state.svelte';
26
import { get } from 'svelte/store';
27
import Icon from '@iconify/svelte';
···
30
import type { PageProps } from './+page';
31
import { JetstreamSubscription } from '@atcute/jetstream';
32
import { settings } from '$lib/settings';
33
34
const { data: loadData }: PageProps = $props();
35
···
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,
···
84
profile: 4
85
};
86
87
-
const switchView = async (newView: View) => {
88
-
if (currentView === newView) return;
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' });
112
let showScrollToTop = $state(false);
113
const handleScroll = () => {
114
-
if (currentView === 'timeline') showScrollToTop = window.scrollY > 300;
115
};
116
-
const scrollToTop = () => {
117
-
window.scrollTo({ top: 0, behavior: 'smooth' });
118
-
};
119
120
onMount(() => {
121
window.addEventListener('scroll', handleScroll);
···
130
'app.bsky.feed.post:reply.parent.uri',
131
'app.bsky.feed.post:embed.record.record.uri',
132
'app.bsky.feed.post:embed.record.uri',
133
-
'app.bsky.feed.repost:subject.uri'
134
)
135
);
136
});
···
214
<div class="flex-1">
215
<!-- timeline -->
216
<TimelineView
217
-
class={currentView === 'timeline' ? `${animClass}` : 'hidden'}
218
client={selectedClient}
219
bind:postComposerState
220
/>
221
222
-
{#if currentView === 'settings'}
223
<div class={animClass}>
224
<SettingsView />
225
</div>
226
{/if}
227
-
{#if currentView === 'notifications'}
228
<div class={animClass}>
229
<NotificationsView />
230
</div>
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>
···
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
···
297
<div class="flex-1">
298
<PostComposer
299
client={selectedClient}
300
-
onPostSent={(post) => allPosts.get(selectedDid!)?.set(post.uri, post)}
301
bind:_state={postComposerState}
302
/>
303
</div>
···
330
</div>
331
<div class="grow"></div>
332
{@render appButton(
333
-
() => switchView('timeline'),
334
'heroicons:home',
335
'timeline',
336
-
currentView === 'timeline',
337
'heroicons:home-solid'
338
)}
339
{@render appButton(
340
-
() => switchView('following'),
341
'heroicons:users',
342
'following',
343
-
currentView === 'following',
344
'heroicons:users-solid'
345
)}
346
{@render appButton(
347
-
() => switchView('notifications'),
348
'heroicons:bell',
349
'notifications',
350
-
currentView === 'notifications',
351
'heroicons:bell-solid'
352
)}
353
{@render appButton(
354
-
() => switchView('settings'),
355
'heroicons:cog-6-tooth',
356
'settings',
357
-
currentView === 'settings',
358
'heroicons:cog-6-tooth-solid'
359
)}
360
</div>
···
17
fetchFollows,
18
follows,
19
notificationStream,
20
viewClient,
21
jetstream,
22
handleJetstreamEvent,
23
+
handleNotification,
24
+
addPosts,
25
+
addTimeline,
26
+
type View,
27
+
currentView,
28
+
previousView,
29
+
setView
30
} from '$lib/state.svelte';
31
import { get } from 'svelte/store';
32
import Icon from '@iconify/svelte';
···
35
import type { PageProps } from './+page';
36
import { JetstreamSubscription } from '@atcute/jetstream';
37
import { settings } from '$lib/settings';
38
+
import type { Sort } from '$lib/following';
39
40
const { data: loadData }: PageProps = $props();
41
···
75
handleAccountSelected(newAccounts[0]?.did);
76
};
77
78
+
let followingSort = $state('active' as Sort);
79
+
80
let animClass = $state('animate-fade-in-scale');
81
let scrollPositions = new SvelteMap<View, number>();
82
let viewingProfileDid = $state<AtprotoDid | null>(null);
83
84
const viewOrder: Record<View, number> = {
85
timeline: 0,
···
89
profile: 4
90
};
91
92
+
const switchView = async () => {
93
+
scrollPositions.set($previousView, window.scrollY);
94
95
+
const direction = viewOrder[$previousView] > viewOrder[$currentView] ? 'right' : 'left';
96
+
// profile always slides in from right unless going back
97
+
if ($currentView === 'profile') animClass = 'animate-slide-in-left';
98
+
else if ($previousView === 'profile') animClass = 'animate-slide-in-right';
99
else animClass = direction === 'right' ? 'animate-slide-in-right' : 'animate-slide-in-left';
100
101
await tick();
102
103
+
window.scrollTo({ top: scrollPositions.get($currentView) || 0, behavior: 'instant' });
104
};
105
+
currentView.subscribe(switchView);
106
107
const goToProfile = (did: AtprotoDid) => {
108
viewingProfileDid = did;
109
+
setView('profile');
110
};
111
112
let postComposerState = $state<PostComposerState>({ type: 'null' });
113
let showScrollToTop = $state(false);
114
const handleScroll = () => {
115
+
if ($currentView === 'timeline') showScrollToTop = window.scrollY > 300;
116
};
117
+
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' });
118
119
onMount(() => {
120
window.addEventListener('scroll', handleScroll);
···
129
'app.bsky.feed.post:reply.parent.uri',
130
'app.bsky.feed.post:embed.record.record.uri',
131
'app.bsky.feed.post:embed.record.uri',
132
+
'app.bsky.feed.repost:subject.uri',
133
+
'app.bsky.feed.like:subject.uri',
134
+
'app.bsky.graph.follow:subject'
135
)
136
);
137
});
···
215
<div class="flex-1">
216
<!-- timeline -->
217
<TimelineView
218
+
class={$currentView === 'timeline' ? `${animClass}` : 'hidden'}
219
client={selectedClient}
220
bind:postComposerState
221
/>
222
223
+
{#if $currentView === 'settings'}
224
<div class={animClass}>
225
<SettingsView />
226
</div>
227
{/if}
228
+
{#if $currentView === 'notifications'}
229
<div class={animClass}>
230
<NotificationsView />
231
</div>
232
{/if}
233
+
{#if $currentView === 'following'}
234
<div class={animClass}>
235
<FollowingView
236
selectedClient={selectedClient!}
237
selectedDid={selectedDid!}
238
onProfileClick={goToProfile}
239
+
bind:followingSort
240
/>
241
</div>
242
{/if}
243
+
{#if $currentView === 'profile' && viewingProfileDid}
244
<div class={animClass}>
245
<ProfileView
246
client={selectedClient!}
247
did={viewingProfileDid}
248
+
onBack={() => setView($previousView)}
249
bind:postComposerState
250
/>
251
</div>
···
278
279
<div
280
class="
281
+
{$currentView === 'timeline' || $currentView === 'following' || $currentView === 'profile'
282
? ''
283
: 'hidden'}
284
z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all
···
299
<div class="flex-1">
300
<PostComposer
301
client={selectedClient}
302
+
onPostSent={(post) => {
303
+
addPosts([post]);
304
+
addTimeline(selectedDid!, [post.uri]);
305
+
}}
306
bind:_state={postComposerState}
307
/>
308
</div>
···
335
</div>
336
<div class="grow"></div>
337
{@render appButton(
338
+
() => setView('timeline'),
339
'heroicons:home',
340
'timeline',
341
+
$currentView === 'timeline',
342
'heroicons:home-solid'
343
)}
344
{@render appButton(
345
+
() => setView('following'),
346
'heroicons:users',
347
'following',
348
+
$currentView === 'following',
349
'heroicons:users-solid'
350
)}
351
{@render appButton(
352
+
() => setView('notifications'),
353
'heroicons:bell',
354
'notifications',
355
+
$currentView === 'notifications',
356
'heroicons:bell-solid'
357
)}
358
{@render appButton(
359
+
() => setView('settings'),
360
'heroicons:cog-6-tooth',
361
'settings',
362
+
$currentView === 'settings',
363
'heroicons:cog-6-tooth-solid'
364
)}
365
</div>