diff --git a/src/components/BskyPost.svelte b/src/components/BskyPost.svelte index d51a34c..0e19345 100644 --- a/src/components/BskyPost.svelte +++ b/src/components/BskyPost.svelte @@ -16,14 +16,13 @@ type ResourceUri } from '@atcute/lexicons'; import { expect, ok } from '$lib/result'; - import { generateColorForDid } from '$lib/accounts'; + import { accounts, generateColorForDid } from '$lib/accounts'; import ProfilePicture from './ProfilePicture.svelte'; import { isBlob } from '@atcute/lexicons/interfaces'; import { blob, img } from '$lib/cdn'; import BskyPost from './BskyPost.svelte'; import Icon from '@iconify/svelte'; import { - clients, allPosts, pulsingPostId, currentTime, @@ -33,7 +32,6 @@ } from '$lib/state.svelte'; import type { PostWithUri } from '$lib/at/fetch'; import { onMount } from 'svelte'; - import { type AtprotoDid } from '@atcute/lexicons/syntax'; import { derived } from 'svelte/store'; import Device from 'svelte-device-info'; import Dropdown from './Dropdown.svelte'; @@ -71,7 +69,7 @@ }: Props = $props(); const selectedDid = $derived(client.user?.did ?? null); - const actionClient = $derived(clients.get(did as AtprotoDid)); + const isLoggedInUser = $derived($accounts.some((acc) => acc.did === did)); const aturi = $derived(`at://${did}/app.bsky.feed.post/${rkey}` as CanonicalResourceUri); const color = $derived(generateColorForDid(did)); @@ -153,7 +151,7 @@ return; } - actionClient?.atcute + client?.atcute ?.post('com.atproto.repo.deleteRecord', { input: { collection: 'app.bsky.feed.post', @@ -212,6 +210,8 @@ style="background: {color}36; border-color: {color}99;" bind:isOpen={profileOpen} trigger={profileInline} + onMouseEnter={() => (profileOpen = true)} + onMouseLeave={() => (profileOpen = false)} > @@ -444,7 +444,7 @@ {@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () => navigator.clipboard.writeText(post.record.text) )} - {#if actionClient} + {#if isLoggedInUser}
{@render dropdownItem( deleteState === 'confirm' ? 'heroicons:check-20-solid' : 'heroicons:trash-20-solid', diff --git a/src/components/Dropdown.svelte b/src/components/Dropdown.svelte index 053d22c..b883061 100644 --- a/src/components/Dropdown.svelte +++ b/src/components/Dropdown.svelte @@ -20,6 +20,8 @@ placement?: Placement; offsetDistance?: number; position?: { x: number; y: number }; + onMouseEnter?: () => void; + onMouseLeave?: () => void; } let { @@ -29,6 +31,8 @@ placement = 'bottom-start', offsetDistance = 2, position = $bindable(), + onMouseEnter, + onMouseLeave, ...restProps }: Props = $props(); @@ -36,6 +40,11 @@ let contentRef: HTMLElement | undefined = $state(); let cleanup: (() => void) | null = null; + // State-based tracking for hover logic + let isTriggerHovered = false; + let isContentHovered = false; + let closeTimer: ReturnType; + const updatePosition = async () => { const { x, y } = await computePosition(triggerRef!, contentRef!, { placement, @@ -70,6 +79,44 @@ const handleScroll = handleClose; + // The central check: "Should we close now?" + const scheduleCloseCheck = () => { + clearTimeout(closeTimer); + closeTimer = setTimeout(() => { + // Only close if we are NOT on the trigger AND NOT on the content + if (!isTriggerHovered && !isContentHovered) if (isOpen && onMouseLeave) onMouseLeave(); + }, 30); // Small buffer to handle the physical gap between elements + }; + + const handleTriggerEnter = () => { + isTriggerHovered = true; + clearTimeout(closeTimer); // We are safe, cancel any pending close + if (!isOpen && onMouseEnter) onMouseEnter(); + }; + + const handleTriggerLeave = () => { + isTriggerHovered = false; + scheduleCloseCheck(); // We left the trigger, check if we should close + }; + + const handleContentEnter = () => { + isContentHovered = true; + clearTimeout(closeTimer); // We made it to the content, cancel close + }; + + const handleContentLeave = () => { + isContentHovered = false; + scheduleCloseCheck(); // We left the content, check if we should close + }; + + // Reset state if the menu is closed externally + $effect(() => { + if (!isOpen) { + isContentHovered = false; + clearTimeout(closeTimer); + } + }); + $effect(() => { if (isOpen) { cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition); @@ -79,16 +126,21 @@ } }); - onMount(() => { - return () => { - if (cleanup) cleanup(); - }; + onMount(() => () => { + if (cleanup) cleanup(); + clearTimeout(closeTimer); }); -
+
{@render trigger?.()}
@@ -100,6 +152,8 @@ style={restProps.style} role="menu" tabindex="-1" + onmouseenter={handleContentEnter} + onmouseleave={handleContentLeave} > {@render children?.()}
diff --git a/src/components/FollowingView.svelte b/src/components/FollowingView.svelte index 7630520..2b16255 100644 --- a/src/components/FollowingView.svelte +++ b/src/components/FollowingView.svelte @@ -16,11 +16,16 @@ selectedDid: Did; selectedClient: AtpClient; onProfileClick: (did: AtprotoDid) => void; + followingSort: Sort; } - const { selectedDid, selectedClient, onProfileClick }: Props = $props(); + let { + selectedDid, + selectedClient, + onProfileClick, + followingSort = $bindable('active') + }: Props = $props(); - let followingSort: Sort = $state('active' as Sort); const followsMap = $derived(follows.get(selectedDid)); // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/components/ProfileInfo.svelte b/src/components/ProfileInfo.svelte index 4fa79b6..ee1007b 100644 --- a/src/components/ProfileInfo.svelte +++ b/src/components/ProfileInfo.svelte @@ -11,10 +11,9 @@ did: Did; handle?: string; profile?: AppBskyActorProfile.Main | null; - interactive?: boolean; } - let { client, did, handle, profile = $bindable(null), interactive = true }: Props = $props(); + let { client, did, handle, profile = $bindable(null) }: Props = $props(); onMount(async () => { await Promise.all([ diff --git a/src/components/ProfileView.svelte b/src/components/ProfileView.svelte index 08e2cac..102ab0a 100644 --- a/src/components/ProfileView.svelte +++ b/src/components/ProfileView.svelte @@ -77,7 +77,13 @@
- + {/if} diff --git a/src/components/TimelineView.svelte b/src/components/TimelineView.svelte index 8bf0569..8a5524d 100644 --- a/src/components/TimelineView.svelte +++ b/src/components/TimelineView.svelte @@ -22,11 +22,14 @@ targetDid?: AtprotoDid; postComposerState: PostComposerState; class?: string; + // whether to show replies that are not the user's own posts + showReplies?: boolean; } let { client = null, targetDid = undefined, + showReplies = true, postComposerState = $bindable(), class: className = '' }: Props = $props(); @@ -38,6 +41,7 @@ const did = $derived(targetDid ?? client?.user?.did); const threads = $derived( + // todo: apply showReplies here filterThreads( did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts) : [], $accounts, @@ -58,7 +62,7 @@ loaderState.status = 'LOADING'; try { - await fetchTimeline(did as AtprotoDid); + await fetchTimeline(did as AtprotoDid, 7, showReplies); // interaction fetching is done lazily so we dont block loading posts fetchMoreInteractions = true; loaderState.loaded(); diff --git a/src/lib/at/client.ts b/src/lib/at/client.ts index 87d13fa..1a762db 100644 --- a/src/lib/at/client.ts +++ b/src/lib/at/client.ts @@ -80,35 +80,7 @@ const cacheWithRecords = cacheWithDidDocs.define('fetchRecord', async (uri: Reso return res.value; }); -type ListRecordsParams = { - atcute: AtcuteClient; - did: Did; - collection: Nsid; - cursor?: string; - limit?: number; -}; -const cacheWithListRecords = cacheWithRecords.define( - 'listRecords', - async (params: ListRecordsParams) => { - const res = await params.atcute.get('com.atproto.repo.listRecords', { - params: { - repo: params.did, - collection: params.collection, - cursor: params.cursor, - limit: params.limit ?? 100, - reverse: false - } - }); - if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); - - for (const record of res.data.records) - await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24); - - return ok(res.data); - } -); - -const cache = cacheWithListRecords; +const cache = cacheWithRecords; export class AtpClient { public atcute: AtcuteClient | null = null; @@ -190,17 +162,21 @@ export class AtpClient { Result, string> > { if (!this.atcute || !this.user) return err('not authenticated'); - try { - return (await cache.listRecords({ - atcute: this.atcute, - did: this.user.did, + const res = await this.atcute.get('com.atproto.repo.listRecords', { + params: { + repo: this.user.did, collection, cursor, - limit - })) as Awaited>; - } catch (e) { - return err(String(e)); - } + limit, + reverse: false + } + }); + if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); + + for (const record of res.data.records) + await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24); + + return ok(res.data); } async listRecordsUntil( diff --git a/src/lib/at/fetch.ts b/src/lib/at/fetch.ts index 9baa634..25631f7 100644 --- a/src/lib/at/fetch.ts +++ b/src/lib/at/fetch.ts @@ -5,28 +5,39 @@ import { type ResourceUri } from '@atcute/lexicons'; import { type AtpClient } from './client'; -import { err, expect, ok, type Result } from '$lib/result'; +import { err, expect, ok, type Ok, type Result } from '$lib/result'; import type { Backlinks } from './constellation'; import { AppBskyFeedPost } from '@atcute/bluesky'; -import type { AtprotoDid } from '@atcute/lexicons/syntax'; +import type { AtprotoDid, Did, RecordKey } from '@atcute/lexicons/syntax'; import { replySource } from '$lib'; export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main }; export type PostWithBacklinks = PostWithUri & { - replies: Backlinks; + replies?: Backlinks; }; -export type PostsWithReplyBacklinks = PostWithBacklinks[]; -export const fetchPostsWithBacklinks = async ( +export const fetchPosts = async ( client: AtpClient, cursor?: string, - limit?: number -): Promise> => { + limit?: number, + withBacklinks: boolean = true +): Promise> => { const recordsList = await client.listRecords('app.bsky.feed.post', cursor, limit); if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`); cursor = recordsList.value.cursor; const records = recordsList.value.records; + if (!withBacklinks) { + return ok({ + posts: records.map((r) => ({ + uri: r.uri, + cid: r.cid, + record: r.value as AppBskyFeedPost.Main + })), + cursor + }); + } + try { const allBacklinks = await Promise.all( records.map(async (r): Promise => { @@ -50,21 +61,26 @@ export const fetchPostsWithBacklinks = async ( export const hydratePosts = async ( client: AtpClient, repo: AtprotoDid, - data: PostsWithReplyBacklinks + data: PostWithBacklinks[], + cacheFn: (did: Did, rkey: RecordKey) => Ok | undefined ): Promise, string>> => { let posts: Map = new Map(); try { const allPosts = await Promise.all( data.map(async (post) => { const result: PostWithUri[] = [post]; - const replies = await Promise.all( - post.replies.records.map(async (r) => { - const reply = await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey); - if (!reply.ok) throw `cant fetch reply: ${reply.error}`; - return reply.value; - }) - ); - result.push(...replies); + if (post.replies) { + const replies = await Promise.all( + post.replies.records.map(async (r) => { + const reply = + cacheFn(r.did, r.rkey) ?? + (await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey)); + if (!reply.ok) throw `cant fetch reply: ${reply.error}`; + return reply.value; + }) + ); + result.push(...replies); + } return result; }) ); @@ -79,7 +95,14 @@ export const hydratePosts = async ( const parentUri = parent.uri as CanonicalResourceUri; // if we already have this parent, then we already fetched this chain / are fetching it if (posts.has(parentUri)) return; - const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parentUri); + const parsedParentUri = expect(parseCanonicalResourceUri(parentUri)); + const p = + cacheFn(parsedParentUri.repo, parsedParentUri.rkey) ?? + (await client.getRecord( + AppBskyFeedPost.mainSchema, + parsedParentUri.repo, + parsedParentUri.rkey + )); if (p.ok) { posts.set(p.value.uri, p.value); parent = p.value.record.reply?.parent; @@ -105,7 +128,9 @@ export const hydratePosts = async ( if (reply.did !== postRepo) continue; // if we already have this reply, then we already fetched this chain / are fetching it if (posts.has(`at://${reply.did}/${reply.collection}/${reply.rkey}`)) continue; - const record = await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey); + const record = + cacheFn(reply.did, reply.rkey) ?? + (await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey)); if (!record.ok) break; // TODO: this doesnt handle deleted posts in between posts.set(record.value.uri, record.value); promises.push(fetchDownwardsChain(record.value)); diff --git a/src/lib/result.ts b/src/lib/result.ts index be22a03..dc3059d 100644 --- a/src/lib/result.ts +++ b/src/lib/result.ts @@ -1,17 +1,17 @@ -export type Result = - | { - ok: true; - value: T; - } - | { - ok: false; - error: E; - }; +export type Ok = { + ok: true; + value: T; +}; +export type Err = { + ok: false; + error: E; +}; +export type Result = Ok | Err; -export const ok = (value: T): Result => { +export const ok = (value: T): Ok => { return { ok: true, value }; }; -export const err = (error: E): Result => { +export const err = (error: E): Err => { return { ok: false, error }; }; export const expect = (v: Result, msg: string = 'expected result to not be error:') => { diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts index ddc4c78..dafcb8a 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -1,4 +1,4 @@ -import { writable } from 'svelte/store'; +import { get, writable } from 'svelte/store'; import { AtpClient, newPublicClient, @@ -6,13 +6,13 @@ import { type NotificationsStreamEvent } from './at/client'; import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity'; -import type { Did, InferOutput, Nsid, ResourceUri } from '@atcute/lexicons'; -import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from './at/fetch'; +import type { Did, InferOutput, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons'; +import { fetchPosts, hydratePosts, type PostWithUri } from './at/fetch'; import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax'; import { AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky'; import type { ComAtprotoRepoListRecords } from '@atcute/atproto'; import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream'; -import { expect } from './result'; +import { expect, ok } from './result'; import type { Backlink, BacklinksSource } from './at/constellation'; import { now as tidNow } from '@atcute/tid'; import type { Records } from '@atcute/lexicons/ambient'; @@ -247,26 +247,33 @@ export const allPosts = new SvelteMap>( // did -> post uris that are replies to that did export const replyIndex = new SvelteMap>(); +export const getPost = (did: Did, rkey: RecordKey) => + allPosts.get(did)?.get(`at://${did}/app.bsky.feed.post/${rkey}`); +const hydrateCacheFn: Parameters[3] = (did, rkey) => { + const cached = getPost(did, rkey); + return cached ? ok(cached) : undefined; +}; + export const addPostsRaw = ( did: AtprotoDid, newPosts: InferOutput ) => { - const postsWithUri = newPosts.records.map((post): [ResourceUri, PostWithUri] => [ - post.uri, - { cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main } as PostWithUri - ]); + const postsWithUri = newPosts.records.map( + (post) => + ({ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main }) as PostWithUri + ); addPosts(postsWithUri); }; -export const addPosts = (newPosts: Iterable<[ResourceUri, PostWithUri]>) => { - for (const [uri, post] of newPosts) { - const parsedUri = expect(parseCanonicalResourceUri(uri)); +export const addPosts = (newPosts: Iterable) => { + for (const post of newPosts) { + const parsedUri = expect(parseCanonicalResourceUri(post.uri)); let posts = allPosts.get(parsedUri.repo); if (!posts) { posts = new SvelteMap(); allPosts.set(parsedUri.repo, posts); } - posts.set(uri, post); + posts.set(post.uri, post); const link: Backlink = { did: parsedUri.repo, collection: parsedUri.collection, @@ -283,7 +290,7 @@ export const addPosts = (newPosts: Iterable<[ResourceUri, PostWithUri]>) => { set = new SvelteSet(); replyIndex.set(parentDid, set); } - set.add(uri); + set.add(post.uri); } } } @@ -292,30 +299,49 @@ export const addPosts = (newPosts: Iterable<[ResourceUri, PostWithUri]>) => { export const timelines = new SvelteMap>(); export const postCursors = new SvelteMap(); +const traversePostChain = (post: PostWithUri) => { + const result = [post.uri]; + const parentUri = post.record.reply?.parent.uri; + if (parentUri) { + const parentPost = allPosts.get(extractDidFromUri(parentUri)!)?.get(parentUri); + if (parentPost) result.push(...traversePostChain(parentPost)); + } + return result; +}; export const addTimeline = (did: Did, uris: Iterable) => { let timeline = timelines.get(did); if (!timeline) { timeline = new SvelteSet(); timelines.set(did, timeline); } - for (const uri of uris) timeline.add(uri); + for (const uri of uris) { + const post = allPosts.get(did)?.get(uri); + // we need to traverse the post chain to add all posts in the chain to the timeline + // because the parent posts might not be in the timeline yet + const chain = post ? traversePostChain(post) : []; + for (const uri of chain) timeline.add(uri); + } }; -export const fetchTimeline = async (did: AtprotoDid, limit: number = 6) => { +export const fetchTimeline = async ( + did: AtprotoDid, + limit: number = 6, + withBacklinks: boolean = true +) => { const targetClient = await getClient(did); const cursor = postCursors.get(did); if (cursor && cursor.end) return; - const accPosts = await fetchPostsWithBacklinks(targetClient, cursor?.value, limit); + const accPosts = await fetchPosts(targetClient, cursor?.value, limit, withBacklinks); if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`; // if the cursor is undefined, we've reached the end of the timeline postCursors.set(did, { value: accPosts.value.cursor, end: !accPosts.value.cursor }); - const hydrated = await hydratePosts(targetClient, did, accPosts.value.posts); + const hydrated = await hydratePosts(targetClient, did, accPosts.value.posts, hydrateCacheFn); if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`; - addPosts(hydrated.value); + addPosts(hydrated.value.values()); addTimeline(did, hydrated.value.keys()); console.log(`${did}: fetchTimeline`, accPosts.value.cursor); @@ -342,7 +368,7 @@ export const handleJetstreamEvent = (event: JetstreamEvent) => { // assume record is valid, we trust the jetstream record: record as AppBskyFeedPost.Main }; - addPosts([[uri, post]]); + addPosts([post]); addTimeline(did, [uri]); } else if (commit.operation === 'delete') { allPosts.get(did)?.delete(uri); @@ -362,7 +388,7 @@ const handlePostNotification = async (event: NotificationsStreamEvent & { type: if (!subjectPost.ok) return; const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record)); - const hydrated = await hydratePosts(client, did, [ + const posts = [ { record: subjectPost.value.record, uri: event.data.link.subject, @@ -379,14 +405,15 @@ const handlePostNotification = async (event: NotificationsStreamEvent & { type: ] } } - ]); + ]; + const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn); if (!hydrated.ok) { console.error(`cant hydrate posts ${did}: ${hydrated.error}`); return; } // console.log(hydrated); - addPosts(hydrated.value); + addPosts(hydrated.value.values()); addTimeline(did, hydrated.value.keys()); }; @@ -414,3 +441,11 @@ if (typeof window !== 'undefined') setInterval(() => { currentTime.setTime(Date.now()); }, 1000); + +export type View = 'timeline' | 'notifications' | 'following' | 'settings' | 'profile'; +export const currentView = writable('timeline'); +export const previousView = writable('timeline'); +export const setView = (view: View) => { + previousView.set(get(currentView)); + currentView.set(view); +}; diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 0513c9a..5c51a2f 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -18,9 +18,7 @@ export const hashColor = (input: string): string => { const id = input.split(':').pop() || input; hash = 0; - for (let i = 0; i < Math.min(10, id.length); i++) { - hash = (hash << 4) + id.charCodeAt(i); - } + for (let i = 0; i < Math.min(10, id.length); i++) hash = (hash << 4) + id.charCodeAt(i); hash = hash >>> 0; // magic mixing diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 34ef0d8..7ecede6 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -17,11 +17,16 @@ fetchFollows, follows, notificationStream, - allPosts, viewClient, jetstream, handleJetstreamEvent, - handleNotification + handleNotification, + addPosts, + addTimeline, + type View, + currentView, + previousView, + setView } from '$lib/state.svelte'; import { get } from 'svelte/store'; import Icon from '@iconify/svelte'; @@ -30,6 +35,7 @@ import type { PageProps } from './+page'; import { JetstreamSubscription } from '@atcute/jetstream'; import { settings } from '$lib/settings'; + import type { Sort } from '$lib/following'; const { data: loadData }: PageProps = $props(); @@ -69,12 +75,11 @@ handleAccountSelected(newAccounts[0]?.did); }; - type View = 'timeline' | 'notifications' | 'following' | 'settings' | 'profile'; - let currentView = $state('timeline'); + let followingSort = $state('active' as Sort); + let animClass = $state('animate-fade-in-scale'); let scrollPositions = new SvelteMap(); let viewingProfileDid = $state(null); - let previousView = $state('timeline'); const viewOrder: Record = { timeline: 0, @@ -84,38 +89,32 @@ profile: 4 }; - const switchView = async (newView: View) => { - if (currentView === newView) return; - scrollPositions.set(currentView, window.scrollY); + const switchView = async () => { + scrollPositions.set($previousView, window.scrollY); - const direction = viewOrder[newView] > viewOrder[currentView] ? 'right' : 'left'; - // Profile always slides in from right unless going back - if (newView === 'profile') animClass = 'animate-slide-in-right'; - else if (currentView === 'profile') animClass = 'animate-slide-in-left'; + const direction = viewOrder[$previousView] > viewOrder[$currentView] ? 'right' : 'left'; + // profile always slides in from right unless going back + if ($currentView === 'profile') animClass = 'animate-slide-in-left'; + else if ($previousView === 'profile') animClass = 'animate-slide-in-right'; else animClass = direction === 'right' ? 'animate-slide-in-right' : 'animate-slide-in-left'; - // Don't overwrite previousView if we're just going to profile - if (newView !== 'profile' && currentView !== 'profile') previousView = currentView; - else if (newView === 'profile' && currentView !== 'profile') previousView = currentView; - currentView = newView; await tick(); - window.scrollTo({ top: scrollPositions.get(newView) || 0, behavior: 'instant' }); + window.scrollTo({ top: scrollPositions.get($currentView) || 0, behavior: 'instant' }); }; + currentView.subscribe(switchView); const goToProfile = (did: AtprotoDid) => { viewingProfileDid = did; - switchView('profile'); + setView('profile'); }; let postComposerState = $state({ type: 'null' }); let showScrollToTop = $state(false); const handleScroll = () => { - if (currentView === 'timeline') showScrollToTop = window.scrollY > 300; - }; - const scrollToTop = () => { - window.scrollTo({ top: 0, behavior: 'smooth' }); + if ($currentView === 'timeline') showScrollToTop = window.scrollY > 300; }; + const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' }); onMount(() => { window.addEventListener('scroll', handleScroll); @@ -130,7 +129,9 @@ 'app.bsky.feed.post:reply.parent.uri', 'app.bsky.feed.post:embed.record.record.uri', 'app.bsky.feed.post:embed.record.uri', - 'app.bsky.feed.repost:subject.uri' + 'app.bsky.feed.repost:subject.uri', + 'app.bsky.feed.like:subject.uri', + 'app.bsky.graph.follow:subject' ) ); }); @@ -214,36 +215,37 @@
- {#if currentView === 'settings'} + {#if $currentView === 'settings'}
{/if} - {#if currentView === 'notifications'} + {#if $currentView === 'notifications'}
{/if} - {#if currentView === 'following'} + {#if $currentView === 'following'}
{/if} - {#if currentView === 'profile' && viewingProfileDid} + {#if $currentView === 'profile' && viewingProfileDid}
switchView(previousView)} + onBack={() => setView($previousView)} bind:postComposerState />
@@ -276,7 +278,7 @@
allPosts.get(selectedDid!)?.set(post.uri, post)} + onPostSent={(post) => { + addPosts([post]); + addTimeline(selectedDid!, [post.uri]); + }} bind:_state={postComposerState} />
@@ -330,31 +335,31 @@
{@render appButton( - () => switchView('timeline'), + () => setView('timeline'), 'heroicons:home', 'timeline', - currentView === 'timeline', + $currentView === 'timeline', 'heroicons:home-solid' )} {@render appButton( - () => switchView('following'), + () => setView('following'), 'heroicons:users', 'following', - currentView === 'following', + $currentView === 'following', 'heroicons:users-solid' )} {@render appButton( - () => switchView('notifications'), + () => setView('notifications'), 'heroicons:bell', 'notifications', - currentView === 'notifications', + $currentView === 'notifications', 'heroicons:bell-solid' )} {@render appButton( - () => switchView('settings'), + () => setView('settings'), 'heroicons:cog-6-tooth', 'settings', - currentView === 'settings', + $currentView === 'settings', 'heroicons:cog-6-tooth-solid' )}