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>