From dd7ce11095c705524e5415a463af62882fc591f0 Mon Sep 17 00:00:00 2001 From: Tim Disney Date: Mon, 23 Feb 2026 14:50:52 -0800 Subject: [PATCH] show standard.site subs in add feeds --- src/lib/components/AddFeedModal.svelte | 168 ++++++----- .../components/sidebar/SidebarAddFeed.svelte | 283 +++++++++++++++++- 2 files changed, 382 insertions(+), 69 deletions(-) diff --git a/src/lib/components/AddFeedModal.svelte b/src/lib/components/AddFeedModal.svelte index f72f91d..02cc0e3 100644 --- a/src/lib/components/AddFeedModal.svelte +++ b/src/lib/components/AddFeedModal.svelte @@ -52,7 +52,6 @@ let sharesSelected = $state(false); let freestandingDocsSelected = $state(false); let isSubscribing = $state(false); - let isStandardSub = $state(false); // Standard subscriptions state type StandardSub = { @@ -133,7 +132,6 @@ sharesSelected = false; freestandingDocsSelected = false; isSubscribing = false; - isStandardSub = false; if (searchTimeout) clearTimeout(searchTimeout); } @@ -283,7 +281,6 @@ freestandingDocsSelected = false; discoveredFeeds = []; error = null; - isStandardSub = false; } function togglePublication(uri: string) { @@ -431,20 +428,35 @@ } } - function handleSelectStandardSub(sub: StandardSub) { - let handle = sub.publisherDid; + let subscribingStandardSub = $state(null); + + async function subscribeStandardSub(sub: StandardSub) { + if (subscribedPublisherDids.has(sub.publisherDid)) return; + if (!subscriptionsStore.canAddMore) { + error = 'Subscription limit reached'; + return; + } + + error = null; + subscribingStandardSub = sub.uri; + try { - handle = new URL(sub.publication.url).hostname; - } catch { - // use DID as fallback + const subId = await subscriptionsStore.add(sub.publication.uri, sub.publication.name, { + sourceType: 'atproto.documents', + subjectDid: sub.publisherDid, + siteUrl: sub.publication.url, + feedUrl: sub.publication.uri, + }); + + socialStore.loadFeed(true); + handleClose(); + goto(`/?feed=${subId}`); + sidebarStore.closeMobile(); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to subscribe'; + } finally { + subscribingStandardSub = null; } - isStandardSub = true; - selectAccount({ - did: sub.publisherDid, - handle, - displayName: sub.publication.name, - avatar: undefined, - }); } $effect(() => { @@ -560,7 +572,7 @@
{#each standardSubs as sub (sub.uri)} {@const isSubscribed = subscribedPublisherDids.has(sub.publisherDid)} - {/if} - +
{/each} {/if} @@ -669,56 +689,52 @@ Detecting available content... {:else} - {#if !isStandardSub} -
- + Free-standing documents by @{selectedAccount.handle} + + {#if subscribedKeys.has('__freestanding__')} + Subscribed + {/if} + - -
- {/if} + + {#if publications.length > 0}
@@ -1159,18 +1175,34 @@ border: none; border-bottom: 1px solid var(--color-border); text-align: left; - cursor: pointer; color: var(--color-text); font: inherit; - transition: background-color 0.1s; } .standard-sub-row:last-child { border-bottom: none; } - .standard-sub-row:hover { - background: var(--color-bg-secondary); + .standard-sub-subscribe-btn { + padding: 0.25rem 0.75rem; + border: none; + border-radius: 6px; + background: var(--color-accent, #0085ff); + color: white; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + flex-shrink: 0; + transition: opacity 0.15s; + } + + .standard-sub-subscribe-btn:hover:not(:disabled) { + opacity: 0.85; + } + + .standard-sub-subscribe-btn:disabled { + opacity: 0.5; + cursor: not-allowed; } .standard-sub-info { diff --git a/src/lib/components/sidebar/SidebarAddFeed.svelte b/src/lib/components/sidebar/SidebarAddFeed.svelte index 7d01078..58d7aeb 100644 --- a/src/lib/components/sidebar/SidebarAddFeed.svelte +++ b/src/lib/components/sidebar/SidebarAddFeed.svelte @@ -4,6 +4,7 @@ import { articlesStore } from '$lib/stores/articles.svelte'; import { socialStore } from '$lib/stores/social.svelte'; import { sidebarStore } from '$lib/stores/sidebar.svelte'; + import { auth } from '$lib/stores/auth.svelte'; import { fetchSingleFeed } from '$lib/services/feedFetcher'; import { searchBlueskyActors, type BlueskySearchResult } from '$lib/services/blueskySearch'; import { api } from '$lib/services/api'; @@ -17,11 +18,23 @@ iconUrl?: string; } + type StandardSub = { + uri: string; + publisherDid: string; + publication: { + uri: string; + name: string; + url: string; + description?: string; + }; + }; + type Mode = 'idle' | 'searching-actors' | 'discovering-feeds' | 'select-feeds' | 'select-content'; let inputValue = $state(''); let mode = $state('idle'); let error = $state(null); + let inputFocused = $state(false); // Bluesky search state let searchResults = $state([]); @@ -41,6 +54,21 @@ let freestandingDocsSelected = $state(false); let isSubscribing = $state(false); + // Standard subscriptions state + let standardSubs = $state([]); + let isLoadingStandardSubs = $state(false); + let standardSubsLoaded = $state(false); + + let subscribedPublisherDids = $derived.by(() => { + const dids = new Set(); + for (const sub of subscriptionsStore.subscriptions) { + if (sub.subjectDid) { + dids.add(sub.subjectDid); + } + } + return dids; + }); + let inputEl: HTMLInputElement | undefined = $state(); let dropdownEl: HTMLDivElement | undefined = $state(); @@ -77,6 +105,166 @@ ); } + function parseAtUri(atUri: string): { did: string; collection: string; rkey: string } | null { + const match = atUri.match(/^at:\/\/(did:[^/]+)\/([^/]+)\/([^/]+)$/); + if (!match) return null; + return { did: match[1], collection: match[2], rkey: match[3] }; + } + + async function resolvePdsUrl(did: string): Promise { + try { + if (did.startsWith('did:plc:')) { + const res = await fetch(`https://plc.directory/${did}`); + if (!res.ok) return null; + const doc = (await res.json()) as { + service?: Array<{ id: string; type: string; serviceEndpoint: string }>; + }; + const svc = doc.service?.find( + (s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' + ); + return svc?.serviceEndpoint || null; + } else if (did.startsWith('did:web:')) { + const domain = did.replace('did:web:', ''); + const res = await fetch(`https://${domain}/.well-known/did.json`); + if (!res.ok) return null; + const doc = (await res.json()) as { + service?: Array<{ id: string; type: string; serviceEndpoint: string }>; + }; + const svc = doc.service?.find( + (s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' + ); + return svc?.serviceEndpoint || null; + } + return null; + } catch { + return null; + } + } + + async function loadStandardSubscriptions() { + if (standardSubsLoaded || isLoadingStandardSubs) return; + isLoadingStandardSubs = true; + try { + const pdsUrl = auth.user?.pdsUrl; + const did = auth.user?.did; + if (!pdsUrl || !did) { + standardSubs = []; + return; + } + + const params = new URLSearchParams({ + repo: did, + collection: 'site.standard.graph.subscription', + limit: '100', + }); + const res = await fetch(`${pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`); + if (!res.ok) { + standardSubs = []; + return; + } + const data = (await res.json()) as { + records: Array<{ uri: string; value: { publication?: string } }>; + }; + + if (data.records.length === 0) { + standardSubs = []; + return; + } + + const entries = data.records + .map((r) => ({ uri: r.uri, pubUri: r.value.publication })) + .filter((e): e is { uri: string; pubUri: string } => !!e.pubUri) + .map((e) => ({ ...e, parsed: parseAtUri(e.pubUri) })) + .filter((e): e is typeof e & { parsed: NonNullable } => !!e.parsed); + + const uniqueDids = [...new Set(entries.map((e) => e.parsed.did))]; + const pdsCache = new Map(); + await Promise.all( + uniqueDids.map(async (d) => { + pdsCache.set(d, await resolvePdsUrl(d)); + }) + ); + + const results = await Promise.allSettled( + entries.map(async (entry): Promise => { + const pubPds = pdsCache.get(entry.parsed.did); + if (!pubPds) return null; + + const pubParams = new URLSearchParams({ + repo: entry.parsed.did, + collection: entry.parsed.collection, + rkey: entry.parsed.rkey, + }); + const pubRes = await fetch(`${pubPds}/xrpc/com.atproto.repo.getRecord?${pubParams}`); + if (!pubRes.ok) return null; + + const pubData = (await pubRes.json()) as { + value: { name?: string; url?: string; description?: string }; + }; + const pub = pubData.value; + if (!pub.url) return null; + + return { + uri: entry.uri, + publisherDid: entry.parsed.did, + publication: { + uri: entry.pubUri, + name: pub.name || pub.url, + url: pub.url, + description: pub.description, + }, + }; + }) + ); + + standardSubs = results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map((r) => r.value) + .filter((s): s is StandardSub => s !== null); + } catch { + // Silently fail - these are just suggestions + } finally { + isLoadingStandardSubs = false; + standardSubsLoaded = true; + } + } + + let subscribingStandardSub = $state(null); + + async function subscribeStandardSub(sub: StandardSub) { + if (subscribedPublisherDids.has(sub.publisherDid)) return; + if (!subscriptionsStore.canAddMore) { + error = 'Subscription limit reached'; + return; + } + + error = null; + subscribingStandardSub = sub.uri; + + try { + const subId = await subscriptionsStore.add(sub.publication.uri, sub.publication.name, { + sourceType: 'atproto.documents', + subjectDid: sub.publisherDid, + siteUrl: sub.publication.url, + feedUrl: sub.publication.uri, + }); + + socialStore.loadFeed(true); + goto(`/?feed=${subId}`); + sidebarStore.closeMobile(); + inputFocused = false; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to subscribe'; + } finally { + subscribingStandardSub = null; + } + } + + function handleFocus() { + inputFocused = true; + loadStandardSubscriptions(); + } + function reset() { inputValue = ''; mode = 'idle'; @@ -338,6 +526,7 @@ inputEl && !inputEl.contains(e.target as Node) ) { + inputFocused = false; if (mode === 'searching-actors') { searchResults = []; mode = 'idle'; @@ -350,11 +539,19 @@ return () => document.removeEventListener('mousedown', handleClickOutside); }); + let showStandardSubs = $derived( + inputFocused && + mode === 'idle' && + inputValue.trim().length === 0 && + (isLoadingStandardSubs || standardSubs.length > 0) + ); + let showDropdown = $derived( searchResults.length > 0 || discoveredFeeds.length > 0 || mode === 'select-content' || - mode === 'discovering-feeds' + mode === 'discovering-feeds' || + showStandardSubs ); @@ -374,6 +571,7 @@ bind:value={inputValue} oninput={handleInput} onkeydown={handleKeydown} + onfocus={handleFocus} disabled={mode === 'discovering-feeds' || mode === 'select-content'} /> {#if inputValue || mode !== 'idle'} @@ -389,6 +587,37 @@ {#if showDropdown}