appview-less bluesky client
24
fork

Configure Feed

Select the types of activity you want to include in your feed.

proper router

dawn aa913b34 18b0c29f

+312 -136
+4 -3
src/components/BskyPost.svelte
··· 28 28 currentTime, 29 29 findBacklinksBy, 30 30 deletePostBacklink, 31 - createPostBacklink 31 + createPostBacklink, 32 + router 32 33 } from '$lib/state.svelte'; 33 34 import type { PostWithUri } from '$lib/at/fetch'; 34 35 import { onMount } from 'svelte'; ··· 189 190 rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10 190 191 " 191 192 style="color: {color};" 192 - onclick={() => (profileOpen = !profileOpen)} 193 + onclick={() => router.navigate(`/profile/${did}`)} 193 194 > 194 195 <ProfilePicture {client} {did} size={8} /> 195 196 196 197 {#if profile} 197 198 <span class="w-min max-w-sm min-w-0 overflow-hidden text-nowrap overflow-ellipsis" 198 - >{profile.displayName}</span 199 + >{profile.displayName?.length === 0 ? handle : profile.displayName}</span 199 200 ><span class="shrink-0 text-sm text-nowrap opacity-70">(@{handle})</span> 200 201 {:else} 201 202 {handle}
+15 -3
src/components/Dropdown.svelte
··· 19 19 children?: import('svelte').Snippet; 20 20 placement?: Placement; 21 21 offsetDistance?: number; 22 + openDelay?: number; 22 23 position?: { x: number; y: number }; 23 24 onMouseEnter?: () => void; 24 25 onMouseLeave?: () => void; ··· 30 31 children, 31 32 placement = 'bottom-start', 32 33 offsetDistance = 2, 34 + openDelay = 400, 33 35 position = $bindable(), 34 36 onMouseEnter, 35 37 onMouseLeave, ··· 40 42 let contentRef: HTMLElement | undefined = $state(); 41 43 let cleanup: (() => void) | null = null; 42 44 43 - // State-based tracking for hover logic 44 45 let isTriggerHovered = false; 45 46 let isContentHovered = false; 46 47 let closeTimer: ReturnType<typeof setTimeout>; 48 + let openTimer: ReturnType<typeof setTimeout>; 47 49 48 50 const updatePosition = async () => { 49 51 const { x, y } = await computePosition(triggerRef!, contentRef!, { ··· 64 66 let rect = element.getBoundingClientRect(); 65 67 let x = event.clientX; 66 68 let y = event.clientY; 69 + 67 70 return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; 68 71 }; 69 72 ··· 90 93 91 94 const handleTriggerEnter = () => { 92 95 isTriggerHovered = true; 93 - clearTimeout(closeTimer); // We are safe, cancel any pending close 94 - if (!isOpen && onMouseEnter) onMouseEnter(); 96 + clearTimeout(closeTimer); 97 + 98 + if (!isOpen) { 99 + clearTimeout(openTimer); 100 + openTimer = setTimeout(() => { 101 + if (onMouseEnter) onMouseEnter(); 102 + }, openDelay); 103 + } 95 104 }; 96 105 97 106 const handleTriggerLeave = () => { 98 107 isTriggerHovered = false; 108 + clearTimeout(openTimer); 99 109 scheduleCloseCheck(); // We left the trigger, check if we should close 100 110 }; 101 111 ··· 114 124 if (!isOpen) { 115 125 isContentHovered = false; 116 126 clearTimeout(closeTimer); 127 + clearTimeout(openTimer); // Ensure open timer is cleared on external close 117 128 } 118 129 }); 119 130 ··· 129 140 onMount(() => () => { 130 141 if (cleanup) cleanup(); 131 142 clearTimeout(closeTimer); 143 + clearTimeout(openTimer); // Cleanup open timer on unmount 132 144 }); 133 145 </script> 134 146
+7 -4
src/components/FollowingItem.svelte
··· 12 12 import type { calculateFollowedUserStats, Sort } from '$lib/following'; 13 13 import type { AtpClient } from '$lib/at/client'; 14 14 import { SvelteMap } from 'svelte/reactivity'; 15 - import { clients, getClient } from '$lib/state.svelte'; 15 + import { clients, getClient, router } from '$lib/state.svelte'; 16 16 17 17 interface Props { 18 18 style: string; ··· 21 21 client: AtpClient; 22 22 sort: Sort; 23 23 currentTime: Date; 24 - onClick?: (did: AtprotoDid) => void; 25 24 } 26 25 27 - let { style, did, stats, client, sort, currentTime, onClick }: Props = $props(); 26 + let { style, did, stats, client, sort, currentTime }: Props = $props(); 28 27 29 28 // svelte-ignore state_referenced_locally 30 29 const cached = profileCache.get(did); ··· 94 93 const lastPostAt = $derived(stats?.lastPostAt ?? new Date(0)); 95 94 const relTime = $derived(getRelativeTime(lastPostAt, currentTime)); 96 95 const color = $derived(generateColorForDid(did)); 96 + 97 + const goToProfile = () => { 98 + router.navigate(`/profile/${did}`); 99 + }; 97 100 </script> 98 101 99 102 <div {style} class="box-border w-full pb-2"> 100 103 <!-- svelte-ignore a11y_click_events_have_key_events --> 101 104 <!-- svelte-ignore a11y_no_static_element_interactions --> 102 105 <div 103 - onclick={() => onClick?.(did as AtprotoDid)} 106 + onclick={goToProfile} 104 107 class="group flex cursor-pointer items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" 105 108 style={`--post-color: ${color};`} 106 109 >
+1 -9
src/components/FollowingView.svelte
··· 1 1 <script lang="ts"> 2 2 import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte'; 3 3 import type { Did } from '@atcute/lexicons'; 4 - import type { AtprotoDid } from '@atcute/lexicons/syntax'; 5 4 import { type AtpClient } from '$lib/at/client'; 6 5 import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; 7 6 import { ··· 15 14 interface Props { 16 15 selectedDid: Did; 17 16 selectedClient: AtpClient; 18 - onProfileClick: (did: AtprotoDid) => void; 19 17 followingSort: Sort; 20 18 } 21 19 22 - let { 23 - selectedDid, 24 - selectedClient, 25 - onProfileClick, 26 - followingSort = $bindable('active') 27 - }: Props = $props(); 20 + let { selectedDid, selectedClient, followingSort = $bindable('active') }: Props = $props(); 28 21 29 22 const followsMap = $derived(follows.get(selectedDid)); 30 23 ··· 162 155 client={selectedClient} 163 156 sort={followingSort} 164 157 {currentTime} 165 - onClick={onProfileClick} 166 158 /> 167 159 {/snippet} 168 160 </VirtualList>
+8 -9
src/components/ProfileInfo.svelte
··· 18 18 onMount(async () => { 19 19 await Promise.all([ 20 20 (async () => { 21 - if (!profile) { 22 - const res = await client.getProfile(did); 23 - if (res.ok) profile = res.value; 24 - } 21 + if (profile) return; 22 + const res = await client.getProfile(did); 23 + if (res.ok) profile = res.value; 25 24 })(), 26 25 (async () => { 27 - if (!handle) { 28 - const res = await resolveDidDoc(did); 29 - if (res.ok) handle = res.value.handle; 30 - } 26 + if (handle) return; 27 + const res = await resolveDidDoc(did); 28 + if (res.ok) handle = res.value.handle; 31 29 })() 32 30 ]); 33 31 }); 34 32 35 33 let displayHandle = $derived(handle ?? 'handle.invalid'); 36 34 let profileDesc = $derived(profile?.description?.trim() ?? ''); 35 + let profileDisplayName = $derived(profile?.displayName ?? ''); 37 36 let showDid = $state(false); 38 37 </script> 39 38 ··· 43 42 44 43 <div class="flex min-w-0 flex-col items-start overflow-hidden overflow-ellipsis"> 45 44 <span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis"> 46 - {profile?.displayName ?? displayHandle} 45 + {profileDisplayName.length > 0 ? profileDisplayName : displayHandle} 47 46 {#if profile?.pronouns} 48 47 <span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span> 49 48 {/if}
+40 -17
src/components/ProfileView.svelte
··· 1 1 <script lang="ts"> 2 - import { AtpClient } from '$lib/at/client'; 3 - import type { AtprotoDid } from '@atcute/lexicons/syntax'; 2 + import { AtpClient, resolveHandle } from '$lib/at/client'; 3 + import type { ActorIdentifier, AtprotoDid } from '@atcute/lexicons/syntax'; 4 4 import TimelineView from './TimelineView.svelte'; 5 5 import ProfileInfo from './ProfileInfo.svelte'; 6 6 import type { State as PostComposerState } from './PostComposer.svelte'; ··· 9 9 import { img } from '$lib/cdn'; 10 10 import { isBlob } from '@atcute/lexicons/interfaces'; 11 11 import type { AppBskyActorProfile } from '@atcute/bluesky'; 12 - import { onMount } from 'svelte'; 13 12 14 13 interface Props { 15 14 client: AtpClient; 16 - did: AtprotoDid; 15 + actor: string; 17 16 onBack: () => void; 18 17 postComposerState?: PostComposerState; 19 18 } 20 19 21 - let { client, did, onBack, postComposerState = $bindable({ type: 'null' }) }: Props = $props(); 20 + let { client, actor, onBack, postComposerState = $bindable({ type: 'null' }) }: Props = $props(); 22 21 23 22 let profile = $state<AppBskyActorProfile.Main | null>(null); 24 23 let loading = $state(true); 25 24 let error = $state<string | null>(null); 25 + let did = $state<AtprotoDid | null>(null); 26 + 27 + const loadProfile = async (identifier: ActorIdentifier) => { 28 + loading = true; 29 + error = null; 30 + profile = null; 26 31 27 - onMount(async () => { 32 + const resDid = await resolveHandle(identifier); 33 + if (resDid.ok) did = resDid.value; 34 + else { 35 + error = resDid.error; 36 + loading = false; 37 + return; 38 + } 39 + 28 40 const res = await client.getProfile(did); 29 41 if (res.ok) profile = res.value; 30 42 else error = res.error; 43 + 31 44 loading = false; 45 + }; 46 + 47 + $effect(() => { 48 + loadProfile(actor as ActorIdentifier); 32 49 }); 33 50 34 - const color = $derived(generateColorForDid(did)); 51 + const color = $derived(did ? generateColorForDid(did) : 'var(--nucleus-fg)'); 35 52 const bannerUrl = $derived( 36 - profile && isBlob(profile.banner) ? img('feed_fullsize', did, profile.banner.ref.$link) : null 53 + did && profile && isBlob(profile.banner) 54 + ? img('feed_fullsize', did, profile.banner.ref.$link) 55 + : null 37 56 ); 38 57 </script> 39 58 ··· 50 69 <Icon icon="heroicons:arrow-left-20-solid" width={24} /> 51 70 </button> 52 71 <h2 class="text-xl font-bold"> 53 - {profile?.displayName ?? (loading ? 'loading...' : 'profile')} 72 + {profile?.displayName ?? (loading ? 'loading...' : actor || 'profile')} 54 73 </h2> 55 74 </div> 56 75 ··· 72 91 73 92 <div class="px-4 pb-4"> 74 93 <div class="relative z-10 -mt-12 mb-4"> 75 - <ProfileInfo {client} {did} bind:profile /> 94 + {#if did} 95 + <ProfileInfo {client} {did} bind:profile /> 96 + {/if} 76 97 </div> 77 98 78 99 <div class="my-4 h-px bg-white/10"></div> 79 100 80 - <TimelineView 81 - showReplies={false} 82 - {client} 83 - targetDid={did} 84 - bind:postComposerState 85 - class="min-h-[50vh]" 86 - /> 101 + {#if did} 102 + <TimelineView 103 + showReplies={false} 104 + {client} 105 + targetDid={did} 106 + bind:postComposerState 107 + class="min-h-[50vh]" 108 + /> 109 + {/if} 87 110 </div> 88 111 {/if} 89 112 </div>
+11 -3
src/components/RichText.svelte
··· 1 1 <script lang="ts"> 2 2 import { parseToRichText } from '$lib/richtext'; 3 3 import { settings } from '$lib/settings'; 4 + import { router } from '$lib/state.svelte'; 4 5 import type { BakedRichtext } from '@atcute/bluesky-richtext-builder'; 5 6 import { segmentize, type Facet, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter'; 6 7 ··· 14 15 const richtext: Promise<BakedRichtext> = $derived( 15 16 facets ? Promise.resolve({ text, facets }) : parseToRichText(text) 16 17 ); 18 + 19 + const handleProfileClick = (e: MouseEvent, did: string) => { 20 + e.preventDefault(); 21 + router.navigate(`/profile/${did}`); 22 + }; 17 23 </script> 18 24 19 25 {#snippet plainText(text: string)} ··· 34 40 {#each features as feature, idx ([feature, idx])} 35 41 {#if feature.$type === 'app.bsky.richtext.facet#mention'} 36 42 <a 37 - class="text-(--nucleus-accent2)" 38 - href={`${$settings.socialAppUrl}/profile/${feature.did}`}>{@render plainText(text)}</a 43 + class="text-(--nucleus-accent2) hover:cursor-pointer hover:underline" 44 + href={`/profile/${feature.did}`} 45 + onclick={(e) => handleProfileClick(e, feature.did)}>{@render plainText(text)}</a 39 46 > 40 47 {:else if feature.$type === 'app.bsky.richtext.facet#link'} 41 48 {@const uri = new URL(feature.uri)} ··· 51 58 <a 52 59 class="text-(--nucleus-accent2)" 53 60 href={`${$settings.socialAppUrl}/search?q=${encodeURIComponent('#' + feature.tag)}`} 54 - >{@render plainText(text)}</a 61 + target="_blank" 62 + rel="noopener noreferrer">{@render plainText(text)}</a 55 63 > 56 64 {:else} 57 65 <span>{@render plainText(text)}</span>
+12 -10
src/components/SettingsView.svelte
··· 5 5 import Tabs from './Tabs.svelte'; 6 6 import { portal } from 'svelte-portal'; 7 7 import { cache } from '$lib/cache'; 8 + import { router } from '$lib/state.svelte'; 8 9 9 - type Tab = 'style' | 'moderation' | 'advanced'; 10 - let activeTab = $state<Tab>('advanced'); 10 + interface Props { 11 + tab: string; 12 + } 13 + 14 + let { tab }: Props = $props(); 11 15 12 16 let localSettings = $state(get(settings)); 13 17 let hasReloadChanges = $derived(needsReload($settings, localSettings)); ··· 32 36 cache.clear(); 33 37 alert('cache cleared!'); 34 38 }; 39 + 40 + const onTabChange = (tab: string) => router.replace(`/settings/${tab}`); 35 41 </script> 36 42 37 43 {#snippet advancedTab()} ··· 138 144 </div> 139 145 140 146 <div class="flex-1"> 141 - {#if activeTab === 'advanced'} 147 + {#if tab === 'advanced'} 142 148 {@render advancedTab()} 143 - {:else if activeTab === 'moderation'} 149 + {:else if tab === 'moderation'} 144 150 <div class="p-4"> 145 151 <div class="flex h-64 items-center justify-center"> 146 152 <div class="text-center"> ··· 149 155 </div> 150 156 </div> 151 157 </div> 152 - {:else if activeTab === 'style'} 158 + {:else if tab === 'style'} 153 159 {@render styleTab()} 154 160 {/if} 155 161 </div> ··· 160 166 z-20 w-full max-w-2xl bg-(--nucleus-bg) p-4 pt-2 pb-1 shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.1)] 161 167 " 162 168 > 163 - <Tabs 164 - tabs={['style', 'moderation', 'advanced']} 165 - bind:activeTab 166 - onTabChange={(tab) => (activeTab = tab)} 167 - /> 169 + <Tabs tabs={['style', 'moderation', 'advanced']} activeTab={tab} {onTabChange} /> 168 170 </div> 169 171 </div> 170 172
+156
src/lib/router.svelte.ts
··· 1 + /* eslint-disable svelte/no-navigation-without-resolve */ 2 + import { pushState, replaceState } from '$app/navigation'; 3 + import { SvelteMap } from 'svelte/reactivity'; 4 + 5 + export const routes = [ 6 + { path: '/', order: 0 }, 7 + { path: '/following', order: 1 }, 8 + { path: '/notifications', order: 2 }, 9 + { path: '/settings', order: 3 }, 10 + { path: '/settings/:tab', order: 3 }, 11 + { path: '/profile/:actor', order: 4 } 12 + ] as const; 13 + 14 + export type RouteConfig = (typeof routes)[number]; 15 + export type RoutePath = RouteConfig['path']; 16 + 17 + type ExtractParams<Path extends string> = 18 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 19 + Path extends `${infer Start}/:${infer Param}/${infer Rest}` 20 + ? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string } 21 + : // eslint-disable-next-line @typescript-eslint/no-unused-vars 22 + Path extends `${infer Start}/:${infer Param}` 23 + ? { [K in Param]: string } 24 + : Record<string, never>; 25 + 26 + export type Route<K extends RoutePath = RoutePath> = { 27 + [T in K]: { 28 + params: ExtractParams<T>; 29 + path: T; 30 + order: number; 31 + url: string; 32 + }; 33 + }[K]; 34 + 35 + type RouteNode = { 36 + children: Map<string, RouteNode>; 37 + paramName?: string; 38 + paramChild?: RouteNode; 39 + config?: RouteConfig; 40 + }; 41 + 42 + const fallbackRoute: Route<'/'> = { 43 + params: {}, 44 + path: '/', 45 + order: 0, 46 + url: '/' 47 + }; 48 + 49 + export class Router { 50 + current = $state<Route>(fallbackRoute); 51 + 52 + direction = $state<'left' | 'right' | 'none'>('none'); 53 + scrollPositions = new SvelteMap<string, number>(); 54 + // eslint-disable-next-line svelte/prefer-svelte-reactivity 55 + private root: RouteNode = { children: new Map() }; 56 + 57 + constructor() { 58 + for (const route of routes) this.addRoute(route); 59 + } 60 + 61 + private addRoute(config: RouteConfig) { 62 + const segments = config.path.split('/').filter(Boolean); 63 + let node = this.root; 64 + 65 + for (const segment of segments) { 66 + if (segment.startsWith(':')) { 67 + const paramName = segment.slice(1); 68 + // eslint-disable-next-line svelte/prefer-svelte-reactivity 69 + if (!node.paramChild) node.paramChild = { children: new Map(), paramName }; 70 + node = node.paramChild; 71 + } else { 72 + // eslint-disable-next-line svelte/prefer-svelte-reactivity 73 + if (!node.children.has(segment)) node.children.set(segment, { children: new Map() }); 74 + node = node.children.get(segment)!; 75 + } 76 + } 77 + node.config = config; 78 + } 79 + 80 + init() { 81 + if (typeof window === 'undefined') return; 82 + // initialize state 83 + this._updateState(window.location.pathname); 84 + // update state on browser navigation 85 + window.addEventListener('popstate', () => this._updateState(window.location.pathname)); 86 + } 87 + 88 + match(urlPath: string): Route { 89 + const segments = urlPath.split('/').filter(Boolean); 90 + const params: Record<string, string> = {}; 91 + 92 + let node = this.root; 93 + 94 + for (const segment of segments) { 95 + if (node.children.has(segment)) { 96 + node = node.children.get(segment)!; 97 + } else if (node.paramChild) { 98 + node = node.paramChild; 99 + if (node.paramName) params[node.paramName] = decodeURIComponent(segment); 100 + } else { 101 + return fallbackRoute; 102 + } 103 + } 104 + 105 + if (node.config) 106 + return { 107 + params: params as unknown, 108 + path: node.config.path, 109 + order: node.config.order, 110 + url: urlPath 111 + } as Route<typeof node.config.path>; 112 + 113 + return fallbackRoute; 114 + } 115 + 116 + updateDirection(newOrder: number, oldOrder: number) { 117 + if (newOrder === oldOrder) this.direction = 'none'; 118 + else if (newOrder > oldOrder) this.direction = 'right'; 119 + else this.direction = 'left'; 120 + } 121 + 122 + private _updateState(url: string) { 123 + const target = this.match(url); 124 + 125 + // save scroll position 126 + if (typeof window !== 'undefined') this.scrollPositions.set(this.current.url, window.scrollY); 127 + 128 + this.updateDirection(target.order, this.current.order); 129 + this.current = target; 130 + 131 + if (typeof window !== 'undefined') { 132 + setTimeout(() => { 133 + const savedScroll = this.scrollPositions.get(target.url) ?? 0; 134 + window.scrollTo({ top: savedScroll, behavior: 'auto' }); 135 + }, 0); 136 + } 137 + } 138 + 139 + navigate(url: string, { replace = false } = {}) { 140 + if (typeof window === 'undefined') return; 141 + if (this.current.url === url) return; 142 + 143 + if (replace) replaceState(url, {}); 144 + else pushState(url, {}); 145 + 146 + this._updateState(url); 147 + } 148 + 149 + replace(url: string) { 150 + this.navigate(url, { replace: true }); 151 + } 152 + 153 + back() { 154 + if (typeof window !== 'undefined') history.back(); 155 + } 156 + }
+3 -8
src/lib/state.svelte.ts
··· 1 - import { get, writable } from 'svelte/store'; 1 + import { writable } from 'svelte/store'; 2 2 import { 3 3 AtpClient, 4 4 newPublicClient, ··· 23 23 repostSource, 24 24 timestampFromCursor 25 25 } from '$lib'; 26 + import { Router } from './router.svelte'; 26 27 27 28 export const notificationStream = writable<NotificationsStream | null>(null); 28 29 export const jetstream = writable<JetstreamSubscription | null>(null); ··· 442 443 currentTime.setTime(Date.now()); 443 444 }, 1000); 444 445 445 - export type View = 'timeline' | 'notifications' | 'following' | 'settings' | 'profile'; 446 - export const currentView = writable<View>('timeline'); 447 - export const previousView = writable<View>('timeline'); 448 - export const setView = (view: View) => { 449 - previousView.set(get(currentView)); 450 - currentView.set(view); 451 - }; 446 + export const router = new Router();
+55 -70
src/routes/+page.svelte
··· 8 8 import ProfileView from '$components/ProfileView.svelte'; 9 9 import { AtpClient, streamNotifications } from '$lib/at/client'; 10 10 import { accounts, type Account } from '$lib/accounts'; 11 - import { onMount, tick } from 'svelte'; 12 - import { SvelteMap } from 'svelte/reactivity'; 11 + import { onMount } from 'svelte'; 13 12 import { 14 13 clients, 15 14 postCursors, ··· 23 22 handleNotification, 24 23 addPosts, 25 24 addTimeline, 26 - type View, 27 - currentView, 28 - previousView, 29 - setView 25 + router 30 26 } from '$lib/state.svelte'; 31 27 import { get } from 'svelte/store'; 32 28 import Icon from '@iconify/svelte'; ··· 38 34 import type { Sort } from '$lib/following'; 39 35 40 36 const { data: loadData }: PageProps = $props(); 37 + 38 + const currentRoute = $derived(router.current); 41 39 42 40 // svelte-ignore state_referenced_locally 43 41 let errors = $state(loadData.client.ok ? [] : [loadData.client.error]); ··· 77 75 78 76 let followingSort = $state('active' as Sort); 79 77 78 + // Animation logic derived from router direction 80 79 let animClass = $state('animate-fade-in-scale'); 81 - let scrollPositions = new SvelteMap<View, number>(); 82 - let viewingProfileDid = $state<AtprotoDid | null>(null); 83 - 84 - const viewOrder: Record<View, number> = { 85 - timeline: 0, 86 - following: 1, 87 - notifications: 2, 88 - settings: 3, 89 - profile: 4 90 - }; 91 - 92 - const switchView = async () => { 93 - scrollPositions.set($previousView, window.scrollY); 94 - 95 - const direction = viewOrder[$previousView] > viewOrder[$currentView] ? 'right' : 'left'; 96 - // profile always slides in from right unless going back 97 - if ($currentView === 'profile') animClass = 'animate-slide-in-left'; 98 - else if ($previousView === 'profile') animClass = 'animate-slide-in-right'; 99 - else animClass = direction === 'right' ? 'animate-slide-in-right' : 'animate-slide-in-left'; 100 - 101 - await tick(); 102 - 103 - window.scrollTo({ top: scrollPositions.get($currentView) || 0, behavior: 'instant' }); 104 - }; 105 - currentView.subscribe(switchView); 106 - 107 - const goToProfile = (did: AtprotoDid) => { 108 - viewingProfileDid = did; 109 - setView('profile'); 110 - }; 80 + $effect(() => { 81 + if (router.direction === 'right') animClass = 'animate-slide-in-right'; 82 + else if (router.direction === 'left') animClass = 'animate-slide-in-left'; 83 + else animClass = 'animate-fade-in-scale'; 84 + }); 111 85 112 86 let postComposerState = $state<PostComposerState>({ type: 'null' }); 113 87 let showScrollToTop = $state(false); 114 88 const handleScroll = () => { 115 - if ($currentView === 'timeline') showScrollToTop = window.scrollY > 300; 89 + if (router.current.path === '/') showScrollToTop = window.scrollY > 300; 116 90 }; 117 91 const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' }); 118 92 119 93 onMount(() => { 94 + router.init(); 95 + 120 96 window.addEventListener('scroll', handleScroll); 121 97 122 98 accounts.subscribe((newAccounts) => { ··· 186 162 for (const follow of followMap.values()) wantedDids.push(follow.subject); 187 163 for (const account of $accounts) wantedDids.push(account.did); 188 164 189 - console.log('updating jetstream options:', wantedDids); 165 + // console.log('updating jetstream options:', wantedDids); 190 166 $jetstream?.updateOptions({ wantedDids }); 191 167 }); 192 168 </script> ··· 211 187 </button> 212 188 {/snippet} 213 189 190 + {#snippet routeButton( 191 + path: (typeof currentRoute)['path'], 192 + icon: string, 193 + ariaLabel: string, 194 + iconHover?: string 195 + )} 196 + {@render appButton( 197 + () => router.navigate(path), 198 + icon, 199 + ariaLabel, 200 + path === currentRoute.path, 201 + iconHover 202 + )} 203 + {/snippet} 204 + 214 205 <div class="mx-auto flex min-h-dvh max-w-2xl flex-col"> 215 206 <div class="flex-1"> 216 207 <!-- timeline --> 217 208 <TimelineView 218 - class={$currentView === 'timeline' ? `${animClass}` : 'hidden'} 209 + class={currentRoute.path === '/' ? `${animClass}` : 'hidden'} 219 210 client={selectedClient} 220 211 bind:postComposerState 221 212 /> 222 213 223 - {#if $currentView === 'settings'} 214 + {#if currentRoute.path === '/settings/:tab' || currentRoute.path === '/settings'} 224 215 <div class={animClass}> 225 - <SettingsView /> 216 + <SettingsView tab={currentRoute.params.tab ?? 'advanced'} /> 226 217 </div> 227 218 {/if} 228 - {#if $currentView === 'notifications'} 219 + {#if currentRoute.path === '/notifications'} 229 220 <div class={animClass}> 230 221 <NotificationsView /> 231 222 </div> 232 223 {/if} 233 - {#if $currentView === 'following'} 224 + {#if currentRoute.path === '/following'} 234 225 <div class={animClass}> 235 226 <FollowingView 236 227 selectedClient={selectedClient!} 237 228 selectedDid={selectedDid!} 238 - onProfileClick={goToProfile} 239 229 bind:followingSort 240 230 /> 241 231 </div> 242 232 {/if} 243 - {#if $currentView === 'profile' && viewingProfileDid} 244 - <div class={animClass}> 245 - <ProfileView 246 - client={selectedClient!} 247 - did={viewingProfileDid} 248 - onBack={() => setView($previousView)} 249 - bind:postComposerState 250 - /> 251 - </div> 233 + {#if currentRoute.path === '/profile/:actor'} 234 + {#key currentRoute.params.actor} 235 + <div class={animClass}> 236 + <ProfileView 237 + client={selectedClient!} 238 + onBack={() => router.back()} 239 + actor={currentRoute.params.actor} 240 + bind:postComposerState 241 + /> 242 + </div> 243 + {/key} 252 244 {/if} 253 245 </div> 254 246 ··· 278 270 279 271 <div 280 272 class=" 281 - {$currentView === 'timeline' || $currentView === 'following' || $currentView === 'profile' 273 + {router.current.path === '/' || 274 + router.current.path === '/following' || 275 + router.current.path === '/profile/:actor' 282 276 ? '' 283 277 : 'hidden'} 284 278 z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all ··· 334 328 </div> 335 329 </div> 336 330 <div class="grow"></div> 337 - {@render appButton( 338 - () => setView('timeline'), 339 - 'heroicons:home', 340 - 'timeline', 341 - $currentView === 'timeline', 342 - 'heroicons:home-solid' 343 - )} 344 - {@render appButton( 345 - () => setView('following'), 331 + {@render routeButton('/', 'heroicons:home', 'timeline', 'heroicons:home-solid')} 332 + {@render routeButton( 333 + '/following', 346 334 'heroicons:users', 347 335 'following', 348 - $currentView === 'following', 349 336 'heroicons:users-solid' 350 337 )} 351 - {@render appButton( 352 - () => setView('notifications'), 338 + {@render routeButton( 339 + '/notifications', 353 340 'heroicons:bell', 354 341 'notifications', 355 - $currentView === 'notifications', 356 342 'heroicons:bell-solid' 357 343 )} 358 - {@render appButton( 359 - () => setView('settings'), 344 + {@render routeButton( 345 + '/settings', 360 346 'heroicons:cog-6-tooth', 361 347 'settings', 362 - $currentView === 'settings', 363 348 'heroicons:cog-6-tooth-solid' 364 349 )} 365 350 </div>