From 960f9bf875ec48faad2284c2aa2da7ab0ba18d0e Mon Sep 17 00:00:00 2001 From: Puyodead1 Date: Thu, 4 Apr 2024 17:25:38 -0400 Subject: [PATCH] work on mobile - replaced banner context with banner controller like modals - replaced network check with new hook - add swipable layout component - fix member list width being slightly larger when overflowing members are displayed god help me --- package.json | 3 + src-tauri/.idea/workspace.xml | 106 +++--------- src/App.tsx | 27 +-- src/components/AuthComponents.tsx | 2 +- src/components/Banner.tsx | 70 -------- src/components/ChannelSidebar.tsx | 33 +++- src/components/Container.tsx | 1 + src/components/GuildSidebar.tsx | 6 +- src/components/MemberList/MemberList.tsx | 10 +- src/components/SectionHeader.tsx | 2 +- src/components/SwipeableLayout.tsx | 80 +++++++++ src/components/styles.module.css | 37 ++++ src/contexts/BannerContext.tsx | 31 ---- src/controllers/banners/BannerController.tsx | 168 +++++++++++++++++++ src/controllers/banners/BannerRenderer.tsx | 6 + src/controllers/banners/index.ts | 3 + src/controllers/banners/types.ts | 9 + src/index.css | 36 ++-- src/index.tsx | 11 +- src/pages/LoadingPage.tsx | 8 +- src/pages/subpages/ChannelPage.tsx | 38 ++++- src/stores/AppStore.ts | 11 +- 22 files changed, 449 insertions(+), 249 deletions(-) delete mode 100644 src/components/Banner.tsx create mode 100644 src/components/SwipeableLayout.tsx create mode 100644 src/components/styles.module.css delete mode 100644 src/contexts/BannerContext.tsx create mode 100644 src/controllers/banners/BannerController.tsx create mode 100644 src/controllers/banners/BannerRenderer.tsx create mode 100644 src/controllers/banners/index.ts create mode 100644 src/controllers/banners/types.ts diff --git a/package.json b/package.json index f5807ea..73c028d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@testing-library/user-event": "^14.5.2", "@types/react-measure": "^2.0.12", "@types/react-portal": "^4.0.7", + "@uidotdev/usehooks": "^2.4.1", "classnames": "^2.5.1", "csstype": "^3.1.3", "dayjs": "^1.11.10", @@ -60,9 +61,11 @@ "react-secure-storage": "^1.3.2", "react-select-search": "^4.1.7", "react-spinners": "^0.13.8", + "react-spring": "^9.7.3", "react-string-replace": "^1.1.1", "react-syntax-highlighter": "^15.5.0", "react-use-error-boundary": "^3.0.0", + "react-use-gesture": "^9.1.3", "react-virtualized": "^9.22.5", "remark-gfm": "^4.0.0", "reoverlay": "^1.0.3", diff --git a/src-tauri/.idea/workspace.xml b/src-tauri/.idea/workspace.xml index eaa9a70..1e42743 100644 --- a/src-tauri/.idea/workspace.xml +++ b/src-tauri/.idea/workspace.xml @@ -1,97 +1,42 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - + + @@ -110,10 +55,13 @@ "keyToString": { "RunOnceActivity.OpenProjectViewOnStart": "true", "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.cidr.known.project.marker": "true", "RunOnceActivity.rust.reset.selective.auto.import": "true", + "cf.first.check.clang-format": "false", + "cidr.known.project.marker": "true", "git-widget-placeholder": "dev", "ignore.virus.scanning.warn.message": "true", - "last_opened_file_path": "C:/Users/23562/Documents/Code/workspaces/spacebar/client-react/src-tauri/Cargo.toml", + "last_opened_file_path": "E:/client-react/src-tauri", "node.js.detected.package.eslint": "true", "node.js.detected.package.tslint": "true", "node.js.selected.package.eslint": "(autodetect)", diff --git a/src/App.tsx b/src/App.tsx index 2bb43a7..6de77e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,11 +8,11 @@ import RegistrationPage from "./pages/RegistrationPage"; import { getTauriVersion, getVersion } from "@tauri-apps/api/app"; import { arch, locale, platform, version } from "@tauri-apps/plugin-os"; +import { useNetworkState } from "@uidotdev/usehooks"; import { reaction } from "mobx"; import ErrorBoundary from "./components/ErrorBoundary"; import Loader from "./components/Loader"; import { UnauthenticatedGuard } from "./components/guards/UnauthenticatedGuard"; -import { BannerContext } from "./contexts/BannerContext"; import useLogger from "./hooks/useLogger"; import AppPage from "./pages/AppPage"; import LogoutPage from "./pages/LogoutPage"; @@ -21,13 +21,14 @@ import { useAppStore } from "./stores/AppStore"; import { Globals } from "./utils/Globals"; // @ts-expect-error no types import FPSStats from "react-fps-stats"; +import { bannerController } from "./controllers/banners"; import { isTauri } from "./utils/Utils"; function App() { const app = useAppStore(); - const bannerContext = React.useContext(BannerContext); const logger = useLogger("App"); const navigate = useNavigate(); + const networkState = useNetworkState(); React.useEffect(() => { // Handles gateway connection/disconnection on token change @@ -78,15 +79,19 @@ function App() { return dispose; }, []); - // TODO: we need more of a stack for banners, closing here will cause any banners to immediately close - // React.useEffect(() => { - // if (!app.isNetworkConnected) - // bannerContext.setContent({ - // forced: true, - // element: , - // }); - // else bannerContext.close(); - // }, [app.isNetworkConnected, bannerContext]); + React.useEffect(() => { + if (!networkState.online) { + bannerController.push( + { + type: "offline", + }, + "offline", + ); + } else { + // only close if the current banner is the offline banner + bannerController.remove("offline"); + } + }, [networkState]); return ( diff --git a/src/components/AuthComponents.tsx b/src/components/AuthComponents.tsx index 81a7c22..38cee90 100644 --- a/src/components/AuthComponents.tsx +++ b/src/components/AuthComponents.tsx @@ -6,8 +6,8 @@ export const Wrapper = styled(Container)` display: flex; justify-content: center; align-items: center; - height: 100vh; background-color: var(--background-tertiary); + flex: 1; `; export const AuthContainer = styled(Container)` diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx deleted file mode 100644 index 5c29253..0000000 --- a/src/components/Banner.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { AnimatePresence, motion } from "framer-motion"; -import React from "react"; -import styled from "styled-components"; -import { BannerContext } from "../contexts/BannerContext"; -import useLogger from "../hooks/useLogger"; -import Icon from "./Icon"; -import IconButton from "./IconButton"; - -const Container = styled(motion.div)` - display: flex; - justify-content: center; - align-items: center; -`; - -const CloseWrapper = styled(IconButton)` - position: absolute; - right: 1%; -`; - -function Banner() { - const logger = useLogger("Banner"); - const bannerContext = React.useContext(BannerContext); - - if (!bannerContext.content) return null; - - return ( - - { - logger.debug("animation complete"); - }} - style={bannerContext.content.style} - > - {bannerContext.content.element} - {!bannerContext.content.forced && ( - { - bannerContext.close(); - }} - > - - - )} - - - ); -} - -export default Banner; diff --git a/src/components/ChannelSidebar.tsx b/src/components/ChannelSidebar.tsx index 4e2d8e2..97173e9 100644 --- a/src/components/ChannelSidebar.tsx +++ b/src/components/ChannelSidebar.tsx @@ -1,5 +1,9 @@ +import { useMediaQuery, useWindowSize } from "@uidotdev/usehooks"; import { observer } from "mobx-react-lite"; +import { useEffect, useState } from "react"; +import { isDesktop } from "react-device-detect"; import styled from "styled-components"; +import { isTouchscreenDevice } from "../utils/isTouchscreenDevice"; import ChannelHeader from "./ChannelHeader"; import ChannelList from "./ChannelList/ChannelList"; import Container from "./Container"; @@ -8,21 +12,36 @@ import UserPanel from "./UserPanel"; const Wrapper = styled(Container)` display: flex; flex-direction: column; - flex: 0 0 240px; background-color: var(--background-secondary); - - @media (max-width: 810px) { - display: none; - } `; function ChannelSidebar() { + const windowSize = useWindowSize(); + const isSmallScreen = useMediaQuery("only screen and (max-width: 810px)"); + const [size, setSize] = useState(); + + useEffect(() => { + if (!windowSize.width) return; + const screenPercent = (windowSize.width * 80) / 100; + setSize(screenPercent - 72); + }, [windowSize]); + return ( - + {/* TODO: replace with dm search if no guild */} - + {!isTouchscreenDevice && } ); } diff --git a/src/components/Container.tsx b/src/components/Container.tsx index 5fcde4d..4af2004 100644 --- a/src/components/Container.tsx +++ b/src/components/Container.tsx @@ -4,4 +4,5 @@ export default styled.div` background-color: var(--background-tertiary); color: var(--text); overflow: hidden; + display: flex; `; diff --git a/src/components/GuildSidebar.tsx b/src/components/GuildSidebar.tsx index c97315f..f7c5197 100644 --- a/src/components/GuildSidebar.tsx +++ b/src/components/GuildSidebar.tsx @@ -13,9 +13,9 @@ const Container = styled.div` flex: 0 0 72px; margin: 4px 0 0 0; - @media (max-width: 560px) { - display: none; - } + // @media (max-width: 560px) { + // display: none; + // } .ReactVirtualized__List { scrollbar-width: none; /* Firefox */ diff --git a/src/components/MemberList/MemberList.tsx b/src/components/MemberList/MemberList.tsx index 5c226ca..88dd0a8 100644 --- a/src/components/MemberList/MemberList.tsx +++ b/src/components/MemberList/MemberList.tsx @@ -12,7 +12,7 @@ const Container = styled.div` flex: 0 0 240px; flex-direction: column; background-color: var(--background-secondary); - height: 100%; + overflow-x: hidden; @media (max-width: 1050px) { display: none; @@ -23,9 +23,9 @@ const List = styled.ul` padding: 0; margin: 0; list-style: none; - overflow-y: auto; - height: 100%; - width: 100%; + // overflow-y: auto; + // height: 100%; + // width: 100%; `; function MemberList() { @@ -59,7 +59,7 @@ function MemberList() { ))} /> - )) + )) : null} diff --git a/src/components/SectionHeader.tsx b/src/components/SectionHeader.tsx index d4b65a7..890f1f3 100644 --- a/src/components/SectionHeader.tsx +++ b/src/components/SectionHeader.tsx @@ -8,5 +8,5 @@ export const SectionHeader = styled.div` align-items: center; justify-content: space-between; white-space: nowrap; - height: 24px; + height: 50px; `; diff --git a/src/components/SwipeableLayout.tsx b/src/components/SwipeableLayout.tsx new file mode 100644 index 0000000..d3caf42 --- /dev/null +++ b/src/components/SwipeableLayout.tsx @@ -0,0 +1,80 @@ +import { useWindowSize } from "@uidotdev/usehooks"; +import React from "react"; +import { animated, config, useSpring } from "react-spring"; +import { useDrag } from "react-use-gesture"; + +import styles from "./styles.module.css"; + +interface Props { + leftChildren: React.ReactNode; + rightChildren: React.ReactNode; + children: React.ReactNode; +} + +function SwipeableLayout({ leftChildren, children, rightChildren }: Props) { + const size = useWindowSize(); + const [{ x }, api] = useSpring(() => ({ + x: 0, + })); + + const open = (canceled: boolean) => { + // when cancel is true, it means that the user passed the upwards threshold + // so we change the spring config to create a nice wobbly effect + api.start({ x: (size.width! * 80) / 100, immediate: false, config: canceled ? config.wobbly : config.stiff }); + }; + + const close = (velocity = 0) => { + api.start({ x: 0, immediate: false, config: { ...config.stiff, velocity } }); + }; + + const bind = useDrag( + ({ last, velocity: v, direction: [dx], offset: [ox], cancel, canceled }) => { + const maxWidth = size.width! * 0.5; + console.debug("=-=-=-=-=-=-=-=-=-=-"); + console.debug(`X is: `, x.get()); + console.debug(`Max width is: `, maxWidth); + console.debug(`Last`, last); + console.debug(`Velocity is: `, v); + console.debug(`Direction is: `, dx); + console.debug(`Offset is: `, ox); + + // // on release, check if passed threshold to close, or reset to open pos + // if (last) { + // // if direction is < 0 (left), and offset is less than 50% of the screen width then close + + // ox < maxWidth && dx === -1 ? close(v) : open(canceled); + // } else api.start({ x: ox }); + + api.start({ x: ox }); + }, + { + from: () => [x.get(), 0], + filterTaps: true, + bounds: { left: 0, right: (size.width! * 80) / 100 }, + rubberband: true, + // initial: () => [x.get(), 0], + axis: "x", + }, + ); + + // handle resize + React.useEffect(() => { + console.log("width change"); + if (x.get() > 0) { + open(false); + } else { + close(); + } + }, [size.width]); + + return ( + + {leftChildren} + + {children} + + + ); +} + +export default SwipeableLayout; diff --git a/src/components/styles.module.css b/src/components/styles.module.css new file mode 100644 index 0000000..c32aea9 --- /dev/null +++ b/src/components/styles.module.css @@ -0,0 +1,37 @@ +.item { + position: relative; + /* width: 100%; */ + /* height: 100%; */ + pointer-events: auto; + /* transform-origin: 50% 50% 0px; */ + /* padding-left: 32px; */ + /* padding-right: 32px; */ + box-sizing: border-box; + display: flex; + flex: 1; + /* align-items: center; */ + /* text-align: center; */ + /* border-radius: 5px; */ + /* box-shadow: 0px 10px 10px -5px rgba(0, 0, 0, 0.2); */ + -webkit-user-select: none; + user-select: none; +} + +.fg { + cursor: -webkit-grab; + position: absolute; + height: 100%; + width: 100%; + display: grid; +} + +.fg > * { + pointer-events: none; +} + +.container { + display: flex; + align-items: center; + height: 100%; + justify-content: center; +} diff --git a/src/contexts/BannerContext.tsx b/src/contexts/BannerContext.tsx deleted file mode 100644 index fbe00cf..0000000 --- a/src/contexts/BannerContext.tsx +++ /dev/null @@ -1,31 +0,0 @@ -// context to handle banner open/close state - -import { MotionStyle } from "framer-motion"; -import React from "react"; - -export interface BannerContent { - element: React.ReactNode; - style?: MotionStyle; - forced?: boolean; -} - -export type BannerContextType = { - content?: BannerContent; - setContent: React.Dispatch>; - close: () => void; -}; - -// @ts-expect-error not specifying a default value here -export const BannerContext = React.createContext(); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const BannerContextProvider: React.FC = ({ children }) => { - const [content, setContent] = React.useState(); - - const close = () => { - // clear content - setContent(undefined); - }; - - return {children}; -}; diff --git a/src/controllers/banners/BannerController.tsx b/src/controllers/banners/BannerController.tsx new file mode 100644 index 0000000..c941187 --- /dev/null +++ b/src/controllers/banners/BannerController.tsx @@ -0,0 +1,168 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { action, computed, makeObservable, observable } from "mobx"; +import styled from "styled-components"; +import IconButton from "../../components/IconButton"; +import OfflineBanner from "../../components/banners/OfflineBanner"; +import { Banner } from "./types"; + +const Container = styled(motion.div)` + display: flex; + justify-content: center; + align-items: center; +`; + +const CloseWrapper = styled(IconButton)` + position: absolute; + right: 1%; +`; + +function randomUUID() { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + // eslint-disable-next-line no-bitwise + const r = (Math.random() * 16) | 0; + // eslint-disable-next-line no-bitwise, no-mixed-operators + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Components = Record>; + +/** + * Handles layering and displaying banners to the user. + */ +class BannerController { + stack: T[] = []; + components: Components; + + constructor(components: Components) { + this.components = components; + + makeObservable(this, { + stack: observable, + push: action, + pop: action, + remove: action, + rendered: computed, + isVisible: computed, + }); + + this.close = this.close.bind(this); + this.closeAll = this.closeAll.bind(this); + } + + /** + * Display a new banner on the stack + * @param banner banner data + */ + push(banner: T, key?: string) { + if (key && this.stack.find((x) => x.key === key)) { + console.warn(`Banner with key '${key}' already exists on the stack!`); + return; + } + + this.stack = [ + ...this.stack, + { + ...banner, + key: key ?? randomUUID(), + }, + ]; + } + + /** + * Remove the top banner from the screen + */ + pop() { + this.stack = this.stack.map((entry, index) => (index === this.stack.length - 1 ? entry : entry)); + } + + /** + * Close the top banner + */ + close() { + this.pop(); + } + + /** + * Close all banners on the stack + */ + closeAll() { + this.stack = []; + } + + /** + * Remove the keyed banner from the stack + */ + remove(key: string) { + this.stack = this.stack.filter((x) => x.key !== key); + } + + /** + * Render banners + */ + get rendered() { + return ( + <> + {this.stack.map((banner) => { + const Component = this.components[banner.type]; + if (!Component) return null; + return ( + + { + console.debug("animation complete"); + }} + //style={bannerContext.content.style} + > + this.remove(banner.key!)} /> + {/* {!bannerContext.content.forced && ( + { + bannerContext.close(); + }} + > + + + )} */} + + + ); + })} + + ); + } + + /** + * Whether a banner is currently visible + */ + get isVisible() { + return this.stack.length > 0; + } +} + +export const bannerController = new BannerController({ + offline: OfflineBanner, +}); diff --git a/src/controllers/banners/BannerRenderer.tsx b/src/controllers/banners/BannerRenderer.tsx new file mode 100644 index 0000000..f29e93a --- /dev/null +++ b/src/controllers/banners/BannerRenderer.tsx @@ -0,0 +1,6 @@ +import { observer } from "mobx-react-lite"; +import { bannerController } from "."; + +export default observer(() => { + return <>{bannerController.rendered}; +}); diff --git a/src/controllers/banners/index.ts b/src/controllers/banners/index.ts new file mode 100644 index 0000000..bfcac2d --- /dev/null +++ b/src/controllers/banners/index.ts @@ -0,0 +1,3 @@ +export * from "./BannerController"; +export * from "./BannerRenderer"; +export * from "./types"; diff --git a/src/controllers/banners/types.ts b/src/controllers/banners/types.ts new file mode 100644 index 0000000..9ee056e --- /dev/null +++ b/src/controllers/banners/types.ts @@ -0,0 +1,9 @@ +export type Banner = { + key?: string; +} & { + type: "offline"; +}; + +export type BannerProps = Banner & { type: T } & { + onClose: () => void; +}; diff --git a/src/index.css b/src/index.css index f0b5706..a6dcec2 100644 --- a/src/index.css +++ b/src/index.css @@ -1,34 +1,30 @@ html, body, -#root, -#root > div { +#root { height: 100%; -} - -h1, -h2, -h3, -h4, -h5, -h6, -p, -span, -textarea { - padding: 0; - margin: 0; -} - -html *:not(code) { - font-family: var(--font-family); + width: 100%; + overflow: hidden; + display: flex; } body { margin: 0; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - overflow: hidden; + /* overflow: hidden; */ + font-family: var(--font-family); } +*, +*:after, +*:before { + box-sizing: border-box; +} + +/* html *:not(code) { + font-family: var(--font-family); +} */ + code { font-family: "Source Code Pro", monospace; } diff --git a/src/index.tsx b/src/index.tsx index 09ae09d..c4d9654 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,7 +19,6 @@ import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { ErrorBoundaryContext } from "react-use-error-boundary"; import App from "./App"; -import { BannerContextProvider } from "./contexts/BannerContext"; import { ContextMenuContextProvider } from "./contexts/ContextMenuContext"; import Theme from "./contexts/Theme"; import ModalRenderer from "./controllers/modals/ModalRenderer"; @@ -32,12 +31,10 @@ dayjs.extend(calendar, calendarStrings); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - - - - + + + + , diff --git a/src/pages/LoadingPage.tsx b/src/pages/LoadingPage.tsx index cfa243f..22cb9dc 100644 --- a/src/pages/LoadingPage.tsx +++ b/src/pages/LoadingPage.tsx @@ -11,8 +11,8 @@ const Wrapper = styled.div` justify-content: center; align-items: center; display: flex; - height: 100vh; flex-direction: column; + flex: 1; `; const SpacebarLogo = styled(SpacebarLogoBlue)` @@ -29,7 +29,11 @@ function LoadingPage() { const app = useAppStore(); return ( - + diff --git a/src/pages/subpages/ChannelPage.tsx b/src/pages/subpages/ChannelPage.tsx index 7e70b1d..12a3027 100644 --- a/src/pages/subpages/ChannelPage.tsx +++ b/src/pages/subpages/ChannelPage.tsx @@ -1,17 +1,20 @@ import { observer } from "mobx-react-lite"; import React from "react"; +import { isMobile } from "react-device-detect"; import { useParams } from "react-router-dom"; import styled from "styled-components"; -import Banner from "../../components/Banner"; import ChannelSidebar from "../../components/ChannelSidebar"; import ContainerComponent from "../../components/Container"; import ErrorBoundary from "../../components/ErrorBoundary"; import GuildSidebar from "../../components/GuildSidebar"; +import SwipeableLayout from "../../components/SwipeableLayout"; import Chat from "../../components/messaging/Chat"; +import BannerRenderer from "../../controllers/banners/BannerRenderer"; import { useAppStore } from "../../stores/AppStore"; const Container = styled(ContainerComponent)` display: flex; + flex: 1; flex-direction: column; `; @@ -22,6 +25,24 @@ const Wrapper = styled.div` overflow: hidden; `; +function LeftPanel() { + return ( +
+ + +
+ ); +} + +function RightPanel() { + return
Right Panel
; +} + function ChannelPage() { const app = useAppStore(); @@ -32,9 +53,22 @@ function ChannelPage() { app.setActiveChannelId(channelId); }, [guildId, channelId]); + if (isMobile) { + return ( + + + } rightChildren={}> + + + + + + ); + } + return ( - + diff --git a/src/stores/AppStore.ts b/src/stores/AppStore.ts index f302310..d546bd2 100644 --- a/src/stores/AppStore.ts +++ b/src/stores/AppStore.ts @@ -31,7 +31,6 @@ export default class AppStore { // whether the app is still loading @observable isAppLoading = true; - @observable isNetworkConnected = true; @observable tokenLoaded = false; @observable token: string | null = null; @observable fpsShown: boolean = process.env.NODE_ENV === "development"; @@ -69,9 +68,6 @@ export default class AppStore { // bind this in windowToggleFps this.windowToggleFps = this.windowToggleFps.bind(this); window.windowToggleFps = this.windowToggleFps; - - window.addEventListener("online", () => this.setNetworkConnected(true)); - window.addEventListener("offline", () => this.setNetworkConnected(false)); } @action @@ -89,17 +85,12 @@ export default class AppStore { this.account = new AccountStore(user); } - @action - setNetworkConnected(value: boolean) { - this.isNetworkConnected = value; - } - @computed /** * Whether the app is done loading and ready to be displayed */ get isReady() { - return !this.isAppLoading && this.isGatewayReady && this.isNetworkConnected; + return !this.isAppLoading && this.isGatewayReady; } @action