diff --git a/package.json b/package.json index 0b20ee7138b79a02aa854641d1c6d199a08bdcf2..7ae716823925338d25045fbf86cfb55b4dee7573 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "react-device-detect": "^1.17.0", "react-helmet": "^6.1.0", "react-hook-form": "6.3.0", - "react-overlapping-panels": "1.1.2-patch.0", + "react-overlapping-panels": "1.2.1", "react-redux": "^7.2.4", "react-router-dom": "^5.2.0", "redux": "^4.1.0", diff --git a/src/app.tsx b/src/app.tsx index 28e68f59f841acf8f51c85de0ca8bdbd6abd8f4a..ee5597930c1efa23a5aca733bb982f5570d92b84 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,40 +1,31 @@ import { CheckAuth } from "./context/revoltjs/CheckAuth"; +import Preloader from "./components/ui/Preloader"; import { Route, Switch } from "react-router-dom"; import Context from "./context"; -import { Login } from "./pages/login/Login"; - -import { useForceUpdate, useSelf, useUser } from "./context/revoltjs/hooks"; - -function Test() { - const ctx = useForceUpdate(); - - let self = useSelf(ctx); - let bree = useUser('01EZZJ98RM1YVB1FW9FG221CAN', ctx); - - return ( - <div> - <h1>logged in as { self?.username }</h1> - <h4>bree: { JSON.stringify(bree) }</h4> - </div> - ) -} +import { lazy, Suspense } from "preact/compat"; +const Login = lazy(() => import('./pages/login/Login')); +const RevoltApp = lazy(() => import('./pages/App')); export function App() { return ( <Context> - <Switch> - <Route path="/login"> - <CheckAuth> - <Login /> - </CheckAuth> - </Route> - <Route path="/"> - <CheckAuth auth> - <Test /> - </CheckAuth> - </Route> - </Switch> + {/* + // @ts-expect-error */} + <Suspense fallback={<Preloader />}> + <Switch> + <Route path="/login"> + <CheckAuth> + <Login /> + </CheckAuth> + </Route> + <Route path="/"> + <CheckAuth auth> + <RevoltApp /> + </CheckAuth> + </Route> + </Switch> + </Suspense> </Context> ); } diff --git a/src/components/common/ChannelIcon.tsx b/src/components/common/ChannelIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8be9fe8085e234444d9c20ddf99116f83029176a --- /dev/null +++ b/src/components/common/ChannelIcon.tsx @@ -0,0 +1,45 @@ +import { useContext } from "preact/hooks"; +import { Hash } from "@styled-icons/feather"; +import IconBase, { IconBaseProps } from "./IconBase"; +import { Channels } from "revolt.js/dist/api/objects"; +import { AppContext } from "../../context/revoltjs/RevoltClient"; + +interface Props extends IconBaseProps<Channels.GroupChannel | Channels.TextChannel> { + isServerChannel?: boolean; +} + +const fallback = '/assets/group.png'; +export default function ChannelIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>) { + const { client } = useContext(AppContext); + + const { size, target, attachment, isServerChannel: server, animate, children, as, ...svgProps } = props; + const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate); + const isServerChannel = server || target?.channel_type === 'TextChannel'; + + if (typeof iconURL === 'undefined') { + if (isServerChannel) { + return ( + <Hash size={size} /> + ) + } + } + + return ( + <IconBase {...svgProps} + width={size} + height={size} + aria-hidden="true" + viewBox="0 0 32 32" + square={isServerChannel}> + <foreignObject x="0" y="0" width="32" height="32"> + <img src={iconURL ?? fallback} + onError={ e => { + let el = e.currentTarget; + if (el.src !== fallback) { + el.src = fallback + } + } } /> + </foreignObject> + </IconBase> + ); +} diff --git a/src/components/common/IconBase.tsx b/src/components/common/IconBase.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fb1f75bdb6bf450576ee0d7e4c5c60318a5e6c16 --- /dev/null +++ b/src/components/common/IconBase.tsx @@ -0,0 +1,22 @@ +import { Attachment } from "revolt.js/dist/api/objects"; +import styled, { css } from "styled-components"; + +export interface IconBaseProps<T> { + target?: T; + attachment?: Attachment; + + size: number; + animate?: boolean; +} + +export default styled.svg<{ square?: boolean }>` + img { + width: 100%; + height: 100%; + object-fit: cover; + + ${ props => !props.square && css` + border-radius: 50%; + ` } + } +`; diff --git a/src/components/common/ServerIcon.tsx b/src/components/common/ServerIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1cf0297f445bb440d6a4ea14c4902f046e9e3002 --- /dev/null +++ b/src/components/common/ServerIcon.tsx @@ -0,0 +1,57 @@ +import styled from "styled-components"; +import { useContext } from "preact/hooks"; +import { Server } from "revolt.js/dist/api/objects"; +import IconBase, { IconBaseProps } from "./IconBase"; +import { AppContext } from "../../context/revoltjs/RevoltClient"; + +interface Props extends IconBaseProps<Server> { + server_name?: string; +} + +const ServerText = styled.div` + display: grid; + padding: .2em; + overflow: hidden; + border-radius: 50%; + place-items: center; + color: var(--foreground); + background: var(--primary-background); +`; + +const fallback = '/assets/group.png'; +export default function ServerIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>) { + const { client } = useContext(AppContext); + + const { target, attachment, size, animate, server_name, children, as, ...svgProps } = props; + const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate); + + if (typeof iconURL === 'undefined') { + const name = target?.name ?? server_name ?? ''; + + return ( + <ServerText style={{ width: size, height: size }}> + { name.split(' ') + .map(x => x[0]) + .filter(x => typeof x !== 'undefined') } + </ServerText> + ) + } + + return ( + <IconBase {...svgProps} + width={size} + height={size} + aria-hidden="true" + viewBox="0 0 32 32"> + <foreignObject x="0" y="0" width="32" height="32"> + <img src={iconURL} + onError={ e => { + let el = e.currentTarget; + if (el.src !== fallback) { + el.src = fallback + } + }} /> + </foreignObject> + </IconBase> + ); +} diff --git a/src/components/common/UserIcon.tsx b/src/components/common/UserIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..71e861ddeee95e749605660d519557929d5abbce --- /dev/null +++ b/src/components/common/UserIcon.tsx @@ -0,0 +1,96 @@ +import { User } from "revolt.js"; +import { useContext } from "preact/hooks"; +import { MicOff } from "@styled-icons/feather"; +import styled, { css } from "styled-components"; +import { ThemeContext } from "../../context/Theme"; +import { Users } from "revolt.js/dist/api/objects"; +import IconBase, { IconBaseProps } from "./IconBase"; +import { AppContext } from "../../context/revoltjs/RevoltClient"; + +type VoiceStatus = "muted"; +interface Props extends IconBaseProps<User> { + status?: boolean; + voice?: VoiceStatus; +} + +export function useStatusColour(user?: User) { + const theme = useContext(ThemeContext); + + return ( + user?.online && + user?.status?.presence !== Users.Presence.Invisible + ? user?.status?.presence === Users.Presence.Idle + ? theme["status-away"] + : user?.status?.presence === + Users.Presence.Busy + ? theme["status-busy"] + : theme["status-online"] + : theme["status-invisible"] + ); +} + +const VoiceIndicator = styled.div<{ status: VoiceStatus }>` + width: 10px; + height: 10px; + border-radius: 50%; + + display: flex; + align-items: center; + justify-content: center; + + svg { + stroke: white; + } + + ${ props => props.status === 'muted' && css` + background: var(--error); + ` } +`; + +const fallback = '/assets/user.png'; +export default function UserIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>) { + const { client } = useContext(AppContext); + + const { target, attachment, size, voice, status, animate, children, as, ...svgProps } = props; + const iconURL = client.generateFileURL(target?.avatar ?? attachment, { max_side: 256 }, animate); + + return ( + <IconBase {...svgProps} + width={size} + height={size} + aria-hidden="true" + viewBox="0 0 32 32"> + <foreignObject x="0" y="0" width="32" height="32"> + { + <img src={iconURL} + draggable={false} + onError={ e => { + let el = e.currentTarget; + if (el.src !== fallback) { + el.src = fallback + } + }} /> + } + </foreignObject> + {props.status && ( + <circle + cx="27" + cy="27" + r="5" + fill={useStatusColour(target)} + /> + )} + {props.voice && ( + <foreignObject + x="22" + y="22" + width="10" + height="10"> + <VoiceIndicator status={props.voice}> + {props.voice === "muted" && <MicOff size={6} />} + </VoiceIndicator> + </foreignObject> + )} + </IconBase> + ); +} diff --git a/src/context/Settings.tsx b/src/context/Settings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f45005ecfceb662802f6b75ca7ab65db5ef744f8 --- /dev/null +++ b/src/context/Settings.tsx @@ -0,0 +1,32 @@ +// This code is more or less redundant, but settings has so little state +// updates that I can't be asked to pass everything through props each +// time when I can just use the Context API. +// +// Replace references to SettingsContext with connectState in the future +// if it does cause problems though. + +import { Settings } from "../redux/reducers/settings"; +import { connectState } from "../redux/connector"; +import { Children } from "../types/Preact"; +import { createContext } from "preact"; + +export const SettingsContext = createContext<Settings>({} as any); + +interface Props { + children?: Children, + settings: Settings +} + +function Settings(props: Props) { + return ( + <SettingsContext.Provider value={props.settings}> + { props.children } + </SettingsContext.Provider> + ) +} + +export default connectState(Settings, state => { + return { + settings: state.settings + } +}); diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx index dc919f203cf0d5ae0b632215212955ef19f23c4b..c93b95a16a41d6ff30b9fb299f337722bc1c3b3d 100644 --- a/src/context/revoltjs/RevoltClient.tsx +++ b/src/context/revoltjs/RevoltClient.tsx @@ -201,11 +201,12 @@ function Context({ auth, sync, children, dispatcher }: Props) { } } } else { - await client - .fetchConfiguration() - .catch(() => - console.error("Failed to connect to API server.") - ); + try { + await client.fetchConfiguration() + } catch (err) { + console.error("Failed to connect to API server."); + } + setStatus(ClientStatus.READY); } })(); diff --git a/src/lib/PaintCounter.tsx b/src/lib/PaintCounter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..81251722d3889c0e66753014886693777803c62f --- /dev/null +++ b/src/lib/PaintCounter.tsx @@ -0,0 +1,12 @@ +import { useState } from "preact/hooks"; + +const counts: { [key: string]: number } = {}; + +export default function PaintCounter() { + const [uniqueId] = useState('' + Math.random()); + const count = counts[uniqueId] ?? 0; + counts[uniqueId] = count + 1; + return ( + <span>Painted {count + 1} time(s).</span> + ) +} diff --git a/src/lib/windowSize.ts b/src/lib/windowSize.ts new file mode 100644 index 0000000000000000000000000000000000000000..55089b4e103ad3eda85ce0d2bec1493340b09382 --- /dev/null +++ b/src/lib/windowSize.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "preact/hooks"; + +export function useWindowSize() { + const [windowSize, setWindowSize] = useState({ + width: window.innerWidth, + height: window.innerHeight + }); + + useEffect(() => { + function handleResize() { + setWindowSize({ + width: window.innerWidth, + height: window.innerHeight + }); + } + + window.addEventListener("resize", handleResize); + handleResize(); + + return () => window.removeEventListener("resize", handleResize); + }, []); + + return windowSize; +} diff --git a/src/pages/App.tsx b/src/pages/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6cfc6d0f0475606f6b6479792670f4de9e34914b --- /dev/null +++ b/src/pages/App.tsx @@ -0,0 +1,18 @@ +import { OverlappingPanels } from "react-overlapping-panels"; +import { Switch, Route } from "react-router-dom"; + +import Home from './home/Home'; + +export default function App() { + return ( + <OverlappingPanels + width="100vw" + height="100%"> + <Switch> + <Route path="/"> + <Home /> + </Route> + </Switch> + </OverlappingPanels> + ); +}; diff --git a/src/pages/home/Home.tsx b/src/pages/home/Home.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4ae75dbdf46e2655de9545e39e158ab1a515d06f --- /dev/null +++ b/src/pages/home/Home.tsx @@ -0,0 +1,59 @@ +import { useChannels, useForceUpdate, useServers, useUser } from "../../context/revoltjs/hooks"; +import ChannelIcon from "../../components/common/ChannelIcon"; +import ServerIcon from "../../components/common/ServerIcon"; +import UserIcon from "../../components/common/UserIcon"; +import PaintCounter from "../../lib/PaintCounter"; + +export function Nested() { + const ctx = useForceUpdate(); + + let user = useUser('01EX2NCWQ0CHS3QJF0FEQS1GR4', ctx)!; + let user2 = useUser('01EX40TVKYNV114H8Q8VWEGBWQ', ctx)!; + let user3 = useUser('01F5GV44HTXP3MTCD2VPV42DPE', ctx)!; + + let channels = useChannels(undefined, ctx); + let servers = useServers(undefined, ctx); + + return ( + <> + <h3>Nested component</h3> + <PaintCounter /> + @{ user.username } is { user.online ? 'online' : 'offline' }<br/><br/> + + <h3>UserIcon Tests</h3> + <UserIcon size={64} target={user} /> + <UserIcon size={64} target={user} status /> + <UserIcon size={64} target={user} voice='muted' /> + <UserIcon size={64} attachment={user2.avatar} /> + <UserIcon size={64} attachment={user3.avatar} /> + <UserIcon size={64} attachment={user3.avatar} animate /> + + <h3>Channels</h3> + { channels.map(channel => + channel && + channel.channel_type !== 'SavedMessages' && + channel.channel_type !== 'DirectMessage' && + <ChannelIcon size={48} target={channel} /> + ) } + + <h3>Servers</h3> + { servers.map(server => + server && + <ServerIcon size={48} target={server} /> + ) } + + <br/><br/> + <p>{ 'test long paragraph'.repeat(2000) }</p> + </> + ) +} + +export default function Home() { + return ( + <div style={{ overflowY: 'scroll', height: '100vh' }}> + <h1>HOME</h1> + <PaintCounter /> + <Nested /> + </div> + ); +} diff --git a/src/pages/login/Login.module.scss b/src/pages/login/Login.module.scss index 3b982bbb3159c37ae6d97ac635a85eb5f73b1613..27bd99baab51296203fb57e70767b4d6db00329d 100644 --- a/src/pages/login/Login.module.scss +++ b/src/pages/login/Login.module.scss @@ -1,4 +1,7 @@ .login { + width: 100%; + height: 100%; + display: flex; flex-direction: row; diff --git a/src/pages/login/Login.tsx b/src/pages/login/Login.tsx index 3eda13ca4097151c1c9f9eb93ae8e68ac6c946e2..2334a17432d0198968671c634acf9fcff6105e76 100644 --- a/src/pages/login/Login.tsx +++ b/src/pages/login/Login.tsx @@ -15,7 +15,7 @@ import { FormCreate } from "./forms/FormCreate"; import { FormResend } from "./forms/FormResend"; import { FormReset, FormSendReset } from "./forms/FormReset"; -export const Login = () => { +export default function Login() { const theme = useContext(ThemeContext); const { client } = useContext(AppContext); diff --git a/src/styles/_page.scss b/src/styles/_page.scss index 210e54c3e560cb36772ff5737b768ca53cf40133..c99320ab4f4498b5001754a476e22d3913d0067c 100644 --- a/src/styles/_page.scss +++ b/src/styles/_page.scss @@ -8,7 +8,7 @@ } html { - contain: content; + // contain: content; background: var(--background); background-size: cover !important; background-repeat: no-repeat !important; @@ -16,6 +16,8 @@ html { html, body { + margin: 0; + height: 100%; font-family: "Open Sans", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -29,3 +31,11 @@ body { scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); } + +#app { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; +} diff --git a/vite.config.ts b/vite.config.ts index d53d1b3c1e75f1f16b8c5ea2c2f1cbf44bd2b573..372f38d435de02caf5103f0677001cd72eca95bf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ }) ], build: { + sourcemap: true, rollupOptions: { input: { main: resolve(__dirname, 'index.html'), diff --git a/yarn.lock b/yarn.lock index dd31e17e9e926eed71efbb13814c54b27cf0598b..76c2a99285a8816571f9463a2c871f71af58859a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2969,10 +2969,10 @@ react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-overlapping-panels@1.1.2-patch.0: - version "1.1.2-patch.0" - resolved "https://registry.yarnpkg.com/react-overlapping-panels/-/react-overlapping-panels-1.1.2-patch.0.tgz#335649735c029d334daea19ef6e30efc76b128fd" - integrity sha512-PaXxk5HxBMYg46iADGGhkgXqqweJWo7yjSeT4/o0Q3s6Q7pl7Rz23lM3oW2gdJHBDOs/zBpZ+ZIP4j6grQlCOA== +react-overlapping-panels@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/react-overlapping-panels/-/react-overlapping-panels-1.2.1.tgz#3775a09ae6c83604d058d4082d1c8fed5cc59fe9" + integrity sha512-vkHLqX+X6HO13nAppZ5Z4tt4s8IMTA8sVf/FZFnnoqlQFIfTJAgdgZDa3LejMIrOJO6YMftVSVpzmusWTxvlUA== react-redux@^7.2.4: version "7.2.4"