[READ-ONLY] Mirror of https://github.com/flo-bit/atmo-social.
0

Configure Feed

Select the types of activity you want to include in your feed.

commiat

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