appview-less bluesky client
24
fork

Configure Feed

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

at main 164 lines 4.5 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 { 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>