appview-less bluesky client
27
fork

Configure Feed

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

implement blocks ui

dawn e1b593c9 24c6dc95

+714 -273
+4
src/app.css
··· 125 125 .animate-slide-in-left { 126 126 animation: slide-in-from-left 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; 127 127 } 128 + 129 + .post-dropdown { 130 + @apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60; 131 + }
+1 -1
src/components/AccountSelector.svelte
··· 1 1 <script lang="ts"> 2 2 import { generateColorForDid, loggingIn, type Account } from '$lib/accounts'; 3 - import { AtpClient, resolveHandle } from '$lib/at/client'; 3 + import { AtpClient, resolveHandle } from '$lib/at/client.svelte'; 4 4 import type { Handle } from '@atcute/lexicons'; 5 5 import ProfilePicture from './ProfilePicture.svelte'; 6 6 import PfpPlaceholder from './PfpPlaceholder.svelte';
+36
src/components/BlockedUserIndicator.svelte
··· 1 + <script lang="ts"> 2 + import type { Did } from '@atcute/lexicons'; 3 + import ProfilePicture from './ProfilePicture.svelte'; 4 + import type { AtpClient } from '$lib/at/client.svelte'; 5 + import { generateColorForDid } from '$lib/accounts'; 6 + 7 + interface Props { 8 + client: AtpClient; 9 + did: Did; 10 + reason: 'blocked' | 'blocks-you'; 11 + size?: 'small' | 'normal' | 'large'; 12 + } 13 + 14 + let { client, did, reason, size = 'normal' }: Props = $props(); 15 + 16 + const color = $derived(generateColorForDid(did)); 17 + const text = $derived(reason === 'blocked' ? 'user blocked' : 'user blocks you'); 18 + const pfpSize = $derived(size === 'small' ? 8 : size === 'large' ? 16 : 10); 19 + </script> 20 + 21 + <div 22 + class="flex items-center gap-2 rounded-sm border-2 p-2 {size === 'small' ? 'text-sm' : ''}" 23 + style="background: {color}11; border-color: {color}44;" 24 + > 25 + <div class="blocked-pfp"> 26 + <ProfilePicture {client} {did} size={pfpSize} /> 27 + </div> 28 + <span class="opacity-80">{text}</span> 29 + </div> 30 + 31 + <style> 32 + .blocked-pfp { 33 + filter: blur(8px) grayscale(100%); 34 + opacity: 0.4; 35 + } 36 + </style>
+139 -88
src/components/BskyPost.svelte
··· 1 1 <script lang="ts"> 2 - import { resolveDidDoc, type AtpClient } from '$lib/at/client'; 2 + import { resolveDidDoc, type AtpClient } from '$lib/at/client.svelte'; 3 3 import { AppBskyActorProfile, AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/bluesky'; 4 4 import { 5 5 parseCanonicalResourceUri, ··· 22 22 router, 23 23 profiles, 24 24 handles, 25 - hasBacklink 25 + hasBacklink, 26 + getBlockRelationship, 27 + clients 26 28 } from '$lib/state.svelte'; 27 29 import type { PostWithUri } from '$lib/at/fetch'; 28 30 import { onMount, type Snippet } from 'svelte'; ··· 49 51 onQuote?: (quote: PostWithUri) => void; 50 52 onReply?: (reply: PostWithUri) => void; 51 53 cornerFragment?: Snippet; 54 + isBlocked?: boolean; 52 55 } 53 56 54 57 const { ··· 61 64 onQuote, 62 65 onReply, 63 66 isOnPostComposer = false /* replyBacklinks */, 64 - cornerFragment 67 + cornerFragment, 68 + isBlocked = false 65 69 }: Props = $props(); 66 70 67 - const selectedDid = $derived(client.user?.did ?? null); 71 + const user = $derived(client.user); 68 72 const isLoggedInUser = $derived($accounts.some((acc) => acc.did === did)); 69 73 70 74 const aturi = $derived(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey })); 71 75 const color = $derived(generateColorForDid(did)); 72 76 77 + let expandBlocked = $state(false); 78 + const blockRel = $derived( 79 + user && !isOnPostComposer 80 + ? getBlockRelationship(user.did, did) 81 + : { userBlocked: false, blockedByTarget: false } 82 + ); 83 + const showAsBlocked = $derived( 84 + (isBlocked || blockRel.userBlocked || blockRel.blockedByTarget) && !expandBlocked 85 + ); 86 + 73 87 let handle: Handle = $state(handles.get(did) ?? 'handle.invalid'); 74 88 onMount(() => { 75 89 resolveDidDoc(did).then((res) => { ··· 135 149 return; 136 150 } 137 151 138 - client?.atcute 139 - ?.post('com.atproto.repo.deleteRecord', { 152 + clients 153 + .get(did) 154 + ?.user?.atcute.post('com.atproto.repo.deleteRecord', { 140 155 input: { 141 156 collection: 'app.bsky.feed.post', 142 157 repo: did, ··· 195 210 {:then post} 196 211 {#if post.ok} 197 212 {@const record = post.value.record} 198 - <!-- svelte-ignore a11y_click_events_have_key_events --> 199 - <!-- svelte-ignore a11y_no_static_element_interactions --> 200 - <div 201 - onclick={() => scrollToAndPulse(post.value.uri)} 202 - class="select-none hover:cursor-pointer hover:underline" 203 - > 204 - <span style="color: {color};">@{handle}</span>: 205 - {#if record.embed} 206 - <EmbedBadge embed={record.embed} /> 207 - {/if} 208 - <span title={record.text}>{record.text}</span> 209 - </div> 213 + {#if showAsBlocked} 214 + <button 215 + onclick={() => (expandBlocked = true)} 216 + class="text-left hover:cursor-pointer hover:underline" 217 + > 218 + <span style="color: {color};">post from blocked user</span> (click to show) 219 + </button> 220 + {:else} 221 + <!-- svelte-ignore a11y_click_events_have_key_events --> 222 + <!-- svelte-ignore a11y_no_static_element_interactions --> 223 + <div 224 + onclick={() => scrollToAndPulse(post.value.uri)} 225 + class="hover:cursor-pointer hover:underline" 226 + > 227 + <span style="color: {color};">@{handle}</span>: 228 + {#if record.embed} 229 + <EmbedBadge embed={record.embed} /> 230 + {/if} 231 + <span title={record.text}>{record.text}</span> 232 + </div> 233 + {/if} 210 234 {:else} 211 235 {post.error} 212 236 {/if} ··· 229 253 {:then post} 230 254 {#if post.ok} 231 255 {@const record = post.value.record} 232 - <!-- svelte-ignore a11y_no_static_element_interactions --> 233 - <div 234 - id="timeline-post-{post.value.uri}-{quoteDepth}" 235 - oncontextmenu={handleRightClick} 236 - class=" 256 + {#if showAsBlocked} 257 + <button 258 + onclick={() => (expandBlocked = true)} 259 + class=" 260 + group w-full rounded-sm border-2 p-3 text-left shadow-lg 261 + backdrop-blur-sm transition-all hover:border-(--nucleus-accent) 262 + " 263 + style="background: {color}18; border-color: {color}66;" 264 + > 265 + <div class="flex items-center gap-2"> 266 + <span class="opacity-80">post from blocked user</span> 267 + <span class="text-sm opacity-60">(click to show)</span> 268 + </div> 269 + </button> 270 + {:else} 271 + <!-- svelte-ignore a11y_no_static_element_interactions --> 272 + <div 273 + id="timeline-post-{post.value.uri}-{quoteDepth}" 274 + oncontextmenu={handleRightClick} 275 + class=" 237 276 group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all 238 277 {$isPulsing ? 'animate-pulse-highlight' : ''} 239 278 {isOnPostComposer ? 'backdrop-brightness-20' : ''} 240 279 " 241 - style=" 280 + style=" 242 281 background: {color}{isOnPostComposer 243 - ? '36' 244 - : Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)}; 282 + ? '36' 283 + : Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)}; 245 284 border-color: {color}{isOnPostComposer ? '99' : '66'}; 246 285 " 247 - > 248 - <div class="mb-3 flex max-w-full items-center justify-between"> 249 - <div class="flex items-center gap-1 rounded-sm pr-1" style="background: {color}33;"> 250 - {@render profilePopout()} 251 - <span>·</span> 252 - <span 253 - title={new Date(record.createdAt).toLocaleString()} 254 - class="pl-0.5 text-nowrap text-(--nucleus-fg)/67" 255 - > 256 - {getRelativeTime(new Date(record.createdAt), currentTime)} 257 - </span> 286 + > 287 + <div class="mb-3 flex max-w-full items-center justify-between"> 288 + <div class="flex items-center gap-1 rounded-sm pr-1" style="background: {color}33;"> 289 + {@render profilePopout()} 290 + <span>·</span> 291 + <span 292 + title={new Date(record.createdAt).toLocaleString()} 293 + class="pl-0.5 text-nowrap text-(--nucleus-fg)/67" 294 + > 295 + {getRelativeTime(new Date(record.createdAt), currentTime)} 296 + </span> 297 + </div> 298 + {@render cornerFragment?.()} 258 299 </div> 259 - {@render cornerFragment?.()} 260 - </div> 261 300 262 - <p class="leading-normal text-wrap wrap-break-word"> 263 - <RichText text={record.text} facets={record.facets ?? []} /> 264 - {#if isOnPostComposer && record.embed} 265 - <EmbedBadge embed={record.embed} {color} /> 301 + <p class="leading-normal text-wrap wrap-break-word"> 302 + <RichText text={record.text} facets={record.facets ?? []} /> 303 + {#if isOnPostComposer && record.embed} 304 + <EmbedBadge embed={record.embed} {color} /> 305 + {/if} 306 + </p> 307 + {#if !isOnPostComposer && record.embed} 308 + {@const embed = record.embed} 309 + <div class="mt-2"> 310 + {#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'} 311 + <EmbedMedia {did} {embed} /> 312 + {:else if embed.$type === 'app.bsky.embed.record'} 313 + {@render embedPost(embed.record.uri)} 314 + {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} 315 + <div class="space-y-1.5"> 316 + <EmbedMedia {did} embed={embed.media} /> 317 + {@render embedPost(embed.record.record.uri)} 318 + </div> 319 + {/if} 320 + </div> 321 + {/if} 322 + {#if !isOnPostComposer} 323 + {@render postControls(post.value)} 266 324 {/if} 267 - </p> 268 - {#if !isOnPostComposer && record.embed} 269 - {@const embed = record.embed} 270 - <div class="mt-2"> 271 - {#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'} 272 - <EmbedMedia {did} {embed} /> 273 - {:else if embed.$type === 'app.bsky.embed.record'} 274 - {@render embedPost(embed.record.uri)} 275 - {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} 276 - <div class="space-y-1.5"> 277 - <EmbedMedia {did} embed={embed.media} /> 278 - {@render embedPost(embed.record.record.uri)} 279 - </div> 280 - {/if} 281 - </div> 282 - {/if} 283 - {#if !isOnPostComposer} 284 - {@render postControls(post.value)} 285 - {/if} 286 - </div> 325 + </div> 326 + {/if} 287 327 {:else} 288 328 <div class="error-disclaimer"> 289 329 <p class="text-sm font-medium">error: {post.error}</p> ··· 295 335 {#snippet embedPost(uri: ResourceUri)} 296 336 {#if quoteDepth < 2} 297 337 {@const parsedUri = expect(parseCanonicalResourceUri(uri))} 338 + {@const embedBlockRel = 339 + user?.did && !isOnPostComposer 340 + ? getBlockRelationship(user.did, parsedUri.repo) 341 + : { userBlocked: false, blockedByTarget: false }} 342 + {@const embedIsBlocked = embedBlockRel.userBlocked || embedBlockRel.blockedByTarget} 343 + 298 344 <!-- reject recursive quotes --> 299 345 {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} 300 - <BskyPost 301 - {client} 302 - quoteDepth={quoteDepth + 1} 303 - did={parsedUri.repo} 304 - rkey={parsedUri.rkey} 305 - {isOnPostComposer} 306 - {onQuote} 307 - {onReply} 308 - /> 346 + {#if embedIsBlocked} 347 + <div 348 + class="rounded-sm border-2 p-2 text-sm opacity-70" 349 + style="background: {generateColorForDid( 350 + parsedUri.repo 351 + )}11; border-color: {generateColorForDid(parsedUri.repo)}44;" 352 + > 353 + quoted post from blocked user 354 + </div> 355 + {:else} 356 + <BskyPost 357 + {client} 358 + quoteDepth={quoteDepth + 1} 359 + did={parsedUri.repo} 360 + rkey={parsedUri.rkey} 361 + {isOnPostComposer} 362 + {onQuote} 363 + {onReply} 364 + /> 365 + {/if} 309 366 {:else} 310 367 <span>you think you're funny with that recursive quote but i'm onto you</span> 311 368 {/if} ··· 315 372 {/snippet} 316 373 317 374 {#snippet postControls(post: PostWithUri)} 318 - {@const myRepost = hasBacklink(post.uri, repostSource, selectedDid!)} 319 - {@const myLike = hasBacklink(post.uri, likeSource, selectedDid!)} 375 + {@const myRepost = user ? hasBacklink(post.uri, repostSource, user.did) : false} 376 + {@const myLike = user ? hasBacklink(post.uri, likeSource, user.did) : false} 320 377 {#snippet control({ 321 378 name, 322 379 icon, ··· 343 400 onclick={(e) => onClick(e)} 344 401 style="color: {isFull ? iconColor : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}" 345 402 title={name} 346 - disabled={canBeDisabled ? selectedDid === null : false} 403 + disabled={canBeDisabled ? user?.did === undefined : false} 347 404 > 348 405 <Icon icon={hasSolid && isFull ? `${icon}-solid` : icon} width={20} /> 349 406 </button> ··· 360 417 name: 'repost', 361 418 icon: 'heroicons:arrow-path-rounded-square-20-solid', 362 419 onClick: () => { 363 - if (!selectedDid) return; 420 + if (!user?.did) return; 364 421 if (myRepost) deletePostBacklink(client, post, repostSource); 365 422 else createPostBacklink(client, post, repostSource); 366 423 }, ··· 375 432 name: 'like', 376 433 icon: 'heroicons:star', 377 434 onClick: () => { 378 - if (!selectedDid) return; 435 + if (!user?.did) return; 379 436 if (myLike) deletePostBacklink(client, post, likeSource); 380 437 else createPostBacklink(client, post, likeSource); 381 438 }, ··· 393 450 {@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () => 394 451 navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`) 395 452 )} 396 - {@render dropdownItem('heroicons:link-20-solid', 'copy at uri', () => 453 + {@render dropdownItem(undefined, 'copy at uri', () => 397 454 navigator.clipboard.writeText(post.uri) 398 455 )} 399 456 {@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () => ··· 429 486 {/snippet} 430 487 431 488 {#snippet dropdownItem( 432 - icon: string, 489 + icon: string | undefined, 433 490 label: string, 434 491 onClick: () => void, 435 492 autoClose: boolean = true, ··· 447 504 }} 448 505 > 449 506 <span class="font-semibold opacity-85">{label}</span> 450 - <Icon class="h-6 w-6" {icon} /> 507 + {#if icon} 508 + <Icon class="h-6 w-6" {icon} /> 509 + {/if} 451 510 </button> 452 511 {/snippet} 453 - 454 - <style> 455 - @reference "../app.css"; 456 - 457 - :global(.post-dropdown) { 458 - @apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60; 459 - } 460 - </style>
+1 -1
src/components/EmbedMedia.svelte
··· 3 3 import PhotoSwipeGallery, { type GalleryItem } from './PhotoSwipeGallery.svelte'; 4 4 import { blob, img } from '$lib/cdn'; 5 5 import { type Did } from '@atcute/lexicons'; 6 - import { resolveDidDoc } from '$lib/at/client'; 6 + import { resolveDidDoc } from '$lib/at/client.svelte'; 7 7 import type { AppBskyEmbedMedia } from '$lib/at/types'; 8 8 9 9 interface Props {
+73 -54
src/components/FollowingItem.svelte
··· 4 4 5 5 <script lang="ts"> 6 6 import ProfilePicture from './ProfilePicture.svelte'; 7 + import BlockedUserIndicator from './BlockedUserIndicator.svelte'; 7 8 import { getRelativeTime } from '$lib/date'; 8 9 import { generateColorForDid } from '$lib/accounts'; 9 10 import type { Did } from '@atcute/lexicons'; 10 11 import type { calculateFollowedUserStats, Sort } from '$lib/following'; 11 - import { resolveDidDoc, type AtpClient } from '$lib/at/client'; 12 + import { resolveDidDoc, type AtpClient } from '$lib/at/client.svelte'; 12 13 import { SvelteMap } from 'svelte/reactivity'; 13 - import { router } from '$lib/state.svelte'; 14 + import { router, getBlockRelationship } from '$lib/state.svelte'; 14 15 import { map } from '$lib/result'; 15 16 16 17 interface Props { ··· 24 25 25 26 let { style, did, stats, client, sort, currentTime }: Props = $props(); 26 27 27 - // svelte-ignore state_referenced_locally 28 - const cached = profileCache.get(did); 29 - let displayName = $state<string | undefined>(cached?.displayName); 30 - let handle = $state<string>(cached?.handle ?? 'handle.invalid'); 28 + const userDid = $derived(client.user?.did); 29 + const blockRel = $derived( 30 + userDid ? getBlockRelationship(userDid, did) : { userBlocked: false, blockedByTarget: false } 31 + ); 32 + const isBlocked = $derived(blockRel.userBlocked || blockRel.blockedByTarget); 33 + 34 + const cached = $derived(profileCache.get(did)); 35 + const displayName = $derived(cached?.displayName); 36 + const handle = $derived(cached?.handle ?? 'handle.invalid'); 37 + 38 + let error = $state(''); 31 39 32 40 const loadProfile = async (targetDid: Did) => { 33 - if (profileCache.has(targetDid)) { 34 - const c = profileCache.get(targetDid)!; 35 - displayName = c.displayName; 36 - handle = c.handle; 37 - } 41 + if (profileCache.has(targetDid)) return; 38 42 39 43 try { 40 44 const [profileRes, handleRes] = await Promise.all([ 41 - client.getProfile(), 45 + client.getProfile(targetDid), 42 46 resolveDidDoc(targetDid).then((r) => map(r, (doc) => doc.handle)) 43 47 ]); 44 48 if (did !== targetDid) return; 45 - if (profileRes.ok) displayName = profileRes.value.displayName; 46 - if (handleRes.ok) handle = handleRes.value; 47 49 48 50 profileCache.set(targetDid, { 49 - handle, 50 - displayName 51 + handle: handleRes.ok ? handleRes.value : handle, 52 + displayName: profileRes.ok ? profileRes.value.displayName : displayName 51 53 }); 52 54 } catch (e) { 53 55 if (did !== targetDid) return; 54 56 console.error(`failed to load profile for ${targetDid}`, e); 55 - handle = 'error'; 57 + error = String(e); 56 58 } 57 59 }; 58 60 ··· 64 66 const relTime = $derived(getRelativeTime(lastPostAt, currentTime)); 65 67 const color = $derived(generateColorForDid(did)); 66 68 67 - const goToProfile = () => { 68 - router.navigate(`/profile/${did}`); 69 - }; 69 + const goToProfile = () => router.navigate(`/profile/${did}`); 70 70 </script> 71 71 72 72 <div {style} class="box-border w-full pb-2"> 73 - <!-- svelte-ignore a11y_click_events_have_key_events --> 74 - <!-- svelte-ignore a11y_no_static_element_interactions --> 75 - <div 76 - onclick={goToProfile} 77 - class="group flex cursor-pointer items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" 78 - style={`--post-color: ${color};`} 79 - > 80 - <ProfilePicture {client} {did} size={10} /> 81 - <div class="min-w-0 flex-1 space-y-1"> 82 - <div 83 - class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)" 84 - style={`--post-color: ${color};`} 85 - > 86 - <span class="truncate">{displayName || handle}</span> 87 - <span class="truncate text-sm opacity-60">@{handle}</span> 88 - </div> 89 - <div class="flex gap-2 text-xs opacity-70"> 90 - <span 91 - class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2 92 - ? 'text-(--nucleus-accent)' 93 - : ''} 94 - > 95 - posted {relTime} 96 - {relTime !== 'now' ? 'ago' : ''} 97 - </span> 98 - {#if stats?.recentPostCount && stats.recentPostCount > 0} 99 - <span class="text-(--nucleus-accent2)"> 100 - {stats.recentPostCount} posts / 6h 101 - </span> 73 + {#if isBlocked} 74 + <!-- svelte-ignore a11y_click_events_have_key_events --> 75 + <!-- svelte-ignore a11y_no_static_element_interactions --> 76 + <div onclick={goToProfile} class="cursor-pointer"> 77 + <BlockedUserIndicator 78 + {client} 79 + {did} 80 + reason={blockRel.userBlocked ? 'blocked' : 'blocks-you'} 81 + size="small" 82 + /> 83 + </div> 84 + {:else} 85 + <!-- svelte-ignore a11y_click_events_have_key_events --> 86 + <!-- svelte-ignore a11y_no_static_element_interactions --> 87 + <div 88 + onclick={goToProfile} 89 + class="group flex cursor-pointer items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" 90 + style={`--post-color: ${color};`} 91 + > 92 + <ProfilePicture {client} {did} size={10} /> 93 + <div class="min-w-0 flex-1 space-y-1"> 94 + {#if error.length === 0} 95 + <div 96 + class="flex items-baseline gap-2 truncate font-bold transition-colors group-hover:text-(--post-color)" 97 + style={`--post-color: ${color};`} 98 + > 99 + <span class="truncate">{displayName || handle}</span> 100 + <span class="truncate text-sm opacity-60">@{handle}</span> 101 + </div> 102 + {:else} 103 + <div class="flex items-baseline truncate text-sm text-red-500"> 104 + error: {error} 105 + </div> 102 106 {/if} 103 - {#if sort === 'conversational' && stats?.conversationalScore && stats.conversationalScore > 0} 104 - <span class="ml-auto font-bold text-(--nucleus-accent)"> 105 - ★ {stats.conversationalScore.toFixed(1)} 107 + <div class="flex gap-2 text-xs opacity-70"> 108 + <span 109 + class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2 110 + ? 'text-(--nucleus-accent)' 111 + : ''} 112 + > 113 + posted {relTime} 114 + {relTime !== 'now' ? 'ago' : ''} 106 115 </span> 107 - {/if} 116 + {#if stats?.recentPostCount && stats.recentPostCount > 0} 117 + <span class="text-(--nucleus-accent2)"> 118 + {stats.recentPostCount} posts / 6h 119 + </span> 120 + {/if} 121 + {#if sort === 'conversational' && stats?.conversationalScore && stats.conversationalScore > 0} 122 + <span class="ml-auto font-bold text-(--nucleus-accent)"> 123 + ★ {stats.conversationalScore.toFixed(1)} 124 + </span> 125 + {/if} 126 + </div> 108 127 </div> 109 128 </div> 110 - </div> 129 + {/if} 111 130 </div>
+2 -2
src/components/FollowingView.svelte
··· 1 1 <script lang="ts"> 2 2 import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte'; 3 3 import type { Did } from '@atcute/lexicons'; 4 - import { type AtpClient } from '$lib/at/client'; 4 + import { type AtpClient } from '$lib/at/client.svelte'; 5 5 import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; 6 6 import { 7 7 calculateFollowedUserStats, ··· 139 139 </div> 140 140 141 141 <div class="min-h-0 flex-1" bind:this={listContainer}> 142 - {#if !client} 142 + {#if !client || !client.user} 143 143 <NotLoggedIn /> 144 144 {:else if sortedFollowing.length === 0 || isLongCalculation} 145 145 <div class="flex justify-center py-8">
+1
src/components/PhotoSwipeGallery.svelte
··· 67 67 {@const isHidden = i > 3} 68 68 {@const isOverlay = i === 3 && images.length > 4} 69 69 70 + <!-- eslint-disable svelte/no-navigation-without-resolve --> 70 71 <a 71 72 href={img.src} 72 73 data-pswp-width={img.width}
+7 -5
src/components/PostComposer.svelte
··· 1 1 <script lang="ts"> 2 - import type { AtpClient } from '$lib/at/client'; 2 + import type { AtpClient } from '$lib/at/client.svelte'; 3 3 import { ok, err, type Result, expect } from '$lib/result'; 4 4 import type { AppBskyEmbedRecordWithMedia, AppBskyFeedPost } from '@atcute/bluesky'; 5 5 import { generateColorForDid } from '$lib/accounts'; ··· 130 130 createdAt: new Date().toISOString() 131 131 }; 132 132 133 - const res = await client.atcute?.post('com.atproto.repo.createRecord', { 133 + const res = await client.user?.atcute.post('com.atproto.repo.createRecord', { 134 134 input: { 135 135 collection: 'app.bsky.feed.post', 136 136 repo: client.user!.did, ··· 271 271 if (res.ok) { 272 272 onPostSent(res.value); 273 273 _state.text = ''; 274 + _state.quoting = undefined; 275 + _state.replying = undefined; 274 276 _state.attachedMedia = undefined; 275 277 _state.blobsState.clear(); 276 278 unfocus(); ··· 539 541 : `color-mix(in srgb, color-mix(in srgb, var(--nucleus-bg) 85%, ${color}) 70%, transparent)`}; 540 542 border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);" 541 543 > 542 - <div class="w-full p-1 px-2"> 543 - {#if !client.atcute} 544 + <div class="w-full p-1"> 545 + {#if !client.user} 544 546 <div 545 547 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 546 548 style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};" ··· 586 588 587 589 input, 588 590 .composer { 589 - @apply single-line-input bg-(--nucleus-bg)/35; 591 + @apply single-line-input rounded-xs bg-(--nucleus-bg)/35; 590 592 border-color: color-mix(in srgb, var(--acc-color) 30%, transparent); 591 593 } 592 594
+145
src/components/ProfileActions.svelte
··· 1 + <script lang="ts"> 2 + import type { AtpClient } from '$lib/at/client.svelte'; 3 + import { parseCanonicalResourceUri, type Did } from '@atcute/lexicons'; 4 + import Dropdown from './Dropdown.svelte'; 5 + import Icon from '@iconify/svelte'; 6 + import { createBlock, deleteBlock, follows } from '$lib/state.svelte'; 7 + import { generateColorForDid } from '$lib/accounts'; 8 + import { now as tidNow } from '@atcute/tid'; 9 + import type { AppBskyGraphFollow } from '@atcute/bluesky'; 10 + import { toCanonicalUri } from '$lib'; 11 + import { SvelteMap } from 'svelte/reactivity'; 12 + 13 + interface Props { 14 + client: AtpClient; 15 + targetDid: Did; 16 + userBlocked: boolean; 17 + blockedByTarget: boolean; 18 + } 19 + 20 + let { client, targetDid, userBlocked = $bindable(), blockedByTarget }: Props = $props(); 21 + 22 + const userDid = $derived(client.user?.did); 23 + const color = $derived(generateColorForDid(targetDid)); 24 + 25 + let actionsOpen = $state(false); 26 + let actionsPos = $state({ x: 0, y: 0 }); 27 + 28 + const followsMap = $derived(userDid ? follows.get(userDid) : undefined); 29 + const follow = $derived( 30 + followsMap 31 + ? Array.from(followsMap.entries()).find(([, follow]) => follow.subject === targetDid) 32 + : undefined 33 + ); 34 + 35 + const handleFollow = async () => { 36 + if (!userDid || !client.user) return; 37 + 38 + if (follow) { 39 + const [uri] = follow; 40 + followsMap?.delete(uri); 41 + 42 + // extract rkey from uri 43 + const parsedUri = parseCanonicalResourceUri(uri); 44 + if (!parsedUri.ok) return; 45 + const rkey = parsedUri.value.rkey; 46 + 47 + await client.user.atcute.post('com.atproto.repo.deleteRecord', { 48 + input: { 49 + repo: userDid, 50 + collection: 'app.bsky.graph.follow', 51 + rkey 52 + } 53 + }); 54 + } else { 55 + // follow 56 + const rkey = tidNow(); 57 + const record: AppBskyGraphFollow.Main = { 58 + $type: 'app.bsky.graph.follow', 59 + subject: targetDid, 60 + createdAt: new Date().toISOString() 61 + }; 62 + 63 + const uri = toCanonicalUri({ 64 + did: userDid, 65 + collection: 'app.bsky.graph.follow', 66 + rkey 67 + }); 68 + 69 + if (!followsMap) follows.set(userDid, new SvelteMap([[uri, record]])); 70 + else followsMap.set(uri, record); 71 + 72 + await client.user.atcute.post('com.atproto.repo.createRecord', { 73 + input: { 74 + repo: userDid, 75 + collection: 'app.bsky.graph.follow', 76 + rkey, 77 + record 78 + } 79 + }); 80 + } 81 + 82 + actionsOpen = false; 83 + }; 84 + 85 + const handleBlock = async () => { 86 + if (!userDid) return; 87 + 88 + if (userBlocked) { 89 + await deleteBlock(client, targetDid); 90 + userBlocked = false; 91 + } else { 92 + await createBlock(client, targetDid); 93 + userBlocked = true; 94 + } 95 + 96 + actionsOpen = false; 97 + }; 98 + </script> 99 + 100 + {#snippet dropdownItem(icon: string, label: string, onClick: () => void, disabled: boolean = false)} 101 + <button 102 + class="flex items-center justify-between rounded-sm px-2 py-1.5 transition-all duration-100 103 + {disabled ? 'cursor-not-allowed opacity-50' : 'hover:[backdrop-filter:brightness(120%)]'}" 104 + onclick={onClick} 105 + {disabled} 106 + > 107 + <span class="font-semibold opacity-85">{label}</span> 108 + <Icon class="h-6 w-6" {icon} /> 109 + </button> 110 + {/snippet} 111 + 112 + <Dropdown 113 + class="post-dropdown" 114 + style="background: {color}36; border-color: {color}99;" 115 + bind:isOpen={actionsOpen} 116 + bind:position={actionsPos} 117 + placement="bottom-end" 118 + > 119 + {#if !blockedByTarget} 120 + {@render dropdownItem( 121 + follow ? 'heroicons:user-minus-20-solid' : 'heroicons:user-plus-20-solid', 122 + follow ? 'unfollow' : 'follow', 123 + handleFollow 124 + )} 125 + {/if} 126 + {@render dropdownItem( 127 + userBlocked ? 'heroicons:eye-20-solid' : 'heroicons:eye-slash-20-solid', 128 + userBlocked ? 'unblock' : 'block', 129 + handleBlock 130 + )} 131 + 132 + {#snippet trigger()} 133 + <button 134 + class="rounded-sm p-1.5 transition-all hover:bg-white/10" 135 + onclick={(e: MouseEvent) => { 136 + e.stopPropagation(); 137 + actionsOpen = !actionsOpen; 138 + actionsPos = { x: 0, y: 0 }; 139 + }} 140 + title="profile actions" 141 + > 142 + <Icon icon="heroicons:ellipsis-horizontal-16-solid" width={24} /> 143 + </button> 144 + {/snippet} 145 + </Dropdown>
+66 -46
src/components/ProfileInfo.svelte
··· 1 1 <script lang="ts"> 2 - import { AtpClient, resolveDidDoc } from '$lib/at/client'; 2 + import { AtpClient, resolveDidDoc } from '$lib/at/client.svelte'; 3 3 import type { Did, Handle } from '@atcute/lexicons/syntax'; 4 4 import type { AppBskyActorProfile } from '@atcute/bluesky'; 5 5 import ProfilePicture from './ProfilePicture.svelte'; 6 6 import RichText from './RichText.svelte'; 7 7 import { onMount } from 'svelte'; 8 - import { handles, profiles } from '$lib/state.svelte'; 8 + import { getBlockRelationship, handles, profiles } from '$lib/state.svelte'; 9 + import BlockedUserIndicator from './BlockedUserIndicator.svelte'; 9 10 10 11 interface Props { 11 12 client: AtpClient; ··· 21 22 profile = $bindable(profiles.get(did) ?? null) 22 23 }: Props = $props(); 23 24 25 + const userDid = $derived(client.user?.did); 26 + const blockRel = $derived( 27 + userDid ? getBlockRelationship(userDid, did) : { userBlocked: false, blockedByTarget: false } 28 + ); 29 + const isBlocked = $derived(blockRel.userBlocked || blockRel.blockedByTarget); 30 + 24 31 onMount(async () => { 32 + // don't load profile info if blocked 33 + if (isBlocked) return; 34 + 25 35 await Promise.all([ 26 36 (async () => { 27 37 if (profile) return; ··· 46 56 let showDid = $state(false); 47 57 </script> 48 58 49 - <div class="flex flex-col gap-2"> 50 - <div class="flex items-center gap-2"> 51 - <ProfilePicture {client} {did} size={20} /> 59 + {#if isBlocked} 60 + <BlockedUserIndicator 61 + {client} 62 + {did} 63 + reason={blockRel.userBlocked ? 'blocked' : 'blocks-you'} 64 + size="normal" 65 + /> 66 + {:else} 67 + <div class="flex flex-col gap-2"> 68 + <div class="flex items-center gap-2"> 69 + <ProfilePicture {client} {did} size={20} /> 52 70 53 - <div class="flex min-w-0 flex-col items-start overflow-hidden overflow-ellipsis"> 54 - <span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis"> 55 - {profileDisplayName.length > 0 ? profileDisplayName : displayHandle} 56 - {#if profile?.pronouns} 57 - <span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span> 58 - {/if} 59 - </span> 60 - <button 61 - oncontextmenu={(e) => { 62 - e.stopPropagation(); 63 - const node = e.target as Node; 64 - const selection = window.getSelection() ?? new Selection(); 65 - const range = document.createRange(); 66 - range.selectNodeContents(node); 67 - selection.removeAllRanges(); 68 - selection.addRange(range); 69 - }} 70 - onmousedown={(e) => { 71 - // disable double clicks to disable "double click to select text" 72 - // since it doesnt work with us toggling did vs handle 73 - if (e.detail > 1) e.preventDefault(); 74 - }} 75 - onclick={() => (showDid = !showDid)} 76 - class="mb-0.5 text-nowrap opacity-85 select-text hover:underline" 77 - > 78 - {showDid ? did : `@${displayHandle}`} 79 - </button> 80 - {#if profile?.website} 81 - <a 82 - target="_blank" 83 - rel="noopener noreferrer" 84 - href={profile.website} 85 - class="text-sm text-nowrap opacity-60 hover:underline">{profile.website}</a 71 + <div class="flex min-w-0 flex-col items-start overflow-hidden overflow-ellipsis"> 72 + <span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis"> 73 + {profileDisplayName.length > 0 ? profileDisplayName : displayHandle} 74 + {#if profile?.pronouns} 75 + <span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span> 76 + {/if} 77 + </span> 78 + <button 79 + oncontextmenu={(e) => { 80 + e.stopPropagation(); 81 + const node = e.target as Node; 82 + const selection = window.getSelection() ?? new Selection(); 83 + const range = document.createRange(); 84 + range.selectNodeContents(node); 85 + selection.removeAllRanges(); 86 + selection.addRange(range); 87 + }} 88 + onmousedown={(e) => { 89 + // disable double clicks to disable "double click to select text" 90 + // since it doesnt work with us toggling did vs handle 91 + if (e.detail > 1) e.preventDefault(); 92 + }} 93 + onclick={() => (showDid = !showDid)} 94 + class="mb-0.5 text-nowrap opacity-85 select-text hover:underline" 86 95 > 87 - {/if} 96 + {showDid ? did : `@${displayHandle}`} 97 + </button> 98 + {#if profile?.website} 99 + <!-- eslint-disable svelte/no-navigation-without-resolve --> 100 + <a 101 + target="_blank" 102 + rel="noopener noreferrer" 103 + href={profile.website} 104 + class="text-sm text-nowrap opacity-60 hover:underline">{profile.website}</a 105 + > 106 + {/if} 107 + </div> 88 108 </div> 89 - </div> 90 109 91 - {#if profileDesc.length > 0} 92 - <div class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word"> 93 - <RichText text={profileDesc} /> 94 - </div> 95 - {/if} 96 - </div> 110 + {#if profileDesc.length > 0} 111 + <div class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word"> 112 + <RichText text={profileDesc} /> 113 + </div> 114 + {/if} 115 + </div> 116 + {/if}
+1 -1
src/components/ProfilePicture.svelte
··· 1 1 <script lang="ts"> 2 2 import { generateColorForDid } from '$lib/accounts'; 3 - import type { AtpClient } from '$lib/at/client'; 3 + import type { AtpClient } from '$lib/at/client.svelte'; 4 4 import { isBlob } from '@atcute/lexicons/interfaces'; 5 5 import PfpPlaceholder from './PfpPlaceholder.svelte'; 6 6 import { img } from '$lib/cdn';
+81 -40
src/components/ProfileView.svelte
··· 1 1 <script lang="ts"> 2 - import { AtpClient, resolveDidDoc, resolveHandle } from '$lib/at/client'; 2 + import { AtpClient, resolveDidDoc, resolveHandle } from '$lib/at/client.svelte'; 3 3 import { 4 4 isHandle, 5 5 type ActorIdentifier, ··· 11 11 import ProfileInfo from './ProfileInfo.svelte'; 12 12 import type { State as PostComposerState } from './PostComposer.svelte'; 13 13 import Icon from '@iconify/svelte'; 14 - import { generateColorForDid } from '$lib/accounts'; 14 + import { accounts, generateColorForDid } from '$lib/accounts'; 15 15 import { img } from '$lib/cdn'; 16 16 import { isBlob } from '@atcute/lexicons/interfaces'; 17 17 import type { AppBskyActorProfile } from '@atcute/bluesky'; 18 - import { onMount } from 'svelte'; 19 - import { handles, profiles } from '$lib/state.svelte'; 18 + import { 19 + handles, 20 + profiles, 21 + getBlockRelationship, 22 + fetchBlocked, 23 + blockFlags 24 + } from '$lib/state.svelte'; 25 + import BlockedUserIndicator from './BlockedUserIndicator.svelte'; 26 + import ProfileActions from './ProfileActions.svelte'; 20 27 21 28 interface Props { 22 29 client: AtpClient; ··· 34 41 let did = $state<AtprotoDid | null>(null); 35 42 let handle = $state<Handle | null>(handles.get(actor as Did) ?? null); 36 43 44 + let userBlocked = $state(false); 45 + let blockedByTarget = $state(false); 46 + 37 47 const loadProfile = async (identifier: ActorIdentifier) => { 38 48 loading = true; 39 49 error = null; ··· 58 68 } 59 69 } 60 70 71 + // check block relationship 72 + if (client.user?.did) { 73 + let blockRel = getBlockRelationship(client.user.did, did); 74 + blockRel = blockFlags.get(client.user.did)?.has(did) 75 + ? blockRel 76 + : { 77 + userBlocked: await fetchBlocked(client, did, client.user.did), 78 + blockedByTarget: await fetchBlocked(client, client.user.did, did) 79 + }; 80 + userBlocked = blockRel.userBlocked; 81 + blockedByTarget = blockRel.blockedByTarget; 82 + } 83 + 84 + // don't load profile if blocked 85 + if (userBlocked || blockedByTarget) { 86 + loading = false; 87 + return; 88 + } 89 + 61 90 const res = await client.getProfile(did); 62 91 if (res.ok) { 63 92 profile = res.value; ··· 67 96 loading = false; 68 97 }; 69 98 70 - onMount(async () => { 71 - await loadProfile(actor as ActorIdentifier); 99 + $effect(() => { 100 + // if we have accounts, wait until we are logged in to load the profile 101 + if (!($accounts.length > 0 && !client.user?.did)) loadProfile(actor as ActorIdentifier); 72 102 }); 73 103 74 - const color = $derived(did ? generateColorForDid(did) : 'var(--nucleus-fg)'); 104 + const color = $derived(did ? generateColorForDid(did) : 'var(--nucleus-accent)'); 75 105 const bannerUrl = $derived( 76 106 did && profile && isBlob(profile.banner) 77 107 ? img('feed_fullsize', did, profile.banner.ref.$link) ··· 82 112 <div class="flex min-h-dvh flex-col"> 83 113 <!-- header --> 84 114 <div 85 - class="sticky top-0 z-20 flex items-center gap-4 border-b-2 bg-(--nucleus-bg)/80 p-4 backdrop-blur-md" 86 - style="border-color: {color}40;" 115 + class="sticky top-0 z-20 flex items-center gap-4 border-b-2 bg-(--nucleus-bg)/80 p-2 backdrop-blur-md" 116 + style="border-color: {color};" 87 117 > 88 118 <button 89 119 onclick={onBack} 90 - class="rounded-full p-1 text-(--nucleus-fg) transition-all hover:bg-(--nucleus-fg)/10" 120 + class="rounded-sm p-1 text-(--nucleus-fg) transition-all hover:bg-(--nucleus-fg)/10" 91 121 > 92 122 <Icon icon="heroicons:arrow-left-20-solid" width={24} /> 93 123 </button> ··· 98 128 ? 'loading...' 99 129 : (handle ?? actor ?? 'profile')} 100 130 </h2> 131 + <div class="grow"></div> 132 + {#if did && client.user && client.user.did !== did} 133 + <ProfileActions {client} targetDid={did} bind:userBlocked {blockedByTarget} /> 134 + {/if} 101 135 </div> 102 136 103 - {#if error} 104 - <div class="p-8 text-center text-red-500"> 105 - <p>failed to load profile: {error}</p> 106 - </div> 107 - {:else} 108 - <!-- banner --> 109 - <div class="relative h-32 w-full overflow-hidden bg-(--nucleus-fg)/5 md:h-48"> 110 - {#if bannerUrl} 111 - <img src={bannerUrl} alt="banner" class="h-full w-full object-cover" /> 112 - {/if} 113 - <div 114 - class="absolute inset-0 bg-linear-to-b from-transparent to-(--nucleus-bg)" 115 - style="opacity: 0.8;" 116 - ></div> 117 - </div> 118 - 119 - <div class="px-4 pb-4"> 120 - <div class="relative z-10 -mt-12 mb-4"> 121 - {#if did} 122 - <ProfileInfo {client} {did} bind:profile /> 137 + {#if !loading} 138 + {#if error} 139 + <div class="p-8 text-center text-red-500"> 140 + <p>failed to load profile: {error}</p> 141 + </div> 142 + {:else if userBlocked || blockedByTarget} 143 + <div class="p-8"> 144 + <BlockedUserIndicator 145 + {client} 146 + did={did!} 147 + reason={userBlocked ? 'blocked' : 'blocks-you'} 148 + size="large" 149 + /> 150 + </div> 151 + {:else} 152 + <!-- banner --> 153 + <div class="relative h-32 w-full overflow-hidden bg-(--nucleus-fg)/5 md:h-48"> 154 + {#if bannerUrl} 155 + <img src={bannerUrl} alt="banner" class="h-full w-full object-cover" /> 123 156 {/if} 157 + <div 158 + class="absolute inset-0 bg-linear-to-b from-transparent to-(--nucleus-bg)" 159 + style="opacity: 0.8;" 160 + ></div> 124 161 </div> 125 - 126 - <div class="my-4 h-px bg-white/10"></div> 127 162 128 163 {#if did} 129 - <TimelineView 130 - showReplies={false} 131 - {client} 132 - targetDid={did} 133 - bind:postComposerState 134 - class="min-h-[50vh]" 135 - /> 164 + <div class="px-4 pb-4"> 165 + <div class="relative z-10 -mt-12 mb-4"> 166 + <ProfileInfo {client} {did} bind:profile /> 167 + </div> 168 + 169 + <TimelineView 170 + showReplies={false} 171 + {client} 172 + targetDid={did} 173 + bind:postComposerState 174 + class="min-h-[50vh]" 175 + /> 176 + </div> 136 177 {/if} 137 - </div> 178 + {/if} 138 179 {/if} 139 180 </div>
+1
src/components/RichText.svelte
··· 37 37 {@const { text, features: _features } = segment} 38 38 {@const features = _features ?? []} 39 39 {#if features.length > 0} 40 + <!-- eslint-disable svelte/no-navigation-without-resolve --> 40 41 {#each features as feature, idx (idx)} 41 42 {#if feature.$type === 'app.bsky.richtext.facet#mention'} 42 43 <a
+25 -5
src/components/TimelineView.svelte
··· 1 1 <script lang="ts"> 2 2 import BskyPost from './BskyPost.svelte'; 3 3 import { type State as PostComposerState } from './PostComposer.svelte'; 4 - import { AtpClient } from '$lib/at/client'; 4 + import { AtpClient } from '$lib/at/client.svelte'; 5 5 import { accounts } from '$lib/accounts'; 6 6 import { type ResourceUri } from '@atcute/lexicons'; 7 7 import { SvelteSet } from 'svelte/reactivity'; ··· 39 39 let viewOwnPosts = $state(true); 40 40 const expandedThreads = new SvelteSet<ResourceUri>(); 41 41 42 - const did = $derived(targetDid ?? client?.user?.did); 42 + const userDid = $derived(client?.user?.did); 43 + const did = $derived(targetDid ?? userDid); 43 44 44 45 const threads = $derived( 45 46 // todo: apply showReplies here ··· 64 65 try { 65 66 await fetchTimeline(client, did as AtprotoDid, 7, showReplies); 66 67 // only fetch interactions if logged in (because if not who is the interactor) 67 - if (client.user) await fetchInteractionsToTimelineEnd(client, did); 68 + if (client.user) { 69 + if (!fetchingInteractions) { 70 + scheduledFetchInteractions = false; 71 + fetchingInteractions = true; 72 + fetchInteractionsToTimelineEnd(client, did).finally(() => (fetchingInteractions = false)); 73 + } else { 74 + scheduledFetchInteractions = true; 75 + } 76 + } 68 77 loaderState.loaded(); 69 78 } catch (error) { 70 79 loadError = `${error}`; ··· 79 88 }; 80 89 81 90 $effect(() => { 82 - if (threads.length === 0 && !loading && did) { 91 + if (threads.length === 0 && !loading && userDid && did) { 83 92 // if we saw all posts dont try to load more. 84 93 // this only really happens if the user has no posts at all 85 94 // but we do have to handle it to not cause an infinite loop ··· 87 96 if (!cursor?.end) loadMore(); 88 97 } 89 98 }); 99 + 100 + let fetchingInteractions = $state(false); 101 + let scheduledFetchInteractions = $state(false); 90 102 // we want to load interactions when changing logged in user on timelines 91 103 // only on timelines that arent logged in users, because those are already 92 104 // loaded by loadMore 93 105 $effect(() => { 94 - if (client && did && client.user?.did !== did) fetchInteractionsToTimelineEnd(client, did); 106 + if (client && did && scheduledFetchInteractions && userDid !== did) { 107 + if (!fetchingInteractions) { 108 + scheduledFetchInteractions = false; 109 + fetchingInteractions = true; 110 + fetchInteractionsToTimelineEnd(client, did).finally(() => (fetchingInteractions = false)); 111 + } else { 112 + scheduledFetchInteractions = true; 113 + } 114 + } 95 115 }); 96 116 </script> 97 117
+22 -14
src/lib/at/client.ts src/lib/at/client.svelte.ts
··· 1 + /* eslint-disable svelte/prefer-svelte-reactivity */ 1 2 import { err, expect, map, ok, type OkType, type Result } from '$lib/result'; 2 3 import { 3 4 ComAtprotoIdentityResolveHandle, ··· 112 113 | { stage: 'processing'; progress?: number } 113 114 | { stage: 'complete' }; 114 115 116 + export type Auth = { 117 + atcute: AtcuteClient; 118 + } & MiniDoc; 119 + 115 120 export class AtpClient { 116 - public atcute: AtcuteClient | null = null; 117 - public user: MiniDoc | null = null; 121 + public user: Auth | null = $state(null); 118 122 119 123 async login(agent: OAuthUserAgent): Promise<Result<null, string>> { 120 124 try { ··· 122 126 const res = await rpc.get('com.atproto.server.getSession'); 123 127 if (!res.ok) throw res.data.error; 124 128 this.user = { 129 + atcute: rpc, 125 130 did: res.data.did, 126 131 handle: res.data.handle, 127 132 pds: agent.session.info.aud as `${string}:${string}`, 128 133 signing_key: '' 129 134 }; 130 - this.atcute = rpc; 131 135 } catch (error) { 132 136 return err(`failed to login: ${error}`); 133 137 } ··· 195 199 ): Promise< 196 200 Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string> 197 201 > { 198 - if (!this.atcute) return err('not authenticated'); 202 + const auth = this.user; 203 + if (!auth) return err('not authenticated'); 199 204 const docRes = await resolveDidDoc(ident); 200 205 if (!docRes.ok) return docRes; 201 206 const atp = 202 - this.user?.did === docRes.value.did 203 - ? this.atcute 207 + auth.did === docRes.value.did 208 + ? auth.atcute 204 209 : new AtcuteClient({ handler: simpleFetchHandler({ service: docRes.value.pds }) }); 205 210 const res = await atp.get('com.atproto.repo.listRecords', { 206 211 params: { ··· 283 288 } 284 289 285 290 async getServiceAuth(lxm: keyof XRPCProcedures, exp: number): Promise<Result<string, string>> { 286 - if (!this.atcute || !this.user) return err('not authenticated'); 287 - const serviceAuthUrl = new URL(`${this.user.pds}xrpc/com.atproto.server.getServiceAuth`); 288 - serviceAuthUrl.searchParams.append('aud', httpToDidWeb(this.user.pds)); 291 + const auth = this.user; 292 + if (!auth) return err('not authenticated'); 293 + const serviceAuthUrl = new URL(`${auth.pds}xrpc/com.atproto.server.getServiceAuth`); 294 + serviceAuthUrl.searchParams.append('aud', httpToDidWeb(auth.pds)); 289 295 serviceAuthUrl.searchParams.append('lxm', 'com.atproto.repo.uploadBlob'); 290 296 serviceAuthUrl.searchParams.append('exp', exp.toString()); // 30 minutes 291 297 292 - const serviceAuthResponse = await this.atcute.handler( 298 + const serviceAuthResponse = await auth.atcute.handler( 293 299 `${serviceAuthUrl.pathname}${serviceAuthUrl.search}`, 294 300 { 295 301 method: 'GET' ··· 307 313 blob: Blob, 308 314 onProgress?: (progress: number) => void 309 315 ): Promise<Result<AtpBlob<string>, string>> { 310 - if (!this.atcute || !this.user) return err('not authenticated'); 316 + const auth = this.user; 317 + if (!auth) return err('not authenticated'); 311 318 const tokenResult = await this.getServiceAuth( 312 319 'com.atproto.repo.uploadBlob', 313 320 Math.floor(Date.now() / 1000) + 60 314 321 ); 315 322 if (!tokenResult.ok) return tokenResult; 316 323 const result = await xhrPost( 317 - `${this.user.pds}xrpc/com.atproto.repo.uploadBlob`, 324 + `${auth.pds}xrpc/com.atproto.repo.uploadBlob`, 318 325 blob, 319 326 { authorization: `Bearer ${tokenResult.value}` }, 320 327 (uploaded, total) => onProgress?.(uploaded / total) ··· 328 335 mimeType: string, 329 336 onStatus?: (status: UploadStatus) => void 330 337 ): Promise<Result<AtpBlob<string>, string>> { 331 - if (!this.atcute || !this.user) return err('not authenticated'); 338 + const auth = this.user; 339 + if (!auth) return err('not authenticated'); 332 340 333 341 onStatus?.({ stage: 'auth' }); 334 342 const tokenResult = await this.getServiceAuth( ··· 339 347 340 348 onStatus?.({ stage: 'uploading' }); 341 349 const uploadUrl = new URL('https://video.bsky.app/xrpc/app.bsky.video.uploadVideo'); 342 - uploadUrl.searchParams.append('did', this.user.did); 350 + uploadUrl.searchParams.append('did', auth.did); 343 351 uploadUrl.searchParams.append('name', 'video'); 344 352 345 353 const uploadResult = await xhrPost(
+1 -1
src/lib/at/fetch.ts
··· 4 4 type Cid, 5 5 type ResourceUri 6 6 } from '@atcute/lexicons'; 7 - import { type AtpClient } from './client'; 7 + import { type AtpClient } from './client.svelte'; 8 8 import { err, expect, ok, type Ok, type Result } from '$lib/result'; 9 9 import type { Backlinks } from './constellation'; 10 10 import { AppBskyFeedPost } from '@atcute/bluesky';
+1 -1
src/lib/richtext/index.ts
··· 1 1 import RichtextBuilder, { type BakedRichtext } from '@atcute/bluesky-richtext-builder'; 2 2 import { tokenize, type Token } from '$lib/richtext/parser'; 3 3 import type { Did, GenericUri, Handle } from '@atcute/lexicons'; 4 - import { resolveHandle } from '$lib/at/client'; 4 + import { resolveHandle } from '$lib/at/client.svelte'; 5 5 6 6 export const parseToRichText = (text: string): ReturnType<typeof processTokens> => 7 7 processTokens(tokenize(text));
+94 -4
src/lib/state.svelte.ts
··· 1 1 import { writable } from 'svelte/store'; 2 - import { AtpClient, type NotificationsStream, type NotificationsStreamEvent } from './at/client'; 2 + import { 3 + AtpClient, 4 + type NotificationsStream, 5 + type NotificationsStreamEvent 6 + } from './at/client.svelte'; 3 7 import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity'; 4 8 import type { Did, Handle, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons'; 5 9 import { fetchPosts, hydratePosts, type PostWithUri } from './at/fetch'; ··· 191 195 removeBacklinks(post.uri, source, links); 192 196 await Promise.allSettled( 193 197 links.map((link) => 194 - client.atcute?.post('com.atproto.repo.deleteRecord', { 198 + client.user?.atcute.post('com.atproto.repo.deleteRecord', { 195 199 input: { repo: did, collection, rkey: link.rkey! } 196 200 }) 197 201 ) ··· 223 227 const subjectPath = subject.split('.'); 224 228 setNestedValue(record, subjectPath, post.uri); 225 229 setNestedValue(record, [...subjectPath.slice(0, -1), 'cid'], post.cid); 226 - await client.atcute?.post('com.atproto.repo.createRecord', { 230 + await client.user?.atcute.post('com.atproto.repo.createRecord', { 227 231 input: { 228 232 repo: did, 229 233 collection, ··· 293 297 export const fetchBlocked = async (client: AtpClient, subject: Did, blocker: Did) => { 294 298 const subjectUri = `at://${subject}` as ResourceUri; 295 299 const res = await client.getBacklinks(subjectUri, blockSource, [blocker], 1); 296 - if (!res.ok) return; 300 + if (!res.ok) return false; 297 301 if (res.value.total > 0) addBacklinks(subjectUri, blockSource, res.value.records); 302 + 303 + // mark as fetched 304 + let flags = blockFlags.get(subject); 305 + if (!flags) { 306 + flags = new SvelteSet(); 307 + blockFlags.set(subject, flags); 308 + } 309 + flags.add(blocker); 310 + 311 + return res.value.total > 0; 298 312 }; 299 313 300 314 export const fetchBlocks = async (account: Account) => { ··· 312 326 } 313 327 ]); 314 328 } 329 + }; 330 + 331 + export const createBlock = async (client: AtpClient, targetDid: Did) => { 332 + const userDid = client.user?.did; 333 + if (!userDid) return; 334 + 335 + const rkey = tidNow(); 336 + const targetUri = `at://${targetDid}` as ResourceUri; 337 + 338 + addBacklinks(targetUri, blockSource, [ 339 + { 340 + did: userDid, 341 + collection: 'app.bsky.graph.block', 342 + rkey 343 + } 344 + ]); 345 + 346 + const record: AppBskyGraphBlock.Main = { 347 + $type: 'app.bsky.graph.block', 348 + subject: targetDid, 349 + // eslint-disable-next-line svelte/prefer-svelte-reactivity 350 + createdAt: new Date().toISOString() 351 + }; 352 + 353 + await client.user?.atcute.post('com.atproto.repo.createRecord', { 354 + input: { 355 + repo: userDid, 356 + collection: 'app.bsky.graph.block', 357 + rkey, 358 + record 359 + } 360 + }); 361 + }; 362 + 363 + export const deleteBlock = async (client: AtpClient, targetDid: Did) => { 364 + const userDid = client.user?.did; 365 + if (!userDid) return; 366 + 367 + const targetUri = `at://${targetDid}` as ResourceUri; 368 + const links = findBacklinksBy(targetUri, blockSource, userDid); 369 + 370 + removeBacklinks(targetUri, blockSource, links); 371 + 372 + await Promise.allSettled( 373 + links.map((link) => 374 + client.user?.atcute.post('com.atproto.repo.deleteRecord', { 375 + input: { 376 + repo: userDid, 377 + collection: 'app.bsky.graph.block', 378 + rkey: link.rkey 379 + } 380 + }) 381 + ) 382 + ); 383 + }; 384 + 385 + export const isBlockedByUser = (targetDid: Did, userDid: Did): boolean => { 386 + return isBlockedBy(targetDid, userDid); 387 + }; 388 + 389 + export const isUserBlockedBy = (userDid: Did, targetDid: Did): boolean => { 390 + return isBlockedBy(userDid, targetDid); 391 + }; 392 + 393 + export const hasBlockRelationship = (did1: Did, did2: Did): boolean => { 394 + return isBlockedBy(did1, did2) || isBlockedBy(did2, did1); 395 + }; 396 + 397 + export const getBlockRelationship = ( 398 + userDid: Did, 399 + targetDid: Did 400 + ): { userBlocked: boolean; blockedByTarget: boolean } => { 401 + return { 402 + userBlocked: isBlockedBy(targetDid, userDid), 403 + blockedByTarget: isBlockedBy(userDid, targetDid) 404 + }; 315 405 }; 316 406 317 407 export const allPosts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();
+6 -3
src/lib/thread.ts
··· 1 + // updated src/lib/thread.ts 2 + 1 3 import { parseCanonicalResourceUri, type Did, type ResourceUri } from '@atcute/lexicons'; 2 4 import type { Account } from './accounts'; 3 5 import { expect } from './result'; 4 6 import type { PostWithUri } from './at/fetch'; 7 + import { isBlockedBy } from './state.svelte'; 5 8 6 9 export type ThreadPost = { 7 10 data: PostWithUri; ··· 11 14 parentUri: ResourceUri | null; 12 15 depth: number; 13 16 newestTime: number; 17 + isBlocked?: boolean; 14 18 }; 15 19 16 20 export type Thread = { ··· 43 47 rkey: parsedUri.rkey, 44 48 parentUri, 45 49 depth: 0, 46 - newestTime: new Date(data.record.createdAt).getTime() 50 + newestTime: new Date(data.record.createdAt).getTime(), 51 + isBlocked: isBlockedBy(parsedUri.repo, account) 47 52 }; 48 53 49 54 if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); ··· 150 155 } 151 156 152 157 threads.sort((a, b) => b.newestTime - a.newestTime); 153 - 154 - // console.log(threads); 155 158 156 159 return threads; 157 160 };
+6 -6
src/routes/[...catchall]/+page.svelte
··· 6 6 import FollowingView from '$components/FollowingView.svelte'; 7 7 import TimelineView from '$components/TimelineView.svelte'; 8 8 import ProfileView from '$components/ProfileView.svelte'; 9 - import { AtpClient, streamNotifications } from '$lib/at/client'; 9 + import { AtpClient, streamNotifications } from '$lib/at/client.svelte'; 10 10 import { accounts, type Account } from '$lib/accounts'; 11 11 import { onMount } from 'svelte'; 12 12 import { ··· 61 61 const handleAccountSelected = async (did: AtprotoDid) => { 62 62 selectedDid = did; 63 63 const account = $accounts.find((acc) => acc.did === did); 64 - if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute)) 64 + if (account && (!clients.has(account.did) || !clients.get(account.did)?.user)) 65 65 await loginAccount(account); 66 66 }; 67 67 const handleLogout = async (did: AtprotoDid) => { ··· 269 269 <div 270 270 class=" 271 271 {['/', '/following', '/profile/:actor'].includes(router.current.path) ? '' : 'hidden'} 272 - z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all 272 + z-20 w-full max-w-2xl p-2.5 px-4 pb-1.25 transition-all 273 273 " 274 274 > 275 275 <!-- composer and error disclaimer (above thread list, not scrollable) --> 276 - <div class="footer-border-bg rounded-sm px-0.5 py-0.5"> 277 - <div class="footer-bg flex gap-2 rounded-sm p-1.5 shadow-2xl"> 276 + <div class="footer-border-bg rounded-sm p-0.5"> 277 + <div class="footer-bg flex gap-2 rounded-sm p-1.5"> 278 278 <AccountSelector 279 279 client={viewClient} 280 280 accounts={$accounts} ··· 311 311 312 312 <div id="footer-portal" class="contents"></div> 313 313 314 - <div class="footer-border-bg rounded-t-sm px-0.5 pt-0.5"> 314 + <div class="footer-border-bg rounded-t-sm px-0.75 pt-0.75"> 315 315 <div class="footer-bg rounded-t-sm"> 316 316 <div class="flex items-center gap-1.5 px-2 py-1"> 317 317 <div class="mb-2">
+1 -1
src/routes/[...catchall]/+page.ts
··· 1 1 import { addAccount, loggingIn } from '$lib/accounts'; 2 - import { AtpClient } from '$lib/at/client'; 2 + import { AtpClient } from '$lib/at/client.svelte'; 3 3 import { flow, sessions } from '$lib/at/oauth'; 4 4 import { err, ok, type Result } from '$lib/result'; 5 5 import type { PageLoad } from './$types';