replies timeline only, appview-less bluesky client
1<script lang="ts"> 2 import BskyPost from '$components/BskyPost.svelte'; 3 import PostComposer from '$components/PostComposer.svelte'; 4 import AccountSelector from '$components/AccountSelector.svelte'; 5 import { AtpClient } from '$lib/at/client'; 6 import { accounts, addAccount, type Account } from '$lib/accounts'; 7 import { type Did, type Handle, parseCanonicalResourceUri } from '@atcute/lexicons'; 8 import { onMount } from 'svelte'; 9 import { theme } from '$lib/theme.svelte'; 10 import { fetchPostsWithReplyBacklinks, fetchReplies } from '$lib/at/fetch'; 11 import { expect } from '$lib/result'; 12 import { writable } from 'svelte/store'; 13 import type { AppBskyFeedPost } from '@atcute/bluesky'; 14 15 let selectedDid = $state<Did | null>(null); 16 let clients = writable<Map<Did, AtpClient>>(new Map()); 17 let selectedClient = $derived(selectedDid ? $clients.get(selectedDid) : null); 18 19 let viewClient = $state<AtpClient>(new AtpClient()); 20 21 onMount(async () => { 22 if ($accounts.length > 0) { 23 selectedDid = $accounts[0].did; 24 Promise.all($accounts.map(loginAccount)).then(() => fetchTimeline($accounts)); 25 } 26 }); 27 28 const loginAccount = async (account: Account) => { 29 const client = new AtpClient(); 30 const result = await client.login(account.handle, account.password); 31 if (result.ok) { 32 clients.update((map) => map.set(account.did, client)); 33 } 34 }; 35 36 const handleAccountSelected = async (did: Did) => { 37 selectedDid = did; 38 const account = $accounts.find((acc) => acc.did === did); 39 if (account && (!$clients.has(account.did) || !$clients.get(account.did)?.atcute)) 40 await loginAccount(account); 41 }; 42 43 const handleLoginSucceed = (did: Did, handle: Handle, password: string) => { 44 const newAccount: Account = { did, handle, password }; 45 addAccount(newAccount); 46 selectedDid = did; 47 loginAccount(newAccount); 48 }; 49 50 let timeline = writable<Map<string, AppBskyFeedPost.Main>>(new Map()); 51 const fetchTimeline = async (newAccounts: Account[]) => { 52 await Promise.all( 53 newAccounts.map(async (account) => { 54 const client = $clients.get(account.did); 55 if (!client) return; 56 const accPosts = await fetchPostsWithReplyBacklinks(client, account.did, undefined, 10); 57 if (!accPosts.ok) { 58 console.error(`failed to fetch posts for account ${account.handle}: ${accPosts.error}`); 59 return; 60 } 61 const accTimeline = await fetchReplies(client, accPosts.value.posts); 62 for (const reply of accTimeline) { 63 if (!reply.ok) { 64 console.error(`failed to fetch reply: ${reply.error}`); 65 return; 66 } 67 timeline.update((map) => map.set(reply.value.uri, reply.value.record)); 68 } 69 }) 70 ); 71 }; 72 accounts.subscribe(fetchTimeline); 73 74 const getSortedTimeline = (_timeline: Map<string, AppBskyFeedPost.Main>) => { 75 const sortedTimeline = Array.from(_timeline).sort( 76 ([_a, post], [_b, post2]) => 77 new Date(post2.createdAt).getTime() - new Date(post.createdAt).getTime() 78 ); 79 return sortedTimeline; 80 }; 81 let sortedTimeline = $derived(getSortedTimeline($timeline)); 82</script> 83 84<div class="mx-auto max-w-2xl p-4"> 85 <div class="mb-6"> 86 <h1 class="text-3xl font-bold tracking-tight" style="color: {theme.fg};">nucleus</h1> 87 <div class="mt-1 flex gap-2"> 88 <div class="h-1 w-11 rounded-full" style="background: {theme.accent};"></div> 89 <div class="h-1 w-8 rounded-full" style="background: {theme.accent2};"></div> 90 </div> 91 </div> 92 93 <div class="space-y-4"> 94 <div class="flex min-h-16 items-stretch gap-2"> 95 <AccountSelector 96 accounts={$accounts} 97 bind:selectedDid 98 onAccountSelected={handleAccountSelected} 99 onLoginSucceed={handleLoginSucceed} 100 /> 101 102 {#if selectedClient} 103 <div class="flex-1"> 104 <PostComposer client={selectedClient} /> 105 </div> 106 {:else} 107 <div 108 class="flex flex-1 items-center justify-center rounded-xl border-2 px-4 py-2.5 backdrop-blur-sm" 109 style="border-color: {theme.accent}33; background: {theme.accent}0a;" 110 > 111 <p class="text-sm opacity-80" style="color: {theme.fg};"> 112 select or add an account to post 113 </p> 114 </div> 115 {/if} 116 </div> 117 118 <hr 119 class="h-[3px] w-full rounded-full border-0" 120 style="background: linear-gradient(to right, {theme.accent}, {theme.accent2});" 121 /> 122 123 <div class="flex flex-col gap-3"> 124 {#each sortedTimeline as [postUri, data] (postUri)} 125 {@const parsedUri = expect(parseCanonicalResourceUri(postUri))} 126 <BskyPost 127 client={viewClient} 128 identifier={parsedUri.repo} 129 rkey={parsedUri.rkey} 130 record={data} 131 /> 132 {/each} 133 </div> 134 </div> 135</div>