appview-less bluesky client
24
fork

Configure Feed

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

implement mutes

dawn b42ee4d0 3fe3b0bb

+657 -223
+33 -190
src/components/AccountSelector.svelte
··· 1 1 <script lang="ts"> 2 - import { generateColorForDid, loggingIn, type Account } from '$lib/accounts'; 3 - import { AtpClient, resolveHandle } from '$lib/at/client.svelte'; 4 - import type { Handle } from '@atcute/lexicons'; 5 - import ProfilePicture from './ProfilePicture.svelte'; 6 - import PfpPlaceholder from './PfpPlaceholder.svelte'; 7 - import Popup from './Popup.svelte'; 8 - import Dropdown from './Dropdown.svelte'; 9 - import { flow } from '$lib/at/oauth'; 10 - import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax'; 2 + import { generateColorForDid, type Account } from '$lib/accounts'; 3 + import type { AtprotoDid } from '@atcute/lexicons/syntax'; 11 4 import Icon from '@iconify/svelte'; 5 + import type { Snippet } from 'svelte'; 12 6 13 7 interface Props { 14 - client: AtpClient; 15 8 accounts: Array<Account>; 16 9 selectedDid?: AtprotoDid | null; 17 - onAccountSelected: (did: AtprotoDid) => void; 18 - onLogout: (did: AtprotoDid) => void; 10 + onSelect: (did: AtprotoDid) => void; 11 + accountActions?: Snippet<[Account]>; 19 12 } 20 13 21 - let { 22 - client, 23 - accounts = [], 24 - selectedDid = $bindable(null), 25 - onAccountSelected, 26 - onLogout 27 - }: Props = $props(); 28 - 29 - let isDropdownOpen = $state(false); 30 - let isLoginModalOpen = $state(false); 31 - let loginHandle = $state(''); 32 - let loginError = $state(''); 33 - let isLoggingIn = $state(false); 34 - 35 - const toggleDropdown = () => (isDropdownOpen = !isDropdownOpen); 36 - const closeDropdown = () => (isDropdownOpen = false); 37 - 38 - const selectAccount = (did: AtprotoDid) => { 39 - onAccountSelected(did); 40 - closeDropdown(); 41 - }; 42 - 43 - const openLoginModal = () => { 44 - isLoginModalOpen = true; 45 - closeDropdown(); 46 - loginHandle = ''; 47 - loginError = ''; 48 - // HACK: i hate this but it works so it doesnt really matter 49 - setTimeout(() => document.getElementById('handle')?.focus(), 100); 50 - }; 51 - 52 - const closeLoginModal = () => { 53 - document.getElementById('handle')?.blur(); 54 - isLoginModalOpen = false; 55 - loginHandle = ''; 56 - loginError = ''; 57 - }; 58 - 59 - const handleLogin = async () => { 60 - try { 61 - if (!loginHandle) throw 'please enter handle'; 62 - 63 - isLoggingIn = true; 64 - loginError = ''; 65 - 66 - let handle: Handle; 67 - if (isHandle(loginHandle)) handle = loginHandle; 68 - else throw 'handle is invalid'; 69 - 70 - let did = await resolveHandle(handle); 71 - if (!did.ok) throw did.error; 72 - 73 - await initiateLogin(did.value, handle); 74 - } catch (error) { 75 - loginError = `login failed: ${error}`; 76 - loggingIn.set(null); 77 - } finally { 78 - isLoggingIn = false; 79 - } 80 - }; 81 - 82 - const initiateLogin = async (did: AtprotoDid, handle: Handle | null) => { 83 - loggingIn.set({ did, handle }); 84 - const result = await flow.start(handle ?? did); 85 - if (!result.ok) throw result.error; 86 - }; 87 - 88 - const handleKeydown = (event: KeyboardEvent) => { 89 - if (event.key === 'Enter' && !isLoggingIn) handleLogin(); 90 - }; 14 + let { accounts = [], selectedDid = $bindable(null), onSelect, accountActions }: Props = $props(); 91 15 </script> 92 16 93 - <Dropdown 94 - class="min-w-52 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl" 95 - bind:isOpen={isDropdownOpen} 96 - placement="top-start" 97 - > 98 - {#snippet trigger()} 99 - <button 100 - onclick={toggleDropdown} 101 - class="flex h-13 w-13 items-center justify-center rounded-sm shadow-md transition-all hover:scale-110 hover:shadow-xl hover:saturate-150" 102 - > 103 - {#if selectedDid} 104 - <ProfilePicture {client} did={selectedDid} size={13} /> 105 - {:else} 106 - <PfpPlaceholder color="var(--nucleus-accent)" size={13} /> 107 - {/if} 108 - </button> 109 - {/snippet} 17 + {#if accounts.length > 0} 18 + <div class="p-2"> 19 + {#each accounts as account (account.did)} 20 + {@const color = generateColorForDid(account.did)} 21 + <button 22 + onclick={() => onSelect(account.did)} 23 + class=" 24 + group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all 25 + {account.did === selectedDid ? 'shadow-lg' : ''} 26 + " 27 + style="color: {color}; background: {account.did === selectedDid 28 + ? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))` 29 + : 'transparent'};" 30 + > 31 + <span>@{account.handle ?? account.did.slice(0, 16)}</span> 110 32 111 - {#if accounts.length > 0} 112 - <div class="p-2"> 113 - {#each accounts as account (account.did)} 114 - {@const color = generateColorForDid(account.did)} 115 - {#snippet action(name: string, icon: string, onClick: () => void)} 116 - <!-- svelte-ignore a11y_click_events_have_key_events --> 117 - <!-- svelte-ignore a11y_no_static_element_interactions --> 118 - <div 119 - title={name} 120 - onclick={onClick} 121 - class="hidden text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md" 122 - > 123 - <Icon class="h-5 w-5" {icon} /> 124 - </div> 125 - {/snippet} 126 - <button 127 - onclick={() => selectAccount(account.did)} 128 - class=" 129 - group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all 130 - {account.did === selectedDid ? 'shadow-lg' : ''} 131 - " 132 - style="color: {color}; background: {account.did === selectedDid 133 - ? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))` 134 - : 'transparent'};" 135 - > 136 - <span>@{account.handle}</span> 137 - 138 - <div class="grow"></div> 33 + <div class="grow"></div> 139 34 140 - {@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () => 141 - initiateLogin(account.did, account.handle) 142 - )} 143 - {@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))} 35 + {#if accountActions} 36 + {@render accountActions(account)} 37 + {/if} 144 38 145 - {#if account.did === selectedDid} 146 - <Icon 147 - icon="heroicons:check-16-solid" 148 - class="h-5 w-5 scale-125 text-(--nucleus-accent) group-hover:hidden" 149 - /> 150 - {/if} 151 - </button> 152 - {/each} 153 - </div> 154 - <div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 155 - {/if} 156 - <button 157 - onclick={openLoginModal} 158 - class="group flex w-full origin-left items-center gap-3 p-3 text-left text-sm font-semibold text-(--nucleus-accent) transition-all hover:scale-[1.1]" 159 - > 160 - <Icon class="h-5 w-5 scale-[130%]" icon="heroicons:plus-16-solid" /> 161 - <span>add account</span> 162 - </button> 163 - </Dropdown> 164 - 165 - <Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account"> 166 - <!-- svelte-ignore a11y_no_static_element_interactions --> 167 - <div class="space-y-2" onkeydown={handleKeydown}> 168 - <div> 169 - <label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 170 - account handle 171 - </label> 172 - <input 173 - id="handle" 174 - type="text" 175 - bind:value={loginHandle} 176 - placeholder="example.bsky.social" 177 - class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3" 178 - disabled={isLoggingIn} 179 - /> 180 - </div> 181 - 182 - {#if loginError} 183 - <div class="error-disclaimer"> 184 - <p> 185 - <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" /> 186 - {loginError} 187 - </p> 188 - </div> 189 - {/if} 190 - 191 - <div class="flex gap-3 pt-3"> 192 - <button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}> 193 - cancel 39 + {#if account.did === selectedDid} 40 + <Icon 41 + icon="heroicons:check-16-solid" 42 + class="h-5 w-5 scale-125 text-(--nucleus-accent) group-hover:hidden" 43 + /> 44 + {/if} 194 45 </button> 195 - <button 196 - onclick={handleLogin} 197 - class="flex-1 action-button border-transparent text-(--nucleus-fg)" 198 - style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));" 199 - disabled={isLoggingIn} 200 - > 201 - {isLoggingIn ? 'logging in...' : 'login'} 202 - </button> 203 - </div> 46 + {/each} 204 47 </div> 205 - </Popup> 48 + {/if}
+184
src/components/AccountSwitcher.svelte
··· 1 + <script lang="ts"> 2 + import { loggingIn, type Account } from '$lib/accounts'; 3 + import { AtpClient, resolveHandle } from '$lib/at/client.svelte'; 4 + import type { Handle } from '@atcute/lexicons'; 5 + import ProfilePicture from './ProfilePicture.svelte'; 6 + import PfpPlaceholder from './PfpPlaceholder.svelte'; 7 + import Popup from './Popup.svelte'; 8 + import Dropdown from './Dropdown.svelte'; 9 + import AccountSelector from './AccountSelector.svelte'; 10 + import { flow } from '$lib/at/oauth'; 11 + import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax'; 12 + import Icon from '@iconify/svelte'; 13 + 14 + interface Props { 15 + client: AtpClient; 16 + accounts: Array<Account>; 17 + selectedDid?: AtprotoDid | null; 18 + onAccountSelected: (did: AtprotoDid) => void; 19 + onLogout: (did: AtprotoDid) => void; 20 + } 21 + 22 + let { 23 + client, 24 + accounts = [], 25 + selectedDid = $bindable(null), 26 + onAccountSelected, 27 + onLogout 28 + }: Props = $props(); 29 + 30 + let isDropdownOpen = $state(false); 31 + let isLoginModalOpen = $state(false); 32 + let loginHandle = $state(''); 33 + let loginError = $state(''); 34 + let isLoggingIn = $state(false); 35 + 36 + const toggleDropdown = () => (isDropdownOpen = !isDropdownOpen); 37 + const closeDropdown = () => (isDropdownOpen = false); 38 + 39 + const selectAccount = (did: AtprotoDid) => { 40 + onAccountSelected(did); 41 + closeDropdown(); 42 + }; 43 + 44 + const openLoginModal = () => { 45 + isLoginModalOpen = true; 46 + closeDropdown(); 47 + loginHandle = ''; 48 + loginError = ''; 49 + // HACK: i hate this but it works so it doesnt really matter 50 + setTimeout(() => document.getElementById('handle')?.focus(), 100); 51 + }; 52 + 53 + const closeLoginModal = () => { 54 + document.getElementById('handle')?.blur(); 55 + isLoginModalOpen = false; 56 + loginHandle = ''; 57 + loginError = ''; 58 + }; 59 + 60 + const handleLogin = async () => { 61 + try { 62 + if (!loginHandle) throw 'please enter handle'; 63 + 64 + isLoggingIn = true; 65 + loginError = ''; 66 + 67 + let handle: Handle; 68 + if (isHandle(loginHandle)) handle = loginHandle; 69 + else throw 'handle is invalid'; 70 + 71 + let did = await resolveHandle(handle); 72 + if (!did.ok) throw did.error; 73 + 74 + await initiateLogin(did.value, handle); 75 + } catch (error) { 76 + loginError = `login failed: ${error}`; 77 + loggingIn.set(null); 78 + } finally { 79 + isLoggingIn = false; 80 + } 81 + }; 82 + 83 + const initiateLogin = async (did: AtprotoDid, handle: Handle | null) => { 84 + loggingIn.set({ did, handle }); 85 + const result = await flow.start(handle ?? did); 86 + if (!result.ok) throw result.error; 87 + }; 88 + 89 + const handleKeydown = (event: KeyboardEvent) => { 90 + if (event.key === 'Enter' && !isLoggingIn) handleLogin(); 91 + }; 92 + </script> 93 + 94 + {#snippet action(name: string, icon: string, onClick: () => void)} 95 + <!-- svelte-ignore a11y_click_events_have_key_events --> 96 + <!-- svelte-ignore a11y_no_static_element_interactions --> 97 + <div 98 + title={name} 99 + onclick={onClick} 100 + class="hidden text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md" 101 + > 102 + <Icon class="h-5 w-5" {icon} /> 103 + </div> 104 + {/snippet} 105 + 106 + <Dropdown 107 + class="min-w-52 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl" 108 + bind:isOpen={isDropdownOpen} 109 + placement="top-start" 110 + > 111 + {#snippet trigger()} 112 + <button 113 + onclick={toggleDropdown} 114 + class="flex h-13 w-13 items-center justify-center rounded-sm shadow-md transition-all hover:scale-110 hover:shadow-xl hover:saturate-150" 115 + > 116 + {#if selectedDid} 117 + <ProfilePicture {client} did={selectedDid} size={13} /> 118 + {:else} 119 + <PfpPlaceholder color="var(--nucleus-accent)" size={13} /> 120 + {/if} 121 + </button> 122 + {/snippet} 123 + 124 + <AccountSelector {accounts} {selectedDid} onSelect={selectAccount}> 125 + {#snippet accountActions(account)} 126 + {@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () => 127 + initiateLogin(account.did, account.handle) 128 + )} 129 + {@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))} 130 + {/snippet} 131 + </AccountSelector> 132 + {#if accounts.length > 0} 133 + <div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 134 + {/if} 135 + <button 136 + onclick={openLoginModal} 137 + class="group flex w-full origin-left items-center gap-3 p-3 text-left text-sm font-semibold text-(--nucleus-accent) transition-all hover:scale-[1.1]" 138 + > 139 + <Icon class="h-5 w-5 scale-[130%]" icon="heroicons:plus-16-solid" /> 140 + <span>add account</span> 141 + </button> 142 + </Dropdown> 143 + 144 + <Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account"> 145 + <!-- svelte-ignore a11y_no_static_element_interactions --> 146 + <div class="space-y-2" onkeydown={handleKeydown}> 147 + <div> 148 + <label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 149 + account handle 150 + </label> 151 + <input 152 + id="handle" 153 + type="text" 154 + bind:value={loginHandle} 155 + placeholder="example.bsky.social" 156 + class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3" 157 + disabled={isLoggingIn} 158 + /> 159 + </div> 160 + 161 + {#if loginError} 162 + <div class="error-disclaimer"> 163 + <p> 164 + <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" /> 165 + {loginError} 166 + </p> 167 + </div> 168 + {/if} 169 + 170 + <div class="flex gap-3 pt-3"> 171 + <button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}> 172 + cancel 173 + </button> 174 + <button 175 + onclick={handleLogin} 176 + class="flex-1 action-button border-transparent text-(--nucleus-fg)" 177 + style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));" 178 + disabled={isLoggingIn} 179 + > 180 + {isLoggingIn ? 'logging in...' : 'login'} 181 + </button> 182 + </div> 183 + </div> 184 + </Popup>
+13 -9
src/components/BskyPost.svelte
··· 52 52 onReply?: (reply: PostWithUri) => void; 53 53 cornerFragment?: Snippet; 54 54 isBlocked?: boolean; 55 + isMuted?: boolean; 55 56 } 56 57 57 58 const { ··· 65 66 onReply, 66 67 isOnPostComposer = false /* replyBacklinks */, 67 68 cornerFragment, 68 - isBlocked = false 69 + isBlocked = false, 70 + isMuted = false 69 71 }: Props = $props(); 70 72 71 73 const user = $derived(client.user); ··· 74 76 const aturi = $derived(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey })); 75 77 const color = $derived(generateColorForDid(did)); 76 78 77 - let expandBlocked = $state(false); 79 + let expandDisallowed = $state(false); 78 80 const blockRel = $derived( 79 81 user && !isOnPostComposer 80 82 ? getBlockRelationship(user.did, did) 81 83 : { userBlocked: false, blockedByTarget: false } 82 84 ); 83 85 const showAsBlocked = $derived( 84 - (isBlocked || blockRel.userBlocked || blockRel.blockedByTarget) && !expandBlocked 86 + (isBlocked || blockRel.userBlocked || blockRel.blockedByTarget) && !expandDisallowed 85 87 ); 88 + const showAsMuted = $derived(isMuted && !expandDisallowed); 86 89 87 90 let handle: Handle = $state(handles.get(did) ?? 'handle.invalid'); 88 91 onMount(() => { ··· 210 213 {:then post} 211 214 {#if post.ok} 212 215 {@const record = post.value.record} 213 - {#if showAsBlocked} 216 + {#if showAsBlocked || showAsMuted} 214 217 <button 215 - onclick={() => (expandBlocked = true)} 218 + onclick={() => (expandDisallowed = true)} 216 219 class="text-left hover:cursor-pointer hover:underline" 217 220 > 218 - <span style="color: {color};">post from blocked user</span> (click to show) 221 + <span style="color: {color};">post from {showAsBlocked ? 'blocked' : 'muted'} user</span 222 + > (click to show) 219 223 </button> 220 224 {:else} 221 225 <!-- svelte-ignore a11y_click_events_have_key_events --> ··· 253 257 {:then post} 254 258 {#if post.ok} 255 259 {@const record = post.value.record} 256 - {#if showAsBlocked} 260 + {#if showAsBlocked || showAsMuted} 257 261 <button 258 - onclick={() => (expandBlocked = true)} 262 + onclick={() => (expandDisallowed = true)} 259 263 class=" 260 264 group w-full rounded-sm border-2 p-3 text-left shadow-lg 261 265 backdrop-blur-sm transition-all hover:border-(--nucleus-accent) ··· 263 267 style="background: {color}18; border-color: {color}66;" 264 268 > 265 269 <div class="flex items-center gap-2"> 266 - <span class="opacity-80">post from blocked user</span> 270 + <span class="opacity-80">post from {showAsBlocked ? 'blocked' : 'muted'} user</span> 267 271 <span class="text-sm opacity-60">(click to show)</span> 268 272 </div> 269 273 </button>
+53
src/components/MutedAccountItem.svelte
··· 1 + <script lang="ts"> 2 + import { generateColorForDid } from '$lib/accounts'; 3 + import { resolveDidDoc } from '$lib/at/client.svelte'; 4 + import { handles, router } from '$lib/state.svelte'; 5 + import { map } from '$lib/result'; 6 + import type { Did } from '@atcute/lexicons'; 7 + import Icon from '@iconify/svelte'; 8 + 9 + interface Props { 10 + style: string; 11 + did: Did; 12 + onRemove: () => void; 13 + } 14 + 15 + let { style, did, onRemove }: Props = $props(); 16 + 17 + const handle = $derived(handles.get(did)); 18 + const color = $derived(generateColorForDid(did)); 19 + 20 + $effect(() => { 21 + if (!handles.has(did)) { 22 + resolveDidDoc(did).then((r) => { 23 + if (r.ok) handles.set(did, r.value.handle); 24 + else handles.set(did, 'handle.invalid'); 25 + }); 26 + } 27 + }); 28 + 29 + const goToProfile = () => router.navigate(`/profile/${did}`); 30 + </script> 31 + 32 + <div {style} class="box-border w-full py-0.5"> 33 + <!-- svelte-ignore a11y_click_events_have_key_events --> 34 + <!-- svelte-ignore a11y_no_static_element_interactions --> 35 + <div 36 + class="group flex cursor-pointer items-center gap-2 rounded-sm bg-(--nucleus-fg)/5 px-2 py-1.5 transition-colors hover:bg-(--post-color)/15" 37 + style={`--post-color: ${color};`} 38 + > 39 + <span 40 + onclick={goToProfile} 41 + class="semibold flex-1 truncate text-sm transition-colors group-hover:text-(--post-color)" 42 + style="color: {color}" 43 + > 44 + {handle ? `@${handle}` : did} 45 + </span> 46 + <button 47 + onclick={onRemove} 48 + class="text-sm text-red-400 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-500" 49 + > 50 + <Icon icon="heroicons:x-mark-16-solid" width="24" /> 51 + </button> 52 + </div> 53 + </div>
+25 -1
src/components/ProfileActions.svelte
··· 3 3 import { parseCanonicalResourceUri, type Did } from '@atcute/lexicons'; 4 4 import Dropdown from './Dropdown.svelte'; 5 5 import Icon from '@iconify/svelte'; 6 - import { createBlock, deleteBlock, follows } from '$lib/state.svelte'; 6 + import { 7 + accountPreferences, 8 + createBlock, 9 + deleteBlock, 10 + follows, 11 + setAccountPreferences, 12 + updateAccountPreferences 13 + } from '$lib/state.svelte'; 7 14 import { generateColorForDid } from '$lib/accounts'; 8 15 import { now as tidNow } from '@atcute/tid'; 9 16 import type { AppBskyGraphFollow } from '@atcute/bluesky'; ··· 31 38 ? Array.from(followsMap.entries()).find(([, follow]) => follow.subject === targetDid) 32 39 : undefined 33 40 ); 41 + 42 + const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); 43 + const mutes = $derived(currentPrefs?.mutes ?? []); 44 + const muted = $derived(mutes.includes(targetDid)); 45 + 46 + const handleMute = async () => { 47 + if (!userDid || !client.user) return; 48 + 49 + if (muted) 50 + await updateAccountPreferences(userDid, { mutes: mutes.filter((m) => m !== targetDid) }); 51 + else await updateAccountPreferences(userDid, { mutes: [...mutes, targetDid] }); 52 + }; 34 53 35 54 const handleFollow = async () => { 36 55 if (!userDid || !client.user) return; ··· 127 146 userBlocked ? 'heroicons:eye-20-solid' : 'heroicons:eye-slash-20-solid', 128 147 userBlocked ? 'unblock' : 'block', 129 148 handleBlock 149 + )} 150 + {@render dropdownItem( 151 + muted ? 'heroicons:speaker-wave-20-solid' : 'heroicons:speaker-x-mark-20-solid', 152 + muted ? 'unmute' : 'mute', 153 + handleMute 130 154 )} 131 155 132 156 {#snippet trigger()}
+149 -12
src/components/SettingsView.svelte
··· 5 5 import Tabs from './Tabs.svelte'; 6 6 import { portal } from 'svelte-portal'; 7 7 import { cache } from '$lib/cache'; 8 - import { router } from '$lib/state.svelte'; 8 + import { 9 + router, 10 + clients, 11 + accountPreferences, 12 + setAccountPreferences, 13 + syncAccountPreferences, 14 + loadAccountPreferences 15 + } from '$lib/state.svelte'; 16 + import { accounts as accountsStore, generateColorForDid } from '$lib/accounts'; 17 + import AccountSelector from './AccountSelector.svelte'; 18 + import Dropdown from './Dropdown.svelte'; 19 + import MutedAccountItem from './MutedAccountItem.svelte'; 20 + import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; 21 + import type { Did } from '@atcute/lexicons'; 22 + import type { AtprotoDid } from '@atcute/lexicons/syntax'; 23 + import Icon from '@iconify/svelte'; 9 24 10 25 interface Props { 11 26 tab: string; ··· 38 53 }; 39 54 40 55 const onTabChange = (tab: string) => router.replace(`/settings/${tab}`); 56 + 57 + let selectedAccount: AtprotoDid | null = $state(null); 58 + let newMuteInput = $state(''); 59 + let syncStatus = $state<'syncing' | 'synced' | null>(null); 60 + let isAccountDropdownOpen = $state(false); 61 + 62 + const accounts = $derived($accountsStore.filter((a) => clients.has(a.did))); 63 + const selectedAccountData = $derived(accounts.find((a) => a.did === selectedAccount)); 64 + const currentPrefs = $derived(selectedAccount ? accountPreferences.get(selectedAccount) : null); 65 + const mutes = $derived(currentPrefs?.mutes ?? []); 66 + 67 + $effect(() => { 68 + if (accounts.length > 0 && !selectedAccount) { 69 + selectedAccount = accounts[0].did; 70 + } 71 + }); 72 + 73 + let syncDebounceTimer: ReturnType<typeof setTimeout> | null = null; 74 + const SYNC_DEBOUNCE_MS = 1000; 75 + 76 + const scheduleSyncFor = (did: AtprotoDid) => { 77 + if (syncDebounceTimer) clearTimeout(syncDebounceTimer); 78 + syncDebounceTimer = setTimeout(async () => { 79 + syncStatus = 'syncing'; 80 + await syncAccountPreferences(did); 81 + syncStatus = 'synced'; 82 + setTimeout(() => (syncStatus = null), 2000); 83 + }, SYNC_DEBOUNCE_MS); 84 + }; 85 + 86 + const handleAddMute = () => { 87 + if (!selectedAccount || !newMuteInput.trim()) return; 88 + const did = newMuteInput.trim() as Did; 89 + setAccountPreferences(selectedAccount, { mutes: [...mutes, did] }); 90 + scheduleSyncFor(selectedAccount); 91 + newMuteInput = ''; 92 + }; 93 + 94 + const handleRemoveMute = (did: Did) => { 95 + if (!selectedAccount) return; 96 + setAccountPreferences(selectedAccount, { mutes: mutes.filter((m) => m !== did) }); 97 + scheduleSyncFor(selectedAccount); 98 + }; 99 + 100 + const handleReload = async () => { 101 + if (!selectedAccount) return; 102 + syncStatus = 'syncing'; 103 + await loadAccountPreferences({ did: selectedAccount, handle: null }); 104 + syncStatus = 'synced'; 105 + setTimeout(() => (syncStatus = null), 2000); 106 + }; 41 107 </script> 42 108 43 109 {#snippet advancedTab()} ··· 136 202 <div class="h-1 w-9.5 rounded-full bg-(--nucleus-accent2)"></div> 137 203 </div> 138 204 </div> 139 - {#if hasReloadChanges} 140 - <button onclick={handleSave} class="action-button animate-pulse shadow-lg"> 141 - save & reload 142 - </button> 143 - {/if} 205 + <div class="flex items-center gap-2"> 206 + {#if tab === 'moderation'} 207 + {#if syncStatus} 208 + <span class="text-xs opacity-70">{syncStatus}</span> 209 + {/if} 210 + <Dropdown 211 + class="min-w-48 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl" 212 + bind:isOpen={isAccountDropdownOpen} 213 + placement="bottom-end" 214 + > 215 + {#snippet trigger()} 216 + <button 217 + onclick={() => (isAccountDropdownOpen = !isAccountDropdownOpen)} 218 + class="flex action-button items-center gap-1.5 text-sm" 219 + style="color: {selectedAccountData 220 + ? generateColorForDid(selectedAccountData.did) 221 + : 'inherit'}" 222 + > 223 + <span>@{selectedAccountData?.handle ?? selectedAccount?.slice(0, 12)}</span> 224 + <span class="opacity-50">▾</span> 225 + </button> 226 + {/snippet} 227 + <AccountSelector 228 + {accounts} 229 + selectedDid={selectedAccount} 230 + onSelect={(did) => { 231 + selectedAccount = did; 232 + isAccountDropdownOpen = false; 233 + }} 234 + /> 235 + </Dropdown> 236 + <button onclick={handleReload} class="action-button p-2" title="reload from pocket"> 237 + <Icon 238 + class={syncStatus === 'syncing' ? 'animate-spin' : ''} 239 + icon="heroicons:arrow-path-16-solid" 240 + width="20" 241 + /> 242 + </button> 243 + {:else if hasReloadChanges} 244 + <button onclick={handleSave} class="action-button animate-pulse shadow-lg"> 245 + save &amp; reload 246 + </button> 247 + {/if} 248 + </div> 144 249 </div> 145 250 146 251 <div class="flex-1"> 147 252 {#if tab === 'advanced'} 148 253 {@render advancedTab()} 149 254 {:else if tab === 'moderation'} 150 - <div class="p-4"> 151 - <div class="flex h-64 items-center justify-center"> 152 - <div class="text-center"> 153 - <div class="mb-4 text-6xl opacity-50">🚧</div> 154 - <h3 class="text-xl font-bold opacity-80">todo</h3> 255 + <div class="space-y-4 p-4"> 256 + <div> 257 + <h3 class="header">muted accounts</h3> 258 + <div class="borders space-y-2"> 259 + <div class="flex gap-2"> 260 + <input 261 + type="text" 262 + bind:value={newMuteInput} 263 + placeholder="did:plc:..." 264 + class="single-line-input flex-1" 265 + /> 266 + <button onclick={handleAddMute} class="action-button">add</button> 267 + </div> 268 + {#if mutes.length > 0} 269 + <div class="h-fit"> 270 + <VirtualList 271 + height={Math.min(mutes.length, 6) * 44} 272 + itemCount={mutes.length} 273 + itemSize={44} 274 + > 275 + {#snippet item({ index, style }: { index: number; style: string })} 276 + <MutedAccountItem 277 + {style} 278 + did={mutes[index]} 279 + onRemove={() => handleRemoveMute(mutes[index])} 280 + /> 281 + {/snippet} 282 + </VirtualList> 283 + </div> 284 + {:else} 285 + <p class="py-2 text-center text-sm opacity-50">no muted accounts</p> 286 + {/if} 155 287 </div> 156 288 </div> 289 + {#if currentPrefs} 290 + <p class="text-xs opacity-50"> 291 + last synced: {new Date(currentPrefs.updatedAt).toLocaleString()} 292 + </p> 293 + {/if} 157 294 </div> 158 295 {:else if tab === 'style'} 159 296 {@render styleTab()} ··· 166 303 z-20 w-full max-w-2xl bg-(--nucleus-bg) p-4 pt-2 pb-1 shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.1)] 167 304 " 168 305 > 169 - <Tabs tabs={['style', 'moderation', 'advanced']} activeTab={tab} {onTabChange} /> 306 + <Tabs tabs={['moderation', 'style', 'advanced']} activeTab={tab} {onTabChange} /> 170 307 </div> 171 308 </div> 172 309
+6 -2
src/components/TimelineView.svelte
··· 11 11 fetchTimeline, 12 12 allPosts, 13 13 timelines, 14 - fetchInteractionsToTimelineEnd 14 + fetchInteractionsToTimelineEnd, 15 + accountPreferences 15 16 } from '$lib/state.svelte'; 16 17 import Icon from '@iconify/svelte'; 17 18 import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; ··· 42 43 const userDid = $derived(client?.user?.did); 43 44 const did = $derived(targetDid ?? userDid); 44 45 46 + const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); 47 + const mutes = $derived(currentPrefs?.mutes ?? []); 48 + 45 49 const threads = $derived( 46 50 // todo: apply showReplies here 47 51 filterThreads( 48 - did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts) : [], 52 + did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts, mutes) : [], 49 53 $accounts, 50 54 { viewOwnPosts } 51 55 )
+73
src/lib/at/pocket.ts
··· 1 + import type { Did } from '@atcute/lexicons/syntax'; 2 + import type { AtpClient } from './client.svelte'; 3 + import { err, ok, type Result } from '$lib/result'; 4 + 5 + const POCKET_PROXY = 'did:web:pocket.at-app.net#pocket_prefs'; 6 + 7 + export type Preferences = { 8 + mutes?: Did[]; 9 + updatedAt: string; 10 + }; 11 + 12 + type PrefsResponse = { 13 + preferences: Preferences; 14 + }; 15 + 16 + export async function getPreferences(client: AtpClient): Promise<Result<Preferences | null, string>> { 17 + const auth = client.user; 18 + if (!auth) return err('not authenticated'); 19 + 20 + try { 21 + const response = await auth.atcute.handler( 22 + '/xrpc/net.at-app.pet.ptr.nucleus.getPreferences', 23 + { 24 + method: 'GET', 25 + headers: { 26 + 'atproto-proxy': POCKET_PROXY 27 + } 28 + } 29 + ); 30 + 31 + if (!response.ok) { 32 + if (response.status === 400) return ok(null); 33 + const error = await response.text(); 34 + return err(`failed to get preferences: ${error}`); 35 + } 36 + 37 + const data = (await response.json()) as PrefsResponse; 38 + return ok(data.preferences); 39 + } catch (error) { 40 + return err(`failed to get preferences: ${error}`); 41 + } 42 + } 43 + 44 + export async function putPreferences( 45 + client: AtpClient, 46 + prefs: Preferences 47 + ): Promise<Result<null, string>> { 48 + const auth = client.user; 49 + if (!auth) return err('not authenticated'); 50 + 51 + try { 52 + const response = await auth.atcute.handler( 53 + '/xrpc/net.at-app.pet.ptr.nucleus.putPreferences', 54 + { 55 + method: 'POST', 56 + headers: { 57 + 'atproto-proxy': POCKET_PROXY, 58 + 'Content-Type': 'application/json' 59 + }, 60 + body: JSON.stringify({ preferences: prefs }) 61 + } 62 + ); 63 + 64 + if (!response.ok) { 65 + const error = await response.text(); 66 + return err(`failed to put preferences: ${error}`); 67 + } 68 + 69 + return ok(null); 70 + } catch (error) { 71 + return err(`failed to put preferences: ${error}`); 72 + } 73 + }
+3 -3
src/lib/oauth.ts
··· 8 8 logo_uri: `${domain}/favicon.png`, 9 9 redirect_uris: [`${domain}/`], 10 10 scope: 11 - 'atproto repo:*?action=create&action=update&action=delete rpc:com.atproto.repo.uploadBlob?aud=* blob:*/*', 11 + 'atproto repo:*?action=create&action=update&action=delete rpc:com.atproto.repo.uploadBlob?aud=* rpc:net.at-app.pet.ptr.nucleus.getPreferences?aud=* rpc:net.at-app.pet.ptr.nucleus.putPreferences?aud=* blob:*/*', 12 12 grant_types: ['authorization_code', 'refresh_token'], 13 13 response_types: ['code'], 14 14 token_endpoint_auth_method: 'none', ··· 19 19 export const redirectUri = `${domain}/`; 20 20 export const clientId = dev 21 21 ? `http://localhost` + 22 - `?redirect_uri=${encodeURIComponent(redirectUri)}` + 23 - `&scope=${encodeURIComponent(oauthMetadata.scope)}` 22 + `?redirect_uri=${encodeURIComponent(redirectUri)}` + 23 + `&scope=${encodeURIComponent(oauthMetadata.scope)}` 24 24 : oauthMetadata.client_id;
+105
src/lib/state.svelte.ts
··· 32 32 } from '$lib'; 33 33 import { Router } from './router.svelte'; 34 34 import type { Account } from './accounts'; 35 + import { 36 + getPreferences, 37 + putPreferences, 38 + type Preferences 39 + } from './at/pocket'; 35 40 36 41 export const notificationStream = writable<NotificationsStream | null>(null); 37 42 export const jetstream = writable<JetstreamSubscription | null>(null); ··· 242 247 243 248 export const viewClient = new AtpClient(); 244 249 export const clients = new SvelteMap<Did, AtpClient>(); 250 + 251 + export const accountPreferences = new SvelteMap<Did, Preferences>(); 252 + 253 + const PREFS_STORAGE_KEY = 'accountPreferences'; 254 + 255 + const loadLocalPreferences = (): Map<Did, Preferences> => { 256 + if (typeof localStorage === 'undefined') return new Map(); 257 + try { 258 + const stored = localStorage.getItem(PREFS_STORAGE_KEY); 259 + if (!stored) return new Map(); 260 + return new Map(Object.entries(JSON.parse(stored))) as Map<Did, Preferences>; 261 + } catch { 262 + return new Map(); 263 + } 264 + }; 265 + 266 + const saveLocalPreferences = () => { 267 + if (typeof localStorage === 'undefined') return; 268 + const obj = Object.fromEntries(accountPreferences.entries()); 269 + localStorage.setItem(PREFS_STORAGE_KEY, JSON.stringify(obj)); 270 + }; 271 + 272 + export const loadAccountPreferences = async (account: Account) => { 273 + const client = clients.get(account.did); 274 + if (!client) return; 275 + 276 + const localPrefs = loadLocalPreferences().get(account.did); 277 + const remoteResult = await getPreferences(client); 278 + 279 + if (!remoteResult.ok) { 280 + console.error('failed to load preferences from pocket:', remoteResult.error); 281 + if (localPrefs) accountPreferences.set(account.did, localPrefs); 282 + return; 283 + } 284 + 285 + const remotePrefs = remoteResult.value; 286 + 287 + if (!remotePrefs && !localPrefs) { 288 + return; 289 + } 290 + 291 + if (!remotePrefs && localPrefs) { 292 + accountPreferences.set(account.did, localPrefs); 293 + await putPreferences(client, localPrefs); 294 + return; 295 + } 296 + 297 + if (remotePrefs && !localPrefs) { 298 + accountPreferences.set(account.did, remotePrefs); 299 + saveLocalPreferences(); 300 + return; 301 + } 302 + 303 + // both exist - last modified wins 304 + const localTime = new Date(localPrefs!.updatedAt).getTime(); 305 + const remoteTime = new Date(remotePrefs!.updatedAt).getTime(); 306 + 307 + if (localTime > remoteTime) { 308 + accountPreferences.set(account.did, localPrefs!); 309 + await putPreferences(client, localPrefs!); 310 + } else { 311 + accountPreferences.set(account.did, remotePrefs!); 312 + saveLocalPreferences(); 313 + } 314 + }; 315 + 316 + export const setAccountPreferences = ( 317 + did: Did, 318 + partial: Partial<Omit<Preferences, 'updatedAt'>> 319 + ) => { 320 + const existing = accountPreferences.get(did) ?? { updatedAt: '' }; 321 + const updated: Preferences = { 322 + ...existing, 323 + ...partial, 324 + updatedAt: new Date().toISOString() 325 + }; 326 + 327 + accountPreferences.set(did, updated); 328 + saveLocalPreferences(); 329 + return updated; 330 + }; 331 + 332 + export const syncAccountPreferences = async (did: Did) => { 333 + const prefs = accountPreferences.get(did); 334 + if (!prefs) return; 335 + 336 + const client = clients.get(did); 337 + if (client) { 338 + const result = await putPreferences(client, prefs); 339 + if (!result.ok) console.error('failed to sync preferences to pocket:', result.error); 340 + } 341 + }; 342 + 343 + export const updateAccountPreferences = async ( 344 + did: Did, 345 + partial: Partial<Omit<Preferences, 'updatedAt'>> 346 + ) => { 347 + setAccountPreferences(did, partial); 348 + await syncAccountPreferences(did); 349 + }; 245 350 246 351 export const follows = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyGraphFollow.Main>>(); 247 352
+5 -2
src/lib/thread.ts
··· 15 15 depth: number; 16 16 newestTime: number; 17 17 isBlocked?: boolean; 18 + isMuted?: boolean; 18 19 }; 19 20 20 21 export type Thread = { ··· 27 28 export const buildThreads = ( 28 29 account: Did, 29 30 timeline: Set<ResourceUri>, 30 - posts: Map<Did, Map<ResourceUri, PostWithUri>> 31 + posts: Map<Did, Map<ResourceUri, PostWithUri>>, 32 + mutes: Did[], 31 33 ): Thread[] => { 32 34 const threadMap = new Map<ResourceUri, ThreadPost[]>(); 33 35 ··· 48 50 parentUri, 49 51 depth: 0, 50 52 newestTime: new Date(data.record.createdAt).getTime(), 51 - isBlocked: isBlockedBy(parsedUri.repo, account) 53 + isBlocked: isBlockedBy(parsedUri.repo, account), 54 + isMuted: mutes.includes(parsedUri.repo), 52 55 }; 53 56 54 57 if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
+8 -4
src/routes/[...catchall]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import PostComposer, { type State as PostComposerState } from '$components/PostComposer.svelte'; 3 - import AccountSelector from '$components/AccountSelector.svelte'; 3 + import AccountSwitcher from '$components/AccountSwitcher.svelte'; 4 4 import SettingsView from '$components/SettingsView.svelte'; 5 5 import NotificationsView from '$components/NotificationsView.svelte'; 6 6 import FollowingView from '$components/FollowingView.svelte'; ··· 21 21 addPosts, 22 22 addTimeline, 23 23 router, 24 - fetchInitial 24 + fetchInitial, 25 + loadAccountPreferences 25 26 } from '$lib/state.svelte'; 26 27 import { get } from 'svelte/store'; 27 28 import Icon from '@iconify/svelte'; ··· 144 145 } 145 146 if (!$accounts.some((account) => account.did === selectedDid)) selectedDid = $accounts[0].did; 146 147 // console.log('onMount selectedDid', selectedDid); 147 - Promise.all($accounts.map(loginAccount)).then(() => $accounts.forEach(fetchInitial)); 148 + Promise.all($accounts.map(loginAccount)).then(() => { 149 + $accounts.forEach(loadAccountPreferences); 150 + $accounts.forEach(fetchInitial); 151 + }); 148 152 } else { 149 153 selectedDid = null; 150 154 } ··· 274 278 <!-- composer and error disclaimer (above thread list, not scrollable) --> 275 279 <div class="footer-border-bg rounded-sm p-0.5"> 276 280 <div class="footer-bg flex gap-2 rounded-sm p-1.5"> 277 - <AccountSelector 281 + <AccountSwitcher 278 282 client={viewClient} 279 283 accounts={$accounts} 280 284 bind:selectedDid