src
lib
atproto
components
action-buttons
bluesky-post
embed
nested-comments
user-profile
routes
bookmarks
chat
hashtag
notifications
profile
search
···
22
22
23
23
- make everything faster, faster, faster
24
24
- add post creator
25
25
-
- allow setting default feed
25
25
+
- add keyboard bindings (e.g. j/k to navigate, enter to open post, r to reply, etc)
26
26
27
27
## posts
28
28
···
39
39
40
40
## settings
41
41
42
42
-
- allow setting theme (auto/dark/light) and theme colors (base and accent color)
42
42
+
- allow setting theme (auto/dark/light) and theme colors (base and accent color)
43
43
+
- allow setting default feed
44
44
+
45
45
+
## bugs
46
46
+
47
47
+
- fix feed flashing sometimes
···
57
57
}
58
58
);
59
59
60
60
+
export const followUser = command(
61
61
+
v.object({
62
62
+
did: v.string()
63
63
+
}),
64
64
+
async (input) => {
65
65
+
const { locals } = getRequestEvent();
66
66
+
if (!locals.client || !locals.did) error(401, 'Not authenticated');
67
67
+
68
68
+
const rkey = TID.now();
69
69
+
const res = await locals.client.post('com.atproto.repo.createRecord', {
70
70
+
input: {
71
71
+
repo: locals.did,
72
72
+
collection: 'app.bsky.graph.follow',
73
73
+
rkey,
74
74
+
record: {
75
75
+
$type: 'app.bsky.graph.follow',
76
76
+
subject: input.did,
77
77
+
createdAt: new Date().toISOString()
78
78
+
}
79
79
+
}
80
80
+
});
81
81
+
82
82
+
if (!res.ok) error(res.status, 'Failed to follow');
83
83
+
return { uri: res.data.uri };
84
84
+
}
85
85
+
);
86
86
+
87
87
+
export const unfollowUser = command(
88
88
+
v.object({
89
89
+
followUri: v.string()
90
90
+
}),
91
91
+
async (input) => {
92
92
+
const { locals } = getRequestEvent();
93
93
+
if (!locals.client || !locals.did) error(401, 'Not authenticated');
94
94
+
95
95
+
const parts = input.followUri.split('/');
96
96
+
const rkey = parts[parts.length - 1];
97
97
+
98
98
+
const res = await locals.client.post('com.atproto.repo.deleteRecord', {
99
99
+
input: {
100
100
+
repo: locals.did,
101
101
+
collection: 'app.bsky.graph.follow',
102
102
+
rkey
103
103
+
}
104
104
+
});
105
105
+
106
106
+
if (!res.ok) error(res.status, 'Failed to unfollow');
107
107
+
return { ok: true };
108
108
+
}
109
109
+
);
110
110
+
60
111
export const getPostThread = command(
61
112
v.object({
62
113
uri: v.string(),
···
106
157
}
107
158
);
108
159
160
160
+
export const searchPosts = command(
161
161
+
v.object({
162
162
+
q: v.string(),
163
163
+
cursor: v.optional(v.string())
164
164
+
}),
165
165
+
async (input) => {
166
166
+
const { locals } = getRequestEvent();
167
167
+
168
168
+
const client = locals.client ?? new Client({
169
169
+
handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
170
170
+
});
171
171
+
172
172
+
const res = await client.get('app.bsky.feed.searchPosts', {
173
173
+
params: {
174
174
+
q: input.q,
175
175
+
limit: 25,
176
176
+
...(input.cursor ? { cursor: input.cursor } : {})
177
177
+
}
178
178
+
});
179
179
+
if (!res.ok) error(res.status, 'Failed to search posts');
180
180
+
return { posts: res.data.posts, cursor: res.data.cursor ?? null };
181
181
+
}
182
182
+
);
183
183
+
109
184
export const loadFeed = command(
110
185
v.object({
111
186
feedUri: v.string(),
···
129
204
return { posts: res.data.feed, cursor: res.data.cursor ?? null };
130
205
}
131
206
);
207
207
+
208
208
+
export const createBookmark = command(
209
209
+
v.object({
210
210
+
uri: v.string(),
211
211
+
cid: v.string()
212
212
+
}),
213
213
+
async (input) => {
214
214
+
const { locals } = getRequestEvent();
215
215
+
if (!locals.client || !locals.did) error(401, 'Not authenticated');
216
216
+
217
217
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
218
218
+
const opts: any = { as: null, input: { uri: input.uri, cid: input.cid } };
219
219
+
const res = await locals.client.post('app.bsky.bookmark.createBookmark' as any, opts); // eslint-disable-line @typescript-eslint/no-explicit-any
220
220
+
if (!res.ok) error(res.status, 'Failed to bookmark');
221
221
+
return { ok: true };
222
222
+
}
223
223
+
);
224
224
+
225
225
+
export const deleteBookmark = command(
226
226
+
v.object({
227
227
+
uri: v.string()
228
228
+
}),
229
229
+
async (input) => {
230
230
+
const { locals } = getRequestEvent();
231
231
+
if (!locals.client || !locals.did) error(401, 'Not authenticated');
232
232
+
233
233
+
try {
234
234
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
235
235
+
const opts: any = { as: null, input: { uri: input.uri } };
236
236
+
await locals.client.post('app.bsky.bookmark.deleteBookmark' as any, opts); // eslint-disable-line @typescript-eslint/no-explicit-any
237
237
+
} catch (e: any) { // eslint-disable-line @typescript-eslint/no-explicit-any
238
238
+
console.error('[deleteBookmark] error:', e?.status, e?.body ?? e?.message ?? e);
239
239
+
error(e?.status ?? 500, 'Failed to remove bookmark');
240
240
+
}
241
241
+
return { ok: true };
242
242
+
}
243
243
+
);
244
244
+
245
245
+
export const getBookmarks = command(
246
246
+
v.object({
247
247
+
cursor: v.optional(v.string()),
248
248
+
limit: v.optional(v.number())
249
249
+
}),
250
250
+
async (input) => {
251
251
+
const { locals } = getRequestEvent();
252
252
+
if (!locals.client || !locals.did) error(401, 'Not authenticated');
253
253
+
254
254
+
const res = await locals.client.get('app.bsky.bookmark.getBookmarks' as any, { // eslint-disable-line @typescript-eslint/no-explicit-any
255
255
+
params: {
256
256
+
limit: input.limit ?? 30,
257
257
+
...(input.cursor ? { cursor: input.cursor } : {})
258
258
+
}
259
259
+
});
260
260
+
261
261
+
if (!res.ok) error(res.status, 'Failed to load bookmarks');
262
262
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
263
263
+
const data = res.data as any;
264
264
+
// API returns [{ createdAt, subject, item: PostView }]
265
265
+
const raw = data.items ?? data.bookmarks ?? [];
266
266
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
267
267
+
const posts = raw.map((entry: any) => entry.item ?? entry.post ?? entry);
268
268
+
return { posts, cursor: data.cursor ?? null };
269
269
+
}
270
270
+
);
···
1
1
+
import { createBookmark, deleteBookmark } from '$lib/atproto/server/feed.remote';
2
2
+
3
3
+
// Track bookmark state: postUri -> bookmarked
4
4
+
let _bookmarkState = $state<Record<string, boolean>>({});
5
5
+
6
6
+
export const bookmarks = {
7
7
+
isBookmarked(postUri: string, viewerBookmark?: boolean): boolean {
8
8
+
if (postUri in _bookmarkState) return _bookmarkState[postUri];
9
9
+
return !!viewerBookmark;
10
10
+
},
11
11
+
12
12
+
async toggle(postUri: string, postCid: string, viewerBookmark?: boolean) {
13
13
+
const currentlyBookmarked = this.isBookmarked(postUri, viewerBookmark);
14
14
+
// Optimistic update
15
15
+
_bookmarkState[postUri] = !currentlyBookmarked;
16
16
+
try {
17
17
+
if (currentlyBookmarked) {
18
18
+
await deleteBookmark({ uri: postUri });
19
19
+
} else {
20
20
+
await createBookmark({ uri: postUri, cid: postCid });
21
21
+
}
22
22
+
} catch {
23
23
+
// Revert on failure
24
24
+
_bookmarkState[postUri] = currentlyBookmarked;
25
25
+
}
26
26
+
}
27
27
+
};
···
1
1
+
<script lang="ts">
2
2
+
import { ArrowUp } from '@lucide/svelte';
3
3
+
4
4
+
let visible = $state(false);
5
5
+
6
6
+
function onscroll() {
7
7
+
visible = window.scrollY > 800;
8
8
+
}
9
9
+
10
10
+
function scrollToTop() {
11
11
+
window.scrollTo({ top: 0, behavior: 'smooth' });
12
12
+
}
13
13
+
</script>
14
14
+
15
15
+
<svelte:window {onscroll} />
16
16
+
17
17
+
{#if visible}
18
18
+
<button
19
19
+
onclick={scrollToTop}
20
20
+
class="fixed bottom-6 right-6 z-40 cursor-pointer rounded-full bg-base-900/80 p-3 text-white shadow-lg backdrop-blur-sm transition-opacity hover:bg-base-900 dark:bg-base-100/80 dark:text-base-900 dark:hover:bg-base-100"
21
21
+
>
22
22
+
<ArrowUp size={20} />
23
23
+
</button>
24
24
+
{/if}
···
11
11
xmlns="http://www.w3.org/2000/svg"
12
12
viewBox="0 0 24 24"
13
13
fill="currentColor"
14
14
-
class="group-hover/post-action:bg-accent-500/10 text-accent-700 dark:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100"
14
14
+
class="group-hover/post-action:bg-accent-500/10 text-accent-700 dark:text-accent-400 -m-1 size-6 rounded-full p-1 transition-all duration-100"
15
15
>
16
16
<path
17
17
fill-rule="evenodd"
···
26
26
viewBox="0 0 24 24"
27
27
stroke-width="1.5"
28
28
stroke="currentColor"
29
29
-
class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100"
29
29
+
class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1 size-6 rounded-full p-1 transition-all duration-100"
30
30
>
31
31
<path
32
32
stroke-linecap="round"
···
39
39
40
40
{#if onclick}
41
41
<button
42
42
-
class="group/post-action inline-flex cursor-pointer items-center gap-2 text-sm"
42
42
+
class="group/post-action inline-flex cursor-pointer items-center gap-1 text-xs"
43
43
{onclick}
44
44
>
45
45
{@render icon()}
46
46
</button>
47
47
{:else}
48
48
-
<span class="inline-flex items-center gap-2 text-sm">
48
48
+
<span class="inline-flex items-center gap-1 text-xs">
49
49
{@render icon()}
50
50
</span>
51
51
{/if}
···
36
36
<a
37
37
class="group/post-action inline-flex cursor-pointer items-center gap-1 text-xs"
38
38
{href}
39
39
-
target="_blank"
40
39
>
41
40
{@render icon()}
42
41
</a>
···
9
9
hashtag?: (tag: string) => string;
10
10
};
11
11
12
12
-
function defaultHrefs(baseUrl: string): Required<BlueskyHrefs> {
12
12
+
function defaultHrefs(_baseUrl: string): Required<BlueskyHrefs> {
13
13
return {
14
14
-
profile: (handle, did) => `${baseUrl}/profile/${did ?? handle}`,
15
15
-
post: (handle, postId) => `${baseUrl}/profile/${handle}/post/${postId}`,
16
16
-
hashtag: (tag) => `${baseUrl}/hashtag/${tag}`
14
14
+
profile: (handle) => `/profile/${handle}`,
15
15
+
post: (handle, postId) => `/profile/${handle}/post/${postId}`,
16
16
+
hashtag: (tag) => `/hashtag/${tag}`
17
17
};
18
18
}
19
19
···
15
15
lightboxState.open = false;
16
16
}
17
17
18
18
+
async function download() {
19
19
+
const img = lightboxState.images[lightboxState.index];
20
20
+
if (!img?.fullsize) return;
21
21
+
try {
22
22
+
const res = await fetch(img.fullsize);
23
23
+
const blob = await res.blob();
24
24
+
const url = URL.createObjectURL(blob);
25
25
+
const a = document.createElement('a');
26
26
+
a.href = url;
27
27
+
a.download = `image-${lightboxState.index + 1}.jpg`;
28
28
+
a.click();
29
29
+
URL.revokeObjectURL(url);
30
30
+
} catch {
31
31
+
// Fallback: open in new tab
32
32
+
window.open(img.fullsize, '_blank');
33
33
+
}
34
34
+
}
35
35
+
18
36
function onkeydown(event: KeyboardEvent) {
19
37
if (!lightboxState.open) return;
20
38
if (event.key === 'Escape') {
···
40
58
aria-modal="true"
41
59
aria-label={image.alt}
42
60
>
61
61
+
<!-- Top bar: close and download -->
62
62
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
63
63
+
<div class="absolute top-4 right-4 z-10 flex gap-2" onclick={(e) => e.stopPropagation()}>
64
64
+
<button
65
65
+
class="rounded-full bg-white/20 p-2 text-white backdrop-blur-sm hover:bg-white/30 cursor-pointer transition-colors"
66
66
+
onclick={download}
67
67
+
aria-label="Download image"
68
68
+
>
69
69
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-5">
70
70
+
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
71
71
+
</svg>
72
72
+
</button>
73
73
+
<button
74
74
+
class="rounded-full bg-white/20 p-2 text-white backdrop-blur-sm hover:bg-white/30 cursor-pointer transition-colors"
75
75
+
onclick={close}
76
76
+
aria-label="Close"
77
77
+
>
78
78
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-5">
79
79
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
80
80
+
</svg>
81
81
+
</button>
82
82
+
</div>
83
83
+
43
84
{#if lightboxState.images.length > 1 && lightboxState.index > 0}
44
85
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
45
86
<div class="absolute left-4 z-10" onclick={(e) => e.stopPropagation()}>
···
54
95
</div>
55
96
{/if}
56
97
57
57
-
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
58
98
<img
59
99
src={image.fullsize}
60
100
alt={image.alt}
61
61
-
class="max-h-full max-w-full object-contain"
62
62
-
onclick={(e) => e.stopPropagation()}
101
101
+
class="h-full w-full object-contain"
63
102
/>
64
103
65
104
{#if lightboxState.images.length > 1 && lightboxState.index < lightboxState.images.length - 1}
···
39
39
}
40
40
};
41
41
embed.record.onclickhandle = (handle) => navigateToProfile(handle);
42
42
-
embed.record.handleHref = (handle) => `/p/${handle}`;
42
42
+
embed.record.handleHref = (handle) => `/profile/${handle}`;
43
43
}
44
44
}
45
45
return embeds;
···
1
1
<script lang="ts">
2
2
+
import type { Snippet } from 'svelte';
2
3
import { Avatar, Button, cn, sanitize } from '@foxui/core';
3
4
4
4
-
let { profile, class: className }: { profile: {
5
5
+
let { profile, class: className, children }: { profile: {
5
6
banner?: string;
6
7
avatar?: string;
7
8
displayName?: string;
8
9
handle?: string;
9
10
description?: string;
10
10
-
}, class: string; } = $props();
11
11
+
}, class: string; children?: Snippet } = $props();
11
12
</script>
12
13
13
14
{#if profile}
···
52
53
53
54
<!-- <Button>Follow</Button> -->
54
55
</div>
56
56
+
57
57
+
{#if children}
58
58
+
<div class="px-4 sm:px-6 lg:px-8 pt-3">
59
59
+
{@render children()}
60
60
+
</div>
61
61
+
{/if}
55
62
56
63
<div class="px-4 sm:px-6 lg:px-8 py-4 text-xs sm:text-sm text-base-800 dark:text-base-200">
57
64
{@html sanitize(profile.description?.replaceAll('\n', '<br/>') ?? '')}
···
21
21
key: string;
22
22
value: string; // JSON-serialized
23
23
expiresAt: number; // timestamp, 0 = no expiry
24
24
+
createdAt: number; // when the entry was written
24
25
accessedAt: number; // for LRU eviction
25
26
}
26
27
···
43
44
44
45
constructor() {
45
46
super('atmo-social');
46
46
-
this.version(1).stores({
47
47
+
this.version(2).stores({
47
48
identity: 'key, expiresAt, accessedAt',
48
49
profiles: 'key, expiresAt, accessedAt',
49
50
posts: 'key, expiresAt, accessedAt',
···
95
96
}
96
97
97
98
/**
98
98
-
* Returns the age of a cached entry in ms, or undefined if not cached.
99
99
+
* Returns the age of a cached entry in ms (since it was written), or undefined if not cached/expired.
99
100
*/
100
101
async getAge(key: string): Promise<number | undefined> {
101
102
const now = Date.now();
···
105
106
const entry = await table.get(key);
106
107
if (!entry) return undefined;
107
108
if (entry.expiresAt > 0 && entry.expiresAt < now) return undefined;
108
108
-
return now - entry.accessedAt;
109
109
+
return now - entry.createdAt;
109
110
}
110
111
} catch {
111
112
// fall through to memory
···
114
115
const entry = mem.get(key);
115
116
if (!entry) return undefined;
116
117
if (entry.expiresAt > 0 && entry.expiresAt < now) return undefined;
117
117
-
return now - entry.accessedAt;
118
118
+
return now - entry.createdAt;
118
119
}
119
120
120
121
async get(key: string): Promise<T | undefined> {
···
154
155
key,
155
156
value: JSON.stringify(value),
156
157
expiresAt: this.config.ttl ? now + this.config.ttl : 0,
158
158
+
createdAt: now,
157
159
accessedAt: now
158
160
};
159
161
···
179
181
key,
180
182
value: JSON.stringify(value),
181
183
expiresAt: this.config.ttl ? now + this.config.ttl : 0,
184
184
+
createdAt: now,
182
185
accessedAt: now
183
186
}));
184
187
···
2
2
import '../app.css';
3
3
import { onMount, onDestroy } from 'svelte';
4
4
import { Head, ThemeToggle, Avatar, Button } from '@foxui/core';
5
5
-
import { House, MessageCircle, Bell } from '@lucide/svelte';
5
5
+
import { House, MessageCircle, Bell, Search, Bookmark } from '@lucide/svelte';
6
6
import { goto } from '$app/navigation';
7
7
import { user } from '$lib/atproto/auth.svelte';
8
8
import { notificationsCache, startUnreadPoll, stopUnreadPoll, chatUnreadCount, startChatPoll, stopChatPoll, applyPendingFeed, startFeedPoll, stopFeedPoll, hydrateFromDb } from '$lib/cache.svelte';
9
9
import LoginModal, { loginModalState } from '$lib/LoginModal.svelte';
10
10
import ImageLightbox from '$lib/components/embed/ImageLightbox.svelte';
11
11
+
import ScrollToTop from '$lib/components/ScrollToTop.svelte';
11
12
import Sidebar from '$lib/Sidebar.svelte';
12
13
let { children } = $props();
13
14
···
31
32
<Button href="/" onmousedown={(e: MouseEvent) => { e.preventDefault(); applyPendingFeed(); window.scrollTo(0, 0); goto('/'); }} variant="ghost" size="icon">
32
33
<House size={20} />
33
34
</Button>
35
35
+
<Button href="/search" onmousedown={(e: MouseEvent) => { e.preventDefault(); goto('/search'); }} variant="ghost" size="icon">
36
36
+
<Search size={20} />
37
37
+
</Button>
34
38
<Button href="/chat" onmousedown={(e: MouseEvent) => { e.preventDefault(); goto('/chat'); }} variant="ghost" size="icon" class="relative">
35
39
<MessageCircle size={20} />
36
40
{#if chatUnreadCount.count > 0}
···
47
51
</span>
48
52
{/if}
49
53
</Button>
54
54
+
{#if user.did}
55
55
+
<Button href="/bookmarks" onmousedown={(e: MouseEvent) => { e.preventDefault(); goto('/bookmarks'); }} variant="ghost" size="icon">
56
56
+
<Bookmark size={20} />
57
57
+
</Button>
58
58
+
{/if}
50
59
<ThemeToggle class="mt-auto" />
51
60
{#if user.did}
52
52
-
{@const profileHref = `/p/${user.profile?.handle ?? user.did}`}
61
61
+
{@const profileHref = `/profile/${user.profile?.handle ?? user.did}`}
53
62
<Button href={profileHref} onmousedown={(e: MouseEvent) => { e.preventDefault(); goto(profileHref); }} variant="ghost" size="icon" class="mb-2">
54
63
<Avatar src={user.profile?.avatar} class="size-8" />
55
64
</Button>
···
66
75
67
76
<LoginModal />
68
77
<ImageLightbox />
78
78
+
<ScrollToTop />
69
79
70
80
<Head
71
81
title="atmo.social"
···
8
8
import { loadFeed, likePost, unlikePost } from '$lib/atproto/server/feed.remote';
9
9
import { cachePosts, prefetchThread, feedCache, prefetchNotifications, setFeedUri } from '$lib/cache.svelte';
10
10
import { wireEmbedClicks } from '$lib/components/embed';
11
11
+
import { bookmarks } from '$lib/bookmarks.svelte';
11
12
12
13
const LOGGED_IN_FEED = 'at://did:plc:3guzzweuqraryl3rdkimjamk/app.bsky.feed.generator/for-you';
13
14
const PUBLIC_FEED = 'at://did:plc:w4xbfzo7kqfes5zb7r6qv3rw/app.bsky.feed.generator/blacksky-trend';
···
157
158
const rootUri = record.reply.root.uri as string;
158
159
const [, , rootDid, , rootRkey] = rootUri.split('/');
159
160
const clickedRkey = feedPost.post.uri.split('/').pop();
160
160
-
return `/p/${rootDid}/post/${rootRkey}?highlight=${feedPost.post.author.handle}/${clickedRkey}`;
161
161
+
return `/profile/${rootDid}/post/${rootRkey}?highlight=${feedPost.post.author.handle}/${clickedRkey}`;
161
162
} else {
162
163
const rkey = feedPost.post.uri.split('/').pop();
163
163
-
return `/p/${feedPost.post.author.handle}/post/${rkey}`;
164
164
+
return `/profile/${feedPost.post.author.handle}/post/${rkey}`;
164
165
}
165
166
})()}
166
167
<div
···
169
170
<Post
170
171
compact={true}
171
172
data={postData}
172
172
-
embeds={wireEmbedClicks(embeds, (handle, rkey) => goto(`/p/${handle}/post/${rkey}`), (handle) => goto(`/p/${handle}`))}
173
173
+
embeds={wireEmbedClicks(embeds, (handle, rkey) => goto(`/profile/${handle}/post/${rkey}`), (handle) => goto(`/profile/${handle}`))}
173
174
href={postHref}
174
174
-
onclickhandle={(handle) => goto(`/p/${handle}`)}
175
175
-
handleHref={(handle) => `/p/${handle}`}
175
175
+
onclickhandle={(handle) => goto(`/profile/${handle}`)}
176
176
+
handleHref={(handle) => `/profile/${handle}`}
176
177
actions={user.did
177
178
? {
178
179
reply: {
179
179
-
count: postData.replyCount
180
180
+
count: postData.replyCount,
181
181
+
href: postHref + '#replies'
180
182
},
181
183
repost: {
182
184
count: postData.repostCount
···
185
187
count: getLikeCount(feedPost.post.uri, postData.likeCount ?? 0),
186
188
active: isLiked(feedPost.post.uri, feedPost.post.viewer?.like),
187
189
onclick: () => handleLike(feedPost.post.uri, feedPost.post.cid, feedPost.post.viewer?.like)
190
190
+
},
191
191
+
bookmark: {
192
192
+
active: bookmarks.isBookmarked(feedPost.post.uri, feedPost.post.viewer?.bookmarked),
193
193
+
onclick: () => bookmarks.toggle(feedPost.post.uri, feedPost.post.cid, feedPost.post.viewer?.bookmarked)
188
194
}
189
195
}
190
196
: {
191
197
reply: {
192
192
-
count: postData.replyCount
198
198
+
count: postData.replyCount,
199
199
+
href: postHref + '#replies'
193
200
},
194
201
repost: {
195
202
count: postData.repostCount
···
1
1
+
<script lang="ts">
2
2
+
import { onMount, onDestroy } from 'svelte';
3
3
+
import { goto } from '$app/navigation';
4
4
+
import { user } from '$lib/atproto/auth.svelte';
5
5
+
import { blueskyPostToPostData } from '$lib/components';
6
6
+
import { Post } from '$lib/components';
7
7
+
import { ArrowLeft, Loader2 } from '@lucide/svelte';
8
8
+
import { likePost, unlikePost, getBookmarks } from '$lib/atproto/server/feed.remote';
9
9
+
import { cachePosts, prefetchThread } from '$lib/cache.svelte';
10
10
+
import { wireEmbedClicks } from '$lib/components/embed';
11
11
+
import { bookmarks } from '$lib/bookmarks.svelte';
12
12
+
13
13
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
14
14
+
let posts = $state<any[]>([]);
15
15
+
let cursor = $state<string | null>(null);
16
16
+
let loading = $state(true);
17
17
+
let loadingMore = $state(false);
18
18
+
19
19
+
let likeState = $state<Record<string, string | null>>({});
20
20
+
let likeCountDelta = $state<Record<string, number>>({});
21
21
+
22
22
+
function isLiked(postUri: string, viewerLike?: string): boolean {
23
23
+
if (postUri in likeState) return likeState[postUri] !== null;
24
24
+
return !!viewerLike;
25
25
+
}
26
26
+
27
27
+
function getLikeCount(postUri: string, originalCount: number): number {
28
28
+
return originalCount + (likeCountDelta[postUri] ?? 0);
29
29
+
}
30
30
+
31
31
+
async function handleLike(postUri: string, postCid: string, viewerLike?: string) {
32
32
+
const currentlyLiked = isLiked(postUri, viewerLike);
33
33
+
if (currentlyLiked) {
34
34
+
const likeUri = likeState[postUri] ?? viewerLike;
35
35
+
if (!likeUri) return;
36
36
+
likeState[postUri] = null;
37
37
+
likeCountDelta[postUri] = (likeCountDelta[postUri] ?? 0) - 1;
38
38
+
try {
39
39
+
await unlikePost({ likeUri });
40
40
+
} catch {
41
41
+
likeState[postUri] = likeUri;
42
42
+
likeCountDelta[postUri] = (likeCountDelta[postUri] ?? 0) + 1;
43
43
+
}
44
44
+
} else {
45
45
+
likeState[postUri] = 'pending';
46
46
+
likeCountDelta[postUri] = (likeCountDelta[postUri] ?? 0) + 1;
47
47
+
try {
48
48
+
const result = await likePost({ uri: postUri, cid: postCid });
49
49
+
likeState[postUri] = result.uri;
50
50
+
} catch {
51
51
+
likeState[postUri] = null;
52
52
+
likeCountDelta[postUri] = (likeCountDelta[postUri] ?? 0) - 1;
53
53
+
}
54
54
+
}
55
55
+
}
56
56
+
57
57
+
onMount(async () => {
58
58
+
if (!user.did) {
59
59
+
goto('/');
60
60
+
return;
61
61
+
}
62
62
+
try {
63
63
+
const result = await getBookmarks({});
64
64
+
posts = JSON.parse(JSON.stringify(result.posts));
65
65
+
cachePosts(posts);
66
66
+
for (const p of posts) {
67
67
+
const post = p.post ?? p;
68
68
+
if (post?.uri) prefetchThread(post.uri);
69
69
+
}
70
70
+
cursor = result.cursor;
71
71
+
} catch (e) {
72
72
+
console.error('Failed to load bookmarks:', e);
73
73
+
} finally {
74
74
+
loading = false;
75
75
+
}
76
76
+
});
77
77
+
78
78
+
async function loadMore() {
79
79
+
if (loadingMore || !cursor) return;
80
80
+
loadingMore = true;
81
81
+
try {
82
82
+
const result = await getBookmarks({ cursor });
83
83
+
const newPosts = JSON.parse(JSON.stringify(result.posts));
84
84
+
cachePosts(newPosts);
85
85
+
for (const p of newPosts) {
86
86
+
const post = p.post ?? p;
87
87
+
if (post?.uri) prefetchThread(post.uri);
88
88
+
}
89
89
+
posts = [...posts, ...newPosts];
90
90
+
cursor = result.cursor;
91
91
+
} catch (e) {
92
92
+
console.error('Failed to load more bookmarks:', e);
93
93
+
} finally {
94
94
+
loadingMore = false;
95
95
+
}
96
96
+
}
97
97
+
98
98
+
function handleScroll() {
99
99
+
if (loadingMore || !cursor) return;
100
100
+
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
101
101
+
if (scrollHeight - scrollTop - clientHeight < 2000) {
102
102
+
loadMore();
103
103
+
}
104
104
+
}
105
105
+
106
106
+
onMount(() => window.addEventListener('scroll', handleScroll));
107
107
+
onDestroy(() => {
108
108
+
if (typeof window !== 'undefined') window.removeEventListener('scroll', handleScroll);
109
109
+
});
110
110
+
111
111
+
// Posts are already normalized to PostView by the server command
112
112
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
113
113
+
function getPostView(item: any) {
114
114
+
return item;
115
115
+
}
116
116
+
</script>
117
117
+
118
118
+
<div class="flex h-dvh flex-col">
119
119
+
<div class="mx-auto w-full max-w-lg py-4">
120
120
+
<div class="mb-2 ml-4 flex items-center gap-2 sm:ml-0">
121
121
+
<button
122
122
+
onclick={() => history.back()}
123
123
+
class="text-base-500 hover:text-base-700 dark:text-base-400 dark:hover:text-base-200 rounded-lg p-1.5 transition-colors"
124
124
+
>
125
125
+
<ArrowLeft size={20} />
126
126
+
</button>
127
127
+
<h1 class="text-base-900 dark:text-base-100 text-lg font-semibold">Bookmarks</h1>
128
128
+
</div>
129
129
+
130
130
+
{#if loading}
131
131
+
<div class="flex items-center justify-center py-12">
132
132
+
<Loader2 class="text-base-400 animate-spin" size={28} />
133
133
+
</div>
134
134
+
{:else if posts.length === 0}
135
135
+
<div class="flex items-center justify-center py-12">
136
136
+
<p class="text-base-400 text-sm">No bookmarks yet</p>
137
137
+
</div>
138
138
+
{:else}
139
139
+
<div>
140
140
+
{#each posts as item, i (getPostView(item)?.uri ? `${getPostView(item).uri}-${i}` : i)}
141
141
+
{@const postView = getPostView(item)}
142
142
+
{#if postView?.uri && postView?.author}
143
143
+
{@const { postData, embeds } = blueskyPostToPostData(postView, 'https://bsky.app')}
144
144
+
{@const postHref = `/profile/${postView.author.handle}/post/${postView.uri.split('/').pop()}`}
145
145
+
<div class="-mx-2 px-6 pt-3 pb-2 sm:px-2 rounded-xl hover:bg-base-100/50 dark:hover:bg-base-800/30 transition-colors">
146
146
+
<Post
147
147
+
compact={true}
148
148
+
data={postData}
149
149
+
embeds={wireEmbedClicks(embeds, (handle, rkey) => goto(`/profile/${handle}/post/${rkey}`), (handle) => goto(`/profile/${handle}`))}
150
150
+
href={postHref}
151
151
+
onclickhandle={(handle) => goto(`/profile/${handle}`)}
152
152
+
handleHref={(handle) => `/profile/${handle}`}
153
153
+
actions={user.did
154
154
+
? {
155
155
+
reply: { count: postData.replyCount },
156
156
+
repost: { count: postData.repostCount },
157
157
+
like: {
158
158
+
count: getLikeCount(postView.uri, postData.likeCount ?? 0),
159
159
+
active: isLiked(postView.uri, postView.viewer?.like),
160
160
+
onclick: () => handleLike(postView.uri, postView.cid, postView.viewer?.like)
161
161
+
},
162
162
+
bookmark: {
163
163
+
active: bookmarks.isBookmarked(postView.uri, postView.viewer?.bookmarked),
164
164
+
onclick: () => bookmarks.toggle(postView.uri, postView.cid, postView.viewer?.bookmarked)
165
165
+
}
166
166
+
}
167
167
+
: {
168
168
+
reply: { count: postData.replyCount },
169
169
+
repost: { count: postData.repostCount },
170
170
+
like: { count: postData.likeCount }
171
171
+
}}
172
172
+
/>
173
173
+
</div>
174
174
+
{#if i < posts.length - 1}
175
175
+
<hr class="border-base-200 dark:border-base-800 mx-4 sm:mx-0" />
176
176
+
{/if}
177
177
+
{/if}
178
178
+
{/each}
179
179
+
</div>
180
180
+
181
181
+
{#if loadingMore}
182
182
+
<div class="flex justify-center py-6">
183
183
+
<Loader2 class="text-base-400 animate-spin" size={24} />
184
184
+
</div>
185
185
+
{/if}
186
186
+
187
187
+
{#if !cursor && posts.length > 0}
188
188
+
<p class="text-base-400 py-6 text-center text-sm">You've reached the end</p>
189
189
+
{/if}
190
190
+
{/if}
191
191
+
192
192
+
<div class="pb-[max(0.75rem,env(safe-area-inset-bottom))]"></div>
193
193
+
</div>
194
194
+
</div>
···
182
182
<ArrowLeft size={20} />
183
183
</a>
184
184
185
185
-
<button onclick={() => goto(`/p/${member.handle}`)} class="cursor-pointer">
185
185
+
<button onclick={() => goto(`/profile/${member.handle}`)} class="cursor-pointer">
186
186
<Avatar src={member.avatar} class="size-8" />
187
187
</button>
188
188
189
189
-
<button onclick={() => goto(`/p/${member.handle}`)} class="cursor-pointer text-left">
189
189
+
<button onclick={() => goto(`/profile/${member.handle}`)} class="cursor-pointer text-left">
190
190
<p class="text-base-900 dark:text-base-100 text-sm font-medium">
191
191
{member.displayName ?? member.handle}
192
192
</p>
···
216
216
{#if isMessageView(msg)}
217
217
<div class="flex items-start gap-3 px-1 {showHeader ? 'mt-2 py-0.5' : 'py-0'}">
218
218
{#if showHeader}
219
219
-
<button class="shrink-0 cursor-pointer" onmousedown={() => goto(`/p/${isOwn ? (user.profile?.handle ?? user.did) : member.handle}`)}>
219
219
+
<button class="shrink-0 cursor-pointer" onmousedown={() => goto(`/profile/${isOwn ? (user.profile?.handle ?? user.did) : member.handle}`)}>
220
220
<Avatar src={isOwn ? user.profile?.avatar : member.avatar} class="mt-0.5 size-8" />
221
221
</button>
222
222
{:else}
···
225
225
<div class="min-w-0 flex-1">
226
226
{#if showHeader}
227
227
<div class="flex items-baseline gap-2">
228
228
-
<button class="cursor-pointer text-sm font-medium hover:underline {isOwn ? 'text-accent-500' : 'text-base-900 dark:text-base-100'}" onmousedown={() => goto(`/p/${isOwn ? (user.profile?.handle ?? user.did) : member.handle}`)}>
228
228
+
<button class="cursor-pointer text-sm font-medium hover:underline {isOwn ? 'text-accent-500' : 'text-base-900 dark:text-base-100'}" onmousedown={() => goto(`/profile/${isOwn ? (user.profile?.handle ?? user.did) : member.handle}`)}>
229
229
{isOwn ? (user.profile?.displayName ?? 'You') : (member.displayName ?? member.handle)}
230
230
</button>
231
231
<span class="text-base-400 text-[10px]">
···
1
1
+
<script lang="ts">
2
2
+
/* eslint-disable svelte/no-navigation-without-resolve */
3
3
+
import { onMount, onDestroy, untrack } from 'svelte';
4
4
+
import { goto } from '$app/navigation';
5
5
+
import { page } from '$app/state';
6
6
+
import { user } from '$lib/atproto/auth.svelte';
7
7
+
import { blueskyPostToPostData } from '$lib/components';
8
8
+
import { Post } from '$lib/components';
9
9
+
import { Hash, Loader2 } from '@lucide/svelte';
10
10
+
import { searchPosts, likePost, unlikePost } from '$lib/atproto/server/feed.remote';
11
11
+
import { cachePosts, prefetchThread } from '$lib/cache.svelte';
12
12
+
import { wireEmbedClicks } from '$lib/components/embed';
13
13
+
14
14
+
let tag = $derived(page.params.tag);
15
15
+
16
16
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
17
17
+
let posts = $state<any[]>([]);
18
18
+
let cursor = $state<string | null>(null);
19
19
+
let loading = $state(true);
20
20
+
let loadingMore = $state(false);
21
21
+
22
22
+
let likeState = $state<Record<string, string | null>>({});
23
23
+
let likeCountDelta = $state<Record<string, number>>({});
24
24
+
25
25
+
function isLiked(postUri: string, viewerLike?: string): boolean {
26
26
+
if (postUri in likeState) return likeState[postUri] !== null;
27
27
+
return !!viewerLike;
28
28
+
}
29
29
+
30
30
+
function getLikeCount(postUri: string, originalCount: number): number {
31
31
+
return originalCount + (likeCountDelta[postUri] ?? 0);
32
32
+
}
33
33
+
34
34
+
async function handleLike(postUri: string, postCid: string, viewerLike?: string) {
35
35
+
const currentlyLiked = isLiked(postUri, viewerLike);
36
36
+
if (currentlyLiked) {
37
37
+
const likeUri = likeState[postUri] ?? viewerLike;
38
38
+
if (!likeUri) return;
39
39
+
likeState[postUri] = null;
40
40
+
likeCountDelta[postUri] = (likeCountDelta[postUri] ?? 0) - 1;
41
41
+
try {
42
42
+
await unlikePost({ likeUri });
43
43
+
} catch {
44
44
+
likeState[postUri] = likeUri;
45
45
+
likeCountDelta[postUri] = (likeCountDelta[postUri] ?? 0) + 1;
46
46
+
}
47
47
+
} else {
48
48
+
likeState[postUri] = 'pending';
49
49
+
likeCountDelta[postUri] = (likeCountDelta[postUri] ?? 0) + 1;
50
50
+
try {
51
51
+
const result = await likePost({ uri: postUri, cid: postCid });
52
52
+
likeState[postUri] = result.uri;
53
53
+
} catch {
54
54
+
likeState[postUri] = null;
55
55
+
likeCountDelta[postUri] = (likeCountDelta[postUri] ?? 0) - 1;
56
56
+
}
57
57
+
}
58
58
+
}
59
59
+
60
60
+
async function loadTag(t: string) {
61
61
+
loading = true;
62
62
+
posts = [];
63
63
+
cursor = null;
64
64
+
likeState = {};
65
65
+
likeCountDelta = {};
66
66
+
try {
67
67
+
const result = await searchPosts({ q: `#${t}` });
68
68
+
posts = result.posts;
69
69
+
cachePosts(posts.map((p: any) => ({ post: p }))); // eslint-disable-line @typescript-eslint/no-explicit-any
70
70
+
for (const p of posts) prefetchThread(p.uri);
71
71
+
cursor = result.cursor;
72
72
+
} catch (e) {
73
73
+
console.error('Failed to load hashtag:', e);
74
74
+
} finally {
75
75
+
loading = false;
76
76
+
}
77
77
+
}
78
78
+
79
79
+
async function loadMore() {
80
80
+
if (loadingMore || !cursor) return;
81
81
+
loadingMore = true;
82
82
+
try {
83
83
+
const result = await searchPosts({ q: `#${tag}`, cursor });
84
84
+
cachePosts(result.posts.map((p: any) => ({ post: p }))); // eslint-disable-line @typescript-eslint/no-explicit-any
85
85
+
for (const p of result.posts) prefetchThread(p.uri);
86
86
+
posts = [...posts, ...result.posts];
87
87
+
cursor = result.cursor;
88
88
+
} catch (e) {
89
89
+
console.error('Failed to load more:', e);
90
90
+
} finally {
91
91
+
loadingMore = false;
92
92
+
}
93
93
+
}
94
94
+
95
95
+
function handleScroll() {
96
96
+
if (loadingMore || !cursor) return;
97
97
+
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
98
98
+
if (scrollHeight - scrollTop - clientHeight < 2000) {
99
99
+
loadMore();
100
100
+
}
101
101
+
}
102
102
+
103
103
+
$effect(() => {
104
104
+
const t = tag;
105
105
+
if (t) untrack(() => loadTag(t));
106
106
+
});
107
107
+
108
108
+
onMount(() => window.addEventListener('scroll', handleScroll));
109
109
+
onDestroy(() => {
110
110
+
if (typeof window !== 'undefined') window.removeEventListener('scroll', handleScroll);
111
111
+
});
112
112
+
</script>
113
113
+
114
114
+
<div class="flex h-dvh flex-col">
115
115
+
<div class="mx-auto w-full max-w-lg">
116
116
+
<!-- Header -->
117
117
+
<div class="flex items-center gap-2 px-4 pt-4 pb-3">
118
118
+
<Hash class="text-base-400" size={20} />
119
119
+
<h1 class="text-base-900 dark:text-base-100 text-lg font-semibold">{tag}</h1>
120
120
+
</div>
121
121
+
122
122
+
{#if loading}
123
123
+
<div class="flex items-center justify-center py-12">
124
124
+
<Loader2 class="text-base-400 animate-spin" size={28} />
125
125
+
</div>
126
126
+
{:else if posts.length === 0}
127
127
+
<div class="flex flex-col items-center justify-center py-20">
128
128
+
<Hash class="text-base-300 dark:text-base-600 mb-3" size={40} />
129
129
+
<p class="text-base-400 text-sm">No posts with #{tag}</p>
130
130
+
</div>
131
131
+
{:else}
132
132
+
<div>
133
133
+
{#each posts as post, i (post.uri ? `${post.uri}-${i}` : i)}
134
134
+
{@const { postData, embeds } = blueskyPostToPostData(post)}
135
135
+
{@const rkey = post.uri.split('/').pop()}
136
136
+
{@const postHref = `/profile/${post.author.handle}/post/${rkey}`}
137
137
+
<div class="-mx-2 rounded-xl px-6 pt-3 pb-2 transition-colors hover:bg-base-100/50 sm:px-2 dark:hover:bg-base-800/30">
138
138
+
<Post
139
139
+
compact={true}
140
140
+
data={postData}
141
141
+
embeds={wireEmbedClicks(embeds, (handle, rk) => goto(`/profile/${handle}/post/${rk}`), (handle) => goto(`/profile/${handle}`))}
142
142
+
href={postHref}
143
143
+
onclickhandle={(handle) => goto(`/profile/${handle}`)}
144
144
+
handleHref={(handle) => `/profile/${handle}`}
145
145
+
actions={user.did
146
146
+
? {
147
147
+
reply: { count: postData.replyCount, href: postHref + '#replies' },
148
148
+
repost: { count: postData.repostCount },
149
149
+
like: {
150
150
+
count: getLikeCount(post.uri, postData.likeCount ?? 0),
151
151
+
active: isLiked(post.uri, post.viewer?.like),
152
152
+
onclick: () => handleLike(post.uri, post.cid, post.viewer?.like)
153
153
+
}
154
154
+
}
155
155
+
: {
156
156
+
reply: { count: postData.replyCount, href: postHref + '#replies' },
157
157
+
repost: { count: postData.repostCount },
158
158
+
like: { count: postData.likeCount }
159
159
+
}}
160
160
+
/>
161
161
+
</div>
162
162
+
{#if i < posts.length - 1}
163
163
+
<hr class="border-base-200 dark:border-base-800 mx-4 sm:mx-0" />
164
164
+
{/if}
165
165
+
{/each}
166
166
+
</div>
167
167
+
168
168
+
{#if loadingMore}
169
169
+
<div class="flex justify-center py-6">
170
170
+
<Loader2 class="text-base-400 animate-spin" size={24} />
171
171
+
</div>
172
172
+
{/if}
173
173
+
174
174
+
{#if !cursor && posts.length > 0}
175
175
+
<p class="text-base-400 py-6 text-center text-sm">No more results</p>
176
176
+
{/if}
177
177
+
178
178
+
<div class="pb-[max(0.75rem,env(safe-area-inset-bottom))]"></div>
179
179
+
{/if}
180
180
+
</div>
181
181
+
</div>
···
166
166
167
167
function navigateToNotification(notif: Notification) {
168
168
if (notif.reason === 'follow') {
169
169
-
goto(`/p/${notif.author.handle}`);
169
169
+
goto(`/profile/${notif.author.handle}`);
170
170
return;
171
171
}
172
172
···
174
174
if (['reply', 'quote', 'mention'].includes(notif.reason)) {
175
175
const parts = notif.uri.split('/');
176
176
const rkey = parts[parts.length - 1];
177
177
-
goto(`/p/${notif.author.handle}/post/${rkey}`);
177
177
+
goto(`/profile/${notif.author.handle}/post/${rkey}`);
178
178
return;
179
179
}
180
180
···
184
184
const rkey = parts[parts.length - 1];
185
185
const did = parts[2];
186
186
// Use the profile handle from user since it's our own post
187
187
-
goto(`/p/${user.profile?.handle ?? did}/post/${rkey}`);
187
187
+
goto(`/profile/${user.profile?.handle ?? did}/post/${rkey}`);
188
188
return;
189
189
}
190
190
}
···
253
253
onmousedown={(e) => {
254
254
e.stopPropagation();
255
255
e.preventDefault();
256
256
-
goto(`/p/${notif.author.handle}`);
256
256
+
goto(`/profile/${notif.author.handle}`);
257
257
}}
258
258
>
259
259
<Avatar src={notif.author.avatar} class="size-8" />
···
267
267
onmousedown={(e) => {
268
268
e.stopPropagation();
269
269
e.preventDefault();
270
270
-
goto(`/p/${notif.author.handle}`);
270
270
+
goto(`/profile/${notif.author.handle}`);
271
271
}}
272
272
>
273
273
@{notif.author.handle}
···
9
9
import { user, logout } from '$lib/atproto/auth.svelte';
10
10
import { actorToDid, getDetailedProfile } from '$lib/atproto/methods';
11
11
import { getCachedProfile, cacheProfile, cachePosts, prefetchThread } from '$lib/cache.svelte';
12
12
-
import { getAuthorFeed, likePost, unlikePost } from '$lib/atproto/server/feed.remote';
12
12
+
import { getAuthorFeed, likePost, unlikePost, followUser, unfollowUser } from '$lib/atproto/server/feed.remote';
13
13
import { wireEmbedClicks } from '$lib/components/embed';
14
14
+
import { bookmarks } from '$lib/bookmarks.svelte';
14
15
import { Client, simpleFetchHandler } from '@atcute/client';
15
16
17
17
+
import { UserPlus, UserCheck } from '@lucide/svelte';
18
18
+
16
19
let isOwnProfile = $derived(user.did && profile?.did === user.did);
20
20
+
let followUri = $state<string | null>(null);
21
21
+
let isFollowing = $derived(followUri !== null);
22
22
+
let followLoading = $state(false);
17
23
18
24
let loading = $state(true);
19
25
let error = $state<string | null>(null);
···
66
72
}
67
73
}
68
74
75
75
+
async function toggleFollow() {
76
76
+
if (!profile?.did || followLoading) return;
77
77
+
followLoading = true;
78
78
+
try {
79
79
+
if (isFollowing) {
80
80
+
await unfollowUser({ followUri: followUri! });
81
81
+
followUri = null;
82
82
+
} else {
83
83
+
const result = await followUser({ did: profile.did });
84
84
+
followUri = result.uri;
85
85
+
}
86
86
+
} catch (e) {
87
87
+
console.error('Failed to toggle follow:', e);
88
88
+
} finally {
89
89
+
followLoading = false;
90
90
+
}
91
91
+
}
92
92
+
93
93
+
function numberToHuman(n: number): string {
94
94
+
if (n < 1000) return String(n);
95
95
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
96
96
+
return `${(n / 1_000_000).toFixed(1)}m`;
97
97
+
}
98
98
+
69
99
async function loadProfile(actor: string) {
70
100
loading = true;
71
101
error = null;
···
75
105
postsLoading = true;
76
106
likeState = {};
77
107
likeCountDelta = {};
108
108
+
followUri = null;
78
109
79
110
// Show cached profile instantly
80
111
const cached = await getCachedProfile(actor);
···
93
124
if (fresh) {
94
125
profile = fresh;
95
126
cacheProfile(fresh);
127
127
+
followUri = fresh.viewer?.following ?? null;
96
128
} else if (!cached) {
97
129
error = 'Profile not found';
98
130
}
···
120
152
}
121
153
122
154
$effect(() => {
123
123
-
const actor = page.params.actor;
155
155
+
const actor = page.params.handle;
124
156
if (actor) untrack(() => loadProfile(actor));
125
157
});
126
158
···
129
161
loadingMore = true;
130
162
try {
131
163
const result = await getAuthorFeed({
132
132
-
actor: page.params.actor,
164
164
+
actor: page.params.handle,
133
165
cursor: postsCursor
134
166
});
135
167
const newPosts = JSON.parse(JSON.stringify(result.posts));
···
180
212
description: profile.description
181
213
}}
182
214
class=""
183
183
-
/>
184
184
-
{#if isOwnProfile}
185
185
-
<div class="px-4 py-4">
186
186
-
<Button variant="ghost" onclick={logout} class="gap-2">
187
187
-
<LogOut size={16} />
188
188
-
Log out
189
189
-
</Button>
215
215
+
>
216
216
+
<div class="flex items-center justify-between">
217
217
+
<div class="flex gap-4 text-sm">
218
218
+
<button onclick={() => {}} class="hover:underline">
219
219
+
<span class="text-base-900 dark:text-base-100 font-semibold">{numberToHuman(profile.followsCount ?? 0)}</span>
220
220
+
<span class="text-base-500 dark:text-base-400"> following</span>
221
221
+
</button>
222
222
+
<button onclick={() => {}} class="hover:underline">
223
223
+
<span class="text-base-900 dark:text-base-100 font-semibold">{numberToHuman(profile.followersCount ?? 0)}</span>
224
224
+
<span class="text-base-500 dark:text-base-400"> followers</span>
225
225
+
</button>
226
226
+
</div>
227
227
+
{#if isOwnProfile}
228
228
+
<Button variant="ghost" onclick={logout} class="gap-2" size="sm">
229
229
+
<LogOut size={14} />
230
230
+
Log out
231
231
+
</Button>
232
232
+
{:else if user.did}
233
233
+
<Button
234
234
+
variant={isFollowing ? 'outline' : 'default'}
235
235
+
size="sm"
236
236
+
onclick={toggleFollow}
237
237
+
disabled={followLoading}
238
238
+
class="gap-1.5"
239
239
+
>
240
240
+
{#if isFollowing}
241
241
+
<UserCheck size={14} />
242
242
+
Following
243
243
+
{:else}
244
244
+
<UserPlus size={14} />
245
245
+
Follow
246
246
+
{/if}
247
247
+
</Button>
248
248
+
{/if}
190
249
</div>
191
191
-
{/if}
250
250
+
</UserProfile>
192
251
193
252
<!-- Author posts -->
194
253
{#if postsLoading}
···
202
261
{@const { postData, embeds } = blueskyPostToPostData(feedPost.post, 'https://bsky.app', feedPost.reason)}
203
262
{@const postHref = (() => {
204
263
const rkey = feedPost.post.uri.split('/').pop();
205
205
-
return `/p/${feedPost.post.author.handle}/post/${rkey}`;
264
264
+
return `/profile/${feedPost.post.author.handle}/post/${rkey}`;
206
265
})()}
207
266
<div
208
267
class="-mx-2 px-6 pt-3 pb-2 sm:px-2 rounded-xl hover:bg-base-100/50 dark:hover:bg-base-800/30 transition-colors"
···
210
269
<Post
211
270
compact={true}
212
271
data={postData}
213
213
-
embeds={wireEmbedClicks(embeds, (handle, rkey) => goto(`/p/${handle}/post/${rkey}`), (handle) => goto(`/p/${handle}`))}
272
272
+
embeds={wireEmbedClicks(embeds, (handle, rkey) => goto(`/profile/${handle}/post/${rkey}`), (handle) => goto(`/profile/${handle}`))}
214
273
href={postHref}
215
215
-
onclickhandle={(handle) => goto(`/p/${handle}`)}
216
216
-
handleHref={(handle) => `/p/${handle}`}
274
274
+
onclickhandle={(handle) => goto(`/profile/${handle}`)}
275
275
+
handleHref={(handle) => `/profile/${handle}`}
217
276
actions={user.did
218
277
? {
219
278
reply: { count: postData.replyCount },
···
222
281
count: getLikeCount(feedPost.post.uri, postData.likeCount ?? 0),
223
282
active: isLiked(feedPost.post.uri, feedPost.post.viewer?.like),
224
283
onclick: () => handleLike(feedPost.post.uri, feedPost.post.cid, feedPost.post.viewer?.like)
284
284
+
},
285
285
+
bookmark: {
286
286
+
active: bookmarks.isBookmarked(feedPost.post.uri, feedPost.post.viewer?.bookmarked),
287
287
+
onclick: () => bookmarks.toggle(feedPost.post.uri, feedPost.post.cid, feedPost.post.viewer?.bookmarked)
225
288
}
226
289
}
227
290
: {
···
12
12
import { getCachedPost, getCachedThread, getThreadAge } from '$lib/cache.svelte';
13
13
import { threadStore } from '$lib/db.svelte';
14
14
import { wireEmbedClicks } from '$lib/components/embed';
15
15
+
import { bookmarks } from '$lib/bookmarks.svelte';
15
16
16
17
let loading = $state(true);
17
18
let loadingComments = $state(true);
···
99
100
onMount(async () => {
100
101
101
102
try {
102
102
-
const did = await actorToDid(page.params.actor);
103
103
+
const did = await actorToDid(page.params.handle);
103
104
const uri = `at://${did}/app.bsky.feed.post/${page.params.rkey}`;
104
105
105
106
// Show cached data instantly
···
152
153
});
153
154
154
155
function handleClickHandle(handle: string) {
155
155
-
goto(`/p/${handle}`);
156
156
+
goto(`/profile/${handle}`);
156
157
}
157
158
</script>
158
159
···
177
178
<div class="px-4 sm:px-0">
178
179
<Post
179
180
data={postData}
180
180
-
embeds={wireEmbedClicks(embeds, (handle, rkey) => goto(`/p/${handle}/post/${rkey}`), (handle) => goto(`/p/${handle}`))}
181
181
+
embeds={wireEmbedClicks(embeds, (handle, rkey) => goto(`/profile/${handle}/post/${rkey}`), (handle) => goto(`/profile/${handle}`))}
181
182
onclickhandle={handleClickHandle}
182
182
-
handleHref={(handle) => `/p/${handle}`}
183
183
+
handleHref={(handle) => `/profile/${handle}`}
183
184
actions={user.did
184
185
? {
185
186
reply: { count: postData.replyCount },
···
188
189
count: getLikeCount(postView.uri, postData.likeCount ?? 0),
189
190
active: isLiked(postView.uri, postView.viewer?.like),
190
191
onclick: () => handleLike(postView.uri, postView.cid, postView.viewer?.like)
192
192
+
},
193
193
+
bookmark: {
194
194
+
active: bookmarks.isBookmarked(postView.uri, postView.viewer?.bookmarked),
195
195
+
onclick: () => bookmarks.toggle(postView.uri, postView.cid, postView.viewer?.bookmarked)
191
196
}
192
197
}
193
198
: {
···
198
203
/>
199
204
</div>
200
205
201
201
-
{#if loadingComments}
202
202
-
<div class="flex justify-center py-6">
203
203
-
<Loader2 class="text-base-400 animate-spin" size={24} />
204
204
-
</div>
205
205
-
{:else if comments.length > 0}
206
206
-
<div class="px-4 sm:px-0">
207
207
-
<NestedComments {comments} onclickhandle={handleClickHandle} actions={commentActions} />
208
208
-
</div>
209
209
-
{/if}
206
206
+
<div id="replies">
207
207
+
{#if loadingComments}
208
208
+
<div class="flex justify-center py-6">
209
209
+
<Loader2 class="text-base-400 animate-spin" size={24} />
210
210
+
</div>
211
211
+
{:else if comments.length > 0}
212
212
+
<div class="px-4 sm:px-0">
213
213
+
<NestedComments {comments} onclickhandle={handleClickHandle} actions={commentActions} />
214
214
+
</div>
215
215
+
{/if}
216
216
+
</div>
210
217
{/if}
211
218
</div>
212
219
</div>
···
1
1
+
<script lang="ts">
2
2
+
/* eslint-disable svelte/no-navigation-without-resolve */
3
3
+
import { onMount, onDestroy } from 'svelte';
4
4
+
import { goto } from '$app/navigation';
5
5
+
import { page } from '$app/state';
6
6
+
import { user } from '$lib/atproto/auth.svelte';
7
7
+
import { blueskyPostToPostData } from '$lib/components';
8
8
+
import { Post } from '$lib/components';
9
9
+
import { Search, Loader2 } from '@lucide/svelte';
10
10
+
import { searchPosts, likePost, unlikePost } from '$lib/atproto/server/feed.remote';
11
11
+
import { cachePosts, prefetchThread } from '$lib/cache.svelte';
12
12
+
import { wireEmbedClicks } from '$lib/components/embed';
13
13
+
14
14
+
let query = $state(page.url.searchParams.get('q') ?? '');
15
15
+
let inputValue = $state(query);
16
16
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
17
17
+
let posts = $state<any[]>([]);
18
18
+
let cursor = $state<string | null>(null);
19
19
+
let loading = $state(false);
20
20
+
let loadingMore = $state(false);
21
21
+
let searched = $state(false);
22
22
+
23
23
+
let likeState = $state<Record<string, string | null>>({});
24
24
+
let likeCountDelta = $state<Record<string, number>>({});
25
25
+
26
26
+
function isLiked(postUri: string, viewerLike?: string): boolean {
27
27
+
if (postUri in likeState) return likeState[postUri] !== null;
28
28
+
return !!viewerLike;
29
29
+
}
30
30
+
31
31
+
function getLikeCount(postUri: string, originalCount: number): number {
32
32
+
return originalCount + (likeCountDelta[postUri] ?? 0);
33
33
+
}
34
34
+
35
35
+
async function handleLike(postUri: string, postCid: string, viewerLike?: string) {
36
36
+
const currentlyLiked = isLiked(postUri, viewerLike);
37
37
+
if (currentlyLiked) {
38
38
+
const likeUri = likeState[postUri] ?? viewerLike;
39
39
+
if (!likeUri) return;
40
40
+
likeState[postUri] = null;
41
41
+
likeCountDelta[postUri] = (likeCountDelta[postUri] ?? 0) - 1;
42
42
+
try {
43
43
+
await unlikePost({ likeUri });
44
44
+
} catch {
45
45
+
likeState[postUri] = likeUri;
46
46
+
likeCountDelta[postUri] = (likeCountDelta[postUri] ?? 0) + 1;
47
47
+
}
48
48
+
} else {
49
49
+
likeState[postUri] = 'pending';
50
50
+
likeCountDelta[postUri] = (likeCountDelta[postUri] ?? 0) + 1;
51
51
+
try {
52
52
+
const result = await likePost({ uri: postUri, cid: postCid });
53
53
+
likeState[postUri] = result.uri;
54
54
+
} catch {
55
55
+
likeState[postUri] = null;
56
56
+
likeCountDelta[postUri] = (likeCountDelta[postUri] ?? 0) - 1;
57
57
+
}
58
58
+
}
59
59
+
}
60
60
+
61
61
+
async function doSearch(q: string) {
62
62
+
if (!q.trim()) return;
63
63
+
query = q.trim();
64
64
+
loading = true;
65
65
+
searched = true;
66
66
+
posts = [];
67
67
+
cursor = null;
68
68
+
likeState = {};
69
69
+
likeCountDelta = {};
70
70
+
71
71
+
// Update URL
72
72
+
const url = new URL(window.location.href);
73
73
+
url.searchParams.set('q', query);
74
74
+
history.replaceState({}, '', url);
75
75
+
76
76
+
try {
77
77
+
const result = await searchPosts({ q: query });
78
78
+
posts = result.posts;
79
79
+
cachePosts(posts.map((p: any) => ({ post: p }))); // eslint-disable-line @typescript-eslint/no-explicit-any
80
80
+
for (const p of posts) prefetchThread(p.uri);
81
81
+
cursor = result.cursor;
82
82
+
} catch (e) {
83
83
+
console.error('Search failed:', e);
84
84
+
} finally {
85
85
+
loading = false;
86
86
+
}
87
87
+
}
88
88
+
89
89
+
async function loadMore() {
90
90
+
if (loadingMore || !cursor || !query) return;
91
91
+
loadingMore = true;
92
92
+
try {
93
93
+
const result = await searchPosts({ q: query, cursor });
94
94
+
cachePosts(result.posts.map((p: any) => ({ post: p }))); // eslint-disable-line @typescript-eslint/no-explicit-any
95
95
+
for (const p of result.posts) prefetchThread(p.uri);
96
96
+
posts = [...posts, ...result.posts];
97
97
+
cursor = result.cursor;
98
98
+
} catch (e) {
99
99
+
console.error('Failed to load more:', e);
100
100
+
} finally {
101
101
+
loadingMore = false;
102
102
+
}
103
103
+
}
104
104
+
105
105
+
function handleScroll() {
106
106
+
if (loadingMore || !cursor) return;
107
107
+
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
108
108
+
if (scrollHeight - scrollTop - clientHeight < 2000) {
109
109
+
loadMore();
110
110
+
}
111
111
+
}
112
112
+
113
113
+
function handleSubmit(e: Event) {
114
114
+
e.preventDefault();
115
115
+
doSearch(inputValue);
116
116
+
}
117
117
+
118
118
+
onMount(() => {
119
119
+
window.addEventListener('scroll', handleScroll);
120
120
+
if (query) doSearch(query);
121
121
+
});
122
122
+
123
123
+
onDestroy(() => {
124
124
+
if (typeof window !== 'undefined') window.removeEventListener('scroll', handleScroll);
125
125
+
});
126
126
+
</script>
127
127
+
128
128
+
<div class="flex h-dvh flex-col">
129
129
+
<div class="mx-auto w-full max-w-lg">
130
130
+
<!-- Search bar -->
131
131
+
<form onsubmit={handleSubmit} class="px-4 pt-4 pb-3">
132
132
+
<div class="relative">
133
133
+
<Search class="text-base-400 absolute top-1/2 left-3 -translate-y-1/2" size={16} />
134
134
+
<input
135
135
+
type="text"
136
136
+
bind:value={inputValue}
137
137
+
placeholder="Search posts..."
138
138
+
class="border-base-200 bg-base-50 text-base-900 placeholder:text-base-400 focus:border-accent-400 focus:ring-accent-400 dark:border-base-700 dark:bg-base-900 dark:text-base-100 dark:placeholder:text-base-500 w-full rounded-full border py-2 pr-4 pl-9 text-sm focus:ring-1 focus:outline-none"
139
139
+
/>
140
140
+
</div>
141
141
+
</form>
142
142
+
143
143
+
{#if loading}
144
144
+
<div class="flex items-center justify-center py-12">
145
145
+
<Loader2 class="text-base-400 animate-spin" size={28} />
146
146
+
</div>
147
147
+
{:else if searched && posts.length === 0}
148
148
+
<div class="flex flex-col items-center justify-center py-20">
149
149
+
<Search class="text-base-300 dark:text-base-600 mb-3" size={40} />
150
150
+
<p class="text-base-400 text-sm">No results for "{query}"</p>
151
151
+
</div>
152
152
+
{:else if posts.length > 0}
153
153
+
<div>
154
154
+
{#each posts as post, i (post.uri ? `${post.uri}-${i}` : i)}
155
155
+
{@const { postData, embeds } = blueskyPostToPostData(post)}
156
156
+
{@const rkey = post.uri.split('/').pop()}
157
157
+
{@const postHref = `/profile/${post.author.handle}/post/${rkey}`}
158
158
+
<div class="-mx-2 rounded-xl px-6 pt-3 pb-2 transition-colors hover:bg-base-100/50 sm:px-2 dark:hover:bg-base-800/30">
159
159
+
<Post
160
160
+
compact={true}
161
161
+
data={postData}
162
162
+
embeds={wireEmbedClicks(embeds, (handle, rk) => goto(`/profile/${handle}/post/${rk}`), (handle) => goto(`/profile/${handle}`))}
163
163
+
href={postHref}
164
164
+
onclickhandle={(handle) => goto(`/profile/${handle}`)}
165
165
+
handleHref={(handle) => `/profile/${handle}`}
166
166
+
actions={user.did
167
167
+
? {
168
168
+
reply: { count: postData.replyCount, href: postHref + '#replies' },
169
169
+
repost: { count: postData.repostCount },
170
170
+
like: {
171
171
+
count: getLikeCount(post.uri, postData.likeCount ?? 0),
172
172
+
active: isLiked(post.uri, post.viewer?.like),
173
173
+
onclick: () => handleLike(post.uri, post.cid, post.viewer?.like)
174
174
+
}
175
175
+
}
176
176
+
: {
177
177
+
reply: { count: postData.replyCount, href: postHref + '#replies' },
178
178
+
repost: { count: postData.repostCount },
179
179
+
like: { count: postData.likeCount }
180
180
+
}}
181
181
+
/>
182
182
+
</div>
183
183
+
{#if i < posts.length - 1}
184
184
+
<hr class="border-base-200 dark:border-base-800 mx-4 sm:mx-0" />
185
185
+
{/if}
186
186
+
{/each}
187
187
+
</div>
188
188
+
189
189
+
{#if loadingMore}
190
190
+
<div class="flex justify-center py-6">
191
191
+
<Loader2 class="text-base-400 animate-spin" size={24} />
192
192
+
</div>
193
193
+
{/if}
194
194
+
195
195
+
{#if !cursor && posts.length > 0}
196
196
+
<p class="text-base-400 py-6 text-center text-sm">No more results</p>
197
197
+
{/if}
198
198
+
199
199
+
<div class="pb-[max(0.75rem,env(safe-area-inset-bottom))]"></div>
200
200
+
{:else if !searched}
201
201
+
<div class="flex flex-col items-center justify-center py-20">
202
202
+
<Search class="text-base-300 dark:text-base-600 mb-3" size={40} />
203
203
+
<p class="text-base-400 text-sm">Search for posts on Bluesky</p>
204
204
+
</div>
205
205
+
{/if}
206
206
+
</div>
207
207
+
</div>