import { msg } from "@lingui/core/macro" import { ActionIcon, AppShell, Box, Center, Group, ScrollArea, Title, useMantineTheme } from "@mantine/core" import { type ReactNode, type RefObject, Suspense, useEffect, useRef, useState } from "react" import Draggable from "react-draggable" import { TbMenu2, TbPlus, TbX } from "react-icons/tb" import { Outlet } from "react-router-dom" import { useSwipeable } from "react-swipeable" import Tinycon from "tinycon" import { Constants } from "@/app/constants" import { redirectToAdd, redirectToRootCategory } from "@/app/redirect/thunks" import { useAppDispatch, useAppSelector } from "@/app/store" import { setMobileMenuOpen } from "@/app/tree/slice" import { reloadTree } from "@/app/tree/thunks" import { setSidebarWidth } from "@/app/user/slice" import { reloadProfile, reloadSettings, reloadTags } from "@/app/user/thunks" import { categoryUnreadCount } from "@/app/utils" import { ActionButton } from "@/components/ActionButton" import { AnnouncementDialog } from "@/components/AnnouncementDialog" import { DisablePullToRefresh } from "@/components/DisablePullToRefresh" import { Loader } from "@/components/Loader" import { Logo } from "@/components/Logo" import { MarkAllAsReadConfirmationDialog } from "@/components/MarkAllAsReadConfirmationDialog" import { OnDesktop } from "@/components/responsive/OnDesktop" import { OnMobile } from "@/components/responsive/OnMobile" import { useAppLoading } from "@/hooks/useAppLoading" import { useBrowserExtension } from "@/hooks/useBrowserExtension" import { useMobile } from "@/hooks/useMobile" import { useWebSocket } from "@/hooks/useWebSocket" import { LoadingPage } from "@/pages/LoadingPage" import { tss } from "@/tss" interface LayoutProps { sidebar: ReactNode sidebarVisible: boolean header: ReactNode } function LogoAndTitle() { const dispatch = useAppDispatch() return (
await dispatch(redirectToRootCategory())} style={{ cursor: "pointer" }} > CommaFeed
) } function UnreadCountTitleHandler({ enabled, }: Readonly<{ enabled?: boolean }>) { const root = useAppSelector(state => state.tree.rootCategory) const unreadCount = categoryUnreadCount(root) return {enabled && unreadCount > 0 ? `(${unreadCount}) CommaFeed` : "CommaFeed"} } function UnreadCountFaviconHandler({ enabled }: { enabled?: boolean }) { const root = useAppSelector(state => state.tree.rootCategory) const unreadCount = categoryUnreadCount(root) useEffect(() => { if (enabled && unreadCount > 0) { Tinycon.setBubble(unreadCount) } else { Tinycon.reset() } }, [unreadCount, enabled]) return null } function BrowserExtensionBadgeUnreadCountHandler() { const root = useAppSelector(state => state.tree.rootCategory) const { setBadgeUnreadCount } = useBrowserExtension() useEffect(() => { if (!root) return const unreadCount = categoryUnreadCount(root) setBadgeUnreadCount(unreadCount) }, [root, setBadgeUnreadCount]) return null } function CustomJsHandler() { const [scriptLoaded, setScriptLoaded] = useState(false) const { loading } = useAppLoading() useEffect(() => { if (scriptLoaded || loading) { return } const script = document.createElement("script") script.src = "custom_js.js" script.async = true document.body.appendChild(script) setScriptLoaded(true) return () => script.remove() }, [scriptLoaded, loading]) return null } function CustomCssHandler() { useEffect(() => { const link = document.createElement("link") link.rel = "stylesheet" link.type = "text/css" link.href = "custom_css.css" document.head.appendChild(link) return () => link.remove() }, []) return null } const useStyles = tss .withParams<{ sidebarWidth: number sidebarPadding: string sidebarRightBorderWidth: string }>() .create(({ sidebarWidth, sidebarPadding, sidebarRightBorderWidth }) => { return { sidebarContent: { maxWidth: `calc(${sidebarWidth}px - ${sidebarPadding} * 2 - ${sidebarRightBorderWidth})`, [`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: { maxWidth: `calc(100vw - ${sidebarPadding} * 2 - ${sidebarRightBorderWidth})`, }, }, } }) export default function Layout(props: Readonly) { const theme = useMantineTheme() const mobile = useMobile() const { isBrowserExtensionPopup } = useBrowserExtension() const draggableSeparator = useRef(null) const { loading } = useAppLoading() const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle) const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon) const disablePullToRefresh = useAppSelector(state => state.user.settings?.disablePullToRefresh) const mobileMenuOpen = useAppSelector(state => state.tree.mobileMenuOpen) const webSocketConnected = useAppSelector(state => state.server.webSocketConnected) const treeReloadInterval = useAppSelector(state => state.server.serverInfos?.treeReloadInterval) const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter) const sidebarWidth = useAppSelector(state => state.user.localSettings.sidebarWidth) const headerInFooter = mobile && !isBrowserExtensionPopup && mobileFooter const dispatch = useAppDispatch() useWebSocket() const sidebarPadding = theme.spacing.xs const { classes } = useStyles({ sidebarWidth, sidebarPadding, sidebarRightBorderWidth: "1px", }) useEffect(() => { // load initial data dispatch(reloadSettings()) dispatch(reloadProfile()) dispatch(reloadTree()) dispatch(reloadTags()) }, [dispatch]) useEffect(() => { let timer: number | undefined if (!webSocketConnected && treeReloadInterval) { // reload tree periodically if not receiving websocket events timer = window.setInterval(async () => await dispatch(reloadTree()), treeReloadInterval) } return () => clearInterval(timer) }, [dispatch, webSocketConnected, treeReloadInterval]) const burger = ( : } onClick={() => dispatch(setMobileMenuOpen(!mobileMenuOpen))} /> ) const addButton = ( await dispatch(redirectToAdd())} aria-label="Subscribe" > ) const header = ( <> {mobileMenuOpen && ( {burger} {addButton} )} {!mobileMenuOpen && ( {burger} {props.header} )} {addButton} {props.header} ) const swipeHandlers = useSwipeable({ onSwiping: e => { const threshold = document.documentElement.clientWidth / 6 if (e.absX > threshold) { dispatch(setMobileMenuOpen(e.dir === "Right")) } }, }) if (loading) return return ( <> {!headerInFooter && header} {headerInFooter && header} {props.sidebar} } axis="x" defaultPosition={{ x: sidebarWidth, y: 0, }} bounds={{ left: 120, right: 1000, }} grid={[30, 30]} onDrag={(_e, data) => { dispatch(setSidebarWidth(data.x)) }} > }> ) }