implement blocks ui
123 unmodified linesExpand all124125126127128129130131123 unmodified linesExpand all
.animate-slide-in-left { animation: slide-in-from-left 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;}
.post-dropdown { @apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60;}12334567198 unmodified linesExpand all<script lang="ts"> import { generateColorForDid, loggingIn, type Account } from '$lib/accounts'; import { AtpClient, resolveHandle } from '$lib/at/client'; import { AtpClient, resolveHandle } from '$lib/at/client.svelte'; import type { Handle } from '@atcute/lexicons'; import ProfilePicture from './ProfilePicture.svelte'; import PfpPlaceholder from './PfpPlaceholder.svelte'; import Popup from './Popup.svelte';198 unmodified linesExpand all123456789101112131415161718192021222324252627282930313233343536<script lang="ts"> import type { Did } from '@atcute/lexicons'; import ProfilePicture from './ProfilePicture.svelte'; import type { AtpClient } from '$lib/at/client.svelte'; import { generateColorForDid } from '$lib/accounts';
interface Props { client: AtpClient; did: Did; reason: 'blocked' | 'blocks-you'; size?: 'small' | 'normal' | 'large'; }
let { client, did, reason, size = 'normal' }: Props = $props();
const color = $derived(generateColorForDid(did)); const text = $derived(reason === 'blocked' ? 'user blocked' : 'user blocks you'); const pfpSize = $derived(size === 'small' ? 8 : size === 'large' ? 16 : 10);</script>
<div class="flex items-center gap-2 rounded-sm border-2 p-2 {size === 'small' ? 'text-sm' : ''}" style="background: {color}11; border-color: {color}44;"> <div class="blocked-pfp"> <ProfilePicture {client} {did} size={pfpSize} /> </div> <span class="opacity-80">{text}</span></div>
<style> .blocked-pfp { filter: blur(8px) grayscale(100%); opacity: 0.4; }</style>122345614 unmodified lines21222324252526272829303118 unmodified lines5051525354555657584 unmodified lines63646566646768697067717273747576777879808182838485868788899057 unmodified lines14814915015113813915215315415515615715850 unmodified lines20921021121221321421521621721821922019819920020120220320420520620720820922122222322422522622722822923023123223323423523623714 unmodified lines2522532542552562572582592602612622632642652662672682692702322332342352362712722732742752762772782792412802812432442822832842852472482492502512522532542552562572862872882892902912922932942952962972982992592603002622632642652662672682692702712722732742752762772782793013023033043053063073083093103113123133143153163173183192812822832842852863203213223233243253263273283293303 unmodified lines3343353363373383393403413423433443453463473483493503513523533543553003013023033043053063073083563573583593603613623633643653663673683691 unmodified line37137237337431831937537637737837938018 unmodified lines3994004014023464034044054064078 unmodified lines4164174184193634204214224234246 unmodified lines4314324334343784354364374384399 unmodified lines44945045145239645345445545645727 unmodified lines4854864874884324894904914924939 unmodified lines503504505506507450508509510511453454455456457458459460<script lang="ts"> import { resolveDidDoc, type AtpClient } from '$lib/at/client'; import { resolveDidDoc, type AtpClient } from '$lib/at/client.svelte'; import { AppBskyActorProfile, AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/bluesky'; import { parseCanonicalResourceUri, type Did,14 unmodified lines createPostBacklink, router, profiles, handles, hasBacklink hasBacklink, getBlockRelationship, clients } from '$lib/state.svelte'; import type { PostWithUri } from '$lib/at/fetch'; import { onMount, type Snippet } from 'svelte'; import { derived } from 'svelte/store';18 unmodified lines isOnPostComposer?: boolean; onQuote?: (quote: PostWithUri) => void; onReply?: (reply: PostWithUri) => void; cornerFragment?: Snippet; isBlocked?: boolean; }
const { client,4 unmodified lines mini, onQuote, onReply, isOnPostComposer = false /* replyBacklinks */, cornerFragment cornerFragment, isBlocked = false }: Props = $props();
const selectedDid = $derived(client.user?.did ?? null); const user = $derived(client.user); const isLoggedInUser = $derived($accounts.some((acc) => acc.did === did));
const aturi = $derived(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey })); const color = $derived(generateColorForDid(did));
let expandBlocked = $state(false); const blockRel = $derived( user && !isOnPostComposer ? getBlockRelationship(user.did, did) : { userBlocked: false, blockedByTarget: false } ); const showAsBlocked = $derived( (isBlocked || blockRel.userBlocked || blockRel.blockedByTarget) && !expandBlocked );
let handle: Handle = $state(handles.get(did) ?? 'handle.invalid'); onMount(() => { resolveDidDoc(did).then((res) => { if (res.ok) {57 unmodified lines deleteState = 'confirm'; return; }
client?.atcute ?.post('com.atproto.repo.deleteRecord', { clients .get(did) ?.user?.atcute.post('com.atproto.repo.deleteRecord', { input: { collection: 'app.bsky.feed.post', repo: did, rkey50 unmodified lines loading... {:then post} {#if post.ok} {@const record = post.value.record} {#if showAsBlocked} <button onclick={() => (expandBlocked = true)} class="text-left hover:cursor-pointer hover:underline" > <span style="color: {color};">post from blocked user</span> (click to show) </button> {:else} <!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions --> <div onclick={() => scrollToAndPulse(post.value.uri)} class="select-none hover:cursor-pointer hover:underline" > <span style="color: {color};">@{handle}</span>: {#if record.embed} <EmbedBadge embed={record.embed} /> {/if} <span title={record.text}>{record.text}</span> </div> <!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions --> <div onclick={() => scrollToAndPulse(post.value.uri)} class="hover:cursor-pointer hover:underline" > <span style="color: {color};">@{handle}</span>: {#if record.embed} <EmbedBadge embed={record.embed} /> {/if} <span title={record.text}>{record.text}</span> </div> {/if} {:else} {post.error} {/if} {/await}14 unmodified lines </div> {:then post} {#if post.ok} {@const record = post.value.record} {#if showAsBlocked} <button onclick={() => (expandBlocked = true)} class=" group w-full rounded-sm border-2 p-3 text-left shadow-lg backdrop-blur-sm transition-all hover:border-(--nucleus-accent) " style="background: {color}18; border-color: {color}66;" > <div class="flex items-center gap-2"> <span class="opacity-80">post from blocked user</span> <span class="text-sm opacity-60">(click to show)</span> </div> </button> {:else} <!-- svelte-ignore a11y_no_static_element_interactions --> <div id="timeline-post-{post.value.uri}-{quoteDepth}" oncontextmenu={handleRightClick} class=" <!-- svelte-ignore a11y_no_static_element_interactions --> <div id="timeline-post-{post.value.uri}-{quoteDepth}" oncontextmenu={handleRightClick} class=" group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all {$isPulsing ? 'animate-pulse-highlight' : ''} {isOnPostComposer ? 'backdrop-brightness-20' : ''} " style=" style=" background: {color}{isOnPostComposer ? '36' : Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)}; ? '36' : Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)}; border-color: {color}{isOnPostComposer ? '99' : '66'}; " > <div class="mb-3 flex max-w-full items-center justify-between"> <div class="flex items-center gap-1 rounded-sm pr-1" style="background: {color}33;"> {@render profilePopout()} <span>·</span> <span title={new Date(record.createdAt).toLocaleString()} class="pl-0.5 text-nowrap text-(--nucleus-fg)/67" > {getRelativeTime(new Date(record.createdAt), currentTime)} </span> > <div class="mb-3 flex max-w-full items-center justify-between"> <div class="flex items-center gap-1 rounded-sm pr-1" style="background: {color}33;"> {@render profilePopout()} <span>·</span> <span title={new Date(record.createdAt).toLocaleString()} class="pl-0.5 text-nowrap text-(--nucleus-fg)/67" > {getRelativeTime(new Date(record.createdAt), currentTime)} </span> </div> {@render cornerFragment?.()} </div> {@render cornerFragment?.()} </div>
<p class="leading-normal text-wrap wrap-break-word"> <RichText text={record.text} facets={record.facets ?? []} /> {#if isOnPostComposer && record.embed} <EmbedBadge embed={record.embed} {color} /> {/if} </p> {#if !isOnPostComposer && record.embed} {@const embed = record.embed} <div class="mt-2"> {#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'} <EmbedMedia {did} {embed} /> {:else if embed.$type === 'app.bsky.embed.record'} {@render embedPost(embed.record.uri)} {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} <div class="space-y-1.5"> <EmbedMedia {did} embed={embed.media} /> {@render embedPost(embed.record.record.uri)} </div> <p class="leading-normal text-wrap wrap-break-word"> <RichText text={record.text} facets={record.facets ?? []} /> {#if isOnPostComposer && record.embed} <EmbedBadge embed={record.embed} {color} /> {/if} </p> {#if !isOnPostComposer && record.embed} {@const embed = record.embed} <div class="mt-2"> {#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'} <EmbedMedia {did} {embed} /> {:else if embed.$type === 'app.bsky.embed.record'} {@render embedPost(embed.record.uri)} {:else if embed.$type === 'app.bsky.embed.recordWithMedia'} <div class="space-y-1.5"> <EmbedMedia {did} embed={embed.media} /> {@render embedPost(embed.record.record.uri)} </div> {/if} </div> {/if} {#if !isOnPostComposer} {@render postControls(post.value)} {/if} </div> </div> {/if} {#if !isOnPostComposer} {@render postControls(post.value)} {/if} </div> {/if} {:else} <div class="error-disclaimer"> <p class="text-sm font-medium">error: {post.error}</p> </div>3 unmodified lines
{#snippet embedPost(uri: ResourceUri)} {#if quoteDepth < 2} {@const parsedUri = expect(parseCanonicalResourceUri(uri))} {@const embedBlockRel = user?.did && !isOnPostComposer ? getBlockRelationship(user.did, parsedUri.repo) : { userBlocked: false, blockedByTarget: false }} {@const embedIsBlocked = embedBlockRel.userBlocked || embedBlockRel.blockedByTarget}
<!-- reject recursive quotes --> {#if !(did === parsedUri.repo && rkey === parsedUri.rkey)} {#if embedIsBlocked} <div class="rounded-sm border-2 p-2 text-sm opacity-70" style="background: {generateColorForDid( parsedUri.repo )}11; border-color: {generateColorForDid(parsedUri.repo)}44;" > quoted post from blocked user </div> {:else} <BskyPost {client} quoteDepth={quoteDepth + 1} did={parsedUri.repo} rkey={parsedUri.rkey} {isOnPostComposer} {onQuote} {onReply} /> <BskyPost {client} quoteDepth={quoteDepth + 1} did={parsedUri.repo} rkey={parsedUri.rkey} {isOnPostComposer} {onQuote} {onReply} /> {/if} {:else} <span>you think you're funny with that recursive quote but i'm onto you</span> {/if} {:else}1 unmodified line {/if}{/snippet}
{#snippet postControls(post: PostWithUri)} {@const myRepost = hasBacklink(post.uri, repostSource, selectedDid!)} {@const myLike = hasBacklink(post.uri, likeSource, selectedDid!)} {@const myRepost = user ? hasBacklink(post.uri, repostSource, user.did) : false} {@const myLike = user ? hasBacklink(post.uri, likeSource, user.did) : false} {#snippet control({ name, icon, onClick,18 unmodified lines " onclick={(e) => onClick(e)} style="color: {isFull ? iconColor : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}" title={name} disabled={canBeDisabled ? selectedDid === null : false} disabled={canBeDisabled ? user?.did === undefined : false} > <Icon icon={hasSolid && isFull ? `${icon}-solid` : icon} width={20} /> </button> {/snippet}8 unmodified lines {@render control({ name: 'repost', icon: 'heroicons:arrow-path-rounded-square-20-solid', onClick: () => { if (!selectedDid) return; if (!user?.did) return; if (myRepost) deletePostBacklink(client, post, repostSource); else createPostBacklink(client, post, repostSource); }, isFull: myRepost6 unmodified lines {@render control({ name: 'like', icon: 'heroicons:star', onClick: () => { if (!selectedDid) return; if (!user?.did) return; if (myLike) deletePostBacklink(client, post, likeSource); else createPostBacklink(client, post, likeSource); }, isFull: myLike,9 unmodified lines > {@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () => navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`) )} {@render dropdownItem('heroicons:link-20-solid', 'copy at uri', () => {@render dropdownItem(undefined, 'copy at uri', () => navigator.clipboard.writeText(post.uri) )} {@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () => navigator.clipboard.writeText(post.record.text)27 unmodified lines </div>{/snippet}
{#snippet dropdownItem( icon: string, icon: string | undefined, label: string, onClick: () => void, autoClose: boolean = true, extraClass: string = ''9 unmodified lines if (autoClose) actionsOpen = false; }} > <span class="font-semibold opacity-85">{label}</span> {#if icon} <Icon class="h-6 w-6" {icon} /> <Icon class="h-6 w-6" {icon} /> {/if} </button>{/snippet}
<style> @reference "../app.css";
:global(.post-dropdown) { @apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60; }</style>1 unmodified line2345667891046 unmodified lines1 unmodified line import { isBlob } from '@atcute/lexicons/interfaces'; import PhotoSwipeGallery, { type GalleryItem } from './PhotoSwipeGallery.svelte'; import { blob, img } from '$lib/cdn'; import { type Did } from '@atcute/lexicons'; import { resolveDidDoc } from '$lib/at/client'; import { resolveDidDoc } from '$lib/at/client.svelte'; import type { AppBskyEmbedMedia } from '$lib/at/types';
interface Props { did: Did;46 unmodified lines2 unmodified lines345678910111112131314151617185 unmodified lines24252627272829302829303132333435363738394033343536374142434441454647484546495049505152535455565557585960613 unmodified lines65666768676869697071727374757677787980818283848586878889909192939495969798991001017374757677787980818283848586878889909192939495969798991001011021031041051061031041051071081091101111121131141151161171181191071201211221231241251261271281101291302 unmodified lines</script>
<script lang="ts"> import ProfilePicture from './ProfilePicture.svelte'; import BlockedUserIndicator from './BlockedUserIndicator.svelte'; import { getRelativeTime } from '$lib/date'; import { generateColorForDid } from '$lib/accounts'; import type { Did } from '@atcute/lexicons'; import type { calculateFollowedUserStats, Sort } from '$lib/following'; import { resolveDidDoc, type AtpClient } from '$lib/at/client'; import { resolveDidDoc, type AtpClient } from '$lib/at/client.svelte'; import { SvelteMap } from 'svelte/reactivity'; import { router } from '$lib/state.svelte'; import { router, getBlockRelationship } from '$lib/state.svelte'; import { map } from '$lib/result';
interface Props { style: string;5 unmodified lines }
let { style, did, stats, client, sort, currentTime }: Props = $props();
// svelte-ignore state_referenced_locally const cached = profileCache.get(did); let displayName = $state<string | undefined>(cached?.displayName); let handle = $state<string>(cached?.handle ?? 'handle.invalid'); const userDid = $derived(client.user?.did); const blockRel = $derived( userDid ? getBlockRelationship(userDid, did) : { userBlocked: false, blockedByTarget: false } ); const isBlocked = $derived(blockRel.userBlocked || blockRel.blockedByTarget);
const cached = $derived(profileCache.get(did)); const displayName = $derived(cached?.displayName); const handle = $derived(cached?.handle ?? 'handle.invalid');
let error = $state('');
const loadProfile = async (targetDid: Did) => { if (profileCache.has(targetDid)) { const c = profileCache.get(targetDid)!; displayName = c.displayName; handle = c.handle; } if (profileCache.has(targetDid)) return;
try { const [profileRes, handleRes] = await Promise.all([ client.getProfile(), client.getProfile(targetDid), resolveDidDoc(targetDid).then((r) => map(r, (doc) => doc.handle)) ]); if (did !== targetDid) return; if (profileRes.ok) displayName = profileRes.value.displayName; if (handleRes.ok) handle = handleRes.value;
profileCache.set(targetDid, { handle, displayName handle: handleRes.ok ? handleRes.value : handle, displayName: profileRes.ok ? profileRes.value.displayName : displayName }); } catch (e) { if (did !== targetDid) return; console.error(`failed to load profile for ${targetDid}`, e); handle = 'error'; error = String(e); } };
$effect(() => {3 unmodified lines const lastPostAt = $derived(stats?.lastPostAt ?? new Date(0)); const relTime = $derived(getRelativeTime(lastPostAt, currentTime)); const color = $derived(generateColorForDid(did));
const goToProfile = () => { router.navigate(`/profile/${did}`); }; const goToProfile = () => router.navigate(`/profile/${did}`);</script>
<div {style} class="box-border w-full pb-2"> <!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions --> <div onclick={goToProfile} class="group flex cursor-pointer items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" style={`--post-color: ${color};`} > <ProfilePicture {client} {did} size={10} /> <div class="min-w-0 flex-1 space-y-1"> <div class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)" style={`--post-color: ${color};`} > <span class="truncate">{displayName || handle}</span> <span class="truncate text-sm opacity-60">@{handle}</span> </div> <div class="flex gap-2 text-xs opacity-70"> <span class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2 ? 'text-(--nucleus-accent)' : ''} > posted {relTime} {relTime !== 'now' ? 'ago' : ''} </span> {#if stats?.recentPostCount && stats.recentPostCount > 0} <span class="text-(--nucleus-accent2)"> {stats.recentPostCount} posts / 6h </span> {#if isBlocked} <!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions --> <div onclick={goToProfile} class="cursor-pointer"> <BlockedUserIndicator {client} {did} reason={blockRel.userBlocked ? 'blocked' : 'blocks-you'} size="small" /> </div> {:else} <!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions --> <div onclick={goToProfile} class="group flex cursor-pointer items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" style={`--post-color: ${color};`} > <ProfilePicture {client} {did} size={10} /> <div class="min-w-0 flex-1 space-y-1"> {#if error.length === 0} <div class="flex items-baseline gap-2 truncate font-bold transition-colors group-hover:text-(--post-color)" style={`--post-color: ${color};`} > <span class="truncate">{displayName || handle}</span> <span class="truncate text-sm opacity-60">@{handle}</span> </div> {:else} <div class="flex items-baseline truncate text-sm text-red-500"> error: {error} </div> {/if} {#if sort === 'conversational' && stats?.conversationalScore && stats.conversationalScore > 0} <span class="ml-auto font-bold text-(--nucleus-accent)"> ★ {stats.conversationalScore.toFixed(1)} <div class="flex gap-2 text-xs opacity-70"> <span class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2 ? 'text-(--nucleus-accent)' : ''} > posted {relTime} {relTime !== 'now' ? 'ago' : ''} </span> {#if stats?.recentPostCount && stats.recentPostCount > 0} <span class="text-(--nucleus-accent2)"> {stats.recentPostCount} posts / 6h </span> {/if} {/if} {#if sort === 'conversational' && stats?.conversationalScore && stats.conversationalScore > 0} <span class="ml-auto font-bold text-(--nucleus-accent)"> ★ {stats.conversationalScore.toFixed(1)} </span> {/if} </div> </div> </div> </div> {/if}</div>123445678129 unmodified linesExpand all13813914014114214214314414514621 unmodified lines<script lang="ts"> import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte'; import type { Did } from '@atcute/lexicons'; import { type AtpClient } from '$lib/at/client'; import { type AtpClient } from '$lib/at/client.svelte'; import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; import { calculateFollowedUserStats, calculateInteractionScores,129 unmodified linesExpand all </div> </div>
<div class="min-h-0 flex-1" bind:this={listContainer}> {#if !client} {#if !client || !client.user} <NotLoggedIn /> {:else if sortedFollowing.length === 0 || isLongCalculation} <div class="flex justify-center py-8"> <div21 unmodified lines65 unmodified lines666768697071727374127 unmodified linesExpand all65 unmodified lines {@const thumb = img.thumbnail ?? img} {@const isHidden = i > 3} {@const isOverlay = i === 3 && images.length > 4}
<!-- eslint-disable svelte/no-navigation-without-resolve --> <a href={img.src} data-pswp-width={img.width} data-pswp-height={img.height}127 unmodified linesExpand all1223456122 unmodified linesExpand all129130131132133133134135136137132 unmodified linesExpand all270271272273274275276277278279260 unmodified linesExpand all54054154254354254354454554654754854937 unmodified lines58758858959058959159259359459594 unmodified lines<script lang="ts"> import type { AtpClient } from '$lib/at/client'; import type { AtpClient } from '$lib/at/client.svelte'; import { ok, err, type Result, expect } from '$lib/result'; import type { AppBskyEmbedRecordWithMedia, AppBskyFeedPost } from '@atcute/bluesky'; import { generateColorForDid } from '$lib/accounts'; import type { PostWithUri } from '$lib/at/fetch';122 unmodified linesExpand all : (media as AppBskyFeedPost.Main['embed']), createdAt: new Date().toISOString() };
const res = await client.atcute?.post('com.atproto.repo.createRecord', { const res = await client.user?.atcute.post('com.atproto.repo.createRecord', { input: { collection: 'app.bsky.feed.post', repo: client.user!.did, record132 unmodified linesExpand all .then((res) => { if (res.ok) { onPostSent(res.value); _state.text = ''; _state.quoting = undefined; _state.replying = undefined; _state.attachedMedia = undefined; _state.blobsState.clear(); unfocus(); } else {260 unmodified linesExpand all ? `color-mix(in srgb, var(--nucleus-bg) 75%, ${color})` : `color-mix(in srgb, color-mix(in srgb, var(--nucleus-bg) 85%, ${color}) 70%, transparent)`}; border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);" > <div class="w-full p-1 px-2"> {#if !client.atcute} <div class="w-full p-1"> {#if !client.user} <div class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};" >37 unmodified lines @reference "../app.css";
input, .composer { @apply single-line-input bg-(--nucleus-bg)/35; @apply single-line-input rounded-xs bg-(--nucleus-bg)/35; border-color: color-mix(in srgb, var(--acc-color) 30%, transparent); }
.composer {94 unmodified lines123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145<script lang="ts"> import type { AtpClient } from '$lib/at/client.svelte'; import { parseCanonicalResourceUri, type Did } from '@atcute/lexicons'; import Dropdown from './Dropdown.svelte'; import Icon from '@iconify/svelte'; import { createBlock, deleteBlock, follows } from '$lib/state.svelte'; import { generateColorForDid } from '$lib/accounts'; import { now as tidNow } from '@atcute/tid'; import type { AppBskyGraphFollow } from '@atcute/bluesky'; import { toCanonicalUri } from '$lib'; import { SvelteMap } from 'svelte/reactivity';
interface Props { client: AtpClient; targetDid: Did; userBlocked: boolean; blockedByTarget: boolean; }
let { client, targetDid, userBlocked = $bindable(), blockedByTarget }: Props = $props();
const userDid = $derived(client.user?.did); const color = $derived(generateColorForDid(targetDid));
let actionsOpen = $state(false); let actionsPos = $state({ x: 0, y: 0 });
const followsMap = $derived(userDid ? follows.get(userDid) : undefined); const follow = $derived( followsMap ? Array.from(followsMap.entries()).find(([, follow]) => follow.subject === targetDid) : undefined );
const handleFollow = async () => { if (!userDid || !client.user) return;
if (follow) { const [uri] = follow; followsMap?.delete(uri);
// extract rkey from uri const parsedUri = parseCanonicalResourceUri(uri); if (!parsedUri.ok) return; const rkey = parsedUri.value.rkey;
await client.user.atcute.post('com.atproto.repo.deleteRecord', { input: { repo: userDid, collection: 'app.bsky.graph.follow', rkey } }); } else { // follow const rkey = tidNow(); const record: AppBskyGraphFollow.Main = { $type: 'app.bsky.graph.follow', subject: targetDid, createdAt: new Date().toISOString() };
const uri = toCanonicalUri({ did: userDid, collection: 'app.bsky.graph.follow', rkey });
if (!followsMap) follows.set(userDid, new SvelteMap([[uri, record]])); else followsMap.set(uri, record);
await client.user.atcute.post('com.atproto.repo.createRecord', { input: { repo: userDid, collection: 'app.bsky.graph.follow', rkey, record } }); }
actionsOpen = false; };
const handleBlock = async () => { if (!userDid) return;
if (userBlocked) { await deleteBlock(client, targetDid); userBlocked = false; } else { await createBlock(client, targetDid); userBlocked = true; }
actionsOpen = false; };</script>
{#snippet dropdownItem(icon: string, label: string, onClick: () => void, disabled: boolean = false)} <button class="flex items-center justify-between rounded-sm px-2 py-1.5 transition-all duration-100 {disabled ? 'cursor-not-allowed opacity-50' : 'hover:[backdrop-filter:brightness(120%)]'}" onclick={onClick} {disabled} > <span class="font-semibold opacity-85">{label}</span> <Icon class="h-6 w-6" {icon} /> </button>{/snippet}
<Dropdown class="post-dropdown" style="background: {color}36; border-color: {color}99;" bind:isOpen={actionsOpen} bind:position={actionsPos} placement="bottom-end"> {#if !blockedByTarget} {@render dropdownItem( follow ? 'heroicons:user-minus-20-solid' : 'heroicons:user-plus-20-solid', follow ? 'unfollow' : 'follow', handleFollow )} {/if} {@render dropdownItem( userBlocked ? 'heroicons:eye-20-solid' : 'heroicons:eye-slash-20-solid', userBlocked ? 'unblock' : 'block', handleBlock )}
{#snippet trigger()} <button class="rounded-sm p-1.5 transition-all hover:bg-white/10" onclick={(e: MouseEvent) => { e.stopPropagation(); actionsOpen = !actionsOpen; actionsPos = { x: 0, y: 0 }; }} title="profile actions" > <Icon icon="heroicons:ellipsis-horizontal-16-solid" width={24} /> </button> {/snippet}</Dropdown>