personal web client for Bluesky
typescript solidjs bluesky atcute
at trunk 5.5 kB view raw
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};