implement mutes
12345678910234567148917181011121321222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990141516939495969798991001011021031041051061071081091718192021222324252627282930313211111233113114115116117118119120121122123124125126127128129130131132133134135136341383536373814014114214314414514614714814915039404142434415115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219345195196197198199200201202203464720548<script lang="ts"> import { generateColorForDid, loggingIn, type Account } from '$lib/accounts'; import { AtpClient, resolveHandle } from '$lib/at/client.svelte'; import type { Handle } from '@atcute/lexicons'; import ProfilePicture from './ProfilePicture.svelte'; import PfpPlaceholder from './PfpPlaceholder.svelte'; import Popup from './Popup.svelte'; import Dropdown from './Dropdown.svelte'; import { flow } from '$lib/at/oauth'; import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax'; import { generateColorForDid, type Account } from '$lib/accounts'; import type { AtprotoDid } from '@atcute/lexicons/syntax'; import Icon from '@iconify/svelte'; import type { Snippet } from 'svelte';
interface Props { client: AtpClient; accounts: Array<Account>; selectedDid?: AtprotoDid | null; onAccountSelected: (did: AtprotoDid) => void; onLogout: (did: AtprotoDid) => void; onSelect: (did: AtprotoDid) => void; accountActions?: Snippet<[Account]>; }
let { client, accounts = [], selectedDid = $bindable(null), onAccountSelected, onLogout }: Props = $props();
let isDropdownOpen = $state(false); let isLoginModalOpen = $state(false); let loginHandle = $state(''); let loginError = $state(''); let isLoggingIn = $state(false);
const toggleDropdown = () => (isDropdownOpen = !isDropdownOpen); const closeDropdown = () => (isDropdownOpen = false);
const selectAccount = (did: AtprotoDid) => { onAccountSelected(did); closeDropdown(); };
const openLoginModal = () => { isLoginModalOpen = true; closeDropdown(); loginHandle = ''; loginError = ''; // HACK: i hate this but it works so it doesnt really matter setTimeout(() => document.getElementById('handle')?.focus(), 100); };
const closeLoginModal = () => { document.getElementById('handle')?.blur(); isLoginModalOpen = false; loginHandle = ''; loginError = ''; };
const handleLogin = async () => { try { if (!loginHandle) throw 'please enter handle';
isLoggingIn = true; loginError = '';
let handle: Handle; if (isHandle(loginHandle)) handle = loginHandle; else throw 'handle is invalid';
let did = await resolveHandle(handle); if (!did.ok) throw did.error;
await initiateLogin(did.value, handle); } catch (error) { loginError = `login failed: ${error}`; loggingIn.set(null); } finally { isLoggingIn = false; } };
const initiateLogin = async (did: AtprotoDid, handle: Handle | null) => { loggingIn.set({ did, handle }); const result = await flow.start(handle ?? did); if (!result.ok) throw result.error; };
const handleKeydown = (event: KeyboardEvent) => { if (event.key === 'Enter' && !isLoggingIn) handleLogin(); }; let { accounts = [], selectedDid = $bindable(null), onSelect, accountActions }: Props = $props();</script>
<Dropdown class="min-w-52 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl" bind:isOpen={isDropdownOpen} placement="top-start"> {#snippet trigger()} <button onclick={toggleDropdown} 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" > {#if selectedDid} <ProfilePicture {client} did={selectedDid} size={13} /> {:else} <PfpPlaceholder color="var(--nucleus-accent)" size={13} /> {/if} </button> {/snippet}{#if accounts.length > 0} <div class="p-2"> {#each accounts as account (account.did)} {@const color = generateColorForDid(account.did)} <button onclick={() => onSelect(account.did)} class=" group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all {account.did === selectedDid ? 'shadow-lg' : ''} " style="color: {color}; background: {account.did === selectedDid ? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))` : 'transparent'};" > <span>@{account.handle ?? account.did.slice(0, 16)}</span>
{#if accounts.length > 0} <div class="p-2"> <div class="grow"></div> {#each accounts as account (account.did)} {@const color = generateColorForDid(account.did)} {#snippet action(name: string, icon: string, onClick: () => void)} <!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions --> <div title={name} onclick={onClick} class="hidden text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md" > <Icon class="h-5 w-5" {icon} /> </div> {/snippet} <button onclick={() => selectAccount(account.did)} class=" group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all {account.did === selectedDid ? 'shadow-lg' : ''} " style="color: {color}; background: {account.did === selectedDid ? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))` : 'transparent'};" > <span>@{account.handle}</span>
<div class="grow"></div> {#if accountActions} {@render accountActions(account)} {/if}
{@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () => initiateLogin(account.did, account.handle) )} {@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))}
{#if account.did === selectedDid} <Icon icon="heroicons:check-16-solid" class="h-5 w-5 scale-125 text-(--nucleus-accent) group-hover:hidden" /> {/if} {#if account.did === selectedDid} <Icon icon="heroicons:check-16-solid" class="h-5 w-5 scale-125 text-(--nucleus-accent) group-hover:hidden" /> {/if} </button> {/each} </div> <div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> {/if} <button onclick={openLoginModal} 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]" > <Icon class="h-5 w-5 scale-[130%]" icon="heroicons:plus-16-solid" /> <span>add account</span> </button></Dropdown>
<Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account"> <!-- svelte-ignore a11y_no_static_element_interactions --> <div class="space-y-2" onkeydown={handleKeydown}> <div> <label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> account handle </label> <input id="handle" type="text" bind:value={loginHandle} placeholder="example.bsky.social" class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3" disabled={isLoggingIn} /> </div>
{#if loginError} <div class="error-disclaimer"> <p> <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" /> {loginError} </p> </div> {/if}
<div class="flex gap-3 pt-3"> <button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}> cancel </button> <button onclick={handleLogin} class="flex-1 action-button border-transparent text-(--nucleus-fg)" style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));" disabled={isLoggingIn} > {isLoggingIn ? 'logging in...' : 'login'} </button> </div> {/each} </div></Popup>{/if}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184<script lang="ts"> import { loggingIn, type Account } from '$lib/accounts'; import { AtpClient, resolveHandle } from '$lib/at/client.svelte'; import type { Handle } from '@atcute/lexicons'; import ProfilePicture from './ProfilePicture.svelte'; import PfpPlaceholder from './PfpPlaceholder.svelte'; import Popup from './Popup.svelte'; import Dropdown from './Dropdown.svelte'; import AccountSelector from './AccountSelector.svelte'; import { flow } from '$lib/at/oauth'; import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax'; import Icon from '@iconify/svelte';
interface Props { client: AtpClient; accounts: Array<Account>; selectedDid?: AtprotoDid | null; onAccountSelected: (did: AtprotoDid) => void; onLogout: (did: AtprotoDid) => void; }
let { client, accounts = [], selectedDid = $bindable(null), onAccountSelected, onLogout }: Props = $props();
let isDropdownOpen = $state(false); let isLoginModalOpen = $state(false); let loginHandle = $state(''); let loginError = $state(''); let isLoggingIn = $state(false);
const toggleDropdown = () => (isDropdownOpen = !isDropdownOpen); const closeDropdown = () => (isDropdownOpen = false);
const selectAccount = (did: AtprotoDid) => { onAccountSelected(did); closeDropdown(); };
const openLoginModal = () => { isLoginModalOpen = true; closeDropdown(); loginHandle = ''; loginError = ''; // HACK: i hate this but it works so it doesnt really matter setTimeout(() => document.getElementById('handle')?.focus(), 100); };
const closeLoginModal = () => { document.getElementById('handle')?.blur(); isLoginModalOpen = false; loginHandle = ''; loginError = ''; };
const handleLogin = async () => { try { if (!loginHandle) throw 'please enter handle';
isLoggingIn = true; loginError = '';
let handle: Handle; if (isHandle(loginHandle)) handle = loginHandle; else throw 'handle is invalid';
let did = await resolveHandle(handle); if (!did.ok) throw did.error;
await initiateLogin(did.value, handle); } catch (error) { loginError = `login failed: ${error}`; loggingIn.set(null); } finally { isLoggingIn = false; } };
const initiateLogin = async (did: AtprotoDid, handle: Handle | null) => { loggingIn.set({ did, handle }); const result = await flow.start(handle ?? did); if (!result.ok) throw result.error; };
const handleKeydown = (event: KeyboardEvent) => { if (event.key === 'Enter' && !isLoggingIn) handleLogin(); };</script>
{#snippet action(name: string, icon: string, onClick: () => void)} <!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions --> <div title={name} onclick={onClick} class="hidden text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md" > <Icon class="h-5 w-5" {icon} /> </div>{/snippet}
<Dropdown class="min-w-52 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl" bind:isOpen={isDropdownOpen} placement="top-start"> {#snippet trigger()} <button onclick={toggleDropdown} 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" > {#if selectedDid} <ProfilePicture {client} did={selectedDid} size={13} /> {:else} <PfpPlaceholder color="var(--nucleus-accent)" size={13} /> {/if} </button> {/snippet}
<AccountSelector {accounts} {selectedDid} onSelect={selectAccount}> {#snippet accountActions(account)} {@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () => initiateLogin(account.did, account.handle) )} {@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))} {/snippet} </AccountSelector> {#if accounts.length > 0} <div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> {/if} <button onclick={openLoginModal} 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]" > <Icon class="h-5 w-5 scale-[130%]" icon="heroicons:plus-16-solid" /> <span>add account</span> </button></Dropdown>
<Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account"> <!-- svelte-ignore a11y_no_static_element_interactions --> <div class="space-y-2" onkeydown={handleKeydown}> <div> <label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> account handle </label> <input id="handle" type="text" bind:value={loginHandle} placeholder="example.bsky.social" class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3" disabled={isLoggingIn} /> </div>
{#if loginError} <div class="error-disclaimer"> <p> <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" /> {loginError} </p> </div> {/if}
<div class="flex gap-3 pt-3"> <button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}> cancel </button> <button onclick={handleLogin} class="flex-1 action-button border-transparent text-(--nucleus-fg)" style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));" disabled={isLoggingIn} > {isLoggingIn ? 'logging in...' : 'login'} </button> </div> </div></Popup>50 unmodified lines5152535455565758595 unmodified lines65666768686970717273747576777877798081828384858486878889909192119 unmodified linesExpand all21221321421521321621721521821922021822122222322422522629 unmodified lines256257258259256260261258262263264265266267268269266270271272273274241 unmodified linesExpand all50 unmodified lines onQuote?: (quote: PostWithUri) => void; onReply?: (reply: PostWithUri) => void; cornerFragment?: Snippet; isBlocked?: boolean; isMuted?: boolean; }
const { client,5 unmodified lines onQuote, onReply, isOnPostComposer = false /* replyBacklinks */, cornerFragment, isBlocked = false isBlocked = false, isMuted = false }: Props = $props();
const user = $derived(client.user); const isLoggedInUser = $derived($accounts.some((acc) => acc.did === did));
const aturi = $derived(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey })); const color = $derived(generateColorForDid(did));
let expandBlocked = $state(false); let expandDisallowed = $state(false); const blockRel = $derived( user && !isOnPostComposer ? getBlockRelationship(user.did, did) : { userBlocked: false, blockedByTarget: false } ); const showAsBlocked = $derived( (isBlocked || blockRel.userBlocked || blockRel.blockedByTarget) && !expandBlocked (isBlocked || blockRel.userBlocked || blockRel.blockedByTarget) && !expandDisallowed ); const showAsMuted = $derived(isMuted && !expandDisallowed);
let handle: Handle = $state(handles.get(did) ?? 'handle.invalid'); onMount(() => { resolveDidDoc(did).then((res) => {119 unmodified linesExpand all loading... {:then post} {#if post.ok} {@const record = post.value.record} {#if showAsBlocked} {#if showAsBlocked || showAsMuted} <button onclick={() => (expandBlocked = true)} onclick={() => (expandDisallowed = true)} class="text-left hover:cursor-pointer hover:underline" > <span style="color: {color};">post from blocked user</span> (click to show) <span style="color: {color};">post from {showAsBlocked ? 'blocked' : 'muted'} user</span > (click to show) </button> {:else} <!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions -->29 unmodified lines </div> {:then post} {#if post.ok} {@const record = post.value.record} {#if showAsBlocked} {#if showAsBlocked || showAsMuted} <button onclick={() => (expandBlocked = true)} onclick={() => (expandDisallowed = true)} class=" group w-full rounded-sm border-2 p-3 text-left shadow-lg backdrop-blur-sm transition-all hover:border-(--nucleus-accent) " style="background: {color}18; border-color: {color}66;" > <div class="flex items-center gap-2"> <span class="opacity-80">post from blocked user</span> <span class="opacity-80">post from {showAsBlocked ? 'blocked' : 'muted'} user</span> <span class="text-sm opacity-60">(click to show)</span> </div> </button> {:else}241 unmodified linesExpand all1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253<script lang="ts"> import { generateColorForDid } from '$lib/accounts'; import { resolveDidDoc } from '$lib/at/client.svelte'; import { handles, router } from '$lib/state.svelte'; import { map } from '$lib/result'; import type { Did } from '@atcute/lexicons'; import Icon from '@iconify/svelte';
interface Props { style: string; did: Did; onRemove: () => void; }
let { style, did, onRemove }: Props = $props();
const handle = $derived(handles.get(did)); const color = $derived(generateColorForDid(did));
$effect(() => { if (!handles.has(did)) { resolveDidDoc(did).then((r) => { if (r.ok) handles.set(did, r.value.handle); else handles.set(did, 'handle.invalid'); }); } });
const goToProfile = () => router.navigate(`/profile/${did}`);</script>
<div {style} class="box-border w-full py-0.5"> <!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions --> <div 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" style={`--post-color: ${color};`} > <span onclick={goToProfile} class="semibold flex-1 truncate text-sm transition-colors group-hover:text-(--post-color)" style="color: {color}" > {handle ? `@${handle}` : did} </span> <button onclick={onRemove} class="text-sm text-red-400 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-500" > <Icon icon="heroicons:x-mark-16-solid" width="24" /> </button> </div></div>1 unmodified line234566789101112131415161720 unmodified lines383940414243444546474849505152535455565788 unmodified lines14614714814915015115215315415515615715811 unmodified lines1 unmodified line import type { AtpClient } from '$lib/at/client.svelte'; import { parseCanonicalResourceUri, type Did } from '@atcute/lexicons'; import Dropdown from './Dropdown.svelte'; import Icon from '@iconify/svelte'; import { createBlock, deleteBlock, follows } from '$lib/state.svelte'; import { accountPreferences, createBlock, deleteBlock, follows, setAccountPreferences, updateAccountPreferences } from '$lib/state.svelte'; import { generateColorForDid } from '$lib/accounts'; import { now as tidNow } from '@atcute/tid'; import type { AppBskyGraphFollow } from '@atcute/bluesky'; import { toCanonicalUri } from '$lib';20 unmodified lines ? Array.from(followsMap.entries()).find(([, follow]) => follow.subject === targetDid) : undefined );
const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); const mutes = $derived(currentPrefs?.mutes ?? []); const muted = $derived(mutes.includes(targetDid));
const handleMute = async () => { if (!userDid || !client.user) return;
if (muted) await updateAccountPreferences(userDid, { mutes: mutes.filter((m) => m !== targetDid) }); else await updateAccountPreferences(userDid, { mutes: [...mutes, targetDid] }); };
const handleFollow = async () => { if (!userDid || !client.user) return;
if (follow) {88 unmodified lines userBlocked ? 'heroicons:eye-20-solid' : 'heroicons:eye-slash-20-solid', userBlocked ? 'unblock' : 'block', handleBlock )} {@render dropdownItem( muted ? 'heroicons:speaker-wave-20-solid' : 'heroicons:speaker-x-mark-20-solid', muted ? 'unmute' : 'mute', handleMute )}
{#snippet trigger()} <button class="rounded-sm p-1.5 transition-all hover:bg-white/10"11 unmodified lines3 unmodified lines456788910111213141516171819202122232425262724 unmodified lines52535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911090 unmodified lines2012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412421391401411421432432442452462472482492502512522532541501511521531542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962974 unmodified lines30230330430516930630730830931011 unmodified lines3 unmodified lines import ColorPicker from 'svelte-awesome-color-picker'; import Tabs from './Tabs.svelte'; import { portal } from 'svelte-portal'; import { cache } from '$lib/cache'; import { router } from '$lib/state.svelte'; import { router, clients, accountPreferences, setAccountPreferences, syncAccountPreferences, loadAccountPreferences } from '$lib/state.svelte'; import { accounts as accountsStore, generateColorForDid } from '$lib/accounts'; import AccountSelector from './AccountSelector.svelte'; import Dropdown from './Dropdown.svelte'; import MutedAccountItem from './MutedAccountItem.svelte'; import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; import type { Did } from '@atcute/lexicons'; import type { AtprotoDid } from '@atcute/lexicons/syntax'; import Icon from '@iconify/svelte';
interface Props { tab: string; }24 unmodified lines alert('cache cleared!'); };
const onTabChange = (tab: string) => router.replace(`/settings/${tab}`);
let selectedAccount: AtprotoDid | null = $state(null); let newMuteInput = $state(''); let syncStatus = $state<'syncing' | 'synced' | null>(null); let isAccountDropdownOpen = $state(false);
const accounts = $derived($accountsStore.filter((a) => clients.has(a.did))); const selectedAccountData = $derived(accounts.find((a) => a.did === selectedAccount)); const currentPrefs = $derived(selectedAccount ? accountPreferences.get(selectedAccount) : null); const mutes = $derived(currentPrefs?.mutes ?? []);
$effect(() => { if (accounts.length > 0 && !selectedAccount) { selectedAccount = accounts[0].did; } });
let syncDebounceTimer: ReturnType<typeof setTimeout> | null = null; const SYNC_DEBOUNCE_MS = 1000;
const scheduleSyncFor = (did: AtprotoDid) => { if (syncDebounceTimer) clearTimeout(syncDebounceTimer); syncDebounceTimer = setTimeout(async () => { syncStatus = 'syncing'; await syncAccountPreferences(did); syncStatus = 'synced'; setTimeout(() => (syncStatus = null), 2000); }, SYNC_DEBOUNCE_MS); };
const handleAddMute = () => { if (!selectedAccount || !newMuteInput.trim()) return; const did = newMuteInput.trim() as Did; setAccountPreferences(selectedAccount, { mutes: [...mutes, did] }); scheduleSyncFor(selectedAccount); newMuteInput = ''; };
const handleRemoveMute = (did: Did) => { if (!selectedAccount) return; setAccountPreferences(selectedAccount, { mutes: mutes.filter((m) => m !== did) }); scheduleSyncFor(selectedAccount); };
const handleReload = async () => { if (!selectedAccount) return; syncStatus = 'syncing'; await loadAccountPreferences({ did: selectedAccount, handle: null }); syncStatus = 'synced'; setTimeout(() => (syncStatus = null), 2000); };</script>
{#snippet advancedTab()} <div class="space-y-3 p-4">90 unmodified lines <div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div> <div class="h-1 w-9.5 rounded-full bg-(--nucleus-accent2)"></div> </div> </div> <div class="flex items-center gap-2"> {#if tab === 'moderation'} {#if syncStatus} <span class="text-xs opacity-70">{syncStatus}</span> {/if} <Dropdown class="min-w-48 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl" bind:isOpen={isAccountDropdownOpen} placement="bottom-end" > {#snippet trigger()} <button onclick={() => (isAccountDropdownOpen = !isAccountDropdownOpen)} class="flex action-button items-center gap-1.5 text-sm" style="color: {selectedAccountData ? generateColorForDid(selectedAccountData.did) : 'inherit'}" > <span>@{selectedAccountData?.handle ?? selectedAccount?.slice(0, 12)}</span> <span class="opacity-50">▾</span> </button> {/snippet} <AccountSelector {accounts} selectedDid={selectedAccount} onSelect={(did) => { selectedAccount = did; isAccountDropdownOpen = false; }} /> </Dropdown> <button onclick={handleReload} class="action-button p-2" title="reload from pocket"> <Icon class={syncStatus === 'syncing' ? 'animate-spin' : ''} icon="heroicons:arrow-path-16-solid" width="20" /> </button> {#if hasReloadChanges} <button onclick={handleSave} class="action-button animate-pulse shadow-lg"> save & reload </button> {/if} {:else if hasReloadChanges} <button onclick={handleSave} class="action-button animate-pulse shadow-lg"> save & reload </button> {/if} </div> </div>
<div class="flex-1"> {#if tab === 'advanced'} {@render advancedTab()} {:else if tab === 'moderation'} <div class="p-4"> <div class="flex h-64 items-center justify-center"> <div class="text-center"> <div class="mb-4 text-6xl opacity-50">🚧</div> <h3 class="text-xl font-bold opacity-80">todo</h3> <div class="space-y-4 p-4"> <div> <h3 class="header">muted accounts</h3> <div class="borders space-y-2"> <div class="flex gap-2"> <input type="text" bind:value={newMuteInput} placeholder="did:plc:..." class="single-line-input flex-1" /> <button onclick={handleAddMute} class="action-button">add</button> </div> {#if mutes.length > 0} <div class="h-fit"> <VirtualList height={Math.min(mutes.length, 6) * 44} itemCount={mutes.length} itemSize={44} > {#snippet item({ index, style }: { index: number; style: string })} <MutedAccountItem {style} did={mutes[index]} onRemove={() => handleRemoveMute(mutes[index])} /> {/snippet} </VirtualList> </div> {:else} <p class="py-2 text-center text-sm opacity-50">no muted accounts</p> {/if} </div> </div> {#if currentPrefs} <p class="text-xs opacity-50"> last synced: {new Date(currentPrefs.updatedAt).toLocaleString()} </p> {/if} </div> {:else if tab === 'style'} {@render styleTab()} {/if}4 unmodified lines class=" 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)] " > <Tabs tabs={['style', 'moderation', 'advanced']} activeTab={tab} {onTabChange} /> <Tabs tabs={['moderation', 'style', 'advanced']} activeTab={tab} {onTabChange} /> </div></div>
<style>11 unmodified lines9 unmodified lines101112131414151617181922 unmodified lines42434445464748495051485253545556182 unmodified linesExpand all9 unmodified lines postCursors, fetchTimeline, allPosts, timelines, fetchInteractionsToTimelineEnd fetchInteractionsToTimelineEnd, accountPreferences } from '$lib/state.svelte'; import Icon from '@iconify/svelte'; import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; import type { Did } from '@atcute/lexicons/syntax';22 unmodified lines
const userDid = $derived(client?.user?.did); const did = $derived(targetDid ?? userDid);
const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); const mutes = $derived(currentPrefs?.mutes ?? []);
const threads = $derived( // todo: apply showReplies here filterThreads( did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts) : [], did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts, mutes) : [], $accounts, { viewOwnPosts } ) );182 unmodified linesExpand all6 unmodified lines789101111121314152 unmodified lines1819202122232223246 unmodified lines client_uri: domain, logo_uri: `${domain}/favicon.png`, redirect_uris: [`${domain}/`], scope: 'atproto repo:*?action=create&action=update&action=delete rpc:com.atproto.repo.uploadBlob?aud=* blob:*/*', '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:*/*', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', application_type: 'web',2 unmodified lines
export const redirectUri = `${domain}/`;export const clientId = dev ? `http://localhost` + `?redirect_uri=${encodeURIComponent(redirectUri)}` + `&scope=${encodeURIComponent(oauthMetadata.scope)}` `?redirect_uri=${encodeURIComponent(redirectUri)}` + `&scope=${encodeURIComponent(oauthMetadata.scope)}` : oauthMetadata.client_id;30 unmodified lines31323334353637383940414243203 unmodified linesExpand all247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354416 unmodified linesExpand all30 unmodified lines toCanonicalUri} from '$lib';import { Router } from './router.svelte';import type { Account } from './accounts';import { getPreferences, putPreferences, type Preferences} from './at/pocket';
export const notificationStream = writable<NotificationsStream | null>(null);export const jetstream = writable<JetstreamSubscription | null>(null);
203 unmodified linesExpand all
export const viewClient = new AtpClient();export const clients = new SvelteMap<Did, AtpClient>();
export const accountPreferences = new SvelteMap<Did, Preferences>();
const PREFS_STORAGE_KEY = 'accountPreferences';
const loadLocalPreferences = (): Map<Did, Preferences> => { if (typeof localStorage === 'undefined') return new Map(); try { const stored = localStorage.getItem(PREFS_STORAGE_KEY); if (!stored) return new Map(); return new Map(Object.entries(JSON.parse(stored))) as Map<Did, Preferences>; } catch { return new Map(); }};
const saveLocalPreferences = () => { if (typeof localStorage === 'undefined') return; const obj = Object.fromEntries(accountPreferences.entries()); localStorage.setItem(PREFS_STORAGE_KEY, JSON.stringify(obj));};
export const loadAccountPreferences = async (account: Account) => { const client = clients.get(account.did); if (!client) return;
const localPrefs = loadLocalPreferences().get(account.did); const remoteResult = await getPreferences(client);
if (!remoteResult.ok) { console.error('failed to load preferences from pocket:', remoteResult.error); if (localPrefs) accountPreferences.set(account.did, localPrefs); return; }
const remotePrefs = remoteResult.value;
if (!remotePrefs && !localPrefs) { return; }
if (!remotePrefs && localPrefs) { accountPreferences.set(account.did, localPrefs); await putPreferences(client, localPrefs); return; }
if (remotePrefs && !localPrefs) { accountPreferences.set(account.did, remotePrefs); saveLocalPreferences(); return; }
// both exist - last modified wins const localTime = new Date(localPrefs!.updatedAt).getTime(); const remoteTime = new Date(remotePrefs!.updatedAt).getTime();
if (localTime > remoteTime) { accountPreferences.set(account.did, localPrefs!); await putPreferences(client, localPrefs!); } else { accountPreferences.set(account.did, remotePrefs!); saveLocalPreferences(); }};
export const setAccountPreferences = ( did: Did, partial: Partial<Omit<Preferences, 'updatedAt'>>) => { const existing = accountPreferences.get(did) ?? { updatedAt: '' }; const updated: Preferences = { ...existing, ...partial, updatedAt: new Date().toISOString() };
accountPreferences.set(did, updated); saveLocalPreferences(); return updated;};
export const syncAccountPreferences = async (did: Did) => { const prefs = accountPreferences.get(did); if (!prefs) return;
const client = clients.get(did); if (client) { const result = await putPreferences(client, prefs); if (!result.ok) console.error('failed to sync preferences to pocket:', result.error); }};
export const updateAccountPreferences = async ( did: Did, partial: Partial<Omit<Preferences, 'updatedAt'>>) => { setAccountPreferences(did, partial); await syncAccountPreferences(did);};
export const follows = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyGraphFollow.Main>>();
export const addFollows = ( did: Did,416 unmodified linesExpand all