BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { useAppPreferences } from "$/contexts/app-preferences";
2import { useAppSession } from "$/contexts/app-session";
3import { useAppShellUi } from "$/contexts/app-shell-ui";
4import { HashRouter, Navigate, Route, useLocation, useParams } from "@solidjs/router";
5import type { RouteSectionProps } from "@solidjs/router";
6import { type Component, createEffect, type JSX, type ParentProps, Show } from "solid-js";
7import { Dynamic } from "solid-js/web";
8import { DeckWorkspace } from "./components/deck/DeckWorkspace";
9import { ExplorerPanel } from "./components/explorer/ExplorerPanel";
10import { SavedPostsPanel } from "./components/saved/SavedPostsPanel";
11import { HashtagPanel } from "./components/search/HashtagPanel";
12import { SearchPanel } from "./components/search/SearchPanel";
13import { SearchPreflightPanel } from "./components/search/SearchPreflightPanel";
14import { SettingsPanel } from "./components/settings/SettingsPanel";
15import { decodeMessagesRouteMemberDid } from "./lib/conversations";
16import { TIMELINE_ROUTE } from "./lib/feeds";
17import { decodePostRouteUri } from "./lib/post-routes";
18import { decodeProfileRouteActor } from "./lib/profile";
19import { buildSearchPreflightRoute, decodeHashtagRouteTag, parseSearchRouteState } from "./lib/search-routes";
20
21type TMessagesRouteProps = { memberDid: string | null };
22type TPostRouteProps = { uri: string | null };
23type TProfileRouteProps = { actor: string | null };
24
25type AppShellProps = ParentProps<{ fullWidth?: boolean }>;
26
27type AppRouterProps = {
28 renderAuth: () => JSX.Element;
29 renderComposer: () => JSX.Element;
30 renderMessages: Component<TMessagesRouteProps>;
31 renderNotifications: () => JSX.Element;
32 renderPostEngagement: Component<TPostRouteProps>;
33 renderPost: Component<TPostRouteProps>;
34 renderProfile: Component<TProfileRouteProps>;
35 renderShell: Component<AppShellProps>;
36 renderTimeline: () => JSX.Element;
37};
38
39export function AppRouter(props: AppRouterProps) {
40 const session = useAppSession();
41 const shell = useAppShellUi();
42
43 const RouterFrame: Component<RouteSectionProps> = (routeProps) => {
44 const location = useLocation();
45 let previousPath = location.pathname;
46 const standaloneComposerRoute = () => location.pathname === "/composer";
47
48 createEffect(() => {
49 const nextPath = location.pathname;
50 if (nextPath !== previousPath) {
51 shell.closeSwitcher();
52 previousPath = nextPath;
53 }
54 });
55
56 const fullWidthShell = () => location.pathname === "/explorer" || location.pathname === "/deck";
57
58 return (
59 <Show
60 when={standaloneComposerRoute()}
61 fallback={<props.renderShell fullWidth={fullWidthShell()}>{routeProps.children}</props.renderShell>}>
62 {routeProps.children}
63 </Show>
64 );
65 };
66
67 const IndexRoute = () => (
68 <Show when={!session.bootstrapping} fallback={<RouteLoadingState />}>
69 <Navigate href={session.hasSession ? TIMELINE_ROUTE : "/auth"} />
70 </Show>
71 );
72
73 const AuthRoute = () => <PublicOnlyRoute redirectHref={TIMELINE_ROUTE}>{props.renderAuth()}</PublicOnlyRoute>;
74
75 const TimelineRoute = () => <ProtectedRouteView>{props.renderTimeline()}</ProtectedRouteView>;
76
77 const SearchRoute = () => (
78 <ProtectedRouteView>
79 <SearchRouteGate />
80 </ProtectedRouteView>
81 );
82
83 const SearchPreflightRoute = () => (
84 <ProtectedRouteView>
85 <SearchPreflightPanel />
86 </ProtectedRouteView>
87 );
88
89 const ProfileRoute = () => (
90 <ProtectedRouteView>
91 <Dynamic component={props.renderProfile} actor={null} />
92 </ProtectedRouteView>
93 );
94
95 const ActorProfileRoute = () => {
96 const params = useParams<{ actor: string }>();
97
98 return (
99 <ProtectedRouteView>
100 <Dynamic component={props.renderProfile} actor={decodeProfileRouteActor(params.actor)} />
101 </ProtectedRouteView>
102 );
103 };
104
105 const NotificationsRoute = () => <ProtectedRouteView>{props.renderNotifications()}</ProtectedRouteView>;
106
107 const PostRoute = () => {
108 const params = useParams<{ encodedUri: string }>();
109
110 return (
111 <ProtectedRouteView>
112 <Dynamic component={props.renderPost} uri={decodePostRouteUri(params.encodedUri)} />
113 </ProtectedRouteView>
114 );
115 };
116
117 const PostEngagementRoute = () => {
118 const params = useParams<{ encodedUri: string }>();
119
120 return (
121 <ProtectedRouteView>
122 <Dynamic component={props.renderPostEngagement} uri={decodePostRouteUri(params.encodedUri)} />
123 </ProtectedRouteView>
124 );
125 };
126
127 const HashtagRoute = () => {
128 const params = useParams<{ hashtag: string }>();
129 const tag = decodeHashtagRouteTag(params.hashtag);
130
131 return (
132 <ProtectedRouteView>
133 <Show when={tag} fallback={<Navigate href="/search" />}>
134 <HashtagPanel />
135 </Show>
136 </ProtectedRouteView>
137 );
138 };
139
140 const MessagesRoute = () => (
141 <ProtectedRouteView>
142 <Dynamic component={props.renderMessages} memberDid={null} />
143 </ProtectedRouteView>
144 );
145
146 const MemberMessagesRoute = () => {
147 const params = useParams<{ memberDid: string }>();
148
149 return (
150 <ProtectedRouteView>
151 <Dynamic component={props.renderMessages} memberDid={decodeMessagesRouteMemberDid(params.memberDid)} />
152 </ProtectedRouteView>
153 );
154 };
155
156 const ComposerRoute = () => <ProtectedRouteView>{props.renderComposer()}</ProtectedRouteView>;
157
158 const DeckRoute = () => (
159 <ProtectedRouteView>
160 <DeckWorkspace />
161 </ProtectedRouteView>
162 );
163
164 const ExplorerRoute = () => (
165 <ProtectedRouteView>
166 <ExplorerPanel />
167 </ProtectedRouteView>
168 );
169
170 const SettingsRoute = () => (
171 <ProtectedRouteView>
172 <SettingsPanel />
173 </ProtectedRouteView>
174 );
175
176 const SavedPostsRoute = () => (
177 <ProtectedRouteView>
178 <SavedPostsPanel />
179 </ProtectedRouteView>
180 );
181
182 const NotFoundRoute = () => (
183 <Show when={session.bootstrapping} fallback={<Navigate href={session.hasSession ? TIMELINE_ROUTE : "/auth"} />}>
184 <RouteLoadingState />
185 </Show>
186 );
187
188 return (
189 <HashRouter root={RouterFrame}>
190 <Route path="/" component={IndexRoute} />
191 <Route path="/auth" component={AuthRoute} />
192 <Route path="/timeline" component={TimelineRoute} />
193 <Route path="/profile" component={ProfileRoute} />
194 <Route path="/profile/:actor" component={ActorProfileRoute} />
195 <Route path="/composer" component={ComposerRoute} />
196 <Route path="/search/preflight" component={SearchPreflightRoute} />
197 <Route path="/search" component={SearchRoute} />
198 <Route path="/hashtag/:hashtag" component={HashtagRoute} />
199 <Route path="/saved" component={SavedPostsRoute} />
200 <Route path="/notifications" component={NotificationsRoute} />
201 <Route path="/post/:encodedUri/engagement" component={PostEngagementRoute} />
202 <Route path="/post/:encodedUri" component={PostRoute} />
203 <Route path="/messages" component={MessagesRoute} />
204 <Route path="/messages/:memberDid" component={MemberMessagesRoute} />
205 <Route path="/deck" component={DeckRoute} />
206 <Route path="/explorer" component={ExplorerRoute} />
207 <Route path="/settings" component={SettingsRoute} />
208 <Route path="*404" component={NotFoundRoute} />
209 </HashRouter>
210 );
211}
212
213function SearchRouteGate() {
214 const preferences = useAppPreferences();
215 const location = useLocation();
216 const routeState = () => parseSearchRouteState(location.search);
217 const nextRoute = () => `${location.pathname}${location.search}`;
218 const showLoading = () => preferences.embeddingsLoading && !preferences.embeddingsConfig;
219 const shouldRedirect = () => {
220 const config = preferences.embeddingsConfig;
221 if (!config || routeState().tab !== "posts") {
222 return false;
223 }
224
225 return !config.enabled && !config.preflightSeen;
226 };
227
228 return (
229 <Show when={!showLoading()} fallback={<RouteLoadingState />}>
230 <Show when={!shouldRedirect()} fallback={<Navigate href={buildSearchPreflightRoute(nextRoute())} />}>
231 <SearchPanel />
232 </Show>
233 </Show>
234 );
235}
236
237function PublicOnlyRoute(props: ParentProps & { redirectHref: string }) {
238 const session = useAppSession();
239
240 return (
241 <Show when={!session.hasSession || session.bootstrapping} fallback={<Navigate href={props.redirectHref} />}>
242 {props.children}
243 </Show>
244 );
245}
246
247function ProtectedRouteView(props: ParentProps) {
248 const session = useAppSession();
249
250 return (
251 <Show
252 when={session.bootstrapping}
253 fallback={<Show when={session.activeSession} fallback={<Navigate href="/auth" />}>{props.children}</Show>}>
254 <RouteLoadingState />
255 </Show>
256 );
257}
258
259function RouteLoadingState() {
260 return (
261 <div class="grid min-h-168 place-items-center rounded-4xl bg-white/2 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]">
262 <div class="grid gap-3 text-center">
263 <p class="overline-copy text-sm text-on-surface-variant">Loading</p>
264 <p class="m-0 text-base text-on-surface">Restoring your workspace.</p>
265 </div>
266 </div>
267 );
268}