add feed support
22 unmodified lines23242526272822 unmodified linesvite.config.ts.timestamp-*
/result/.direnv
/.scratchpadNo newline at end of file56 unmodified lines575859606162636465666768697098 unmodified lines56 unmodified lines scrollbar-width: thin; scrollbar-color: var(--nucleus-accent) var(--nucleus-bg);}
html,body { background-color: var(--nucleus-bg); color: var(--nucleus-fg);}
button { @apply hover:cursor-pointer;
&:disabled {98 unmodified lines12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152<script lang="ts"> import { type FeedGenerator, fetchFeedGenerator } from '$lib/at/feeds'; import type { SavedFeed } from '$lib/settings'; import Icon from '@iconify/svelte';
interface Props { style: string; data: SavedFeed; onRemove: () => void; onTogglePin: () => void; }
let { style, data, onRemove, onTogglePin }: Props = $props();
const feedData = $derived(data.feed); const uri = $derived(feedData.uri); const pinned = $derived(data.pinned);</script>
<div {style} class="box-border w-full py-0.5"> <div class="group flex items-center gap-2 rounded-sm bg-(--nucleus-fg)/5 px-2 py-1.5 transition-colors hover:bg-(--nucleus-fg)/10" > {#if feedData.avatar} <img src={feedData.avatar} alt="" class="h-6 w-6 shrink-0 rounded-sm object-cover" /> {/if} {#if feedData} <span class="semibold flex-1 truncate text-sm"> {feedData.displayName} </span> {:else} <span class="flex-1 truncate text-sm opacity-50">{uri.split('/').pop()}</span> {/if} <button onclick={onTogglePin} class="text-sm opacity-50 transition-opacity hover:opacity-100" title={pinned ? 'unpin' : 'pin'} > <Icon icon={pinned ? 'heroicons:star-solid' : 'heroicons:star'} width="20" class={pinned ? 'text-yellow-400' : ''} /> </button> <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>123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104<script lang="ts"> import { settings, type SavedFeed } from '$lib/settings'; import { fetchFeedGenerator, type FeedGenerator } from '$lib/at/feeds'; import { router, viewClient } from '$lib/state.svelte'; import Dropdown from './Dropdown.svelte'; import Icon from '@iconify/svelte';
interface Props { selectedFeed: string | null; onSelect: (feedUri: string | null) => void; }
let { selectedFeed, onSelect }: Props = $props();
let isOpen = $state(false);
const savedFeeds = $derived($settings.feeds); const sortedFeeds = $derived( [...savedFeeds].sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0)) );
let feedMeta = $state<Map<string, FeedGenerator>>(new Map());
$effect(() => { for (const savedFeed of savedFeeds) { if (!feedMeta.has(savedFeed.feed.uri)) { fetchFeedGenerator(viewClient, savedFeed.feed.uri).then((meta) => { if (meta) feedMeta.set(savedFeed.feed.uri, meta); }); } } });
const getDisplayName = (uri: string) => { const meta = feedMeta.get(uri); return meta?.displayName ?? uri.split('/').pop() ?? 'Feed'; };
const selectedName = $derived(selectedFeed === null ? 'replies' : getDisplayName(selectedFeed));</script>
<Dropdown class="min-w-48 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl" bind:isOpen placement="bottom-start"> {#snippet trigger()} <button onclick={() => (isOpen = !isOpen)} class="flex action-button items-center gap-1.5 text-sm hover:scale-102!" > <Icon icon="heroicons:list-bullet" width="16" /> <span>{selectedName}</span> <span class="opacity-50">▾</span> </button> {/snippet} <div class="flex flex-col p-1"> <button onclick={() => { onSelect(null); isOpen = false; }} class="my-0.5 flex items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-(--nucleus-fg)/10 {selectedFeed === null ? 'bg-(--nucleus-accent)/20' : ''}" > <Icon icon="heroicons:chat-bubble-left-ellipsis-16-solid" width="20" /> <span>replies</span> </button> {#each sortedFeeds as savedFeed (savedFeed.feed.uri)} <button onclick={() => { onSelect(savedFeed.feed.uri); isOpen = false; }} class="my-0.5 flex items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-(--nucleus-fg)/10 {selectedFeed === savedFeed.feed.uri ? 'bg-(--nucleus-accent)/20' : ''}" > {#if savedFeed.feed.avatar} <img src={savedFeed.feed.avatar} alt="" class="h-5 w-5 shrink-0 rounded-sm object-cover" /> {:else if savedFeed.pinned} <Icon icon="heroicons:star-solid" width="20" class="text-yellow-400" /> {:else} <Icon icon="heroicons:rss" width="20" /> {/if} <span class="truncate">{savedFeed.feed.displayName}</span> </button> {/each} {#if sortedFeeds.length === 0} <a href="/settings/feeds" onclick={(e) => (e.preventDefault(), (isOpen = false), router.navigate('/settings/feeds'))} class="px-3 py-2 text-sm opacity-70">add feeds in settings</a > {/if} </div></Dropdown>12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970<script lang="ts"> import FeedItem from './FeedItem.svelte'; import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; import type { FeedGenerator } from '$lib/at/feeds'; import { fetchFeedGenerator, parseFeedUri } from '$lib/at/feeds'; import type { SavedFeed } from '$lib/settings'; import { viewClient } from '$lib/state.svelte';
interface Props { feeds: SavedFeed[]; onAddFeed: (feed: FeedGenerator) => void; onRemoveFeed: (uri: string) => void; onTogglePin: (uri: string) => void; }
let { feeds, onAddFeed, onRemoveFeed, onTogglePin }: Props = $props();
let newFeedInput = $state('');
const handleAddFeed = async () => { const uri = newFeedInput.trim(); if (!uri) return; if (!parseFeedUri(uri)) return; if (feeds.some((f) => f.feed.uri === uri)) return; const feed = await fetchFeedGenerator(viewClient, uri); if (!feed) return; onAddFeed(feed); newFeedInput = ''; };
const sortedFeeds = $derived([...feeds].sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0))); const isValidUri = $derived(parseFeedUri(newFeedInput.trim()) !== null);</script>
<div class="space-y-4 p-4"> <div> <h3 class="settings-header">saved feeds</h3> <div class="settings-box space-y-2"> <div class="flex gap-2"> <input type="text" bind:value={newFeedInput} placeholder="https://bsky.app/profile/bsky.app/feed/whats-hot" class="single-line-input flex-1" /> <button disabled={!isValidUri} onclick={handleAddFeed} class="action-button">add</button> </div> {#if sortedFeeds.length > 0} <div class="h-fit"> <VirtualList height={Math.min(sortedFeeds.length, 7) * 44} itemCount={sortedFeeds.length} itemSize={44} > {#snippet item({ index, style }: { index: number; style: string })} <FeedItem {style} data={sortedFeeds[index]} onRemove={() => onRemoveFeed(sortedFeeds[index].feed.uri)} onTogglePin={() => onTogglePin(sortedFeeds[index].feed.uri)} /> {/snippet} </VirtualList> </div> {:else} <p class="py-2 text-center text-sm opacity-50">no saved feeds</p> {/if} </div> </div></div>15 unmodified lines1617181920212223242526272869 unmodified lines989910010110210310410510610710810911011111211311411511611711811912012112212312461 unmodified lines1861871881891901911921931941951961971981992001 unmodified line20220320420517820620720815 unmodified lines import Dropdown from './Dropdown.svelte'; import SettingsAdvancedTab from './SettingsAdvancedTab.svelte'; import SettingsStyleTab from './SettingsStyleTab.svelte'; import SettingsModerationTab from './SettingsModerationTab.svelte'; import SettingsFeedsTab from './SettingsFeedsTab.svelte'; import type { Did } from '@atcute/lexicons'; import type { AtprotoDid } from '@atcute/lexicons/syntax'; import Icon from '@iconify/svelte'; import type { FeedGenerator } from '$lib/at/feeds';
interface Props { tab: string; }69 unmodified lines await loadAccountPreferences({ did: selectedAccount, handle: null }); syncStatus = 'synced'; setTimeout(() => (syncStatus = null), 2000); };
const feeds = $derived(localSettings.feeds);
const handleAddFeed = (feed: FeedGenerator) => { localSettings.feeds = [...localSettings.feeds, { feed, pinned: false }]; settings.set(localSettings); };
const handleRemoveFeed = (uri: string) => { localSettings.feeds = localSettings.feeds.filter((f) => f.feed.uri !== uri); settings.set(localSettings); };
const handleTogglePin = (uri: string) => { localSettings.feeds = localSettings.feeds.map((f) => f.feed.uri === uri ? { ...f, pinned: !f.pinned } : f ); settings.set(localSettings); };</script>
<div class="flex flex-col"> <div class="mb-6 flex items-center justify-between p-4 pb-0">61 unmodified lines {selectedAccount} /> {:else if tab === 'style'} <SettingsStyleTab bind:localSettings /> {:else if tab === 'feeds'} <SettingsFeedsTab {feeds} onAddFeed={handleAddFeed} onRemoveFeed={handleRemoveFeed} onTogglePin={handleTogglePin} /> {/if} </div>
<div1 unmodified line 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={['moderation', 'style', 'advanced']} activeTab={tab} {onTabChange} /> <Tabs tabs={['feeds', 'moderation', 'style', 'advanced']} activeTab={tab} {onTabChange} /> </div></div>9 unmodified lines101112131415151617181920212223192425262728293031322733343536373839404142434445465 unmodified lines5253545556575859606162636465666768697071727374755076777879808182838485868788899091929394959697989964100101102103104105106107108109707172737475767778798081110111112113114115116117118119120121122123124125126127841281291301311321331341359394136137138989910010110210313914014114214314414514614714814915015182 unmodified lines23423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027119920020120220320420527227327427527627727827928028129 unmodified lines9 unmodified lines postCursors, fetchTimeline, allPosts, timelines, viewClient, fetchInteractionsToTimelineEnd, accountPreferences accountPreferences, feedTimelines, feedCursors, fetchFeed, resetFeed } from '$lib/state.svelte'; import Icon from '@iconify/svelte'; import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; import type { Did } from '@atcute/lexicons/syntax'; import type { Did, RecordKey } from '@atcute/lexicons/syntax'; import NotLoggedIn from './NotLoggedIn.svelte'; import { fetchFeedGenerator } from '$lib/at/feeds';
interface Props { client?: AtpClient | null; targetDid?: Did; postComposerState: PostComposerState; class?: string; // whether to show replies that are not the user's own posts showReplies?: boolean; selectedFeed?: string | null; }
let { client = null, targetDid = undefined, showReplies = true, postComposerState = $bindable(), selectedFeed = $bindable(null), class: className = '' }: Props = $props();
let reverseChronological = $state(true);5 unmodified lines
const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); const mutes = $derived(currentPrefs?.mutes ?? []);
let feedServiceDid = $state<string | null>(null);
$effect(() => { if (selectedFeed) { fetchFeedGenerator(client ?? viewClient, selectedFeed).then((meta) => { feedServiceDid = meta?.did ?? null; }); } else { feedServiceDid = null; } });
export const clearFeed = () => { if (!selectedFeed || !userDid) return; resetFeed(userDid, selectedFeed); loaderState.reset(); loadMore(); };
const threads = $derived( // todo: apply showReplies here filterThreads( did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts, mutes) : [], $accounts, { viewOwnPosts } ) );
const feedPosts = $derived.by(() => { if (!selectedFeed || !userDid) return []; const uris = feedTimelines.get(userDid)?.get(selectedFeed) ?? []; return uris .map((uri) => { const did = uri.split('/')[2] as Did; return allPosts.get(did)?.get(uri); }) .filter((p): p is NonNullable<typeof p> => p !== undefined); });
const loaderState = new LoaderState(); let scrollContainer = $state<HTMLDivElement>(); let loading = $state(false); let loadError = $state('');
const loadMore = async () => { if (loading || !client || !did) return; if (loading || !client || !userDid) return;
loading = true; loaderState.status = 'LOADING';
try { if (selectedFeed && feedServiceDid) { const result = await fetchFeed(client, selectedFeed, feedServiceDid); loaderState.loaded(); if (result?.end) loaderState.complete(); await fetchTimeline(client, did, 7, showReplies, { downwards: userDid === did ? 'sameAuthor' : 'none' }); // only fetch interactions if logged in (because if not who is the interactor) if (client.user && userDid) { if (!fetchingInteractions) { scheduledFetchInteractions = false; fetchingInteractions = true; await fetchInteractionsToTimelineEnd(client, userDid, did); fetchingInteractions = false; } else { scheduledFetchInteractions = true; } else if (did) { await fetchTimeline(client, did, 7, showReplies, { downwards: userDid === did ? 'sameAuthor' : 'none' }); if (client.user && userDid) { if (!fetchingInteractions) { scheduledFetchInteractions = false; fetchingInteractions = true; await fetchInteractionsToTimelineEnd(client, userDid, did); fetchingInteractions = false; } else { scheduledFetchInteractions = true; } } loaderState.loaded(); const cursor = postCursors.get(did); if (cursor?.end) loaderState.complete(); } loaderState.loaded(); } catch (error) { loadError = `${error}`; loaderState.error(); loading = false; return; }
loading = false; const cursor = postCursors.get(did); if (cursor && cursor.end) loaderState.complete(); };
$effect(() => { if (threads.length === 0 && !loading && userDid && did) { // if we saw all posts dont try to load more. // this only really happens if the user has no posts at all // but we do have to handle it to not cause an infinite loop const cursor = did ? postCursors.get(did) : undefined; if (!cursor?.end) loadMore(); const isEmpty = selectedFeed ? feedPosts.length === 0 : threads.length === 0; if (isEmpty && !loading && userDid) { if (selectedFeed) { const cursor = feedCursors.get(userDid)?.get(selectedFeed); if (!cursor?.end) loadMore(); } else if (did) { const cursor = postCursors.get(did); if (!cursor?.end) loadMore(); } } });
let fetchingInteractions = $state(false);82 unmodified lines {/if} {/each}{/snippet}
{#snippet feedPostsView()} {#each feedPosts as post, i (post.uri)} {@const uriParts = post.uri.split('/')} {@const postDid = uriParts[2] as Did} {@const postRkey = uriParts[4] as RecordKey} <div class="mb-1.5"> <BskyPost client={client!} did={postDid} rkey={postRkey} data={post} onQuote={(p) => { postComposerState.focus = 'focused'; postComposerState.quoting = p; }} onReply={(p) => { postComposerState.focus = 'focused'; postComposerState.replying = p; }} /> </div> {#if i < feedPosts.length - 1} <div class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" ></div> {/if} {/each}{/snippet}
<div class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" bind:this={scrollContainer}> {#if targetDid || $accounts.length > 0} <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0} intersectionOptions={{ root: scrollContainer }} > {@render threadsView()} <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> {#if selectedFeed} {@render feedPostsView()} {:else} {@render threadsView()} {/if} {#snippet noData()} <div class="flex justify-center py-4"> <p class="text-xl opacity-80"> all posts seen! <span class="text-2xl">:o</span>29 unmodified lines6 unmodified lines789101111121314159 unmodified lines6 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=* rpc:net.at-app.pet.ptr.nucleus.getPreferences?aud=* rpc:net.at-app.pet.ptr.nucleus.putPreferences?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=* rpc:app.bsky.feed.getFeedSkeleton?aud=* blob:*/*', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', application_type: 'web',9 unmodified lines123456787 unmodified lines16171819192021222320212223242526272853 unmodified lines8283848586878889909192939428 unmodified lines1231241251261211271281291301312 unmodified lines13413513613713213313413513813914014114214314417 unmodified lines/* eslint-disable svelte/no-navigation-without-resolve */import { pushState, replaceState } from '$app/navigation';import { SvelteMap } from 'svelte/reactivity';import { tick } from 'svelte';
export const routes = [ { path: '/', order: 0 }, { path: '/following', order: 1 },7 unmodified lines
type ExtractParams<Path extends string> = // eslint-disable-next-line @typescript-eslint/no-unused-vars Path extends `${infer Start}/:${infer Param}/${infer Rest}` ? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string } : // eslint-disable-next-line @typescript-eslint/no-unused-vars Path extends `${infer Start}/:${infer Param}` ? { [K in Param]: string } : Record<string, never>; ? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string } : // eslint-disable-next-line @typescript-eslint/no-unused-vars Path extends `${infer Start}/:${infer Param}` ? { [K in Param]: string } : Record<string, never>;
export type Route<K extends RoutePath = RoutePath> = { [T in K]: { params: ExtractParams<T>;53 unmodified lines // initialize state this._updateState(window.location.pathname); // update state on browser navigation window.addEventListener('popstate', () => this._updateState(window.location.pathname));
// disable browser scroll restoration if ('scrollRestoration' in history) { history.scrollRestoration = 'manual'; } }
match(urlPath: string): Route | undefined { const segments = urlPath.split('/').filter(Boolean);28 unmodified lines else if (newOrder > oldOrder) this.direction = 'right'; else this.direction = 'left'; }
private _updateState(url: string) { private async _updateState(url: string) { const target = this.match(url); if (!target) return;
// save scroll position2 unmodified lines this.updateDirection(target.order, this.current.order); this.current = target;
if (typeof window !== 'undefined') { setTimeout(() => { const savedScroll = this.scrollPositions.get(target.url) ?? 0; window.scrollTo({ top: savedScroll, behavior: 'auto' }); }, 0); await tick(); const savedScroll = this.scrollPositions.get(target.url) ?? 0; window.scrollTo({ top: savedScroll, behavior: 'instant' }); } }
navigate(url: string, { replace = false } = {}) {17 unmodified lines1234567891011121314151617181920212223242 unmodified lines27282930243132333435362 unmodified lines39404142434445464749 unmodified linesimport { writable } from 'svelte/store';import { defaultTheme, type Theme } from './theme';import type { FeedGenerator } from './at/feeds';
export type ApiEndpoints = Record<string, string> & { slingshot: string; spacedust: string; constellation: string; jetstream: string;};export type SavedFeed = { feed: FeedGenerator, pinned: boolean,};
export type Settings = { endpoints: ApiEndpoints; theme: Theme; socialAppUrl: string; feeds: SavedFeed[];};
export const defaultSettings: Settings = { endpoints: {2 unmodified lines constellation: 'https://constellation.microcosm.blue', jetstream: 'wss://jetstream2.fr.hose.cam' }, theme: defaultTheme, socialAppUrl: 'https://bsky.app' socialAppUrl: 'https://bsky.app', feeds: []};
const createSettingsStore = () => { // Prevent SSR crash if localStorage is missing2 unmodified lines const initial: Partial<Settings> = stored ? JSON.parse(stored) : defaultSettings; initial.endpoints = { ...defaultSettings.endpoints, ...initial.endpoints }; initial.theme = { ...defaultSettings.theme, ...initial.theme }; initial.socialAppUrl = initial.socialAppUrl ?? defaultSettings.socialAppUrl; initial.feeds = initial.feeds ?? defaultSettings.feeds;
const { subscribe, set, update } = writable<Settings>(initial as Settings);
subscribe((settings) => {49 unmodified lines415 unmodified linesExpand all416417418419420421422423424425426427428429430431432433434435436437144 unmodified linesExpand all58258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665744 unmodified lines702703704705624625626627628629630631632633634706707708709710132 unmodified linesExpand all415 unmodified linesExpand all
return res.value.total > 0;};
export const fetchBlocksForPosts = async (client: AtpClient, postUris: Iterable<ResourceUri>) => { const userDid = client.user?.did; if (!userDid) return;
// check if any of the post authors block the user // eslint-disable-next-line svelte/prefer-svelte-reactivity let distinctDids = new Set(Array.from(postUris).map((uri) => extractDidFromUri(uri)!)); distinctDids.delete(userDid); // dont need to check if user blocks themselves const alreadyFetched = blockFlags.get(userDid); if (alreadyFetched) distinctDids = distinctDids.difference(alreadyFetched); if (distinctDids.size > 0) await Promise.all(distinctDids.values().map((did) => fetchBlocked(client, userDid, did)));};
export const fetchBlocks = async (account: Account) => { const client = clients.get(account.did)!; const res = await client.listRecordsUntil(account.did, 'app.bsky.graph.block'); if (!res.ok) return;144 unmodified linesExpand all
export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>();export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>();
// feed state: Did -> feedUri -> post URIsexport const feedTimelines = new SvelteMap<Did, SvelteMap<string, ResourceUri[]>>();export const feedCursors = new SvelteMap<Did, SvelteMap<string, { value?: string; end: boolean }>>();
export const fetchFeed = async ( client: AtpClient, feedUri: string, feedServiceDid: string, limit: number = 25) => { const userDid = client.user?.did; if (!userDid) return;
let userFeedCursors = feedCursors.get(userDid); if (!userFeedCursors) { userFeedCursors = new SvelteMap(); feedCursors.set(userDid, userFeedCursors); }
const cursor = userFeedCursors.get(feedUri); if (cursor?.end) return;
const skeleton = await import('./at/feeds').then((m) => m.fetchFeedSkeleton(client, feedUri, feedServiceDid, cursor?.value) ); if (!skeleton) throw `failed to fetch feed skeleton for ${feedUri}`;
const newCursor = { value: skeleton.cursor, end: !skeleton.cursor }; userFeedCursors.set(feedUri, newCursor);
const uris = skeleton.feed.slice(0, limit).map((item) => item.post as ResourceUri);
let userFeedTimelines = feedTimelines.get(userDid); if (!userFeedTimelines) { userFeedTimelines = new SvelteMap(); feedTimelines.set(userDid, userFeedTimelines); }
const existing = userFeedTimelines.get(feedUri) ?? []; userFeedTimelines.set(feedUri, [...existing, ...uris]);
// fetch each post record const posts = await Promise.all( uris.map(async (uri) => { const result = await client.getRecordUri(AppBskyFeedPost.mainSchema, uri); if (!result.ok) return null; return { uri: result.value.uri, cid: result.value.cid, record: result.value.record }; }) );
const validPosts = posts.filter((p): p is PostWithUri => p !== null); addPosts(validPosts);
// check if any of the post authors block the user await fetchBlocksForPosts( client, validPosts.map((p) => p.uri) );
return newCursor;};
export const resetFeed = (did: Did, feedUri: string) => { feedTimelines.get(did)?.delete(feedUri); feedCursors.get(did)?.delete(feedUri);};
const traversePostChain = (post: PostWithUri) => { const result = [post.uri]; const parentUri = post.record.reply?.parent.uri; if (parentUri) {44 unmodified lines
addPosts(hydrated.value.values()); addTimeline(subject, hydrated.value.keys());
if (client.user?.did) { const userDid = client.user.did; // check if any of the post authors block the user // eslint-disable-next-line svelte/prefer-svelte-reactivity let distinctDids = new Set(hydrated.value.keys().map((uri) => extractDidFromUri(uri)!)); distinctDids.delete(userDid); // dont need to check if user blocks themselves const alreadyFetched = blockFlags.get(userDid); if (alreadyFetched) distinctDids = distinctDids.difference(alreadyFetched); if (distinctDids.size > 0) await Promise.all(distinctDids.values().map((did) => fetchBlocked(client, userDid, did))); } await fetchBlocksForPosts(client, hydrated.value.keys());
console.log(`${subject}: fetchTimeline`, accPosts.value.cursor); return newCursor;};132 unmodified linesExpand all