From 18b0c29fc0a373550473ac6181f8d2b3f28024d0 Mon Sep 17 00:00:00 2001
From: dawn <90008@gaze.systems>
Date: Thu, 1 Jan 2026 01:18:40 +0300
Subject: [PATCH] refactor fetching, state and dropdown hover logic
Replace fetchPostsWithBacklinks with fetchPosts(withBacklinks) and
adjust hydratePosts to accept a cacheFn. Add caching for listRecords in
AtpClient. Overhaul state management to handle post chains, hydration
caching and timeline traversal. Improve Dropdown hover logic to prevent
premature closes when moving between trigger and content. Introduce
explicit Ok/Err result types and update helpers accordingly.
---
src/components/BskyPost.svelte | 12 ++---
src/components/Dropdown.svelte | 64 ++++++++++++++++++++--
src/components/FollowingView.svelte | 9 +++-
src/components/ProfileInfo.svelte | 3 +-
src/components/ProfileView.svelte | 8 ++-
src/components/TimelineView.svelte | 6 ++-
src/lib/at/client.ts | 52 +++++-------------
src/lib/at/fetch.ts | 61 ++++++++++++++-------
src/lib/result.ts | 22 ++++----
src/lib/state.svelte.ts | 79 +++++++++++++++++++--------
src/lib/theme.ts | 4 +-
src/routes/+page.svelte | 83 +++++++++++++++--------------
12 files changed, 255 insertions(+), 148 deletions(-)
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'
)}
--
2.51.2