replies timeline only, appview-less bluesky client
at main 3.8 kB view raw
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>