appview-less bluesky client
24
fork

Configure Feed

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

feat: implement like a bunch of shit

dusk e3816a1f 536d1a22

+931 -70
+16
deno.lock
··· 14 14 "npm:@tailwindcss/forms@~0.5.10": "0.5.10_tailwindcss@4.1.14", 15 15 "npm:@tailwindcss/vite@^4.1.13": "4.1.14_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0", 16 16 "npm:@types/node@24": "24.8.0", 17 + "npm:@wora/cache-persist@^2.2.1": "2.2.1", 17 18 "npm:eslint-config-prettier@^10.1.8": "10.1.8_eslint@9.37.0", 18 19 "npm:eslint-plugin-svelte@^3.12.4": "3.12.4_eslint@9.37.0_svelte@5.40.1__acorn@8.15.0_postcss@8.5.6", 19 20 "npm:eslint@^9.36.0": "9.37.0", 20 21 "npm:globals@^16.4.0": "16.4.0", 22 + "npm:hash-wasm@^4.12.0": "4.12.0", 21 23 "npm:lru-cache@^11.2.2": "11.2.2", 22 24 "npm:prettier-plugin-svelte@^3.4.0": "3.4.0_prettier@3.6.2_svelte@5.40.1__acorn@8.15.0", 23 25 "npm:prettier-plugin-tailwindcss@~0.6.14": "0.6.14_prettier@3.6.2_prettier-plugin-svelte@3.4.0__prettier@3.6.2__svelte@5.40.1___acorn@8.15.0_svelte@5.40.1__acorn@8.15.0", ··· 723 725 "dependencies": [ 724 726 "@typescript-eslint/types", 725 727 "eslint-visitor-keys@4.2.1" 728 + ] 729 + }, 730 + "@wora/cache-persist@2.2.1": { 731 + "integrity": "sha512-X9MHiML5F8z3mabnl6J8hAwjn9g6Sria6+wUGwo97UDLMOWpZtJ+Jp/DQ7GjI1JirVXMQUDXBftVDgpvjhpNcw==", 732 + "dependencies": [ 733 + "idb" 726 734 ] 727 735 }, 728 736 "acorn-jsx@5.3.2_acorn@8.15.0": { ··· 1102 1110 "has-flag@4.0.0": { 1103 1111 "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" 1104 1112 }, 1113 + "hash-wasm@4.12.0": { 1114 + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==" 1115 + }, 1116 + "idb@4.0.5": { 1117 + "integrity": "sha512-P+Fk9HT2h1DhXoE1YNK183SY+CRh2GHNh28de94sGwhe0bUA75JJeVJWt3SenE5p0BXK7maflIq29dl6UZHrFw==" 1118 + }, 1105 1119 "ignore@5.3.2": { 1106 1120 "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" 1107 1121 }, ··· 1707 1721 "npm:@tailwindcss/forms@~0.5.10", 1708 1722 "npm:@tailwindcss/vite@^4.1.13", 1709 1723 "npm:@types/node@24", 1724 + "npm:@wora/cache-persist@^2.2.1", 1710 1725 "npm:eslint-config-prettier@^10.1.8", 1711 1726 "npm:eslint-plugin-svelte@^3.12.4", 1712 1727 "npm:eslint@^9.36.0", 1713 1728 "npm:globals@^16.4.0", 1729 + "npm:hash-wasm@^4.12.0", 1714 1730 "npm:lru-cache@^11.2.2", 1715 1731 "npm:prettier-plugin-svelte@^3.4.0", 1716 1732 "npm:prettier-plugin-tailwindcss@~0.6.14",
+2
package.json
··· 19 19 "@atcute/client": "^4.0.5", 20 20 "@atcute/identity": "^1.1.1", 21 21 "@atcute/lexicons": "^1.2.2", 22 + "@wora/cache-persist": "^2.2.1", 23 + "hash-wasm": "^4.12.0", 22 24 "lru-cache": "^11.2.2" 23 25 }, 24 26 "devDependencies": {
+14
src/app.css
··· 1 1 @import 'tailwindcss'; 2 2 @plugin '@tailwindcss/forms'; 3 + 4 + .grain:before { 5 + content: ''; 6 + background-color: transparent; 7 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)'/%3E%3C/svg%3E"); 8 + background-repeat: repeat; 9 + background-size: 364px; 10 + opacity: 0.05; 11 + top: 0; 12 + left: 0; 13 + position: absolute; 14 + width: 100%; 15 + height: 100%; 16 + }
+295
src/components/AccountSelector.svelte
··· 1 + <script lang="ts"> 2 + import { generateColorForDid, type Account } from '$lib/accounts'; 3 + import { AtpClient } from '$lib/at/client'; 4 + import type { Did, Handle } from '@atcute/lexicons'; 5 + import { theme } from '$lib/theme.svelte'; 6 + 7 + let { 8 + accounts = [], 9 + selectedDid = $bindable(null), 10 + onAccountSelected, 11 + onLoginSucceed 12 + }: { 13 + accounts: Array<Account>; 14 + selectedDid?: Did | null; 15 + onAccountSelected: (did: Did) => void; 16 + onLoginSucceed: (did: Did, handle: Handle, password: string) => void; 17 + } = $props(); 18 + 19 + let color = $derived(selectedDid ? (generateColorForDid(selectedDid) ?? theme.fg) : theme.fg); 20 + 21 + let isDropdownOpen = $state(false); 22 + let isLoginModalOpen = $state(false); 23 + let loginHandle = $state(''); 24 + let loginPassword = $state(''); 25 + let loginError = $state(''); 26 + let isLoggingIn = $state(false); 27 + 28 + const toggleDropdown = (e: MouseEvent) => { 29 + e.stopPropagation(); 30 + isDropdownOpen = !isDropdownOpen; 31 + }; 32 + 33 + const selectAccount = (did: Did) => { 34 + onAccountSelected(did); 35 + isDropdownOpen = false; 36 + }; 37 + 38 + const openLoginModal = () => { 39 + isLoginModalOpen = true; 40 + isDropdownOpen = false; 41 + loginHandle = ''; 42 + loginPassword = ''; 43 + loginError = ''; 44 + }; 45 + 46 + const closeLoginModal = () => { 47 + isLoginModalOpen = false; 48 + loginHandle = ''; 49 + loginPassword = ''; 50 + loginError = ''; 51 + }; 52 + 53 + const handleLogin = async () => { 54 + if (!loginHandle || !loginPassword) { 55 + loginError = 'please enter both handle and password'; 56 + return; 57 + } 58 + 59 + isLoggingIn = true; 60 + loginError = ''; 61 + 62 + try { 63 + const client = new AtpClient(); 64 + const result = await client.login(loginHandle as Handle, loginPassword); 65 + 66 + if (!result.ok) { 67 + loginError = result.error; 68 + isLoggingIn = false; 69 + return; 70 + } 71 + 72 + if (!client.didDoc) { 73 + loginError = 'failed to get did document'; 74 + isLoggingIn = false; 75 + return; 76 + } 77 + 78 + onLoginSucceed(client.didDoc.did, loginHandle as Handle, loginPassword); 79 + closeLoginModal(); 80 + } catch (error) { 81 + loginError = `login failed: ${error}`; 82 + } finally { 83 + isLoggingIn = false; 84 + } 85 + }; 86 + 87 + const handleKeydown = (event: KeyboardEvent) => { 88 + if (event.key === 'Escape') { 89 + closeLoginModal(); 90 + } else if (event.key === 'Enter' && !isLoggingIn) { 91 + handleLogin(); 92 + } 93 + }; 94 + 95 + const closeDropdown = () => { 96 + isDropdownOpen = false; 97 + }; 98 + 99 + let selectedAccount = $derived(accounts.find((acc) => acc.did === selectedDid)); 100 + </script> 101 + 102 + <svelte:window onclick={closeDropdown} /> 103 + 104 + <div class="relative"> 105 + <button 106 + onclick={toggleDropdown} 107 + class="group flex h-full items-center gap-2 rounded-2xl border-2 px-4 font-medium shadow-lg transition-all hover:scale-105 hover:shadow-xl" 108 + style="border-color: {theme.accent}66; background: {theme.accent}18; color: {color}; backdrop-filter: blur(8px);" 109 + > 110 + <span class="text-sm"> 111 + {selectedAccount ? `@${selectedAccount.handle}` : 'select account'} 112 + </span> 113 + <svg 114 + class="h-4 w-4 transition-transform {isDropdownOpen ? 'rotate-180' : ''}" 115 + style="color: {theme.accent};" 116 + fill="none" 117 + stroke="currentColor" 118 + viewBox="0 0 24 24" 119 + > 120 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" /> 121 + </svg> 122 + </button> 123 + 124 + {#if isDropdownOpen} 125 + <!-- svelte-ignore a11y_click_events_have_key_events --> 126 + <!-- svelte-ignore a11y_no_static_element_interactions --> 127 + <div 128 + class="absolute left-0 z-10 mt-3 min-w-52 overflow-hidden rounded-2xl border-2 shadow-2xl backdrop-blur-lg" 129 + style="border-color: {theme.accent}; background: {theme.bg}f0;" 130 + onclick={(e) => e.stopPropagation()} 131 + > 132 + {#if accounts.length > 0} 133 + <div class="p-2"> 134 + {#each accounts as account (account.did)} 135 + {@const color = generateColorForDid(account.did)} 136 + <button 137 + onclick={() => selectAccount(account.did)} 138 + class="flex w-full items-center gap-3 rounded-xl p-2 text-left text-sm font-medium transition-all {account.did === 139 + selectedDid 140 + ? 'shadow-lg' 141 + : 'hover:scale-[1.02]'}" 142 + style="color: {color}; background: {account.did === selectedDid 143 + ? `linear-gradient(135deg, ${theme.accent}33, ${theme.accent2}33)` 144 + : 'transparent'};" 145 + > 146 + <span>@{account.handle}</span> 147 + {#if account.did === selectedDid} 148 + <svg 149 + class="ml-auto h-5 w-5" 150 + style="color: {theme.accent};" 151 + fill="currentColor" 152 + viewBox="0 0 20 20" 153 + > 154 + <path 155 + fill-rule="evenodd" 156 + d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" 157 + clip-rule="evenodd" 158 + /> 159 + </svg> 160 + {/if} 161 + </button> 162 + {/each} 163 + </div> 164 + <div 165 + class="mx-2 h-px" 166 + style="background: linear-gradient(to right, {theme.accent}, {theme.accent2});" 167 + ></div> 168 + {/if} 169 + <button 170 + onclick={openLoginModal} 171 + class="flex w-full items-center gap-3 p-3 text-left text-sm font-semibold transition-all hover:scale-[1.02]" 172 + style="color: {theme.accent};" 173 + > 174 + <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 175 + <path 176 + stroke-linecap="round" 177 + stroke-linejoin="round" 178 + stroke-width="2.5" 179 + d="M12 4v16m8-8H4" 180 + /> 181 + </svg> 182 + <span>add account</span> 183 + </button> 184 + </div> 185 + {/if} 186 + </div> 187 + 188 + {#if isLoginModalOpen} 189 + <div 190 + class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm" 191 + style="background: {theme.bg}cc;" 192 + onclick={closeLoginModal} 193 + onkeydown={handleKeydown} 194 + role="button" 195 + tabindex="-1" 196 + > 197 + <!-- svelte-ignore a11y_interactive_supports_focus --> 198 + <!-- svelte-ignore a11y_click_events_have_key_events --> 199 + <div 200 + class="w-full max-w-md rounded-3xl border-2 p-5 shadow-2xl" 201 + style="background: {theme.bg}; border-color: {theme.accent};" 202 + onclick={(e) => e.stopPropagation()} 203 + role="dialog" 204 + > 205 + <div class="mb-6 flex items-center justify-between"> 206 + <div> 207 + <h2 class="text-2xl font-bold" style="color: {theme.fg};">add account</h2> 208 + <div class="mt-2 flex gap-2"> 209 + <div class="h-1 w-10 rounded-full" style="background: {theme.accent};"></div> 210 + <div class="h-1 w-9 rounded-full" style="background: {theme.accent2};"></div> 211 + </div> 212 + </div> 213 + <!-- svelte-ignore a11y_consider_explicit_label --> 214 + <button 215 + onclick={closeLoginModal} 216 + class="rounded-xl p-2 transition-all hover:scale-110" 217 + style="color: {theme.fg}66; hover:color: {theme.fg};" 218 + > 219 + <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 220 + <path 221 + stroke-linecap="round" 222 + stroke-linejoin="round" 223 + stroke-width="2.5" 224 + d="M6 18L18 6M6 6l12 12" 225 + /> 226 + </svg> 227 + </button> 228 + </div> 229 + 230 + <div class="space-y-5"> 231 + <div> 232 + <label for="handle" class="mb-2 block text-sm font-semibold" style="color: {theme.fg}cc;"> 233 + handle 234 + </label> 235 + <input 236 + id="handle" 237 + type="text" 238 + bind:value={loginHandle} 239 + placeholder="example.bsky.social" 240 + class="placeholder-opacity-40 w-full rounded-xl border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none" 241 + style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};" 242 + disabled={isLoggingIn} 243 + /> 244 + </div> 245 + 246 + <div> 247 + <label 248 + for="password" 249 + class="mb-2 block text-sm font-semibold" 250 + style="color: {theme.fg}cc;" 251 + > 252 + app password 253 + </label> 254 + <input 255 + id="password" 256 + type="password" 257 + bind:value={loginPassword} 258 + placeholder="xxxx-xxxx-xxxx-xxxx" 259 + class="placeholder-opacity-40 w-full rounded-xl border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none" 260 + style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};" 261 + disabled={isLoggingIn} 262 + /> 263 + </div> 264 + 265 + {#if loginError} 266 + <div 267 + class="rounded-xl border-2 p-4" 268 + style="background: #ef444422; border-color: #ef4444;" 269 + > 270 + <p class="text-sm font-medium" style="color: #fca5a5;">{loginError}</p> 271 + </div> 272 + {/if} 273 + 274 + <div class="flex gap-3 pt-3"> 275 + <button 276 + onclick={closeLoginModal} 277 + class="flex-1 rounded-xl border-2 px-5 py-3 font-semibold transition-all hover:scale-105" 278 + style="background: {theme.bg}; border-color: {theme.fg}33; color: {theme.fg};" 279 + disabled={isLoggingIn} 280 + > 281 + cancel 282 + </button> 283 + <button 284 + onclick={handleLogin} 285 + class="flex-1 rounded-xl border-2 px-5 py-3 font-semibold transition-all hover:scale-105 hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-50" 286 + style="background: linear-gradient(135deg, {theme.accent}, {theme.accent2}); border-color: transparent; color: {theme.fg};" 287 + disabled={isLoggingIn} 288 + > 289 + {isLoggingIn ? 'logging in...' : 'login'} 290 + </button> 291 + </div> 292 + </div> 293 + </div> 294 + </div> 295 + {/if}
+109 -12
src/components/BskyPost.svelte
··· 2 2 import type { AtpClient } from '$lib/at/client'; 3 3 import { AppBskyFeedPost } from '@atcute/bluesky'; 4 4 import type { ActorIdentifier, RecordKey } from '@atcute/lexicons'; 5 + import { theme } from '$lib/theme.svelte'; 6 + import { map, ok } from '$lib/result'; 7 + import type { Backlinks } from '$lib/at/constellation'; 8 + import { generateColorForDid } from '$lib/accounts'; 5 9 6 10 interface Props { 7 11 client: AtpClient; 8 12 identifier: ActorIdentifier; 9 13 rkey: RecordKey; 14 + replyBacklinks?: Backlinks; 15 + record?: AppBskyFeedPost.Main; 10 16 } 11 17 12 - const { client, identifier, rkey }: Props = $props(); 18 + const { client, identifier, rkey, record, replyBacklinks }: Props = $props(); 13 19 14 - const post = client.getRecord(AppBskyFeedPost.mainSchema, identifier, rkey); 20 + const color = generateColorForDid(identifier) ?? theme.accent2; 21 + 22 + let handle = $state(identifier); 23 + client 24 + .resolveDidDoc(identifier) 25 + .then((res) => map(res, (data) => data.handle)) 26 + .then((res) => { 27 + if (res.ok) handle = res.value; 28 + }); 29 + const post = record 30 + ? Promise.resolve(ok(record)) 31 + : client.getRecord(AppBskyFeedPost.mainSchema, identifier, rkey); 32 + const replies = replyBacklinks 33 + ? Promise.resolve(ok(replyBacklinks)) 34 + : client.getBacklinks( 35 + identifier, 36 + 'app.bsky.feed.post', 37 + rkey, 38 + 'app.bsky.feed.post:reply.parent.uri' 39 + ); 15 40 16 41 const getEmbedText = (embedType: string) => { 17 42 switch (embedType) { 18 43 case 'app.bsky.embed.external': 19 - return 'contains external link'; 44 + return '🔗 has external link'; 20 45 case 'app.bsky.embed.record': 21 - return 'quotes post'; 46 + return '💬 has quote'; 22 47 case 'app.bsky.embed.images': 23 - return 'contains images'; 48 + return '🖼️ has images'; 24 49 case 'app.bsky.embed.video': 25 - return 'contains video'; 50 + return '🎥 has video'; 26 51 case 'app.bsky.embed.recordWithMedia': 27 - return 'quotes post with media'; 52 + return '📎 has quote with media'; 28 53 default: 29 - return 'contains unknown embed'; 54 + return '❓ has unknown embed'; 30 55 } 31 56 }; 57 + 58 + const getRelativeTime = (date: Date) => { 59 + const now = new Date(); 60 + const diff = now.getTime() - date.getTime(); 61 + const seconds = Math.floor(diff / 1000); 62 + const minutes = Math.floor(seconds / 60); 63 + const hours = Math.floor(minutes / 60); 64 + const days = Math.floor(hours / 24); 65 + const months = Math.floor(days / 30); 66 + const years = Math.floor(months / 12); 67 + 68 + if (years > 0) return `${years} year${years > 1 ? 's' : ''} ago`; 69 + if (months > 0) return `${months} month${months > 1 ? 's' : ''} ago`; 70 + if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`; 71 + if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`; 72 + if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; 73 + return `${seconds} second${seconds > 1 ? 's' : ''} ago`; 74 + }; 32 75 </script> 33 76 34 77 {#await post} 35 - loading post... 78 + <div 79 + class="rounded-xl border-2 p-3 text-center backdrop-blur-sm" 80 + style="background: {color}18; border-color: {color}66;" 81 + > 82 + <div 83 + class="inline-block h-6 w-6 animate-spin rounded-full border-3" 84 + style="border-color: {theme.accent}; border-left-color: transparent;" 85 + ></div> 86 + <p class="mt-3 text-sm font-medium opacity-60" style="color: {theme.fg};">loading post...</p> 87 + </div> 36 88 {:then post} 37 89 {#if post.ok} 38 90 {@const record = post.value} 39 - {identifier} - [{record.embed ? getEmbedText(record.embed.$type) : null}] 40 - {record.text} 91 + <div 92 + class="rounded-xl border-2 p-3 shadow-lg backdrop-blur-sm transition-all hover:scale-[1.01]" 93 + style="background: {color}18; border-color: {color}66;" 94 + > 95 + <div class="mb-3 flex items-center gap-1.5"> 96 + <span class="font-bold" style="color: {color};"> 97 + @{handle} 98 + </span> 99 + <span>·</span> 100 + {#await replies} 101 + <span style="color: {theme.fg}aa;">… replies</span> 102 + {:then replies} 103 + {#if replies.ok} 104 + {@const repliesValue = replies.value} 105 + <span style="color: {theme.fg}aa;"> 106 + {#if repliesValue.total > 0} 107 + {repliesValue.total} 108 + {repliesValue.total > 1 ? 'replies' : 'reply'} 109 + {:else} 110 + no replies 111 + {/if} 112 + </span> 113 + {:else} 114 + <span 115 + title={`${replies.error}`} 116 + class="max-w-[32ch] overflow-hidden text-nowrap" 117 + style="color: {theme.fg}aa;">{replies.error}</span 118 + > 119 + {/if} 120 + {/await} 121 + <span>·</span> 122 + <span style="color: {theme.fg}aa;">{getRelativeTime(new Date(record.createdAt))}</span> 123 + </div> 124 + <p class="leading-relaxed text-wrap" style="color: {theme.fg};"> 125 + {record.text} 126 + {#if record.embed} 127 + <span 128 + class="rounded-full px-2.5 py-0.5 text-xs font-medium" 129 + style="background: {color}22; color: {color};" 130 + > 131 + {getEmbedText(record.embed.$type)} 132 + </span> 133 + {/if} 134 + </p> 135 + </div> 41 136 {:else} 42 - error fetching post: {post.error} 137 + <div class="rounded-xl border-2 p-4" style="background: #ef444422; border-color: #ef4444;"> 138 + <p class="text-sm font-medium" style="color: #fca5a5;">error: {post.error}</p> 139 + </div> 43 140 {/if} 44 141 {/await}
+41 -19
src/components/PostComposer.svelte
··· 4 4 import type { ComAtprotoRepoCreateRecord } from '@atcute/atproto'; 5 5 import type { AppBskyFeedPost } from '@atcute/bluesky'; 6 6 import type { InferOutput } from '@atcute/lexicons'; 7 + import { theme } from '$lib/theme.svelte'; 7 8 8 9 interface Props { 9 10 client: AtpClient; ··· 45 46 let info = $state(''); 46 47 </script> 47 48 48 - <div class="flex flex-col gap-0.5"> 49 - {#if info.length > 0} 50 - <span class="text-sm text-gray-500">{info}</span> 51 - {/if} 52 - <div class="flex gap-2"> 53 - <input bind:value={postText} type="text" placeholder="write your post here..." /> 54 - <button 55 - onclick={() => { 56 - post(postText).then((res) => { 57 - if (res.ok) { 58 - postText = ''; 59 - info = 'posted!'; 60 - setTimeout(() => (info = ''), 1000 * 3); 61 - } else { 62 - info = res.error; 63 - } 64 - }); 65 - }}>post</button 66 - > 49 + <div 50 + class="flex min-h-16 max-w-full items-center rounded-xl border-2 px-1 shadow-lg backdrop-blur-sm" 51 + style="background: {theme.accent}18; border-color: {theme.accent}66;" 52 + > 53 + <div class="w-full p-1"> 54 + {#if info.length > 0} 55 + <div 56 + class="rounded-lg px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 57 + style="background: {theme.accent}22; color: {theme.accent};" 58 + > 59 + {info} 60 + </div> 61 + {:else} 62 + <div class="flex gap-2"> 63 + <input 64 + bind:value={postText} 65 + type="text" 66 + placeholder="what's on your mind?" 67 + class="placeholder-opacity-50 flex-1 rounded-lg border-2 px-3 py-2 text-sm font-medium transition-all focus:scale-[1.01] focus:shadow-lg focus:outline-none" 68 + style="background: {theme.bg}66; border-color: {theme.accent}44; color: {theme.fg};" 69 + /> 70 + <button 71 + onclick={() => { 72 + post(postText).then((res) => { 73 + if (res.ok) { 74 + postText = ''; 75 + info = 'posted! aaaaaaaaaasdf asdlfkasl;df kjasdfjalsdkfjaskd fajksdhf'; 76 + setTimeout(() => (info = ''), 1000 * 3); 77 + } else { 78 + info = res.error; 79 + } 80 + }); 81 + }} 82 + class="rounded-lg border-none px-5 py-2 text-sm font-bold transition-all hover:scale-105 hover:shadow-xl" 83 + style="background: linear-gradient(120deg, {theme.accent}c0, {theme.accent2}c0); color: {theme.fg}f0;" 84 + > 85 + post 86 + </button> 87 + </div> 88 + {/if} 67 89 </div> 68 90 </div>
+41
src/lib/accounts.ts
··· 1 + import type { Did, Handle } from '@atcute/lexicons'; 2 + import { writable } from 'svelte/store'; 3 + import { createXXHash3, type IHasher } from 'hash-wasm'; 4 + 5 + export type Account = { 6 + did: Did; 7 + handle: Handle; 8 + password: string; 9 + }; 10 + 11 + let _accounts: Account[] = []; 12 + export const accounts = (() => { 13 + const raw = localStorage.getItem('accounts'); 14 + _accounts = raw ? JSON.parse(raw) : []; 15 + const store = writable<Account[]>(_accounts); 16 + store.subscribe((accounts) => { 17 + _accounts = accounts; 18 + localStorage.setItem('accounts', JSON.stringify(accounts)); 19 + }); 20 + return store; 21 + })(); 22 + 23 + export const addAccount = (account: Account): void => { 24 + accounts.update((accounts) => [...accounts, account]); 25 + }; 26 + 27 + // fucked up and evil (i hate promises :3) 28 + const _initHasher = () => { 29 + createXXHash3(90001, 8008135).then((s) => (hasher = s)); 30 + return null; 31 + }; 32 + let hasher: IHasher | null = _initHasher(); 33 + 34 + export const generateColorForDid = (did: string): string | null => { 35 + const h = hasher!; 36 + h.init(); 37 + h.update(did); 38 + const hex = h.digest(); 39 + const color = hex.slice(-6); 40 + return `#${color}`; 41 + };
+90 -33
src/lib/at/client.ts
··· 1 - import { err, map, ok, type Result } from '$lib/result'; 2 - import { ComAtprotoIdentityResolveIdentity, ComAtprotoRepoGetRecord } from '@atcute/atproto'; 1 + import { err, expect, map, ok, type Result } from '$lib/result'; 2 + import { 3 + ComAtprotoIdentityResolveHandle, 4 + ComAtprotoRepoGetRecord, 5 + ComAtprotoRepoListRecords 6 + } from '@atcute/atproto'; 3 7 import { Client as AtcuteClient, CredentialManager } from '@atcute/client'; 4 8 import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons'; 5 - import type { ActorIdentifier, AtprotoDid, Nsid, RecordKey } from '@atcute/lexicons/syntax'; 9 + import { 10 + isHandle, 11 + parseCanonicalResourceUri, 12 + type ActorIdentifier, 13 + type AtprotoDid, 14 + type CanonicalResourceUri, 15 + type Nsid, 16 + type RecordKey 17 + } from '@atcute/lexicons/syntax'; 6 18 import type { 7 19 InferXRPCBodyOutput, 8 20 ObjectSchema, ··· 11 23 XRPCQueryMetadata 12 24 } from '@atcute/lexicons/validations'; 13 25 import * as v from '@atcute/lexicons/validations'; 14 - import { LRUCache } from 'lru-cache'; 15 - 16 - export const MiniDocQuery = v.query('com.bad-example.identity.resolveMiniDoc', { 17 - params: v.object({ 18 - identifier: v.actorIdentifierString() 19 - }), 20 - output: { 21 - type: 'lex', 22 - schema: v.object({ 23 - did: v.didString(), 24 - handle: v.handleString(), 25 - pds: v.genericUriString(), 26 - signing_key: v.string() 27 - }) 28 - } 29 - }); 30 - export type MiniDoc = InferOutput<typeof MiniDocQuery.output.schema>; 26 + import { MiniDocQuery, type MiniDoc } from './slingshot'; 27 + import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation'; 28 + import type { Records } from '@atcute/lexicons/ambient'; 29 + import { PersistedLRU } from '$lib/cache'; 31 30 32 31 const cacheTtl = 1000 * 60 * 60 * 24; 33 - const handleCache = new LRUCache<Handle, AtprotoDid>({ 32 + const handleCache = new PersistedLRU<Handle, AtprotoDid>({ 34 33 max: 1000, 35 - ttl: cacheTtl 34 + ttl: cacheTtl, 35 + prefix: 'handle' 36 36 }); 37 - const didDocCache = new LRUCache<ActorIdentifier, MiniDoc>({ 37 + const didDocCache = new PersistedLRU<ActorIdentifier, MiniDoc>({ 38 38 max: 1000, 39 - ttl: cacheTtl 39 + ttl: cacheTtl, 40 + prefix: 'didDoc' 40 41 }); 41 - const recordCache = new LRUCache< 42 + const recordCache = new PersistedLRU< 42 43 string, 43 44 InferOutput<typeof ComAtprotoRepoGetRecord.mainSchema.output.schema> 44 45 >({ 45 46 max: 5000, 46 - ttl: cacheTtl 47 + ttl: cacheTtl, 48 + prefix: 'record' 47 49 }); 48 50 49 51 export class AtpClient { ··· 52 54 53 55 private slingshotUrl: URL = new URL('https://slingshot.microcosm.blue'); 54 56 private spacedustUrl: URL = new URL('https://spacedust.microcosm.blue'); 57 + private constellationUrl: URL = new URL('https://constellation.microcosm.blue'); 55 58 56 59 async login(handle: Handle, password: string): Promise<Result<null, string>> { 57 60 const didDoc = await this.resolveDidDoc(handle); ··· 101 104 return ok(parsed.value as Output); 102 105 } 103 106 107 + async listRecords<Collection extends keyof Records>( 108 + collection: Collection, 109 + repo: ActorIdentifier, 110 + cursor?: string, 111 + limit?: number 112 + ): Promise< 113 + Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string> 114 + > { 115 + if (!this.atcute) return err('not authenticated'); 116 + const res = await this.atcute.get('com.atproto.repo.listRecords', { 117 + params: { 118 + repo, 119 + collection, 120 + cursor, 121 + limit 122 + } 123 + }); 124 + if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); 125 + return ok(res.data); 126 + } 127 + 104 128 async resolveHandle(handle: Handle): Promise<Result<AtprotoDid, string>> { 105 129 const cached = handleCache.get(handle); 106 130 if (cached) return ok(cached); 107 131 108 132 const res = await fetchMicrocosm( 109 133 this.slingshotUrl, 110 - ComAtprotoIdentityResolveIdentity.mainSchema, 134 + ComAtprotoIdentityResolveHandle.mainSchema, 111 135 { 112 - handle: handle 136 + handle 113 137 } 114 138 ); 115 139 ··· 136 160 137 161 return result; 138 162 } 163 + 164 + async getBacklinksUri( 165 + uri: CanonicalResourceUri, 166 + source: BacklinksSource 167 + ): Promise<Result<Backlinks, string>> { 168 + const parsedResourceUri = expect(parseCanonicalResourceUri(uri)); 169 + return await this.getBacklinks( 170 + parsedResourceUri.repo, 171 + parsedResourceUri.collection, 172 + parsedResourceUri.rkey, 173 + source 174 + ); 175 + } 176 + 177 + async getBacklinks( 178 + repo: ActorIdentifier, 179 + collection: Nsid, 180 + rkey: RecordKey, 181 + source: BacklinksSource 182 + ): Promise<Result<Backlinks, string>> { 183 + let did = repo; 184 + if (isHandle(did)) { 185 + const resolvedDid = await this.resolveHandle(did); 186 + if (!resolvedDid.ok) { 187 + return err(`failed to resolve handle: ${resolvedDid.error}`); 188 + } 189 + did = resolvedDid.value; 190 + } 191 + return await fetchMicrocosm(this.constellationUrl, BacklinksQuery, { 192 + subject: `at://${did}/${collection}/${rkey}`, 193 + source, 194 + limit: 100 195 + }); 196 + } 139 197 } 140 198 141 199 const fetchMicrocosm = async < 142 200 Schema extends XRPCQueryMetadata, 201 + Input extends Schema['params'] extends ObjectSchema ? InferOutput<Schema['params']> : undefined, 143 202 Output extends InferXRPCBodyOutput<Schema['output']> 144 203 >( 145 204 api: URL, 146 205 schema: Schema, 147 - params?: URLSearchParams | Record<string, string>, 206 + params: Input, 148 207 init?: RequestInit 149 208 ): Promise<Result<Output, string>> => { 150 209 if (!schema.output || schema.output.type === 'blob') return err('schema must be blob'); 151 - if (params && !(params instanceof URLSearchParams)) params = new URLSearchParams(params); 152 - if (params?.size === 0) params = undefined; 153 210 try { 154 211 api.pathname = `/xrpc/${schema.nsid}`; 155 - api.search = params ? `?${params}` : ''; 156 - // console.info(`fetching:`, api.href); 212 + api.search = params ? `?${new URLSearchParams(params)}` : ''; 213 + console.info(`fetching:`, api.href); 157 214 const response = await fetch(api, init); 158 215 const body = await response.json(); 159 216 if (response.status === 400) return err(`${body.error}: ${body.message}`);
+27
src/lib/at/constellation.ts
··· 1 + import type { Nsid } from '@atcute/lexicons'; 2 + import * as v from '@atcute/lexicons/validations'; 3 + 4 + export type BacklinksSource = `${Nsid}:${string}`; 5 + export const BacklinkSchema = v.object({ 6 + did: v.didString(), 7 + collection: v.nsidString(), 8 + rkey: v.recordKeyString() 9 + }); 10 + export const BacklinksQuery = v.query('blue.microcosm.links.getBacklinks', { 11 + params: v.object({ 12 + subject: v.resourceUriString(), 13 + source: v.string(), 14 + did: v.optional(v.array(v.didString())), 15 + limit: v.optional(v.integer()) 16 + }), 17 + output: { 18 + type: 'lex', 19 + schema: v.object({ 20 + total: v.integer(), 21 + records: v.array(BacklinkSchema), 22 + cursor: v.nullable(v.string()) 23 + }) 24 + } 25 + }); 26 + export type Backlink = v.InferOutput<typeof BacklinkSchema>; 27 + export type Backlinks = v.InferOutput<typeof BacklinksQuery.output.schema>;
+59
src/lib/at/fetch.ts
··· 1 + import type { ActorIdentifier, CanonicalResourceUri } from '@atcute/lexicons'; 2 + import type { AtpClient } from './client'; 3 + import { err, map, ok, type Result } from '$lib/result'; 4 + import type { Backlinks } from './constellation'; 5 + import { AppBskyFeedPost } from '@atcute/bluesky'; 6 + 7 + export type PostWithBacklinks = { 8 + post: AppBskyFeedPost.Main; 9 + replies: Backlinks | string; 10 + }; 11 + export type PostsWithReplyBacklinks = Map<CanonicalResourceUri, PostWithBacklinks>; 12 + 13 + export const fetchPostsWithReplyBacklinks = async ( 14 + client: AtpClient, 15 + repo: ActorIdentifier, 16 + cursor?: string, 17 + limit?: number 18 + ): Promise<Result<{ posts: PostsWithReplyBacklinks; cursor?: string }, string>> => { 19 + const recordsList = await client.listRecords('app.bsky.feed.post', repo, cursor, limit); 20 + if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`); 21 + cursor = recordsList.value.cursor; 22 + const records = recordsList.value.records; 23 + 24 + const allBacklinks = await Promise.all( 25 + records.map((r) => 26 + client 27 + .getBacklinksUri(r.uri as CanonicalResourceUri, 'app.bsky.feed.post:reply.parent.uri') 28 + .then((res) => ({ 29 + key: r.uri as CanonicalResourceUri, 30 + value: { 31 + post: r.value as AppBskyFeedPost.Main, 32 + replies: res.ok ? res.value : res.error 33 + } 34 + })) 35 + ) 36 + ); 37 + 38 + return ok({ posts: new Map(allBacklinks.map((b) => [b.key, b.value])), cursor }); 39 + }; 40 + 41 + export const fetchReplies = async (client: AtpClient, data: PostsWithReplyBacklinks) => { 42 + const allReplies = await Promise.all( 43 + Array.from(data.values()).map(async (d) => { 44 + if (typeof d.replies === 'string') return []; 45 + const replies = await Promise.all( 46 + d.replies.records.map((r) => 47 + client 48 + .getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey) 49 + .then((res) => 50 + map(res, (d) => ({ uri: `at://${r.did}/app.bsky.feed.post/${r.rkey}`, record: d })) 51 + ) 52 + ) 53 + ); 54 + return replies; 55 + }) 56 + ); 57 + 58 + return allReplies.flat(); 59 + };
+17
src/lib/at/slingshot.ts
··· 1 + import * as v from '@atcute/lexicons/validations'; 2 + 3 + export const MiniDocQuery = v.query('com.bad-example.identity.resolveMiniDoc', { 4 + params: v.object({ 5 + identifier: v.actorIdentifierString() 6 + }), 7 + output: { 8 + type: 'lex', 9 + schema: v.object({ 10 + did: v.didString(), 11 + handle: v.handleString(), 12 + pds: v.genericUriString(), 13 + signing_key: v.string() 14 + }) 15 + } 16 + }); 17 + export type MiniDoc = v.InferOutput<typeof MiniDocQuery.output.schema>;
+71
src/lib/cache.ts
··· 1 + import { Cache, type CacheOptions } from '@wora/cache-persist'; 2 + import { LRUCache } from 'lru-cache'; 3 + 4 + export interface PersistedLRUOptions { 5 + prefix?: string; 6 + max: number; 7 + ttl?: number; 8 + persistOptions?: CacheOptions; 9 + } 10 + 11 + // eslint-disable-next-line @typescript-eslint/no-empty-object-type 12 + export class PersistedLRU<K extends string, V extends {}> { 13 + private memory: LRUCache<K, V>; 14 + private storage: Cache; // from wora/cache-persist 15 + 16 + private prefix = ''; // or derive from options 17 + 18 + constructor(opts: PersistedLRUOptions) { 19 + this.memory = new LRUCache<K, V>({ 20 + max: opts.max, 21 + ttl: opts.ttl 22 + }); 23 + this.storage = new Cache(opts.persistOptions); 24 + this.prefix = opts.prefix ? `${opts.prefix}%` : ''; 25 + 26 + this.init(); 27 + } 28 + 29 + async init(): Promise<void> { 30 + await this.storage.restore(); 31 + 32 + const state = this.storage.getState(); 33 + for (const [key, val] of Object.entries(state)) { 34 + try { 35 + console.log('restoring', key); 36 + const k = this.unprefix(key) as unknown as K; 37 + const v = val as V; 38 + this.memory.set(k, v); 39 + } catch (err) { 40 + console.warn('skipping invalid persisted entry', key, err); 41 + } 42 + } 43 + } 44 + 45 + get(key: K): V | undefined { 46 + return this.memory.get(key); 47 + } 48 + set(key: K, value: V): void { 49 + this.memory.set(key, value); 50 + this.storage.set(this.prefixed(key), value); 51 + this.storage.flush(); // TODO: uh evil and fucked up (this whole file is evil honestly) 52 + } 53 + has(key: K): boolean { 54 + return this.memory.has(key); 55 + } 56 + delete(key: K): void { 57 + this.memory.delete(key); 58 + this.storage.delete(this.prefixed(key)); 59 + } 60 + clear(): void { 61 + this.memory.clear(); 62 + this.storage.purge(); // clears stored state 63 + } 64 + 65 + private prefixed(key: K): string { 66 + return this.prefix + key; 67 + } 68 + private unprefix(prefixed: string): string { 69 + return prefixed.slice(this.prefix.length); 70 + } 71 + }
+13
src/lib/theme.svelte.ts
··· 1 + export const theme = $state({ 2 + bg: '#0f172a', // slate-900 - deep blue-grey background 3 + fg: '#f8fafc', // slate-50 - crisp white foreground 4 + accent: '#ec4899', // pink-500 - vibrant pink accent 5 + accent2: '#8b5cf6' // violet-500 - purple secondary accent 6 + }); 7 + 8 + export const setTheme = (bg: string, fg: string, accent: string, accent2: string) => { 9 + theme.bg = bg; 10 + theme.fg = fg; 11 + theme.accent = accent; 12 + theme.accent2 = accent2; 13 + };
+7 -1
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 + import { theme } from '$lib/theme.svelte'; 3 4 import favicon from '$lib/assets/favicon.svg'; 4 5 5 6 let { children } = $props(); ··· 9 10 <link rel="icon" href={favicon} /> 10 11 </svelte:head> 11 12 12 - {@render children?.()} 13 + <div 14 + class="grain min-h-screen transition-colors duration-300" 15 + style="background: {theme.bg}; color: {theme.fg};" 16 + > 17 + {@render children?.()} 18 + </div>
+129 -5
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import BskyPost from '$components/BskyPost.svelte'; 3 3 import PostComposer from '$components/PostComposer.svelte'; 4 - import { client } from '$lib'; 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)); 5 82 </script> 6 83 7 - <div class="flex flex-col gap-4"> 8 - <PostComposer {client} /> 9 - <hr /> 10 - <BskyPost {client} identifier="nil.ptr.pet" rkey="3m3d5zguuxk2c" /> 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> 11 135 </div>