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>