diff --git a/src/components/AccountSelector.svelte b/src/components/AccountSelector.svelte index b189465..df22fae 100644 --- a/src/components/AccountSelector.svelte +++ b/src/components/AccountSelector.svelte @@ -1,205 +1,48 @@ - - {#snippet trigger()} - - {/snippet} - - {#if accounts.length > 0} -
- {#each accounts as account (account.did)} - {@const color = generateColorForDid(account.did)} - {#snippet action(name: string, icon: string, onClick: () => void)} - - - - {/snippet} - - {/each} -
-
- {/if} - -
+{#if accounts.length > 0} +
+ {#each accounts as account (account.did)} + {@const color = generateColorForDid(account.did)} + - -
+ {/each} - +{/if} diff --git a/src/components/AccountSwitcher.svelte b/src/components/AccountSwitcher.svelte new file mode 100644 index 0000000..56d3d98 --- /dev/null +++ b/src/components/AccountSwitcher.svelte @@ -0,0 +1,184 @@ + + +{#snippet action(name: string, icon: string, onClick: () => void)} + + + +{/snippet} + + + {#snippet trigger()} + + {/snippet} + + + {#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} + + {#if accounts.length > 0} +
+ {/if} + +
+ + + +
+
+ + +
+ + {#if loginError} +
+

+ + {loginError} +

+
+ {/if} + +
+ + +
+
+
diff --git a/src/components/BskyPost.svelte b/src/components/BskyPost.svelte index 2e5ad22..833521f 100644 --- a/src/components/BskyPost.svelte +++ b/src/components/BskyPost.svelte @@ -52,6 +52,7 @@ onReply?: (reply: PostWithUri) => void; cornerFragment?: Snippet; isBlocked?: boolean; + isMuted?: boolean; } const { @@ -65,7 +66,8 @@ onReply, isOnPostComposer = false /* replyBacklinks */, cornerFragment, - isBlocked = false + isBlocked = false, + isMuted = false }: Props = $props(); const user = $derived(client.user); @@ -74,15 +76,16 @@ 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(() => { @@ -210,12 +213,13 @@ {:then post} {#if post.ok} {@const record = post.value.record} - {#if showAsBlocked} + {#if showAsBlocked || showAsMuted} {:else} @@ -253,9 +257,9 @@ {:then post} {#if post.ok} {@const record = post.value.record} - {#if showAsBlocked} + {#if showAsBlocked || showAsMuted} diff --git a/src/components/MutedAccountItem.svelte b/src/components/MutedAccountItem.svelte new file mode 100644 index 0000000..063ed5c --- /dev/null +++ b/src/components/MutedAccountItem.svelte @@ -0,0 +1,53 @@ + + +
+ + +
+ + {handle ? `@${handle}` : did} + + +
+
diff --git a/src/components/ProfileActions.svelte b/src/components/ProfileActions.svelte index c453bff..5d27bb8 100644 --- a/src/components/ProfileActions.svelte +++ b/src/components/ProfileActions.svelte @@ -3,7 +3,14 @@ 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'; @@ -32,6 +39,18 @@ : 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; @@ -128,6 +147,11 @@ userBlocked ? 'unblock' : 'block', handleBlock )} + {@render dropdownItem( + muted ? 'heroicons:speaker-wave-20-solid' : 'heroicons:speaker-x-mark-20-solid', + muted ? 'unmute' : 'mute', + handleMute + )} {#snippet trigger()} - {/if} +
+ {#if tab === 'moderation'} + {#if syncStatus} + {syncStatus} + {/if} + + {#snippet trigger()} + + {/snippet} + { + selectedAccount = did; + isAccountDropdownOpen = false; + }} + /> + + + {:else if hasReloadChanges} + + {/if} +
{#if tab === 'advanced'} {@render advancedTab()} {:else if tab === 'moderation'} -
-
-
-
🚧
-

todo

+
+
+

muted accounts

+
+
+ + +
+ {#if mutes.length > 0} +
+ + {#snippet item({ index, style }: { index: number; style: string })} + handleRemoveMute(mutes[index])} + /> + {/snippet} + +
+ {:else} +

no muted accounts

+ {/if}
+ {#if currentPrefs} +

+ last synced: {new Date(currentPrefs.updatedAt).toLocaleString()} +

+ {/if}
{:else if tab === 'style'} {@render styleTab()} @@ -166,7 +303,7 @@ 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)] " > - +
diff --git a/src/components/TimelineView.svelte b/src/components/TimelineView.svelte index 6f7b480..8bdfa01 100644 --- a/src/components/TimelineView.svelte +++ b/src/components/TimelineView.svelte @@ -11,7 +11,8 @@ fetchTimeline, allPosts, timelines, - fetchInteractionsToTimelineEnd + fetchInteractionsToTimelineEnd, + accountPreferences } from '$lib/state.svelte'; import Icon from '@iconify/svelte'; import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; @@ -42,10 +43,13 @@ 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 } ) diff --git a/src/lib/at/pocket.ts b/src/lib/at/pocket.ts new file mode 100644 index 0000000..5d13278 --- /dev/null +++ b/src/lib/at/pocket.ts @@ -0,0 +1,73 @@ +import type { Did } from '@atcute/lexicons/syntax'; +import type { AtpClient } from './client.svelte'; +import { err, ok, type Result } from '$lib/result'; + +const POCKET_PROXY = 'did:web:pocket.at-app.net#pocket_prefs'; + +export type Preferences = { + mutes?: Did[]; + updatedAt: string; +}; + +type PrefsResponse = { + preferences: Preferences; +}; + +export async function getPreferences(client: AtpClient): Promise> { + const auth = client.user; + if (!auth) return err('not authenticated'); + + try { + const response = await auth.atcute.handler( + '/xrpc/net.at-app.pet.ptr.nucleus.getPreferences', + { + method: 'GET', + headers: { + 'atproto-proxy': POCKET_PROXY + } + } + ); + + if (!response.ok) { + if (response.status === 400) return ok(null); + const error = await response.text(); + return err(`failed to get preferences: ${error}`); + } + + const data = (await response.json()) as PrefsResponse; + return ok(data.preferences); + } catch (error) { + return err(`failed to get preferences: ${error}`); + } +} + +export async function putPreferences( + client: AtpClient, + prefs: Preferences +): Promise> { + const auth = client.user; + if (!auth) return err('not authenticated'); + + try { + const response = await auth.atcute.handler( + '/xrpc/net.at-app.pet.ptr.nucleus.putPreferences', + { + method: 'POST', + headers: { + 'atproto-proxy': POCKET_PROXY, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ preferences: prefs }) + } + ); + + if (!response.ok) { + const error = await response.text(); + return err(`failed to put preferences: ${error}`); + } + + return ok(null); + } catch (error) { + return err(`failed to put preferences: ${error}`); + } +} diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 91d2ca9..1b01954 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -8,7 +8,7 @@ export const oauthMetadata = { 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', @@ -19,6 +19,6 @@ export const oauthMetadata = { 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; diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts index e4be88c..81e1d49 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -32,6 +32,11 @@ import { } from '$lib'; import { Router } from './router.svelte'; import type { Account } from './accounts'; +import { + getPreferences, + putPreferences, + type Preferences +} from './at/pocket'; export const notificationStream = writable(null); export const jetstream = writable(null); @@ -243,6 +248,106 @@ export const pulsingPostId = writable(null); export const viewClient = new AtpClient(); export const clients = new SvelteMap(); +export const accountPreferences = new SvelteMap(); + +const PREFS_STORAGE_KEY = 'accountPreferences'; + +const loadLocalPreferences = (): Map => { + 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; + } 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> +) => { + 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> +) => { + setAccountPreferences(did, partial); + await syncAccountPreferences(did); +}; + export const follows = new SvelteMap>(); export const addFollows = ( diff --git a/src/lib/thread.ts b/src/lib/thread.ts index 69e000c..1cc1b4f 100644 --- a/src/lib/thread.ts +++ b/src/lib/thread.ts @@ -15,6 +15,7 @@ export type ThreadPost = { depth: number; newestTime: number; isBlocked?: boolean; + isMuted?: boolean; }; export type Thread = { @@ -27,7 +28,8 @@ export type Thread = { export const buildThreads = ( account: Did, timeline: Set, - posts: Map> + posts: Map>, + mutes: Did[], ): Thread[] => { const threadMap = new Map(); @@ -48,7 +50,8 @@ export const buildThreads = ( parentUri, depth: 0, newestTime: new Date(data.record.createdAt).getTime(), - isBlocked: isBlockedBy(parsedUri.repo, account) + isBlocked: isBlockedBy(parsedUri.repo, account), + isMuted: mutes.includes(parsedUri.repo), }; if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); diff --git a/src/routes/[...catchall]/+page.svelte b/src/routes/[...catchall]/+page.svelte index cc55534..9e12f0a 100644 --- a/src/routes/[...catchall]/+page.svelte +++ b/src/routes/[...catchall]/+page.svelte @@ -1,6 +1,6 @@