appview-less bluesky client
24
fork

Configure Feed

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

at main 161 lines 4.4 kB view raw
1/* eslint-disable svelte/no-navigation-without-resolve */ 2import { pushState, replaceState } from '$app/navigation'; 3import { SvelteMap } from 'svelte/reactivity'; 4import { tick } from 'svelte'; 5 6export const routes = [ 7 { path: '/', order: 0 }, 8 { path: '/following', order: 1 }, 9 { path: '/notifications', order: 2 }, 10 { path: '/settings/:tab', order: 3 }, 11 { path: '/profile/:actor', order: 4 } 12] as const; 13 14export type RouteConfig = (typeof routes)[number]; 15export type RoutePath = RouteConfig['path']; 16 17type 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 26export 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 35type RouteNode = { 36 children: Map<string, RouteNode>; 37 paramName?: string; 38 paramChild?: RouteNode; 39 config?: RouteConfig; 40}; 41 42const fallbackRoute: Route<'/'> = { 43 params: {}, 44 path: '/', 45 order: 0, 46 url: '/' 47}; 48 49export 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 // disable browser scroll restoration 88 if ('scrollRestoration' in history) { 89 history.scrollRestoration = 'manual'; 90 } 91 } 92 93 match(urlPath: string): Route | undefined { 94 const segments = urlPath.split('/').filter(Boolean); 95 const params: Record<string, string> = {}; 96 97 let node = this.root; 98 99 for (const segment of segments) { 100 if (node.children.has(segment)) { 101 node = node.children.get(segment)!; 102 } else if (node.paramChild) { 103 node = node.paramChild; 104 if (node.paramName) params[node.paramName] = decodeURIComponent(segment); 105 } else { 106 return undefined; 107 } 108 } 109 110 if (node.config) 111 return { 112 params: params as unknown, 113 path: node.config.path, 114 order: node.config.order, 115 url: urlPath 116 } as Route<typeof node.config.path>; 117 118 return undefined; 119 } 120 121 updateDirection(newOrder: number, oldOrder: number) { 122 if (newOrder === oldOrder) this.direction = 'none'; 123 else if (newOrder > oldOrder) this.direction = 'right'; 124 else this.direction = 'left'; 125 } 126 127 private async _updateState(url: string) { 128 const target = this.match(url); 129 if (!target) return; 130 131 // save scroll position 132 if (typeof window !== 'undefined') this.scrollPositions.set(this.current.url, window.scrollY); 133 134 this.updateDirection(target.order, this.current.order); 135 this.current = target; 136 137 if (typeof window !== 'undefined') { 138 await tick(); 139 const savedScroll = this.scrollPositions.get(target.url) ?? 0; 140 window.scrollTo({ top: savedScroll, behavior: 'instant' }); 141 } 142 } 143 144 navigate(url: string, { replace = false } = {}) { 145 if (typeof window === 'undefined') return; 146 if (this.current.url === url) return; 147 148 if (replace) replaceState(url, {}); 149 else pushState(url, {}); 150 151 this._updateState(url); 152 } 153 154 replace(url: string) { 155 this.navigate(url, { replace: true }); 156 } 157 158 back() { 159 if (typeof window !== 'undefined') history.back(); 160 } 161}