bluesky client without react native baggage written in sveltekit

idk but the last thing i did was likes

+311 -101
+7
src/lib/atproto.ts
··· 46 46 /** Current OAuth session */ 47 47 let currentSession: Session | null = null; 48 48 49 + export async function getClient(): Promise<Client> { 50 + if (rpc === null) { 51 + await resumeSession(); 52 + } 53 + return rpc ?? publicClient; 54 + } 55 + 49 56 /** Try to resume an existing session from localStorage */ 50 57 export async function resumeSession(): Promise<boolean> { 51 58 const dids = listStoredSessions();
+2
src/lib/components/Post.stories.svelte
··· 34 34 35 35 <Story name="Liked" args={{ post: { ...basePost, viewer: { like: 'at://liked' } } }} /> 36 36 37 + <Story name="Reposted" args={{ post: { ...basePost, viewer: { repost: 'at://reposted' } } }} /> 38 + 37 39 <Story name="WithImage" args={{ post: { 38 40 ...basePost, 39 41 embed: {
+127 -38
src/lib/components/Post.svelte
··· 1 1 <script lang="ts"> 2 - import RichText from "./RichText.svelte"; 3 - import Avatar from "./Avatar.svelte"; 4 - import type { PostView } from "@atcute/bluesky/types/app/feed/defs"; 2 + import RichText from './RichText.svelte'; 3 + import Avatar from './Avatar.svelte'; 4 + import { getClient } from '$lib/atproto'; 5 + import { getUserContext } from '$lib/context'; 6 + import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 7 + 8 + let { post }: { post: PostView } = $props(); 9 + const user = getUserContext(); 10 + let liked = $state(Object.hasOwn(post.viewer, 'like')); 11 + let reposted = $state(Object.hasOwn(post.viewer, 'repost')); 12 + let likeCount = $derived( 13 + Object.hasOwn(post.viewer, 'like') ? post.likeCount - 1 : post.likeCount 14 + ); 15 + let repostCount = $derived( 16 + Object.hasOwn(post.viewer, 'repost') ? post.repostCount - 1 : post.repostCount 17 + ); 18 + 19 + async function likePost() { 20 + liked = true; 21 + const client = await getClient(); 22 + 23 + if (!user.profile?.did) { 24 + liked = false; 25 + throw new Error('you must be authenticated to do this action'); 26 + } 27 + 28 + const { data, ok } = await client.post('com.atproto.repo.createRecord', { 29 + input: { 30 + collection: 'app.bsky.feed.like', 31 + record: { 32 + $type: 'app.bsky.feed.like', 33 + createdAt: new Date().toISOString(), 34 + subject: { 35 + cid: post.cid, 36 + uri: post.uri 37 + } 38 + }, 39 + repo: user.profile.did 40 + } 41 + }); 42 + 43 + if (!ok) { 44 + liked = false; 45 + throw new Error('failed to like the post'); 46 + } 47 + console.log('liked post!'); 48 + } 49 + 50 + async function unlikePost() { 51 + liked = false; 52 + const client = await getClient(); 53 + 54 + if (!user.profile?.did) { 55 + liked = true; 56 + throw new Error('you must be authenticated to do this action (how did you even like this)'); 57 + } 58 + const rkey = post.uri.split('/').at(-1); 59 + if (!rkey) { 60 + liked = true; 61 + throw new Error("couldn't properly extract rkey"); 62 + 63 + } 64 + const { data, ok } = await client.post('com.atproto.repo.deleteRecord', { 65 + input: { 66 + collection: 'app.bsky.feed.like', 67 + rkey, 68 + repo: user.profile.did 69 + } 70 + }); 5 71 6 - let { post }: { post: PostView } = $props(); 7 - console.log(post) 8 - // let liked = post.viewer?.like; 9 - let liked = Object.hasOwn(post.viewer, "like"); 72 + if (!ok) { 73 + liked = true; 74 + throw new Error('failed to unlike the post'); 75 + } 76 + console.log('liked post!'); 77 + } 10 78 </script> 11 79 12 - <article class="flex pr-4 pl-2.5 pt-2 pb-2 border-post-border border"> 13 - <div class="ml-2 mr-2.5 shrink-0"> 14 - <Avatar user={post.author} /> 15 - </div> 16 - <div> 17 - <div class="mb-1"> 18 - <a href="#" > 19 - <b>{post.author.displayName || post.author.handle}</b> 20 - <span class="text-secondary-text">@{post.author.handle}</span> 21 - </a> 22 - </div> 23 - <RichText text={post.record.text} facets={post.record.facets} /> 24 - {#if post.embed} 25 - {#if post.embed.$type === "app.bsky.embed.images#view"} 26 - {#each post.embed.images as image} 27 - <img class="my-2 rounded-xl aspect-[1.23151 / 1]" src={image.thumb} alt={image.alt} /> 28 - {/each} 29 - {/if} 30 - {/if} 31 - <div class="flex mt-0.5 w-full"> 32 - <div class="w-[320px] max-w-[320px] flex justify-between"> 33 - <div class="grow"><button class="flex items-center gap-1 py-1.25 pr-1.25"><span class="icon-[boxicons--message-reply] w-4.5 h-4.5 text-4.5"></span> {post.replyCount}</button></div> 34 - <div class="grow flex items-center gap-1"><span class="icon-[mdi--repost] w-4.5 h-4.5 text-4.5"></span> {post.repostCount}</div> 35 - {#if liked} 36 - <div class="grow flex items-center gap-1"><span class="icon-[icon-park-solid--like] w-4.5 h-4.5 text-4.5 fill-red"></span> {post.likeCount}</div> 37 - {:else} 38 - <div class="grow flex items-center gap-1"><span class="icon-[icon-park-outline--like] w-4.5 h-4.5 text-4.5"></span> {post.likeCount}</div> 39 - {/if} 40 - </div> 41 - </div> 42 - </div> 80 + <article class="flex border border-post-border pt-2 pr-4 pb-2 pl-2.5"> 81 + <div class="mr-2.5 ml-2 shrink-0"> 82 + <Avatar user={post.author} /> 83 + </div> 84 + <div> 85 + <div class="mb-1"> 86 + <a href="#"> 87 + <b>{post.author.displayName || post.author.handle}</b> 88 + <span class="text-secondary-text">@{post.author.handle}</span> 89 + </a> 90 + </div> 91 + <RichText text={post.record.text} facets={post.record.facets} /> 92 + {#if post.embed} 93 + {#if post.embed.$type === 'app.bsky.embed.images#view'} 94 + {#each post.embed.images as image} 95 + <img class="aspect-[1.23151 / 1] my-2 rounded-xl" src={image.thumb} alt={image.alt} /> 96 + {/each} 97 + {/if} 98 + {/if} 99 + <div class="mt-0.5 flex w-full"> 100 + <div class="flex w-[320px] max-w-[320px] justify-between"> 101 + <div class="grow"> 102 + <button class="flex items-center gap-1 py-1.25 pr-1.25" 103 + ><span class="text-4.5 icon-[boxicons--message-reply] h-4.5 w-4.5"></span> 104 + {post.replyCount}</button 105 + > 106 + </div> 107 + {#if reposted} 108 + <div class="flex grow items-center gap-1"> 109 + <span class="text-4.5 icon-[mdi--repost] h-4.5 w-4.5 text-green-500"></span> 110 + {(repostCount ?? 0) + 1} 111 + </div> 112 + {:else} 113 + <div class="flex grow items-center gap-1"> 114 + <span class="text-4.5 icon-[mdi--repost] h-4.5 w-4.5"></span> 115 + {repostCount} 116 + </div> 117 + {/if} 118 + {#if liked} 119 + <button class="flex grow items-center gap-1 hover:cursor-pointer" onclick={unlikePost}> 120 + <span class="text-4.5 icon-[icon-park-solid--like] h-4.5 w-4.5 text-red-500"></span> 121 + {(likeCount ?? 0) + 1} 122 + </button> 123 + {:else} 124 + <button class="flex grow items-center gap-1 hover:cursor-pointer" onclick={likePost}> 125 + <span class="text-4.5 icon-[icon-park-outline--like] h-4.5 w-4.5"></span> 126 + {likeCount} 127 + </button> 128 + {/if} 129 + </div> 130 + </div> 131 + </div> 43 132 </article>
+56 -2
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import './layout.css'; 3 + import Avatar from '$lib/components/Avatar.svelte'; 3 4 import favicon from '$lib/assets/favicon.svg'; 4 - import { setUserContext } from '$lib/context'; 5 + import { setUserContext, getUserContext } from '$lib/context'; 5 6 import type { AppBskyActorDefs } from '@atcute/bluesky'; 6 7 7 8 let { children, data } = $props(); ··· 10 11 loggedIn: data.loggedIn, 11 12 profile: data.profile 12 13 }); 14 + 15 + const user = getUserContext(); 16 + let handle = $state(''); 17 + let loggingIn = $state(false); 18 + 19 + async function handleLogin() { 20 + if (!handle.trim()) return; 21 + loggingIn = true; 22 + try { 23 + await login(handle.trim()); 24 + } catch { 25 + loggingIn = false; 26 + } 27 + } 13 28 </script> 14 29 15 30 <svelte:head><link rel="icon" href={favicon} /></svelte:head> 16 - {@render children()} 31 + <div class="mx-auto flex max-h-screen max-w-290"> 32 + <div class="sidebar p-4"> 33 + {#if user.loggedIn && user.profile} 34 + <div class="flex items-center gap-2"> 35 + <Avatar user={user.profile} /> 36 + {user.profile?.handle} 37 + </div> 38 + <button class="flex align-items bg-post-button gap-3 rounded-full py-3 px-6 text-white hover:cursor-pointer"> 39 + New Post 40 + </button> 41 + {:else} 42 + <form onsubmit={(e) => { e.preventDefault(); handleLogin(); }}> 43 + <input 44 + type="text" 45 + placeholder="handle (e.g. alice.bsky.social)" 46 + bind:value={handle} 47 + class="mb-2 w-full rounded border px-2 py-1 text-sm" 48 + /> 49 + <button 50 + type="submit" 51 + disabled={loggingIn} 52 + class="w-full rounded bg-blue-500 px-3 py-1.5 text-sm text-white hover:bg-blue-600 disabled:opacity-50" 53 + > 54 + {loggingIn ? 'Logging in...' : 'Log in'} 55 + </button> 56 + </form> 57 + {/if} 58 + </div> 59 + <div class="max-w-150.5 overflow-y-scroll"> 60 + {@render children()} 61 + </div> 62 + <div class="sidebar">owo</div> 63 + </div> 64 + 65 + <style> 66 + .sidebar { 67 + height: 100vh; 68 + flex: 1 0 0; 69 + } 70 + </style>
+3 -4
src/routes/+page.js
··· 2 2 3 3 export async function load() { 4 4 const client = rpc ?? publicClient; 5 - const { data, ok } = await client.get('app.bsky.feed.getFeed', { 5 + const { data, ok } = await client.get('app.bsky.feed.getTimeline', { 6 6 params: { 7 - feed: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot', 8 7 limit: 30 9 8 } 10 9 }); 11 10 if (!ok) { 12 - throw new Error("couldn't load profile"); 11 + throw new Error("couldn't load following timeline"); 13 12 } 14 13 return { data }; 15 - } 14 + }
+3 -57
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import Post from '$lib/components/Post.svelte'; 3 - import Avatar from '$lib/components/Avatar.svelte'; 4 - import { login } from '$lib/atproto'; 5 - import { getUserContext } from '$lib/context'; 6 3 7 4 let { data } = $props(); 8 - const user = getUserContext(); 9 - let handle = $state(''); 10 - let loggingIn = $state(false); 11 - 12 - async function handleLogin() { 13 - if (!handle.trim()) return; 14 - loggingIn = true; 15 - try { 16 - await login(handle.trim()); 17 - } catch { 18 - loggingIn = false; 19 - } 20 - } 21 5 </script> 22 6 23 - <div class="mx-auto flex max-h-screen max-w-290"> 24 - <div class="sidebar p-4"> 25 - {#if user.loggedIn && user.profile} 26 - <div class="flex items-center gap-2"> 27 - <Avatar user={user.profile} /> 28 - {user.profile?.handle} 29 - </div> 30 - <button class="flex align-items bg-post-button gap-3 rounded-full py-3 px-6 text-white hover:cursor-pointer"> 31 - New Post 32 - </button> 33 - {:else} 34 - <form onsubmit={(e) => { e.preventDefault(); handleLogin(); }}> 35 - <input 36 - type="text" 37 - placeholder="handle (e.g. alice.bsky.social)" 38 - bind:value={handle} 39 - class="mb-2 w-full rounded border px-2 py-1 text-sm" 40 - /> 41 - <button 42 - type="submit" 43 - disabled={loggingIn} 44 - class="w-full rounded bg-blue-500 px-3 py-1.5 text-sm text-white hover:bg-blue-600 disabled:opacity-50" 45 - > 46 - {loggingIn ? 'Logging in...' : 'Log in'} 47 - </button> 48 - </form> 49 - {/if} 50 - </div> 51 - <div class="max-w-150.5 overflow-y-scroll"> 52 - {#each data.data.feed as entry, i (i)} 53 - <Post post={entry.post} /> 54 - {/each} 55 - </div> 56 - <div class="sidebar">owo</div> 57 - </div> 7 + {#each data.data.feed as entry, i (i)} 8 + <Post post={entry.post} /> 9 + {/each} 58 10 59 - <style> 60 - .sidebar { 61 - height: 100vh; 62 - flex: 1 0 0; 63 - } 64 - </style>
+37
src/routes/feed/[[aturl]]/+layout.js
··· 1 + import { publicClient, rpc } from '$lib/atproto'; 2 + import { resumeSession, getProfile } from '$lib/atproto'; 3 + 4 + export async function load() { 5 + await resumeSession(); 6 + const client = rpc; 7 + if (!client) { 8 + throw new Error(`authenticated client not loaded`); 9 + } 10 + const { data, ok } = await client.get('app.bsky.actor.getPreferences', { 11 + params: {} 12 + }); 13 + if (!ok) { 14 + throw new Error(`couldn't load preferences`); 15 + } 16 + // extract the savedFeedsPrefV2 from this 17 + const savedFeedsPrefV2 = data.preferences.find(x => x.$type === "app.bsky.actor.defs#savedFeedsPrefV2"); 18 + 19 + if (savedFeedsPrefV2?.items) { 20 + const pinnedItems = savedFeedsPrefV2?.items.filter(x => x.pinned && x.type === 'feed') 21 + const { data, ok } = await client.get('app.bsky.feed.getFeedGenerators', { 22 + params: { 23 + feeds: /** @type {import('@atcute/lexicons').ResourceUri[]} */ (pinnedItems.map(x => x.value)) 24 + } 25 + }) 26 + 27 + if (!ok) { 28 + throw new Error(`failed to get list of feeds`); 29 + } 30 + 31 + const feedMap = Object.fromEntries(data.feeds.map(item => [item.uri, item])); 32 + console.log(feedMap) 33 + return { items: savedFeedsPrefV2.items, feedMap }; 34 + } 35 + 36 + return { items: [], feedMap: {} }; 37 + }
+35
src/routes/feed/[[aturl]]/+layout.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/state'; 3 + let { children, data } = $props(); 4 + </script> 5 + 6 + <div class="flex overflow-x-auto"> 7 + {#each data.items as feedItem} 8 + {#if feedItem.pinned} 9 + {#if feedItem.type === "timeline" && feedItem.value === "following"} 10 + <a href="/feed" class="feedItem {!page.params.aturl ? 'feedItem--selected' : ''}">Following</a> 11 + {:else if feedItem.type === "feed" && data.feedMap[feedItem.value]} 12 + <a href="/feed/{encodeURIComponent(feedItem.value)}" class="feedItem {page.params.aturl === feedItem.value ? 'feedItem--selected' : ''}">{data.feedMap[feedItem.value]?.displayName ?? feedItem.value}</a> 13 + {/if} 14 + {/if} 15 + {/each} 16 + </div> 17 + {@render children()} 18 + 19 + <style> 20 + .feedItem { 21 + padding: 14px; 22 + white-space: nowrap; 23 + flex-shrink: 0; 24 + font-weight: 600; 25 + line-height: 20px; 26 + font-size: 15px; 27 + color: var(--color-feed-not-selected); 28 + border-top: 3px solid transparent; 29 + } 30 + 31 + .feedItem--selected { 32 + color: var(--color-feed-selected); 33 + border-top: 3px solid var(--color-post-button); 34 + } 35 + </style>
+29
src/routes/feed/[[aturl]]/+page.js
··· 1 + import { getClient } from '$lib/atproto'; 2 + import { resumeSession, getProfile } from '$lib/atproto'; 3 + 4 + export async function load({ params }) { 5 + 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 }; 17 + } 18 + 19 + // use following feed 20 + const { data, ok } = await client.get('app.bsky.feed.getTimeline', { 21 + params: { 22 + limit: 30 23 + } 24 + }); 25 + if (!ok) { 26 + throw new Error("couldn't load following timeline"); 27 + } 28 + return { data }; 29 + }
+10
src/routes/feed/[[aturl]]/+page.svelte
··· 1 + <script lang="ts"> 2 + import Post from '$lib/components/Post.svelte'; 3 + 4 + let { data } = $props(); 5 + </script> 6 + 7 + {#each data.data.feed as entry, i (i)} 8 + <Post post={entry.post} /> 9 + {/each} 10 +
+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-feed-selected: rgb(0, 0, 0); 9 + --color-feed-not-selected: rgb(64, 81, 104); 8 10 } 9 11 10 12 html,