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