personal web client for Bluesky
typescript
solidjs
bluesky
atcute
1import {
2 type Accessor,
3 type Component,
4 type ComponentProps,
5 ErrorBoundary,
6 Suspense,
7 createMemo,
8 lazy,
9} from 'solid-js';
10
11import type { AppBskyNotificationGetUnreadCount } from '@atcute/bluesky';
12import type { DefinedCreateQueryResult } from '@mary/solid-query';
13
14import { createNotificationCountQuery } from '~/api/queries/notification-count';
15
16import { globalEvents } from '~/globals/events';
17import { hasModals } from '~/globals/modals';
18import { history } from '~/globals/navigation';
19
20import { type MatchedRouteState, RouterView, useMatchedRoute } from '~/lib/navigation/router';
21import { useSession } from '~/lib/states/session';
22
23import BellOutlinedIcon from '~/components/icons-central/bell-outline';
24import BellSolidIcon from '~/components/icons-central/bell-solid';
25import HomeOutlinedIcon from '~/components/icons-central/home-outline';
26import HomeSolidIcon from '~/components/icons-central/home-solid';
27import MagnifyingGlassOutlinedIcon from '~/components/icons-central/magnifying-glass-outline';
28import MailOutlinedIcon from '~/components/icons-central/mail-outline';
29import MailSolidIcon from '~/components/icons-central/mail-solid';
30
31import ErrorPage from '~/views/_error';
32
33const SignedOutView = lazy(() => import('~/views/_signed-out'));
34
35const Shell = () => {
36 const { currentAccount } = useSession();
37
38 // Will always match because we've set a 404 handler.
39 const route = useMatchedRoute() as Accessor<MatchedRouteState>;
40
41 const showNavBar = createMemo((): boolean => {
42 return !!(currentAccount && route().def.meta?.main);
43 });
44
45 const unread = createNotificationCountQuery({
46 get disabled() {
47 return !showNavBar();
48 },
49 });
50
51 return (
52 <div
53 inert={hasModals()}
54 class="relative z-0 mx-auto box-content flex min-h-[100dvh] max-w-[600px] flex-col-reverse border-outline sm:border-x"
55 >
56 {!!(currentAccount && route().def.meta?.main) && <NavBar route={route} unread={unread} />}
57
58 <div class="z-0 flex min-h-0 grow flex-col overflow-clip">
59 <RouterView
60 render={({ def }) => {
61 return (
62 <ErrorBoundary fallback={(error, reset) => <ErrorPage error={error} reset={reset} />}>
63 <Suspense
64 children={(() => {
65 if (!currentAccount && !def.meta?.public) {
66 return <SignedOutView />;
67 }
68
69 return <def.component />;
70 })()}
71 />
72 </ErrorBoundary>
73 );
74 }}
75 />
76 </div>
77 </div>
78 );
79};
80
81export default Shell;
82
83const enum MainTabs {
84 HOME = 'Home',
85 EXPLORE = 'Explore',
86 NOTIFICATIONS = 'Notifications',
87 MESSAGES = 'Messages',
88}
89
90const MainTabsRoutes = {
91 [MainTabs.HOME]: '/',
92 [MainTabs.EXPLORE]: '/explore',
93 [MainTabs.NOTIFICATIONS]: '/notifications',
94 [MainTabs.MESSAGES]: '/messages',
95};
96
97const NavBar = ({
98 route,
99 unread,
100}: {
101 route: Accessor<MatchedRouteState>;
102 unread: DefinedCreateQueryResult<AppBskyNotificationGetUnreadCount.$output>;
103}) => {
104 const active = () => route().def.meta?.name;
105
106 const bindClick = (to: MainTabs) => {
107 return () => {
108 const from = active();
109
110 if (from === to) {
111 window.scrollTo({ top: 0, behavior: 'instant' });
112 globalEvents.emit('softreset');
113 return;
114 }
115
116 const fromHome = !!(history.location.state as any)?.fromHome;
117 const href = MainTabsRoutes[to];
118
119 if (to === MainTabs.HOME && fromHome) {
120 history.back();
121 return;
122 }
123
124 history.navigate(href, {
125 replace: from !== MainTabs.HOME,
126 state: {
127 // inherit `fromHome` state
128 fromHome: fromHome || from === MainTabs.HOME,
129 },
130 });
131 };
132 };
133
134 return (
135 <>
136 <div class="sticky bottom-0 z-1 flex h-13 w-full shrink-0 items-stretch border-t border-outline bg-background">
137 <NavItem
138 label="Home"
139 active={active() === MainTabs.HOME}
140 onClick={bindClick(MainTabs.HOME)}
141 icon={HomeOutlinedIcon}
142 iconActive={HomeSolidIcon}
143 />
144 <NavItem
145 label="Search"
146 active={active() === MainTabs.EXPLORE}
147 onClick={bindClick(MainTabs.EXPLORE)}
148 icon={MagnifyingGlassOutlinedIcon}
149 />
150 <NavItem
151 label="Notifications"
152 badge={getUnreadCountLabel(unread.data.count)}
153 active={active() === MainTabs.NOTIFICATIONS}
154 onClick={bindClick(MainTabs.NOTIFICATIONS)}
155 icon={BellOutlinedIcon}
156 iconActive={BellSolidIcon}
157 />
158 <NavItem
159 label="Direct Messages"
160 active={active() === MainTabs.MESSAGES}
161 onClick={bindClick(MainTabs.MESSAGES)}
162 icon={MailOutlinedIcon}
163 iconActive={MailSolidIcon}
164 />
165 </div>
166 </>
167 );
168};
169
170const getUnreadCountLabel = (count: number = 0) => {
171 return count > 0 ? (count > 30 ? `30+` : `${count}`) : ``;
172};
173
174type IconComponent = Component<ComponentProps<'svg'>>;
175
176interface NavItemProps {
177 active?: boolean;
178 label: string;
179 badge?: string;
180 icon: IconComponent;
181 iconActive?: IconComponent;
182 onClick?: () => void;
183}
184
185const NavItem = (props: NavItemProps) => {
186 const InactiveIcon = props.icon;
187 const ActiveIcon = props.iconActive;
188
189 return (
190 <button title={props.label} onClick={props.onClick} class="relative grid grow basis-0 place-items-center">
191 {(() => {
192 const active = props.active;
193
194 const Icon = active && ActiveIcon ? ActiveIcon : InactiveIcon;
195 return <Icon class={`text-2xl` + (active && !ActiveIcon ? ` stroke-contrast *:stroke-[3]` : ``)} />;
196 })()}
197
198 {props.badge && (
199 <div class="absolute -mr-4 -mt-4 flex items-center justify-center rounded-md bg-accent px-1 py-0.5 text-xs font-medium leading-none text-white outline-2 outline-background outline">
200 {props.badge}
201 </div>
202 )}
203 </button>
204 );
205};