appview-less bluesky client
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}