bluesky client without react native baggage written in sveltekit
0
fork

Configure Feed

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

added feed cache and added post composing

ansxor f22a0208 ee3b330a

+191 -66
+2 -1
package.json
··· 64 64 "@atcute/bluesky-richtext-segmenter": "^3.0.0", 65 65 "@atcute/client": "^4.2.1", 66 66 "@atcute/identity-resolver": "^1.2.2", 67 - "@atcute/oauth-browser-client": "^3.0.0" 67 + "@atcute/oauth-browser-client": "^3.0.0", 68 + "@atcute/tid": "^1.1.2" 68 69 } 69 70 }
+38
pnpm-lock.yaml
··· 29 29 '@atcute/oauth-browser-client': 30 30 specifier: ^3.0.0 31 31 version: 3.0.0(@atcute/identity@1.1.3) 32 + '@atcute/tid': 33 + specifier: ^1.1.2 34 + version: 1.1.2 32 35 devDependencies: 33 36 '@chromatic-com/storybook': 34 37 specifier: ^5.0.1 ··· 190 193 191 194 '@atcute/oauth-types@0.1.1': 192 195 resolution: {integrity: sha512-u+3KMjse3Uc/9hDyilu1QVN7IpcnjVXgRzhddzBB8Uh6wePHNVBDdi9wQvFTVVA3zmxtMJVptXRyLLg6Ou9bqg==} 196 + 197 + '@atcute/tid@1.1.2': 198 + resolution: {integrity: sha512-bmPuOX/TOfcm/vsK9vM98spjkcx2wgd9S2PeK5oLgEr8IbNRPq7iMCAPzOL1nu5XAW3LlkOYQEbYRcw5vcQ37w==} 199 + 200 + '@atcute/time-ms@1.2.3': 201 + resolution: {integrity: sha512-pRrkYSVyPDCWHKp77Ygwg3lxgvfwnh52J3kOIWI1z93kM2jWQDSezbTNDLdJFAJT9xm4rnHsA+toN9Rdhrnidw==} 193 202 194 203 '@atcute/uint8array@1.1.1': 195 204 resolution: {integrity: sha512-3LsC8XB8TKe9q/5hOA5sFuzGaIFdJZJNewC5OKa3o/eU6+K7JR6see9Zy2JbQERNVnRl11EzbNov1efgLMAs4g==} ··· 915 924 '@types/aria-query@5.0.4': 916 925 resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} 917 926 927 + '@types/bun@1.3.9': 928 + resolution: {integrity: sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==} 929 + 918 930 '@types/chai@5.2.3': 919 931 resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} 920 932 ··· 1136 1148 brace-expansion@5.0.3: 1137 1149 resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} 1138 1150 engines: {node: 18 || 20 || >=22} 1151 + 1152 + bun-types@1.3.9: 1153 + resolution: {integrity: sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==} 1139 1154 1140 1155 bundle-name@4.1.0: 1141 1156 resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} ··· 1752 1767 1753 1768 natural-compare@1.4.0: 1754 1769 resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} 1770 + 1771 + node-gyp-build@4.8.4: 1772 + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} 1773 + hasBin: true 1755 1774 1756 1775 nth-check@2.1.1: 1757 1776 resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} ··· 2408 2427 '@atcute/oauth-keyset': 0.1.0 2409 2428 '@badrap/valita': 0.4.6 2410 2429 2430 + '@atcute/tid@1.1.2': 2431 + dependencies: 2432 + '@atcute/time-ms': 1.2.3 2433 + 2434 + '@atcute/time-ms@1.2.3': 2435 + dependencies: 2436 + '@types/bun': 1.3.9 2437 + node-gyp-build: 4.8.4 2438 + 2411 2439 '@atcute/uint8array@1.1.1': {} 2412 2440 2413 2441 '@atcute/util-fetch@1.0.5': ··· 3015 3043 3016 3044 '@types/aria-query@5.0.4': {} 3017 3045 3046 + '@types/bun@1.3.9': 3047 + dependencies: 3048 + bun-types: 1.3.9 3049 + 3018 3050 '@types/chai@5.2.3': 3019 3051 dependencies: 3020 3052 '@types/deep-eql': 4.0.2 ··· 3299 3331 brace-expansion@5.0.3: 3300 3332 dependencies: 3301 3333 balanced-match: 4.0.4 3334 + 3335 + bun-types@1.3.9: 3336 + dependencies: 3337 + '@types/node': 22.19.11 3302 3338 3303 3339 bundle-name@4.1.0: 3304 3340 dependencies: ··· 3844 3880 nanoid@5.1.6: {} 3845 3881 3846 3882 natural-compare@1.4.0: {} 3883 + 3884 + node-gyp-build@4.8.4: {} 3847 3885 3848 3886 nth-check@2.1.1: 3849 3887 dependencies:
+117 -39
src/routes/+layout.svelte
··· 4 4 import favicon from '$lib/assets/favicon.svg'; 5 5 import { setUserContext, getUserContext } from '$lib/context'; 6 6 import type { AppBskyActorDefs } from '@atcute/bluesky'; 7 - import { login } from "$lib/atproto"; 7 + import * as TID from '@atcute/tid'; 8 + import { getClient, login } from '$lib/atproto'; 8 9 9 10 let { children, data } = $props(); 10 11 11 12 setUserContext({ 12 - loggedIn: data.loggedIn, 13 - profile: data.profile 13 + loggedIn: data.loggedIn, 14 + profile: data.profile 14 15 }); 15 - 16 + 16 17 const user = getUserContext(); 17 18 let handle = $state(''); 18 19 let loggingIn = $state(false); 19 - 20 + let composerDialog; 21 + let postContent = $state(''); 22 + 20 23 async function handleLogin() { 21 - if (!handle.trim()) return; 22 - loggingIn = true; 23 - try { 24 - await login(handle.trim()); 25 - } catch (exception) { 26 - console.error(exception) 27 - loggingIn = false; 28 - } 24 + if (!handle.trim()) return; 25 + loggingIn = true; 26 + try { 27 + await login(handle.trim()); 28 + } catch (exception) { 29 + console.error(exception); 30 + loggingIn = false; 31 + } 32 + } 33 + 34 + async function createPost() { 35 + if (!user.profile?.did) { 36 + throw new Error('need to be authenticated to make a post'); 37 + } 38 + 39 + const client = await getClient(); 40 + const { data, ok } = await client.post('com.atproto.repo.createRecord', { 41 + input: { 42 + repo: user.profile.did, 43 + collection: 'app.bsky.feed.post', 44 + rkey: TID.now(), // generates a sortable timestamp-based key 45 + record: { 46 + $type: 'app.bsky.feed.post', 47 + text: postContent, 48 + createdAt: new Date().toISOString(), 49 + langs: ['en'] 50 + } 51 + } 52 + }); 53 + if (!ok) { 54 + throw new Error('failed to create post'); 55 + } 56 + console.log('success!'); 57 + composerDialog.close(); 29 58 } 30 59 </script> 31 60 ··· 33 62 <div class="mx-auto flex max-h-screen max-w-290"> 34 63 <div class="sidebar p-4"> 35 64 {#if user.loggedIn && user.profile} 36 - <div class="flex items-center gap-2"> 37 - <Avatar user={user.profile} /> 38 - {user.profile?.handle} 39 - </div> 40 - <button class="flex align-items bg-post-button gap-3 rounded-full py-3 px-6 text-white hover:cursor-pointer"> 41 - New Post 42 - </button> 65 + <div class="flex items-center gap-2"> 66 + <Avatar user={user.profile} /> 67 + {user.profile?.handle} 68 + </div> 69 + <button 70 + class="align-items flex gap-3 rounded-full bg-post-button px-6 py-3 text-white hover:cursor-pointer" 71 + onclick={() => composerDialog.showModal()} 72 + > 73 + New Post 74 + </button> 43 75 {:else} 44 - <form onsubmit={(e) => { e.preventDefault(); handleLogin(); }}> 45 - <input 46 - type="text" 47 - placeholder="handle (e.g. alice.bsky.social)" 48 - bind:value={handle} 49 - class="mb-2 w-full rounded border px-2 py-1 text-sm" 50 - /> 51 - <button 52 - type="submit" 53 - disabled={loggingIn} 54 - class="w-full rounded bg-blue-500 px-3 py-1.5 text-sm text-white hover:bg-blue-600 disabled:opacity-50" 55 - > 56 - {loggingIn ? 'Logging in...' : 'Log in'} 57 - </button> 58 - </form> 76 + <form 77 + onsubmit={(e) => { 78 + e.preventDefault(); 79 + handleLogin(); 80 + }} 81 + > 82 + <input 83 + type="text" 84 + placeholder="handle (e.g. alice.bsky.social)" 85 + bind:value={handle} 86 + class="mb-2 w-full rounded border px-2 py-1 text-sm" 87 + /> 88 + <button 89 + type="submit" 90 + disabled={loggingIn} 91 + class="w-full rounded bg-blue-500 px-3 py-1.5 text-sm text-white hover:bg-blue-600 disabled:opacity-50" 92 + > 93 + {loggingIn ? 'Logging in...' : 'Log in'} 94 + </button> 95 + </form> 59 96 {/if} 60 - </div> 97 + </div> 61 98 <div class="max-w-150.5 overflow-y-scroll"> 62 99 {@render children()} 63 - </div> 100 + </div> 64 101 <div class="sidebar">owo</div> 65 102 </div> 103 + <dialog 104 + bind:this={composerDialog} 105 + class="mx-auto mt-[50px] w-full max-w-[600px] rounded-[8px] border border-modal-border p-2" 106 + > 107 + <form 108 + onsubmit={async (e) => { 109 + e.preventDefault(); 110 + await createPost(); 111 + }} 112 + > 113 + <header class="flex h-[54px] items-center justify-between"> 114 + <div> 115 + <button 116 + type="button" 117 + onclick={() => composerDialog.close()} 118 + class="p-2 text-secondary-blue hover:cursor-pointer" 119 + > 120 + Cancel 121 + </button> 122 + </div> 123 + <div> 124 + <button class="rounded-full bg-post-button px-3.5 py-2 text-white"> Post </button> 125 + </div> 126 + </header> 127 + <main class="flex pl-2"> 128 + {#if user.loggedIn && user.profile} 129 + <div class="shrink-0"> 130 + <Avatar user={user.profile} /> 131 + </div> 132 + {/if} 133 + <textarea 134 + class="m-[1px] mb-[11px] ml-[9px] min-h-[140px] grow resize-none p-[4px] text-[16.9px] leading-[24px]" 135 + placeholder="What's up?" 136 + bind:value={postContent} 137 + ></textarea> 138 + </main> 139 + </form> 140 + </dialog> 66 141 67 142 <style> 68 143 .sidebar { 69 - height: 100vh; 70 - flex: 1 0 0; 144 + height: 100vh; 145 + flex: 1 0 0; 146 + } 147 + dialog::backdrop { 148 + background-color: rgba(0, 0, 0, 0.8); 71 149 } 72 150 </style>
+1
src/routes/+page.svelte
··· 2 2 import Post from '$lib/components/Post.svelte'; 3 3 4 4 let { data } = $props(); 5 + 5 6 </script> 6 7 7 8 {#each data.data.feed as entry, i (i)}
+16 -22
src/routes/feed/[[aturl]]/+page.js
··· 1 1 import { getClient } from '$lib/atproto'; 2 - import { resumeSession, getProfile } from '$lib/atproto'; 2 + 3 + const cache = new Map(); 3 4 4 - export async function load({ params }) { 5 + export async function load({ params, depends }) { 6 + depends(`feed:${params.aturl ?? 'timeline'}`); 7 + const key = params.aturl ?? 'timeline'; 5 8 const client = await getClient(); 6 - if (params.aturl) { 7 - const { data, ok } = await client.get('app.bsky.feed.getFeed', { 8 - params: { 9 - feed: params.aturl, 10 - limit: 30 11 - } 12 - }); 13 - if (!ok) { 14 - throw new Error(`couldn't load feed ${params.aturl}`); 15 - } 16 - return { data }; 9 + 10 + if (cache.has(key)) { 11 + return { feed: cache.get(key) }; 17 12 } 18 13 19 - // use following feed 20 - const { data, ok } = await client.get('app.bsky.feed.getTimeline', { 21 - params: { 22 - limit: 30 23 - } 14 + const promise = params.aturl 15 + ? client.get('app.bsky.feed.getFeed', { params: { feed: params.aturl, limit: 30 } }) 16 + : client.get('app.bsky.feed.getTimeline', { params: { limit: 30 } }); 17 + 18 + promise.then(result => { 19 + if (result.ok !== false) cache.set(key, result); 24 20 }); 25 - if (!ok) { 26 - throw new Error("couldn't load following timeline"); 27 - } 28 - return { data }; 21 + 22 + return { feed: promise }; 29 23 }
+15 -4
src/routes/feed/[[aturl]]/+page.svelte
··· 4 4 let { data } = $props(); 5 5 </script> 6 6 7 - {#each data.data.feed as entry, i (i)} 8 - <Post post={entry.post} /> 9 - {/each} 10 - 7 + {#if data.feed instanceof Promise} 8 + {#await data.feed} 9 + <p>Loading...</p> 10 + {:then feedData} 11 + {#each feedData.data.feed as entry, i (i)} 12 + <Post post={entry.post} /> 13 + {/each} 14 + {:catch error} 15 + <p>Error: {error.message}</p> 16 + {/await} 17 + {:else} 18 + {#each data.feed.data.feed as entry, i (i)} 19 + <Post post={entry.post} /> 20 + {/each} 21 + {/if}
+2
src/routes/layout.css
··· 5 5 --color-post-border: rgb(222, 225, 234); 6 6 --color-secondary-text: rgb(71, 79, 104); 7 7 --color-post-button: rgb(0, 106, 255); 8 + --color-secondary-blue: rgb(0, 89, 214); 8 9 --color-feed-selected: rgb(0, 0, 0); 9 10 --color-feed-not-selected: rgb(64, 81, 104); 10 11 --color-item-hover: rgb(249, 250, 251); 12 + --color-modal-border: rgb(192, 202, 216); 11 13 } 12 14 13 15 html,