virtual list timeline, kinda ass but works for now
6 unmodified lines7891011111213141570 unmodified lines86878889909091929394959693949596100101102103979899100107108101102103104105106107108109110111112113114115116401 unmodified linesExpand all6 unmodified lines type Handle, type RecordKey, type ResourceUri } from '@atcute/lexicons'; import { expect, ok } from '$lib/result'; import { err, expect, ok, type Result } from '$lib/result'; import { accounts, generateColorForDid } from '$lib/accounts'; import ProfilePicture from './ProfilePicture.svelte'; import BskyPost from './BskyPost.svelte'; import Icon from '@iconify/svelte';70 unmodified lines (isBlocked || blockRel.userBlocked || blockRel.blockedByTarget) && !expandDisallowed ); const showAsMuted = $derived(isMuted && !expandDisallowed);
let handle: Handle = $state(handles.get(did) ?? 'handle.invalid'); const handle = $derived(handles.get(did) ?? 'handle.invalid'); onMount(() => { resolveDidDoc(did).then((res) => { if (res.ok) { handle = res.value.handle; handles.set(did, handle); } if (res.ok) handles.set(did, res.value.handle); return res; }); }); const post = data ? Promise.resolve(ok(data)) : client.getRecord(AppBskyFeedPost.mainSchema, did, rkey); let profile: AppBskyActorProfile.Main | null = $state(profiles.get(did) ?? null); const profile = $derived(profiles.get(did)); onMount(async () => { const p = await client.getProfile(did); if (!p.ok) return; profile = p.value; profiles.set(did, profile); profiles.set(did, p.value); });
// svelte-ignore state_referenced_locally let post: Result<PostWithUri, string> = $state(data ? ok(data) : err("post couldn't be loaded")); $effect(() => { client.getRecord(AppBskyFeedPost.mainSchema, did, rkey).then((res) => { if (!res.ok) return; post = res; }); });
const postId = $derived( `timeline-post-${did.replace(/[^a-zA-Z0-9]/g, '_')}-${rkey}-${quoteDepth}` ); const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId);401 unmodified linesExpand all46 unmodified lines47484950515152535455565757585960616266 unmodified lines12913013113213213313313413513613713835 unmodified lines46 unmodified lines let closeTimer: ReturnType<typeof setTimeout>; let openTimer: ReturnType<typeof setTimeout>;
const updatePosition = async () => { if (!triggerRef || !contentRef) return; const { x, y } = await computePosition(triggerRef!, contentRef!, { const { x, y } = await computePosition(triggerRef, contentRef, { placement, middleware: [offset(offsetDistance), flip(), shift({ padding: 8 })], strategy: 'fixed' });
Object.assign(contentRef!.style, { Object.assign(contentRef.style, { left: `${x}px`, top: `${y}px` }); };66 unmodified lines } });
$effect(() => { if (isOpen) { cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition); if (isOpen && triggerRef && contentRef) { cleanup = autoUpdate(triggerRef, contentRef, updatePosition); } else if (cleanup) { cleanup(); cleanup = null; }35 unmodified lines123456789101112131428 unmodified lines434445464748495051524653545556575859606162636412 unmodified lines777879807181828384858687888089909192939495969710 unmodified lines10810911011110111211311411511611711811912012112212312412512612712812913036 unmodified lines167168169170171172173174175176177178179180181149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233184185186187188189190191192193194195234235236237238239240241242243244245246247248249250251252253<script lang="ts"> import BskyPost from './BskyPost.svelte'; import { type State as PostComposerState } from './PostComposer.svelte'; import { AtpClient } from '$lib/at/client.svelte'; import { estimatePostHeight } from '$lib/post-height';
import { accounts } from '$lib/accounts'; import type { Did, RecordKey } from '@atcute/lexicons/syntax'; import { InfiniteLoader, LoaderState } from 'svelte-infinite'; import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; import { allPosts, viewClient, accountPreferences,28 unmodified lines const userDid = $derived(client?.user?.did);
let feedServiceDid = $state<string | null>(null); let newPostsAvailable = $state(false); let virtualList = $state<VirtualList | null>(null); let scrollToIndex = $state<number | undefined>(undefined);
const viewKey = $derived(`${userDid ?? 'anon'}-${selectedFeed}`);
$effect(() => { selectedFeed; viewKey; // dependency feedServiceDid = null; newPostsAvailable = false; displayCount = 15; measuredHeights = []; loaderState.reset(); scrollToIndex = undefined;
fetchFeedGenerator(client ?? viewClient, selectedFeed).then((meta) => { feedServiceDid = meta?.did ?? null; }); });12 unmodified lines return () => clearInterval(interval); });
const loaderState = new LoaderState(); let scrollContainer = $state<HTMLDivElement>(); let loading = $state(false); let loadError = $state('');
let fetchingInteractions = $state(false); let scheduledFetchInteractions = $state(false);
export const clearFeed = () => { if (!userDid) return; scrollContainer?.scrollTo({ top: 0, behavior: 'smooth' }); scrollToIndex = 0; setTimeout(() => (scrollToIndex = undefined), 100); newPostsAvailable = false; displayCount = 15; measuredHeights = []; resetFeed(userDid, selectedFeed); loaderState.reset(); loadMore(); };10 unmodified lines }) .filter((p): p is NonNullable<typeof p> => p !== undefined); });
const renderedPosts = $derived(feedPosts.slice(0, displayCount)); let measuredHeights: number[] = $state([]); const itemHeights = $derived.by(() => { const heights = measuredHeights.slice(0, feedPosts.length); while (heights.length < feedPosts.length) { heights.push(estimatePostHeight(feedPosts[heights.length])); } return heights; });
const averageHeight = $derived.by(() => { if (measuredHeights.length === 0) return 150; const sum = measuredHeights.reduce((a, b) => a + b, 0); return sum / measuredHeights.length; });
const loadMore = async () => { if (loading || !client || !userDid || !feedServiceDid) return;
loading = true;36 unmodified lines const cursor = feedCursors.get(userDid)?.get(selectedFeed); if (!cursor?.end) loadMore(); } });
const renderItem = (index: number) => { const post = feedPosts[index]; if (!post) return { post: null, postDid: null, postRkey: null }; const uriParts = post.uri.split('/'); const postDid = uriParts[2] as Did; const postRkey = uriParts[4] as RecordKey; return { post, postDid, postRkey }; };</script>
{#snippet feedPostsView()} {#each renderedPosts 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 < renderedPosts.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}><div class="h-full [scrollbar-color:var(--nucleus-accent)_transparent] {className}"> <LoadNewPosts visible={newPostsAvailable} onclick={clearFeed} /> {#if userDid || $accounts.length > 0} {#key viewKey} <VirtualList bind:this={virtualList} height="100%" itemCount={feedPosts.length} itemSize={itemHeights} estimatedItemSize={averageHeight} scrollToIndex={feedPosts.length > 0 ? scrollToIndex : undefined} > {#snippet item({ index, style }: { index: number; style: string })} {@const { post, postDid, postRkey } = renderItem(index)} <div style="{style} height: auto;" bind:clientHeight={ () => { // we need to return this so the bind works return measuredHeights[index] ?? estimatePostHeight(post); }, (h) => { // update the height if (measuredHeights[index] !== h) measuredHeights[index] = h; } } > <div class="mx-2 mb-1.5 border-b border-dashed border-[color-mix(in_srgb,var(--nucleus-accent)_30%,transparent)] pb-3 last:border-0" > {#if post && postDid && postRkey} <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; }} /> {/if} </div> </div> {/snippet}
{#snippet footer()} <div class="pb-20"> <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> {@render feedPostsView()} {#snippet noData()} <EndOfList /> {/snippet} {#snippet loading()} <LoadingSpinner /> {/snippet} {#snippet error()} <LoadError error={loadError} onRetry={loadMore} /> {/snippet} </InfiniteLoader> <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> <div class="h-px w-px opacity-0"></div> {#snippet noData()} <EndOfList /> {/snippet} {#snippet loading()} <LoadingSpinner /> {/snippet} {#snippet error()} <LoadError error={loadError} onRetry={loadMore} /> {/snippet} </InfiniteLoader> </div> {/snippet} </VirtualList> {/key} {:else} <NotLoggedIn /> {/if}</div>3 unmodified lines4567891011121 unmodified line14151617181920212224 unmodified lines474849505152535455562 unmodified lines59606162636465666167686970712 unmodified lines7475767772787980818276838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813929 unmodified lines1691701711721731741751761771781211221791801811821 unmodified line1841851861871321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852861961971981992002012022032042052062072872882892902912922932942952962972982993003013023033043053063 unmodified lines import { AtpClient } from '$lib/at/client.svelte'; import { type ResourceUri } from '@atcute/lexicons'; import { SvelteSet } from 'svelte/reactivity'; import { InfiniteLoader, LoaderState } from 'svelte-infinite'; import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; import Icon from '@iconify/svelte'; import { type ThreadPost, type Thread } from '$lib/thread'; import NotLoggedIn from './NotLoggedIn.svelte'; import LoadingSpinner from './LoadingSpinner.svelte';1 unmodified line import LoadError from './LoadError.svelte'; import LoadNewPosts from './LoadNewPosts.svelte'; import { onMount } from 'svelte'; import { initialDone } from '$lib/state.svelte'; import { estimatePostHeight } from '$lib/post-height';
interface Props { client?: AtpClient | null; threads: Thread[];24 unmodified lines const expandedThreads = new SvelteSet<ResourceUri>();
let isAtTop = $state(true); let boundaryTime = $state<number | null>(null); let virtualList = $state<VirtualList | null>(null); let scrollToIndex = $state<number | undefined>(undefined);
const visibleThreads = $derived.by(() => { if (boundaryTime === null) return threads; return threads.filter((t) => t.newestTime <= boundaryTime!);2 unmodified lines let displayCount = $state(15); $effect(() => { timelineId; displayCount = 15; measuredHeights = []; scrollToIndex = undefined; });
const renderedThreads = $derived(visibleThreads.slice(0, displayCount)); // const renderedThreads = $derived(visibleThreads.slice(0, displayCount));
$effect(() => { if (threads.length > 0) { if (isAtTop) boundaryTime = threads[0].newestTime;2 unmodified lines });
const showNewPosts = () => { boundaryTime = threads[0]?.newestTime ?? null; window.scrollTo({ top: 0, behavior: 'instant' }); // @ts-ignore virtualList?.scrollToIndex(0); isAtTop = true; };
const onScroll = () => (isAtTop = window.scrollY < 300); const onScroll = (event: { event: Event; offset: number }) => { const { offset } = event; isAtTop = offset < 300; };
const loaderState = new LoaderState(); let loading = $state(false); let loadError = $state('');
// helper to estimate thread height const estimateThreadHeight = (thread: Thread) => { let height = 0; if (thread.branchParentPost) height += 20; // approx height for mini parent
const isExpanded = expandedThreads.has(thread.rootUri); const len = thread.posts.length; const isLong = len > 4;
for (let i = 0; i < len; i++) { const post = thread.posts[i]; const mini = !isExpanded && isLong && i > 0 && i < len - 2;
if (!mini) { // normal post height += estimatePostHeight(post.data); if (i < len - 1) height += 6; // mb-1.5 } else { // mini / collapsed if (i === 1) height += 88; // "view full chain" button + reply post // other mini posts are hidden or collapsed } }
height += 28; // for the thread spacer
return height; };
let measuredHeights: number[] = $state([]); const itemHeights = $derived.by(() => { const heights = measuredHeights.slice(0, visibleThreads.length); while (heights.length < visibleThreads.length) { heights.push(estimateThreadHeight(visibleThreads[heights.length])); } return heights; });
const averageHeight = $derived.by(() => { if (measuredHeights.length === 0) return 300; const sum = measuredHeights.reduce((a, b) => a + b, 0); return sum / measuredHeights.length; });
const loadMore = async () => { if (loading || !shouldLoad) return;
loading = true;29 unmodified lines loading = false; loaderState.loaded(); } });
const renderItem = (index: number) => { return visibleThreads[index]; };</script>
<svelte:window onscroll={onScroll} />
{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} <span class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" >1 unmodified line <BskyPost mini client={client!} {...post} /> </span>{/snippet}
{#snippet threadsView()} {#each renderedThreads as thread, i (thread.rootUri)} <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> {#if thread.branchParentPost} {@render replyPost(thread.branchParentPost)} {/if} {#each thread.posts as post, idx (post.data.uri)} {@const mini = !expandedThreads.has(thread.rootUri) && thread.posts.length > 4 && idx > 0 && idx < thread.posts.length - 2} {#if !mini} <div class="mb-1.5"> <BskyPost client={client!} onQuote={(post) => { postComposerState.focus = 'focused'; postComposerState.quoting = post; }} onReply={(post) => { postComposerState.focus = 'focused'; postComposerState.replying = post; }} {...post} /> </div> {:else if mini} {#if idx === 1} {@render replyPost(post, !reverseChronological)} <button class="mx-1.5 mt-1.5 mb-2.5 flex items-center gap-1.5 text-[color-mix(in_srgb,var(--nucleus-fg)_50%,var(--nucleus-accent))]/70 transition-colors hover:text-(--nucleus-accent)" onclick={() => expandedThreads.add(thread.rootUri)} > <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> <Icon class="shrink-0" icon={reverseChronological ? 'heroicons:bars-arrow-up-solid' : 'heroicons:bars-arrow-down-solid'} width={32} /><span class="shrink-0 pb-1">view full chain</span> <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> </button> {:else if idx === thread.posts.length - 3} {@render replyPost(post)} {/if} {/if} {/each} </div> {#if i < renderedThreads.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}"><div class="h-full [scrollbar-color:var(--nucleus-accent)_transparent] {className}"> <LoadNewPosts visible={threads.length > 0 && boundaryTime !== null && threads[0].newestTime > boundaryTime} onclick={showNewPosts} /> {#if isLoggedIn} {#key timelineId} <VirtualList bind:this={virtualList} height="100%" itemCount={visibleThreads.length} itemSize={itemHeights} estimatedItemSize={averageHeight} onAfterScroll={onScroll} scrollToIndex={visibleThreads.length > 0 ? scrollToIndex : undefined} > {#snippet item({ index, style }: { index: number; style: string })} {@const thread = renderItem(index)} <div style="{style} height: auto;" bind:clientHeight={ () => { // we need to return this so the bind works return measuredHeights[index] ?? estimateThreadHeight(thread); }, (h) => { // update the height if (measuredHeights[index] !== h) measuredHeights[index] = h; } } > {#if thread} <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}" > {#if thread.branchParentPost} {@render replyPost(thread.branchParentPost)} {/if} {#each thread.posts as post, idx (post.data.uri)} {@const mini = !expandedThreads.has(thread.rootUri) && thread.posts.length > 4 && idx > 0 && idx < thread.posts.length - 2} {#if !mini} <div class="mb-1.5"> <BskyPost client={client!} onQuote={(post) => { postComposerState.focus = 'focused'; postComposerState.quoting = post; }} onReply={(post) => { postComposerState.focus = 'focused'; postComposerState.replying = post; }} {...post} /> </div> {:else if mini} {#if idx === 1} {@render replyPost(post, !reverseChronological)} <button class="mx-1.5 mt-1.5 mb-2.5 flex items-center gap-1.5 text-[color-mix(in_srgb,var(--nucleus-fg)_50%,var(--nucleus-accent))]/70 transition-colors hover:text-(--nucleus-accent)" onclick={() => expandedThreads.add(thread.rootUri)} > <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50" ></div> <Icon class="shrink-0" icon={reverseChronological ? 'heroicons:bars-arrow-up-solid' : 'heroicons:bars-arrow-down-solid'} width={32} /><span class="shrink-0 pb-1">view full chain</span> <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50" ></div> </button> {:else if idx === thread.posts.length - 3} {@render replyPost(post)} {/if} {/if} {/each} </div> {#if index < visibleThreads.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} {/if} </div> {/snippet}
{#snippet footer()} <div class="pb-20"> <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> {@render threadsView()} {#snippet noData()} <EndOfList /> {/snippet} {#snippet loading()} <LoadingSpinner /> {/snippet} {#snippet error()} <LoadError error={loadError} onRetry={loadMore} /> {/snippet} </InfiniteLoader> <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> <div class="h-px w-px opacity-0"></div> {#snippet noData()} <EndOfList /> {/snippet} {#snippet loading()} <LoadingSpinner /> {/snippet} {#snippet error()} <LoadError error={loadError} onRetry={loadMore} /> {/snippet} </InfiniteLoader> </div> {/snippet} </VirtualList> {/key} {:else} <NotLoggedIn /> {/if}</div>60 unmodified lines61626364656566676869130 unmodified linesExpand all60 unmodified lines });</script>
<div class="gallery styling-twitter" data-total={images.length} bind:this={element}> {#each images as img, i (img.src)} {#each images as img, i (`${img.src}#${i}`)} {@const thumb = img.thumbnail ?? img} {@const isHidden = i > 3} {@const isOverlay = i === 3 && images.length > 4}
130 unmodified linesExpand all10 unmodified lines11121314151516171819202122231819202122232425267 unmodified lines3435363740383940414243474445464765 unmodified lines10 unmodified lines interface Props { client: AtpClient; did: Did; handle?: Handle; profile?: AppBskyActorProfile.Main | null; profile?: AppBskyActorProfile.Main; }
let { client, did, handle = handles.get(did), profile = $bindable(profiles.get(did) ?? null) }: Props = $props(); let { client, did, handle: _handle, profile: _profile }: Props = $props();
const handle = $derived(_handle ?? handles.get(did)); const profile = $derived(_profile ?? profiles.get(did));
const userDid = $derived(client.user?.did); const blockRel = $derived( userDid ? getBlockRelationship(userDid, did) : { userBlocked: false, blockedByTarget: false } );7 unmodified lines (async () => { if (profile) return; const res = await client.getProfile(did); if (!res.ok) return; profile = res.value; profiles.set(did, res.value); })(), (async () => { if (handle) return; const res = await resolveDidDoc(did); if (!res.ok) return; handle = res.value.handle; handles.set(did, res.value.handle); })() ]); });65 unmodified lines13 unmodified lines1415161718191819202122232526272829242531323334353637383940262728293031323334354736373839409 unmodified lines13 unmodified lines }
let { client, did, size }: Props = $props();
// svelte-ignore state_referenced_locally let avatarBlob = $state(profiles.get(did)?.avatar); const avatarBlob = $derived(profiles.get(did)?.avatar); const avatarUrl: string | null = $derived( isBlob(avatarBlob) ? img('avatar_thumbnail', did, avatarBlob.ref.$link) : null );
const loadProfile = async (targetDid: Did) => { const cachedBlob = profiles.get(did)?.avatar; if (cachedBlob) { avatarBlob = cachedBlob; return; } if (avatarBlob) return;
try { const profile = await client.getProfile(targetDid); if (profile.ok) { avatarBlob = profile.value.avatar; profiles.set(did, profile.value); } else avatarBlob = undefined; } catch (e) { console.error(`${targetDid}: failed to load pfp`, e); avatarBlob = undefined; } const profile = await client.getProfile(targetDid); if (profile.ok) profiles.set(did, profile.value); else console.error(`${targetDid}: failed to load pfp: ${profile.error}`); };
$effect(() => { client; loadProfile(did); });
let color = $derived(generateColorForDid(did)); const color = $derived(generateColorForDid(did));</script>
{#if avatarUrl} <img9 unmodified lines28 unmodified lines293031323334353536373839120 unmodified linesExpand all28 unmodified lines
const profile = $derived(profiles.get(actor as Did)); const displayName = $derived(profile?.displayName ?? ''); const handle = $derived(isHandle(actor) ? actor : handles.get(actor as Did)); let did = $derived(isDid(actor) ? actor : null); let loading = $state(true); let error = $state<string | null>(null); let did = $state(isDid(actor) ? actor : null);
let userBlocked = $state(false); let blockedByTarget = $state(false);
120 unmodified linesExpand all1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677import type { PostWithUri } from './at/fetch';
const LINE_HEIGHT = 20; // approx line height in pxconst CHARS_PER_LINE = 66; // approx chars per lineconst BASE_HEIGHT = 100; // header + footer + paddingconst IMAGE_HEIGHT = 300; // constrained height for single imagesconst IMAGE_GRID_HEIGHT = 200; // height for grid of imagesconst VIDEO_HEIGHT = 300; // default video heightconst LINK_CARD_HEIGHT = 100; // external link cardconst QUOTE_FALLBACK_HEIGHT = 100; // fallback for quote
// idk this is dumb idk i hate virtual lists and shit blehexport const estimatePostHeight = (post: PostWithUri | undefined | null, depth = 0): number => { if (!post) return 150; // default fallback if post is missing if (depth > 1) return 0; // prevent infinite recursion
const record = post.record; let height = BASE_HEIGHT;
// 1. text height if (record.text) { const lines = Math.ceil(record.text.length / CHARS_PER_LINE) || 1; // add a bit more for newlines if present const newlines = (record.text.match(/\n/g) || []).length; height += (Math.max(lines, newlines + 1) * LINE_HEIGHT); }
// 2. embeds if (record.embed) { const embed = record.embed;
if (embed.$type === 'app.bsky.embed.images') { // images if (embed.images.length === 1) { const aspect = embed.images[0].aspectRatio; if (aspect) { // w / h = a => h = w / a // assuming max width of ~500px in feed const calculatedHeight = 500 / (aspect.width / aspect.height); height += Math.min(calculatedHeight, 500); // clamp max height } else { height += IMAGE_HEIGHT; } } else { height += IMAGE_GRID_HEIGHT; } } else if (embed.$type === 'app.bsky.embed.video') { // video const aspect = embed.aspectRatio; if (aspect) { const calculatedHeight = 500 / (aspect.width / aspect.height); height += Math.min(calculatedHeight, 500); } else { height += VIDEO_HEIGHT; } } else if (embed.$type === 'app.bsky.embed.record') { // quote post height += QUOTE_FALLBACK_HEIGHT;
} else if (embed.$type === 'app.bsky.embed.recordWithMedia') { // recordWithMedia // media part const media = embed.media; if (media.$type === 'app.bsky.embed.images') { height += (media.images.length === 1 ? IMAGE_HEIGHT : IMAGE_GRID_HEIGHT); } else if (media.$type === 'app.bsky.embed.video') { height += VIDEO_HEIGHT; } // quote part height += QUOTE_FALLBACK_HEIGHT; } else if (embed.$type === 'app.bsky.embed.external') { height += LINK_CARD_HEIGHT; } }
return Math.round(height);};917 unmodified linesExpand all91891992092192292292392492592692792892892993093193214 unmodified lines947948949950951951952953954955956957957958959960961142 unmodified linesExpand all917 unmodified linesExpand all
const feed = followingFeed.get(userDid); if (!feed || feed.size === 0) return;
let minTimestamp = Date.now(); let minTimestamp = Date.now() * 1000; let found = false;
for (const uri of feed) { const post = getPostFromUri(uri); if (post) { const ts = new Date(post.record.createdAt).getTime(); const ts = new Date(post.record.createdAt).getTime() * 1000; if (ts < minTimestamp) minTimestamp = ts; found = true; } }14 unmodified lines if (!userFeedTimelines) return; const posts = userFeedTimelines.get(feedUri); if (!posts || posts.length === 0) return;
let minTimestamp = Date.now(); let minTimestamp = Date.now() * 1000; let found = false;
for (const uri of posts) { const post = getPostFromUri(uri); if (post) { const ts = new Date(post.record.createdAt).getTime(); const ts = new Date(post.record.createdAt).getTime() * 1000; if (ts < minTimestamp) minTimestamp = ts; found = true; } }142 unmodified linesExpand all