appview-less bluesky client
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 {
7 accountPreferences,
8 createBlock,
9 deleteBlock,
10 follows,
11 updateAccountPreferences
12 } from '$lib/state.svelte';
13 import { generateColorForDid } from '$lib/accounts';
14 import { now as tidNow } from '@atcute/tid';
15 import type { AppBskyGraphFollow } from '@atcute/bluesky';
16 import { toCanonicalUri } from '$lib';
17 import { SvelteMap } from 'svelte/reactivity';
18
19 interface Props {
20 client: AtpClient;
21 targetDid: Did;
22 userBlocked: boolean;
23 blockedByTarget: boolean;
24 }
25
26 let { client, targetDid, userBlocked = $bindable(), blockedByTarget }: Props = $props();
27
28 const userDid = $derived(client.user?.did);
29 const color = $derived(generateColorForDid(targetDid));
30
31 let actionsOpen = $state(false);
32 let actionsPos = $state({ x: 0, y: 0 });
33
34 const followsMap = $derived(userDid ? follows.get(userDid) : undefined);
35 const follow = $derived(followsMap ? followsMap.get(targetDid) : undefined);
36
37 const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null);
38 const mutes = $derived(currentPrefs?.mutes ?? []);
39 const muted = $derived(mutes.includes(targetDid));
40
41 const handleMute = async () => {
42 if (!userDid || !client.user) return;
43
44 if (muted)
45 await updateAccountPreferences(userDid, { mutes: mutes.filter((m) => m !== targetDid) });
46 else await updateAccountPreferences(userDid, { mutes: [...mutes, targetDid] });
47 };
48
49 const handleFollow = async () => {
50 if (!userDid || !client.user) return;
51
52 if (follow) {
53 const { uri } = follow;
54 followsMap?.delete(targetDid);
55
56 // extract rkey from uri
57 const parsedUri = parseCanonicalResourceUri(uri);
58 if (!parsedUri.ok) return;
59 const rkey = parsedUri.value.rkey;
60
61 await client.user.atcute.post('com.atproto.repo.deleteRecord', {
62 input: {
63 repo: userDid,
64 collection: 'app.bsky.graph.follow',
65 rkey
66 }
67 });
68 } else {
69 // follow
70 const rkey = tidNow();
71 const record: AppBskyGraphFollow.Main = {
72 $type: 'app.bsky.graph.follow',
73 subject: targetDid,
74 createdAt: new Date().toISOString()
75 };
76
77 const uri = toCanonicalUri({
78 did: userDid,
79 collection: 'app.bsky.graph.follow',
80 rkey
81 });
82
83 if (!followsMap) follows.set(userDid, new SvelteMap([[targetDid, { uri, record }]]));
84 else followsMap.set(targetDid, { uri, record });
85
86 await client.user.atcute.post('com.atproto.repo.createRecord', {
87 input: {
88 repo: userDid,
89 collection: 'app.bsky.graph.follow',
90 rkey,
91 record
92 }
93 });
94 }
95
96 actionsOpen = false;
97 };
98
99 const handleBlock = async () => {
100 if (!userDid) return;
101
102 if (userBlocked) {
103 await deleteBlock(client, targetDid);
104 userBlocked = false;
105 } else {
106 await createBlock(client, targetDid);
107 userBlocked = true;
108 }
109
110 actionsOpen = false;
111 };
112</script>
113
114{#snippet dropdownItem(icon: string, label: string, onClick: () => void, disabled: boolean = false)}
115 <button
116 class="flex items-center justify-between rounded-sm px-2 py-1.5 transition-all duration-100
117 {disabled ? 'cursor-not-allowed opacity-50' : 'hover:[backdrop-filter:brightness(120%)]'}"
118 onclick={onClick}
119 {disabled}
120 >
121 <span class="font-semibold opacity-85">{label}</span>
122 <Icon class="h-6 w-6" {icon} />
123 </button>
124{/snippet}
125
126<Dropdown
127 class="post-dropdown"
128 style="background: {color}36; border-color: {color}99;"
129 bind:isOpen={actionsOpen}
130 bind:position={actionsPos}
131 placement="bottom-end"
132>
133 {#if !blockedByTarget && !userBlocked}
134 {@render dropdownItem(
135 follow ? 'heroicons:user-minus-20-solid' : 'heroicons:user-plus-20-solid',
136 follow ? 'unfollow' : 'follow',
137 handleFollow
138 )}
139 {/if}
140 {@render dropdownItem(
141 userBlocked ? 'heroicons:eye-20-solid' : 'heroicons:eye-slash-20-solid',
142 userBlocked ? 'unblock' : 'block',
143 handleBlock
144 )}
145 {@render dropdownItem(
146 muted ? 'heroicons:speaker-wave-20-solid' : 'heroicons:speaker-x-mark-20-solid',
147 muted ? 'unmute' : 'mute',
148 handleMute
149 )}
150
151 {#snippet trigger()}
152 <button
153 class="rounded-sm p-1.5 transition-all hover:bg-white/10"
154 onclick={(e: MouseEvent) => {
155 e.stopPropagation();
156 actionsOpen = !actionsOpen;
157 actionsPos = { x: 0, y: 0 };
158 }}
159 title="profile actions"
160 >
161 <Icon icon="heroicons:ellipsis-horizontal-16-solid" width={24} />
162 </button>
163 {/snippet}
164</Dropdown>