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'
)}