From 31d8950ea1786914e952d0ade6bfa0e7db2c792d Mon Sep 17 00:00:00 2001 From: Paul <paulmakles@gmail.com> Date: Sat, 19 Jun 2021 22:37:12 +0100 Subject: [PATCH] Port settings. --- package.json | 2 + .../navigation/items/ButtonItem.tsx | 38 +- .../navigation/left/HomeSidebar.tsx | 10 +- .../navigation/left/ServerListSidebar.tsx | 7 +- .../navigation/left/ServerSidebar.tsx | 31 +- src/components/ui/Checkbox.tsx | 15 +- src/components/ui/Modal.tsx | 10 +- src/components/ui/TextArea.module.scss | 31 ++ src/components/ui/TextArea.tsx | 146 +++++++ src/context/Theme.tsx | 19 +- .../intermediate/modals/Onboarding.tsx | 3 +- .../popovers/UserProfile.module.scss | 1 + src/context/revoltjs/FileUploads.module.scss | 82 ++++ src/context/revoltjs/FileUploads.tsx | 148 +++++++ src/context/revoltjs/RequiresOnline.tsx | 44 +++ src/lib/conversion.ts | 9 + src/lib/debounce.ts | 15 + src/lib/fileSize.ts | 9 + src/main.tsx | 2 +- src/pages/{App.tsx => RevoltApp.tsx} | 61 +-- src/{ => pages}/app.tsx | 10 +- src/pages/home/Home.tsx | 3 +- src/pages/login/forms/FormLogin.tsx | 4 +- src/pages/settings/ChannelSettings.tsx | 46 +++ src/pages/settings/GenericSettings.tsx | 120 ++++++ src/pages/settings/ServerSettings.tsx | 65 +++ src/pages/settings/Settings.module.scss | 209 ++++++++++ src/pages/settings/Settings.tsx | 153 +++++++ src/pages/settings/SettingsTextArea.tsx | 6 + src/pages/settings/channel/Overview.tsx | 89 +++++ src/pages/settings/channel/Panes.module.scss | 14 + src/pages/settings/panes/Account.tsx | 95 +++++ src/pages/settings/panes/Appearance.tsx | 286 ++++++++++++++ src/pages/settings/panes/Experiments.tsx | 53 +++ src/pages/settings/panes/Feedback.tsx | 95 +++++ src/pages/settings/panes/Languages.tsx | 68 ++++ src/pages/settings/panes/Notifications.tsx | 142 +++++++ src/pages/settings/panes/Panes.module.scss | 374 ++++++++++++++++++ src/pages/settings/panes/Profile.tsx | 126 ++++++ src/pages/settings/panes/Sessions.tsx | 186 +++++++++ src/pages/settings/panes/Sync.tsx | 53 +++ src/pages/settings/server/Bans.tsx | 23 ++ src/pages/settings/server/Invites.tsx | 70 ++++ src/pages/settings/server/Members.tsx | 24 ++ src/pages/settings/server/Overview.tsx | 98 +++++ src/pages/settings/server/Panes.module.scss | 48 +++ src/version.ts | 2 +- yarn.lock | 17 +- 48 files changed, 3056 insertions(+), 106 deletions(-) create mode 100644 src/components/ui/TextArea.module.scss create mode 100644 src/components/ui/TextArea.tsx create mode 100644 src/context/revoltjs/FileUploads.module.scss create mode 100644 src/context/revoltjs/FileUploads.tsx create mode 100644 src/context/revoltjs/RequiresOnline.tsx create mode 100644 src/lib/conversion.ts create mode 100644 src/lib/debounce.ts create mode 100644 src/lib/fileSize.ts rename src/pages/{App.tsx => RevoltApp.tsx} (50%) rename src/{ => pages}/app.tsx (74%) create mode 100644 src/pages/settings/ChannelSettings.tsx create mode 100644 src/pages/settings/GenericSettings.tsx create mode 100644 src/pages/settings/ServerSettings.tsx create mode 100644 src/pages/settings/Settings.module.scss create mode 100644 src/pages/settings/Settings.tsx create mode 100644 src/pages/settings/SettingsTextArea.tsx create mode 100644 src/pages/settings/channel/Overview.tsx create mode 100644 src/pages/settings/channel/Panes.module.scss create mode 100644 src/pages/settings/panes/Account.tsx create mode 100644 src/pages/settings/panes/Appearance.tsx create mode 100644 src/pages/settings/panes/Experiments.tsx create mode 100644 src/pages/settings/panes/Feedback.tsx create mode 100644 src/pages/settings/panes/Languages.tsx create mode 100644 src/pages/settings/panes/Notifications.tsx create mode 100644 src/pages/settings/panes/Panes.module.scss create mode 100644 src/pages/settings/panes/Profile.tsx create mode 100644 src/pages/settings/panes/Sessions.tsx create mode 100644 src/pages/settings/panes/Sync.tsx create mode 100644 src/pages/settings/server/Bans.tsx create mode 100644 src/pages/settings/server/Invites.tsx create mode 100644 src/pages/settings/server/Members.tsx create mode 100644 src/pages/settings/server/Overview.tsx create mode 100644 src/pages/settings/server/Panes.module.scss diff --git a/package.json b/package.json index 8862bde..c639394 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@preact/preset-vite": "^2.0.0", "@styled-icons/bootstrap": "^10.34.0", "@styled-icons/feather": "^10.34.0", + "@styled-icons/simple-icons": "^10.33.0", "@traptitech/markdown-it-katex": "^3.4.3", "@traptitech/markdown-it-spoiler": "^1.1.6", "@types/markdown-it": "^12.0.2", @@ -70,6 +71,7 @@ "revolt.js": "4.3.0", "rimraf": "^3.0.2", "sass": "^1.35.1", + "shade-blend-color": "^1.0.0", "styled-components": "^5.3.0", "twemoji": "^13.1.0", "typescript": "^4.3.2", diff --git a/src/components/navigation/items/ButtonItem.tsx b/src/components/navigation/items/ButtonItem.tsx index 0acfbf0..bcbd59a 100644 --- a/src/components/navigation/items/ButtonItem.tsx +++ b/src/components/navigation/items/ButtonItem.tsx @@ -1,13 +1,17 @@ import classNames from 'classnames'; import styles from "./Item.module.scss"; +import Tooltip from '../../common/Tooltip'; +import IconButton from '../../ui/IconButton'; import UserIcon from '../../common/UserIcon'; import { Localizer, Text } from "preact-i18n"; import { X, Zap } from "@styled-icons/feather"; import UserStatus from '../../common/UserStatus'; import { Children } from "../../../types/Preact"; import ChannelIcon from '../../common/ChannelIcon'; +import { attachContextMenu } from 'preact-context-menu'; import { Channels, Users } from "revolt.js/dist/api/objects"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; +import { useIntermediate } from '../../../context/intermediate/Intermediate'; interface CommonProps { active?: boolean @@ -22,7 +26,7 @@ type UserProps = CommonProps & { } export function UserButton({ active, alert, alertCount, user, context, channel }: UserProps) { - // const { openScreen } = useContext(IntermediateContext); + const { openScreen } = useIntermediate(); return ( <div @@ -30,13 +34,12 @@ export function UserButton({ active, alert, alertCount, user, context, channel } data-active={active} data-alert={typeof alert === 'string'} data-online={typeof channel !== 'undefined' || (user.online && user.status?.presence !== Users.Presence.Invisible)} - /*onContextMenu={attachContextMenu('Menu', { + onContextMenu={attachContextMenu('Menu', { user: user._id, channel: channel?._id, unread: alert, contextualChannel: context?._id - })}*/ - > + })}> <div className={styles.avatar}> <UserIcon target={user} size={32} status /> </div> @@ -56,24 +59,22 @@ export function UserButton({ active, alert, alertCount, user, context, channel } { context?.channel_type === "Group" && context.owner === user._id && ( <Localizer> - {/*<Tooltip + <Tooltip content={ <Text id="app.main.groups.owner" /> } - >*/} + > <Zap size={20} /> - {/*</Tooltip>*/} + </Tooltip> </Localizer> )} {alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>} { !isTouchscreenDevice && channel && - /*<IconButton + <IconButton className={styles.icon} - style="default" - onClick={() => openScreen({ id: 'special_prompt', type: 'close_dm', target: channel })} - >*/ + onClick={() => openScreen({ id: 'special_prompt', type: 'close_dm', target: channel })}> <X size={24} /> - /*</IconButton>*/ + </IconButton> } </div> </div> @@ -93,15 +94,14 @@ export function ChannelButton({ active, alert, alertCount, channel, user, compac return <UserButton {...{ active, alert, channel, user }} /> } - //const { openScreen } = useContext(IntermediateContext); + const { openScreen } = useIntermediate(); return ( <div data-active={active} data-alert={typeof alert === 'string'} className={classNames(styles.item, { [styles.compact]: compact })} - //onContextMenu={attachContextMenu('Menu', { channel: channel._id })}> - > + onContextMenu={attachContextMenu('Menu', { channel: channel._id })}> <div className={styles.avatar}> <ChannelIcon target={channel} size={compact ? 24 : 32} /> </div> @@ -124,13 +124,11 @@ export function ChannelButton({ active, alert, alertCount, channel, user, compac <div className={styles.button}> {alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>} {!isTouchscreenDevice && channel.channel_type === "Group" && ( - /*<IconButton + <IconButton className={styles.icon} - style="default" - onClick={() => openScreen({ id: 'special_prompt', type: 'leave_group', target: channel })} - >*/ + onClick={() => openScreen({ id: 'special_prompt', type: 'leave_group', target: channel })}> <X size={24} /> - /*</IconButton>*/ + </IconButton> )} </div> </div> diff --git a/src/components/navigation/left/HomeSidebar.tsx b/src/components/navigation/left/HomeSidebar.tsx index bf1b99c..d014de1 100644 --- a/src/components/navigation/left/HomeSidebar.tsx +++ b/src/components/navigation/left/HomeSidebar.tsx @@ -1,24 +1,20 @@ import { Localizer, Text } from "preact-i18n"; -import { useContext, useLayoutEffect } from "preact/hooks"; -import { Home, Users, Tool, Settings, Save } from "@styled-icons/feather"; +import { useContext } from "preact/hooks"; +import { Home, Users, Tool, Save } from "@styled-icons/feather"; -import { Link, Redirect, useHistory, useLocation, useParams } from "react-router-dom"; +import { Link, Redirect, useLocation, useParams } from "react-router-dom"; import { WithDispatcher } from "../../../redux/reducers"; import { Unreads } from "../../../redux/reducers/unreads"; import { connectState } from "../../../redux/connector"; import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { useChannels, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks"; -import { User } from "revolt.js"; import { Users as UsersNS } from 'revolt.js/dist/api/objects'; import { mapChannelWithUnread, useUnreads } from "./common"; import { Channels } from "revolt.js/dist/api/objects"; -import UserIcon from '../../common/UserIcon'; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import ConnectionStatus from '../items/ConnectionStatus'; -import UserStatus from '../../common/UserStatus'; import ButtonItem, { ChannelButton } from '../items/ButtonItem'; import styled from "styled-components"; -import Header from '../../ui/Header'; import UserHeader from "../../common/UserHeader"; import Category from '../../ui/Category'; import PaintCounter from "../../../lib/PaintCounter"; diff --git a/src/components/navigation/left/ServerListSidebar.tsx b/src/components/navigation/left/ServerListSidebar.tsx index 7ff3e74..f64e4fe 100644 --- a/src/components/navigation/left/ServerListSidebar.tsx +++ b/src/components/navigation/left/ServerListSidebar.tsx @@ -1,5 +1,3 @@ -import { useContext } from "preact/hooks"; -import { PlusCircle } from "@styled-icons/feather"; import { Channel, Servers } from "revolt.js/dist/api/objects"; import { Link, useLocation, useParams } from "react-router-dom"; import { useChannels, useForceUpdate, useServers } from "../../../context/revoltjs/hooks"; @@ -11,6 +9,7 @@ import { Children } from "../../../types/Preact"; import LineDivider from "../../ui/LineDivider"; import ServerIcon from "../../common/ServerIcon"; import PaintCounter from "../../../lib/PaintCounter"; +import { attachContextMenu } from 'preact-context-menu'; function Icon({ children, unread, size }: { children: Children, unread?: 'mention' | 'unread', size: number }) { return ( @@ -157,8 +156,7 @@ export function ServerListSidebar({ unreads }: Props) { <Link to={`/server/${entry!._id}`}> <ServerEntry active={entry!._id === server?._id} - //onContextMenu={attachContextMenu('Menu', { server: entry!._id })}> - > + onContextMenu={attachContextMenu('Menu', { server: entry!._id })}> <Icon size={36} unread={entry.unread}> <ServerIcon size={32} target={entry} /> </Icon> @@ -169,6 +167,7 @@ export function ServerListSidebar({ unreads }: Props) { <PaintCounter small /> </ServerList> </ServersBase> + // ! FIXME: add overlay back /*<div className={styles.servers}> <div className={styles.list}> <Link to={`/`}> diff --git a/src/components/navigation/left/ServerSidebar.tsx b/src/components/navigation/left/ServerSidebar.tsx index e500855..f4cae5f 100644 --- a/src/components/navigation/left/ServerSidebar.tsx +++ b/src/components/navigation/left/ServerSidebar.tsx @@ -12,11 +12,32 @@ import Header from '../../ui/Header'; import ConnectionStatus from '../items/ConnectionStatus'; import { connectState } from "../../../redux/connector"; import PaintCounter from "../../../lib/PaintCounter"; +import styled from "styled-components"; +import { attachContextMenu } from 'preact-context-menu'; interface Props { unreads: Unreads; } +const ServerBase = styled.div` + height: 100%; + width: 240px; + display: flex; + flex-shrink: 0; + flex-direction: column; + background: var(--secondary-background); +`; + +const ServerList = styled.div` + padding: 6px; + flex-grow: 1; + overflow-y: scroll; + + > svg { + width: 100%; + } +`; + function ServerSidebar(props: Props & WithDispatcher) { const { server: server_id, channel: channel_id } = useParams<{ server?: string, channel?: string }>(); const ctx = useForceUpdate(); @@ -33,7 +54,7 @@ function ServerSidebar(props: Props & WithDispatcher) { if (channel) useUnreads({ ...props, channel }, ctx); return ( - <div> + <ServerBase> <Header placement="secondary" background style={{ background: `url('${ctx.client.servers.getBannerURL(server._id, { width: 480 }, true)}')` }}> <div> { server.name } @@ -45,9 +66,7 @@ function ServerSidebar(props: Props & WithDispatcher) { </div> } </Header> <ConnectionStatus /> - <div - //onContextMenu={attachContextMenu('Menu', { server_list: server._id })}> - > + <ServerList onContextMenu={attachContextMenu('Menu', { server_list: server._id })}> {channels.map(entry => { return ( <Link to={`/server/${server._id}/channel/${entry._id}`}> @@ -61,9 +80,9 @@ function ServerSidebar(props: Props & WithDispatcher) { </Link> ); })} - </div> + </ServerList> <PaintCounter small /> - </div> + </ServerBase> ) }; diff --git a/src/components/ui/Checkbox.tsx b/src/components/ui/Checkbox.tsx index fb9adf3..e7cd62b 100644 --- a/src/components/ui/Checkbox.tsx +++ b/src/components/ui/Checkbox.tsx @@ -23,6 +23,14 @@ const CheckboxBase = styled.label` input { display: none; } + + &:hover { + background: var(--secondary-background); + + .check { + background: var(--background); + } + } `; const CheckboxContent = styled.span` @@ -46,6 +54,7 @@ const Checkmark = styled.div<{ checked: boolean }>` display: grid; border-radius: 4px; place-items: center; + transition: 0.2s ease all; background: var(--secondary-background); svg { @@ -56,7 +65,7 @@ const Checkmark = styled.div<{ checked: boolean }>` ${(props) => props.checked && css` - background: var(--accent); + background: var(--accent) !important; `} `; @@ -71,7 +80,7 @@ export interface CheckboxProps { export default function Checkbox(props: CheckboxProps) { return ( - <CheckboxBase disabled={props.disabled}> + <CheckboxBase disabled={props.disabled} className={props.className}> <CheckboxContent> <span>{props.children}</span> {props.description && ( @@ -87,7 +96,7 @@ export default function Checkbox(props: CheckboxProps) { !props.disabled && props.onChange(!props.checked) } /> - <Checkmark checked={props.checked}> + <Checkmark checked={props.checked} className="check"> <Check size={20} /> </Checkmark> </CheckboxBase> diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 7d80cb8..26197b2 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -47,7 +47,7 @@ const ModalContainer = styled.div` animation-timing-function: cubic-bezier(.3,.3,.18,1.1); `; -const ModalContent = styled.div<{ [key in 'attachment' | 'noBackground' | 'border']?: boolean }>` +const ModalContent = styled.div<{ [key in 'attachment' | 'noBackground' | 'border' | 'padding']?: boolean }>` border-radius: 8px; text-overflow: ellipsis; @@ -56,10 +56,13 @@ const ModalContent = styled.div<{ [key in 'attachment' | 'noBackground' | 'borde } ${ props => !props.noBackground && css` - padding: 1.5em; background: var(--secondary-header); ` } + ${ props => props.padding && css` + padding: 1.5em; + ` } + ${ props => props.attachment && css` border-radius: 8px 8px 0 0; ` } @@ -110,7 +113,8 @@ export default function Modal(props: Props) { <ModalContent attachment={!!props.actions} noBackground={props.noBackground} - border={props.border}> + border={props.border} + padding={!props.dontModal}> {props.title && <h3>{props.title}</h3>} {props.children} </ModalContent> diff --git a/src/components/ui/TextArea.module.scss b/src/components/ui/TextArea.module.scss new file mode 100644 index 0000000..7e009fd --- /dev/null +++ b/src/components/ui/TextArea.module.scss @@ -0,0 +1,31 @@ +.container { + font-size: 0.875rem; + line-height: 20px; + position: relative; +} + +.textarea { + width: 100%; + white-space: pre-wrap; + + textarea::placeholder { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } +} + +.hide { + width: 100%; + overflow: hidden; + position: relative; +} + +.ghost { + width: 100%; + white-space: pre-wrap; + + top: 0; + position: absolute; + visibility: hidden; +} diff --git a/src/components/ui/TextArea.tsx b/src/components/ui/TextArea.tsx new file mode 100644 index 0000000..d88dcb7 --- /dev/null +++ b/src/components/ui/TextArea.tsx @@ -0,0 +1,146 @@ +// ! FIXME: temporarily here until re-written +// ! DO NOT IMRPOVE, JUST RE-WRITE + +import classNames from "classnames"; +import { memo } from "preact/compat"; +import styles from "./TextArea.module.scss"; +import { useState, useEffect, useRef, useLayoutEffect } from "preact/hooks"; + +export interface TextAreaProps { + id?: string; + value: string; + maxRows?: number; + padding?: number; + minHeight?: number; + disabled?: boolean; + maxLength?: number; + className?: string; + autoFocus?: boolean; + forceFocus?: boolean; + placeholder?: string; + onKeyDown?: (ev: KeyboardEvent) => void; + onKeyUp?: (ev: KeyboardEvent) => void; + onChange: ( + value: string, + ev: JSX.TargetedEvent<HTMLTextAreaElement, Event> + ) => void; + onFocus?: (current: HTMLTextAreaElement) => void; + onBlur?: () => void; +} + +const lineHeight = 20; + +export const TextArea = memo((props: TextAreaProps) => { + const padding = props.padding ? props.padding * 2 : 0; + + const [height, setHeightState] = useState( + props.minHeight ?? lineHeight + padding + ); + const ghost = useRef<HTMLDivElement>(); + const ref = useRef<HTMLTextAreaElement>(); + + function setHeight(h: number = lineHeight) { + let newHeight = Math.min( + Math.max( + lineHeight, + props.maxRows ? Math.min(h, props.maxRows * lineHeight) : h + ), + props.minHeight ?? Infinity + ); + + if (props.padding) newHeight += padding; + if (height !== newHeight) { + setHeightState(newHeight); + } + } + + function onChange(ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) { + props.onChange(ev.currentTarget.value, ev); + } + + useLayoutEffect(() => { + setHeight(ghost.current.clientHeight); + }, [ghost, props.value]); + + useEffect(() => { + if (props.autoFocus) ref.current.focus(); + }, [props.value]); + + const inputSelected = () => + ["TEXTAREA", "INPUT"].includes(document.activeElement?.nodeName ?? ""); + + useEffect(() => { + if (props.forceFocus) { + ref.current.focus(); + } + + if (props.autoFocus && !inputSelected()) { + ref.current.focus(); + } + + // ? if you are wondering what this is + // ? it is a quick and dirty hack to fix + // ? value not setting correctly + // ? I have no clue what's going on + ref.current.value = props.value; + + if (!props.autoFocus) return; + function keyDown(e: KeyboardEvent) { + if ((e.ctrlKey && e.key !== "v") || e.altKey || e.metaKey) return; + if (e.key.length !== 1) return; + if (ref && !inputSelected()) { + ref.current.focus(); + } + } + + document.body.addEventListener("keydown", keyDown); + return () => document.body.removeEventListener("keydown", keyDown); + }, [ref]); + + useEffect(() => { + function focus(textarea_id: string) { + if (props.id === textarea_id) { + ref.current.focus(); + } + } + + // InternalEventEmitter.addListener("focus_textarea", focus); + // return () => + // InternalEventEmitter.removeListener("focus_textarea", focus); + }, [ref]); + + return ( + <div className={classNames(styles.container, props.className)}> + <textarea + id={props.id} + name={props.id} + style={{ height }} + value={props.value} + onChange={onChange} + disabled={props.disabled} + maxLength={props.maxLength} + className={styles.textarea} + onKeyDown={props.onKeyDown} + placeholder={props.placeholder} + onContextMenu={e => e.stopPropagation()} + onKeyUp={ev => { + setHeight(ghost.current.clientHeight); + props.onKeyUp && props.onKeyUp(ev); + }} + ref={ref} + onFocus={() => props.onFocus && props.onFocus(ref.current)} + onBlur={props.onBlur} + /> + <div className={styles.hide}> + <div className={styles.ghost} ref={ghost}> + {props.value + ? props.value + .split("\n") + .map(x => `‎${x}`) + .join("\n") + : undefined ?? "‎\n"} + </div> + </div> + </div> + ); +}); diff --git a/src/context/Theme.tsx b/src/context/Theme.tsx index 42365cb..4c8a8dc 100644 --- a/src/context/Theme.tsx +++ b/src/context/Theme.tsx @@ -1,5 +1,6 @@ import { isTouchscreenDevice } from "../lib/isTouchscreenDevice"; import { createGlobalStyle } from "styled-components"; +import { connectState } from "../redux/connector"; import { Children } from "../types/Preact"; import { createContext } from "preact"; import { Helmet } from "react-helmet"; @@ -116,10 +117,15 @@ export const ThemeContext = createContext<Theme>({} as any); interface Props { children: Children; + options?: ThemeOptions; } -export default function Theme(props: Props) { - const theme = PRESETS.dark; +function Theme(props: Props) { + const theme: Theme = { + ...PRESETS["dark"], + ...(PRESETS as any)[props.options?.preset as any], + ...props.options?.custom + }; return ( <ThemeContext.Provider value={theme}> @@ -134,7 +140,16 @@ export default function Theme(props: Props) { /> </Helmet> <GlobalTheme theme={theme} /> + {theme.css && ( + <style dangerouslySetInnerHTML={{ __html: theme.css }} /> + )} {props.children} </ThemeContext.Provider> ); } + +export default connectState(Theme, state => { + return { + options: state.settings.theme + }; +}); diff --git a/src/context/intermediate/modals/Onboarding.tsx b/src/context/intermediate/modals/Onboarding.tsx index b1741e2..17fc0fd 100644 --- a/src/context/intermediate/modals/Onboarding.tsx +++ b/src/context/intermediate/modals/Onboarding.tsx @@ -7,8 +7,6 @@ import Button from "../../../components/ui/Button"; import FormField from "../../../pages/login/FormField"; import Preloader from "../../../components/ui/Preloader"; -// import WideSvg from "../../../assets/wide.svg"; - interface Props { onClose: () => void; callback: (username: string, loginAfterSuccess?: true) => Promise<void>; @@ -34,6 +32,7 @@ export function OnboardingModal({ onClose, callback }: Props) { <div className={styles.header}> <h1> <Text id="app.special.modals.onboarding.welcome" /> + <img src="/assets/wide.svg" /> </h1> </div> <div className={styles.form}> diff --git a/src/context/intermediate/popovers/UserProfile.module.scss b/src/context/intermediate/popovers/UserProfile.module.scss index 448595a..38094de 100644 --- a/src/context/intermediate/popovers/UserProfile.module.scss +++ b/src/context/intermediate/popovers/UserProfile.module.scss @@ -9,6 +9,7 @@ background-size: cover; border-radius: 8px 8px 0 0; background-position: center; + background-color: var(--secondary-background); &[data-force="light"] { color: white; diff --git a/src/context/revoltjs/FileUploads.module.scss b/src/context/revoltjs/FileUploads.module.scss new file mode 100644 index 0000000..2d5a3b3 --- /dev/null +++ b/src/context/revoltjs/FileUploads.module.scss @@ -0,0 +1,82 @@ +.uploader { + display: flex; + flex-direction: column; + + &.icon { + .image { + border-radius: 50%; + } + } + + &.banner { + .image { + border-radius: 4px; + } + + .modify { + gap: 4px; + flex-direction: row; + } + } + + .image { + cursor: pointer; + overflow: hidden; + background-size: cover; + background-position: center; + background-color: var(--secondary-background); + + .uploading { + width: 100%; + height: 100%; + display: grid; + place-items: center; + background: rgba(0, 0, 0, 0.5); + } + + &:hover .edit { + opacity: 1; + } + + &:active .edit { + filter: brightness(0.8); + } + + .edit { + opacity: 0; + width: 100%; + height: 100%; + display: grid; + color: white; + place-items: center; + background: rgba(95, 95, 95, 0.5); + transition: .2s ease-in-out opacity; + } + } + + .modify { + display: flex; + margin-top: 5px; + font-size: 12px; + align-items: center; + flex-direction: column; + justify-content: center; + + :first-child { + cursor: pointer; + } + + .small { + display: flex; + font-size: 10px; + flex-direction: column; + color: var(--tertiary-foreground); + } + } + + &[data-uploading="true"] { + .image, .modify:first-child { + cursor: not-allowed !important; + } + } +} diff --git a/src/context/revoltjs/FileUploads.tsx b/src/context/revoltjs/FileUploads.tsx new file mode 100644 index 0000000..61df463 --- /dev/null +++ b/src/context/revoltjs/FileUploads.tsx @@ -0,0 +1,148 @@ +// ! FIXME: also TEMP CODE +// ! RE-WRITE WITH STYLED-COMPONENTS + +import { Text } from "preact-i18n"; +import { takeError } from "./util"; +import classNames from "classnames"; +import styles from './FileUploads.module.scss'; +import Axios, { AxiosRequestConfig } from "axios"; +import { useContext, useState } from "preact/hooks"; +import { Edit, Plus, X } from "@styled-icons/feather"; +import Preloader from "../../components/ui/Preloader"; +import { determineFileSize } from "../../lib/fileSize"; +import IconButton from '../../components/ui/IconButton'; +import { useIntermediate } from "../intermediate/Intermediate"; +import { AppContext } from "./RevoltClient"; + +type Props = { + maxFileSize: number + remove: () => Promise<void> + fileType: 'backgrounds' | 'icons' | 'avatars' | 'attachments' | 'banners' +} & ( + { behaviour: 'ask', onChange: (file: File) => void } | + { behaviour: 'upload', onUpload: (id: string) => Promise<void> } +) & ( + { style: 'icon' | 'banner', defaultPreview?: string, previewURL?: string, width?: number, height?: number } | + { style: 'attachment', attached: boolean, uploading: boolean, cancel: () => void, size?: number } +) + +export async function uploadFile(autumnURL: string, tag: string, file: File, config?: AxiosRequestConfig) { + const formData = new FormData(); + formData.append("file", file); + + const res = await Axios.post(autumnURL + "/" + tag, formData, { + headers: { + "Content-Type": "multipart/form-data" + }, + ...config + }); + + return res.data.id; +} + +export function FileUploader(props: Props) { + const { fileType, maxFileSize, remove } = props; + const { openScreen } = useIntermediate(); + const client = useContext(AppContext); + + const [ uploading, setUploading ] = useState(false); + + function onClick() { + if (uploading) return; + + const input = document.createElement("input"); + input.type = "file"; + + input.onchange = async e => { + setUploading(true); + + try { + const files = (e.target as any)?.files; + if (files && files[0]) { + let file = files[0]; + + if (file.size > maxFileSize) { + return openScreen({ id: "error", error: "FileTooLarge" }); + } + + if (props.behaviour === 'ask') { + await props.onChange(file); + } else { + await props.onUpload(await uploadFile(client.configuration!.features.autumn.url, fileType, file)); + } + } + } catch (err) { + return openScreen({ id: "error", error: takeError(err) }); + } finally { + setUploading(false); + } + }; + + input.click(); + } + + function removeOrUpload() { + if (uploading) return; + + if (props.style === 'attachment') { + if (props.attached) { + props.remove(); + } else { + onClick(); + } + } else { + if (props.previewURL) { + props.remove(); + } else { + onClick(); + } + } + } + + if (props.style === 'icon' || props.style === 'banner') { + const { style, previewURL, defaultPreview, width, height } = props; + return ( + <div className={classNames(styles.uploader, + { [styles.icon]: style === 'icon', + [styles.banner]: style === 'banner' })} + data-uploading={uploading}> + <div className={styles.image} + style={{ backgroundImage: + style === 'icon' ? `url('${previewURL ?? defaultPreview}')` : + (previewURL ? `linear-gradient( rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url('${previewURL}')` : 'black'), + width, height + }} + onClick={onClick}> + { uploading ? + <div className={styles.uploading}> + <Preloader /> + </div> : + <div className={styles.edit}> + <Edit size={30} /> + </div> } + </div> + <div className={styles.modify}> + <span onClick={removeOrUpload}>{ + uploading ? <Text id="app.main.channel.uploading_file" /> : + props.previewURL ? <Text id="app.settings.actions.remove" /> : + <Text id="app.settings.actions.upload" /> }</span> + <span className={styles.small}><Text id="app.settings.actions.max_filesize" fields={{ filesize: determineFileSize(maxFileSize) }} /></span> + </div> + </div> + ) + } else if (props.style === 'attachment') { + const { attached, uploading, cancel, size } = props; + return ( + <IconButton + onClick={() => { + if (uploading) return cancel(); + if (attached) return remove(); + onClick(); + }}> + { attached ? <X size={size} /> : <Plus size={size} />} + </IconButton> + ) + } + + return null; +} diff --git a/src/context/revoltjs/RequiresOnline.tsx b/src/context/revoltjs/RequiresOnline.tsx new file mode 100644 index 0000000..73b7f72 --- /dev/null +++ b/src/context/revoltjs/RequiresOnline.tsx @@ -0,0 +1,44 @@ +import { Text } from "preact-i18n"; +import styled from "styled-components"; +import { useContext } from "preact/hooks"; +import { Children } from "../../types/Preact"; +import { WifiOff } from "@styled-icons/feather"; +import Preloader from "../../components/ui/Preloader"; +import { ClientStatus, StatusContext } from "./RevoltClient"; + +interface Props { + children: Children; +} + +const Base = styled.div` + gap: 16px; + padding: 1em; + display: flex; + user-select: none; + align-items: center; + flex-direction: row; + justify-content: center; + color: var(--tertiary-foreground); + background: var(--secondary-header); + + > div { + font-size: 18px; + } +`; + +export default function RequiresOnline(props: Props) { + const status = useContext(StatusContext); + + if (status === ClientStatus.CONNECTING) return <Preloader />; + if (status !== ClientStatus.ONLINE && status !== ClientStatus.READY) + return ( + <Base> + <WifiOff size={16} /> + <div> + <Text id="app.special.requires_online" /> + </div> + </Base> + ); + + return <>{ props.children }</>; +} diff --git a/src/lib/conversion.ts b/src/lib/conversion.ts new file mode 100644 index 0000000..61dcad9 --- /dev/null +++ b/src/lib/conversion.ts @@ -0,0 +1,9 @@ +export function urlBase64ToUint8Array(base64String: string) { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, "+") + .replace(/_/g, "/"); + const rawData = window.atob(base64); + + return Uint8Array.from([...rawData].map(char => char.charCodeAt(0))); +} diff --git a/src/lib/debounce.ts b/src/lib/debounce.ts new file mode 100644 index 0000000..c7ac383 --- /dev/null +++ b/src/lib/debounce.ts @@ -0,0 +1,15 @@ +export function debounce(cb: Function, duration: number) { + // Store the timer variable. + let timer: number; + // This function is given to React. + return (...args: any[]) => { + // Get rid of the old timer. + clearTimeout(timer); + // Set a new timer. + timer = setTimeout(() => { + // Instead calling the new function. + // (with the newer data) + cb(...args); + }, duration); + }; +} diff --git a/src/lib/fileSize.ts b/src/lib/fileSize.ts new file mode 100644 index 0000000..c6bba81 --- /dev/null +++ b/src/lib/fileSize.ts @@ -0,0 +1,9 @@ +export function determineFileSize(size: number) { + if (size > 1e6) { + return `${(size / 1e6).toFixed(2)} MB`; + } else if (size > 1e3) { + return `${(size / 1e3).toFixed(2)} KB`; + } + + return `${size} B`; +} diff --git a/src/main.tsx b/src/main.tsx index 70a4359..5415f66 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,6 @@ import { render } from "preact"; import "./styles/index.scss"; -import { App } from "./app"; +import { App } from "./pages/app"; import { registerSW } from 'virtual:pwa-register' diff --git a/src/pages/App.tsx b/src/pages/RevoltApp.tsx similarity index 50% rename from src/pages/App.tsx rename to src/pages/RevoltApp.tsx index f0ff58d..88738fd 100644 --- a/src/pages/App.tsx +++ b/src/pages/RevoltApp.tsx @@ -11,7 +11,10 @@ import RightSidebar from "../components/navigation/RightSidebar"; import Home from './home/Home'; import Friends from "./friends/Friends"; +import Settings from './settings/Settings'; import Developer from "./developer/Developer"; +import ServerSettings from "./settings/ServerSettings"; +import ChannelSettings from "./settings/ChannelSettings"; const Routes = styled.div` min-width: 0; @@ -31,17 +34,19 @@ export default function App() { docked={isTouchscreenDevice ? Docked.None : Docked.Left}> <Routes> <Switch> - <Route path="/dev"> - <Developer /> - </Route> + <Route path="/server/:server/channel/:channel/settings/:page" component={ChannelSettings} /> + <Route path="/server/:server/channel/:channel/settings" component={ChannelSettings} /> + <Route path="/server/:server/settings/:page" component={ServerSettings} /> + <Route path="/server/:server/settings" component={ServerSettings} /> + <Route path="/channel/:channel/settings/:page" component={ChannelSettings} /> + <Route path="/channel/:channel/settings" component={ChannelSettings} /> + + <Route path="/settings/:page" component={Settings} /> + <Route path="/settings" component={Settings} /> - <Route path="/friends"> - <Friends /> - </Route> - - <Route path="/"> - <Home /> - </Route> + <Route path="/dev" component={Developer} /> + <Route path="/friends" component={Friends} /> + <Route path="/" component={Home} /> </Switch> </Routes> <ContextMenus /> @@ -55,31 +60,6 @@ export default function App() { * <Route path="/channel/:channel/message/:message"> <ChannelWrapper /> </Route> - <Route path="/server/:server/channel/:channel/settings/:page"> - <ChannelSettings key="channel_settings" /> - </Route> - <Route path="/server/:server/channel/:channel/settings"> - <ChannelSettings key="channel_settings" /> - </Route> - <Route path="/server/:server/settings/:page"> - <ServerSettings key="channel_settings" /> - </Route> - <Route path="/server/:server/settings"> - <ServerSettings key="channel_settings" /> - </Route> - <Route path="/channel/:channel/settings/:page"> - <ChannelSettings key="channel_settings" /> - </Route> - <Route path="/channel/:channel/settings"> - <ChannelSettings key="channel_settings" /> - </Route> - - <Route path="/settings/:page"> - <Settings key="settings" /> - </Route> - <Route path="/settings"> - <Settings key="settings" /> - </Route> <Route path="/server/:server/channel/:channel"> <ChannelWrapper /> @@ -89,21 +69,10 @@ export default function App() { <ChannelWrapper /> </Route> - <Route path="/friends"> - <Friends /> - </Route> - <Route path="/dev"> - <Developer /> - </Route> - <Route path="/open/:id"> <Open /> </Route> {/*<Route path="/invite/:code"> <OpenInvite /> </Route> - - <Route path="/"> - <Home /> - </Route> */ diff --git a/src/app.tsx b/src/pages/app.tsx similarity index 74% rename from src/app.tsx rename to src/pages/app.tsx index ee55979..c6af90a 100644 --- a/src/app.tsx +++ b/src/pages/app.tsx @@ -1,11 +1,11 @@ -import { CheckAuth } from "./context/revoltjs/CheckAuth"; -import Preloader from "./components/ui/Preloader"; +import { CheckAuth } from "../context/revoltjs/CheckAuth"; +import Preloader from "../components/ui/Preloader"; import { Route, Switch } from "react-router-dom"; -import Context from "./context"; +import Context from "../context"; import { lazy, Suspense } from "preact/compat"; -const Login = lazy(() => import('./pages/login/Login')); -const RevoltApp = lazy(() => import('./pages/App')); +const Login = lazy(() => import('./login/Login')); +const RevoltApp = lazy(() => import('./RevoltApp')); export function App() { return ( diff --git a/src/pages/home/Home.tsx b/src/pages/home/Home.tsx index 78e0348..01371fc 100644 --- a/src/pages/home/Home.tsx +++ b/src/pages/home/Home.tsx @@ -3,14 +3,13 @@ import { Link } from "react-router-dom"; import { Text } from "preact-i18n"; import Header from "../../components/ui/Header"; -// import WideLogo from "../../../../../assets/wide.svg"; export default function Home() { return ( <div className={styles.home}> <Header placement="primary"><Text id="app.navigation.tabs.home" /></Header> <h3> - <Text id="app.special.modals.onboarding.welcome" /> {/*<WideLogo />*/} + <Text id="app.special.modals.onboarding.welcome" /> <img src="/assets/wide.svg" /> </h3> <ul> <li> diff --git a/src/pages/login/forms/FormLogin.tsx b/src/pages/login/forms/FormLogin.tsx index 6588a52..d3af8a1 100644 --- a/src/pages/login/forms/FormLogin.tsx +++ b/src/pages/login/forms/FormLogin.tsx @@ -1,7 +1,7 @@ import { Form } from "./Form"; +import { detect } from "detect-browser"; import { useContext } from "preact/hooks"; import { useHistory } from "react-router-dom"; -import { deviceDetect } from "react-device-detect"; import { OperationsContext } from "../../../context/revoltjs/RevoltClient"; export function FormLogin() { @@ -12,7 +12,7 @@ export function FormLogin() { <Form page="login" callback={async data => { - const browser = deviceDetect(); + const browser = detect(); let device_name; if (browser) { const { name, os } = browser; diff --git a/src/pages/settings/ChannelSettings.tsx b/src/pages/settings/ChannelSettings.tsx new file mode 100644 index 0000000..0f7cf15 --- /dev/null +++ b/src/pages/settings/ChannelSettings.tsx @@ -0,0 +1,46 @@ +import { Text } from "preact-i18n"; +import { List } from "@styled-icons/feather"; +import Category from "../../components/ui/Category"; +import { GenericSettings } from "./GenericSettings"; +import { getChannelName } from "../../context/revoltjs/util"; +import { Route, useHistory, useParams } from "react-router-dom"; +import { useChannel, useForceUpdate } from "../../context/revoltjs/hooks"; + +import { Overview } from "./channel/Overview"; + +export default function ChannelSettings() { + const { channel: cid } = useParams<{ channel: string; }>(); + const ctx = useForceUpdate(); + const channel = useChannel(cid, ctx); + if (!channel) return null; + if (channel.channel_type === 'SavedMessages' || channel.channel_type === 'DirectMessage') return null; + + const history = useHistory(); + function switchPage(to?: string) { + if (to) { + history.replace(`/channel/${cid}/settings/${to}`); + } else { + history.replace(`/channel/${cid}/settings`); + } + } + + return ( + <GenericSettings + pages={[ + { + category: <Category variant="uniform" text={getChannelName(ctx.client, channel, [], true)} />, + id: 'overview', + icon: <List size={20} strokeWidth={2} />, + title: <Text id="app.settings.channel_pages.overview.title" /> + } + ]} + children={[ + <Route path="/"><Overview channel={channel} /></Route> + ]} + category="channel_pages" + switchPage={switchPage} + defaultPage="overview" + showExitButton + /> + ) +} diff --git a/src/pages/settings/GenericSettings.tsx b/src/pages/settings/GenericSettings.tsx new file mode 100644 index 0000000..89579f4 --- /dev/null +++ b/src/pages/settings/GenericSettings.tsx @@ -0,0 +1,120 @@ +import { Text } from "preact-i18n"; +import { useEffect } from "preact/hooks"; +import styles from "./Settings.module.scss"; +import { Children } from "../../types/Preact"; +import Header from '../../components/ui/Header'; +import Category from '../../components/ui/Category'; +import IconButton from "../../components/ui/IconButton"; +import LineDivider from "../../components/ui/LineDivider"; +import { ArrowLeft, X, XCircle } from "@styled-icons/feather"; +import { Switch, useHistory, useParams } from "react-router-dom"; +import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; +import ButtonItem from "../../components/navigation/items/ButtonItem"; + +interface Props { + pages: { + category?: Children, + divider?: boolean, + id: string, + icon: Children + title: Children + }[] + custom?: Children + children: Children + defaultPage: string + showExitButton?: boolean + switchPage: (to?: string) => void + category: 'pages' | 'channel_pages' | 'server_pages' +} + +export function GenericSettings({ pages, switchPage, category, custom, children, defaultPage, showExitButton }: Props) { + const history = useHistory(); + const { page } = useParams<{ page: string; }>(); + + function exitSettings() { + if (history.length > 0) { + history.goBack(); + } else { + history.push('/'); + } + } + + useEffect(() => { + function keyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + exitSettings(); + } + } + + document.body.addEventListener("keydown", keyDown); + return () => document.body.removeEventListener("keydown", keyDown); + }, []); + + return ( + <div className={styles.settings} data-mobile={isTouchscreenDevice}> + {isTouchscreenDevice && ( + <Header placement="primary"> + {typeof page === "undefined" ? ( + <> + { showExitButton && + <IconButton onClick={exitSettings}> + <X size={24} /> + </IconButton> } + <Text id="app.settings.title" /> + </> + ) : ( + <> + <IconButton onClick={() => switchPage()}> + <ArrowLeft size={24} /> + </IconButton> + <Text + id={`app.settings.${category}.${page}.title`} + /> + </> + )} + </Header> + )} + {(!isTouchscreenDevice || typeof page === "undefined") && ( + <div className={styles.sidebar}> + <div className={styles.container}> + { + pages.map((entry, i) => + <> + { entry.category && <Category variant="uniform" text={entry.category} /> } + <ButtonItem + active={page === entry.id || (i === 0 && !isTouchscreenDevice && typeof page === "undefined")} + onClick={() => switchPage(entry.id)} + compact + >{entry.icon} {entry.title}</ButtonItem> + { entry.divider && <LineDivider /> } + </> + ) + } + { custom } + </div> + </div> + )} + {(!isTouchscreenDevice || typeof page === "string") && ( + <div className={styles.content}> + {!isTouchscreenDevice && ( + <h1> + <Text + id={`app.settings.${category}.${page ?? defaultPage}.title`} + /> + </h1> + )} + <Switch> + { children } + </Switch> + </div> + )} + {!isTouchscreenDevice && ( + <div className={styles.action}> + <IconButton onClick={exitSettings}> + <XCircle size={48} /> + </IconButton> + </div> + )} + </div> + ); +} diff --git a/src/pages/settings/ServerSettings.tsx b/src/pages/settings/ServerSettings.tsx new file mode 100644 index 0000000..c3946c7 --- /dev/null +++ b/src/pages/settings/ServerSettings.tsx @@ -0,0 +1,65 @@ +import { Text } from "preact-i18n"; +import Category from "../../components/ui/Category"; +import { GenericSettings } from "./GenericSettings"; +import { useServer } from "../../context/revoltjs/hooks"; +import { Route, useHistory, useParams } from "react-router-dom"; +import { List, Share, Users, XSquare } from "@styled-icons/feather"; +import RequiresOnline from "../../context/revoltjs/RequiresOnline"; + +import { Overview } from "./server/Overview"; +import { Members } from "./server/Members"; +import { Invites } from "./server/Invites"; +import { Bans } from "./server/Bans"; + +export default function ServerSettings() { + const { server: sid } = useParams<{ server: string; }>(); + const server = useServer(sid); + if (!server) return null; + + const history = useHistory(); + function switchPage(to?: string) { + if (to) { + history.replace(`/server/${sid}/settings/${to}`); + } else { + history.replace(`/server/${sid}/settings`); + } + } + + return ( + <GenericSettings + pages={[ + { + category: <Category variant="uniform" text={server.name} />, + id: 'overview', + icon: <List size={20} strokeWidth={2} />, + title: <Text id="app.settings.channel_pages.overview.title" /> + }, + { + id: 'members', + icon: <Users size={20} strokeWidth={2} />, + title: "Members" + }, + { + id: 'invites', + icon: <Share size={20} strokeWidth={2} />, + title: "Invites" + }, + { + id: 'bans', + icon: <XSquare size={20} strokeWidth={2} />, + title: "Bans" + } + ]} + children={[ + <Route path="/server/:server/settings/members"><RequiresOnline><Members server={server} /></RequiresOnline></Route>, + <Route path="/server/:server/settings/invites"><RequiresOnline><Invites server={server} /></RequiresOnline></Route>, + <Route path="/server/:server/settings/bans"><RequiresOnline><Bans server={server} /></RequiresOnline></Route>, + <Route path="/"><Overview server={server} /></Route> + ]} + category="server_pages" + switchPage={switchPage} + defaultPage="overview" + showExitButton + /> + ) +} diff --git a/src/pages/settings/Settings.module.scss b/src/pages/settings/Settings.module.scss new file mode 100644 index 0000000..9497a95 --- /dev/null +++ b/src/pages/settings/Settings.module.scss @@ -0,0 +1,209 @@ +@keyframes open { + 0% {transform: scale(1.2);}; + 100% {transform: scale(1);}; +} + +@keyframes opacity { + 0% {opacity: 0;}; + 20% {opacity: .5;} + 50% {opacity: 1;} +} + +@keyframes close { + 0% {transform: scale(1); opacity: 1;}; + 100% {transform: scale(1.2); opacity: 0;}; +} + +[data-touchscreen-device="true"] .settings { + flex-direction: column; + background: var(--primary-header); + + .sidebar, .content { + background: var(--primary-background); + } + + .sidebar { + justify-content: flex-start; + + .container { + padding: 20px 8px; + min-width: 218px; + } + + > div { + width: 100%; + } + + .version { + place-items: center; + + } + } + + .content { + padding: 10px 12px 50px; + } +} + +:global(.app):not([data-touchscreen-device="true"]) .settings { + top: 0; + left: 0; + z-index: 10; + width: 100%; + height: 100%; + position: fixed; + animation: open .18s ease-out, + opacity .18s; +} + +.settings { + height: 100%; + display: flex; + user-select: none; + flex-direction: row; + justify-content: center; + background: var(--primary-background); + + .sidebar { + flex-grow: 1; + display: flex; + flex-shrink: 0; + overflow-y: scroll; + justify-content: flex-end; + background: var(--secondary-background); + + .container { + width: 218px; + padding: 60px 8px; + } + + .divider { + height: 30px; + } + + .donate { + color: goldenrod !important; + } + + .logOut { + color: var(--error) !important; + } + + .version { + margin: 1rem 12px 0; + font-size: 10px; + color: var(--secondary-foreground); + font-family: "Fira Mono", monospace; + user-select: text; + + display: grid; + //place-items: center; + + > div { + gap: 2px; + display: flex; + flex-direction: column; + } + } + + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); + } + + .content { + flex-grow: 2; + max-width: 740px; + padding: 60px 2em; + overflow-y: scroll; + overflow-x: hidden; + + details { + margin: 14px 0; + } + + h1 { + margin-top: 0; + line-height: 1em; + font-size: 1.2em; + font-weight: 600; + } + + h3 { + font-size: 13px; + text-transform: uppercase; + color: var(--secondary-foreground); + } + + h4 { + margin: 4px 2px; + font-size: 13px; + + color: var(--tertiary-foreground); + text-transform: uppercase; + } + + .footer { + border-top: 1px solid; + margin: 0; + padding-top: 5px; + font-size: 14px; + color: var(--secondary-foreground); + } + } + + .action { + flex-grow: 1; + flex-shrink: 0; + padding: 60px 8px; + color: var(--tertiary-background); + + &:after { + content: "ESC"; + display: flex; + text-align: center; + align-content: center; + justify-content: center; + position: relative; + color: var(--foreground); + width: 48px; + opacity: .5; + font-size: .75em; + } + + > div { + display: inline; + > svg { + &:active { + transform: translateY(2px); + } + } + } + } +} + +.loader { + > div { + margin: auto; + } +} + +.textarea { + margin-bottom: 1em; + border-radius: 4px; + font-family: 'Courier New', Courier, monospace; + + textarea { + resize: none; + padding: 12px; + min-height: 180px; + border-radius: 4px; + color: var(--foreground); + border: 2px solid transparent; + background: var(--secondary-background); + transition: border-color .2s ease-in-out; + + &:focus { + outline: none; + border: 2px solid var(--accent); + } + } +} diff --git a/src/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx new file mode 100644 index 0000000..a4115c9 --- /dev/null +++ b/src/pages/settings/Settings.tsx @@ -0,0 +1,153 @@ +import { Text } from "preact-i18n"; +import { Sync } from "./panes/Sync"; +import { useContext } from "preact/hooks"; +import styles from "./Settings.module.scss"; +import { LIBRARY_VERSION } from "revolt.js"; +import { APP_VERSION } from "../../version"; +import { GenericSettings } from "./GenericSettings"; +import { Route, useHistory } from "react-router-dom"; +import { + Bell, + Box, + Coffee, + Gitlab, + Globe, + Image, + LogOut, + RefreshCw, + Shield, + ToggleRight, + User +} from "@styled-icons/feather"; +import { Megaphone } from "@styled-icons/bootstrap"; +import LineDivider from "../../components/ui/LineDivider"; +import RequiresOnline from "../../context/revoltjs/RequiresOnline"; +import ButtonItem from "../../components/navigation/items/ButtonItem"; +import { AppContext, OperationsContext } from "../../context/revoltjs/RevoltClient"; + +import { Account } from "./panes/Account"; +import { Profile } from "./panes/Profile"; +import { Sessions } from "./panes/Sessions"; +import { Feedback } from "./panes/Feedback"; +import { Languages } from "./panes/Languages"; +import { Appearance } from "./panes/Appearance"; +import { Notifications } from "./panes/Notifications"; +import { ExperimentsPage } from "./panes/Experiments"; + +export default function Settings() { + const history = useHistory(); + const client = useContext(AppContext); + const operations = useContext(OperationsContext); + + function switchPage(to?: string) { + if (to) { + history.replace(`/settings/${to}`); + } else { + history.replace(`/settings`); + } + } + + return ( + <GenericSettings + pages={[ + { + category: <Text id="app.settings.categories.user_settings" />, + id: 'account', + icon: <User size={20} strokeWidth={2} />, + title: <Text id="app.settings.pages.account.title" /> + }, + { + id: 'profile', + icon: <Image size={20} strokeWidth={2} />, + title: <Text id="app.settings.pages.profile.title" /> + }, + { + id: 'sessions', + icon: <Shield size={20} strokeWidth={2} />, + title: <Text id="app.settings.pages.sessions.title" /> + }, + { + category: <Text id="app.settings.categories.client_settings" />, + id: 'appearance', + icon: <Box size={20} strokeWidth={2} />, + title: <Text id="app.settings.pages.appearance.title" /> + }, + { + id: 'notifications', + icon: <Bell size={20} strokeWidth={2} />, + title: <Text id="app.settings.pages.notifications.title" /> + }, + { + id: 'language', + icon: <Globe size={20} strokeWidth={2} />, + title: <Text id="app.settings.pages.language.title" /> + }, + { + id: 'sync', + icon: <RefreshCw size={20} strokeWidth={2} />, + title: <Text id="app.settings.pages.sync.title" /> + }, + { + divider: true, + id: 'experiments', + icon: <ToggleRight size={20} strokeWidth={2} />, + title: <Text id="app.settings.pages.experiments.title" /> + }, + { + id: 'feedback', + icon: <Megaphone size={20} strokeWidth={0.3} />, + title: <Text id="app.settings.pages.feedback.title" /> + } + ]} + children={[ + <Route path="/settings/profile"><Profile /></Route>, + <Route path="/settings/sessions"> + <RequiresOnline><Sessions /></RequiresOnline> + </Route>, + <Route path="/settings/appearance"><Appearance /></Route>, + <Route path="/settings/notifications"><Notifications /></Route>, + <Route path="/settings/language"><Languages /></Route>, + <Route path="/settings/sync"><Sync /></Route>, + <Route path="/settings/experiments"><ExperimentsPage /></Route>, + <Route path="/settings/feedback"><Feedback /></Route>, + <Route path="/"><Account /></Route> + ]} + defaultPage="account" + switchPage={switchPage} + category="pages" + custom={[ + <a + href="https://gitlab.insrt.uk/revolt" + target="_blank" + > + <ButtonItem compact> + <Gitlab size={20} strokeWidth={2} /> + <Text id="app.settings.pages.source_code" /> + </ButtonItem> + </a>, + <a href="https://ko-fi.com/insertish" target="_blank"> + <ButtonItem className={styles.donate} compact> + <Coffee size={20} strokeWidth={2} /> + <Text id="app.settings.pages.donate.title" /> + </ButtonItem> + </a>, + <LineDivider />, + <ButtonItem + onClick={() => operations.logout()} + className={styles.logOut} + compact + > + <LogOut size={20} strokeWidth={2} /> + <Text id="app.settings.pages.logOut" /> + </ButtonItem>, + <div className={styles.version}> + <div> + <span>Stable {APP_VERSION}</span> + <span>API: {client.configuration?.revolt ?? "N/A"}</span> + <span>revolt.js: {LIBRARY_VERSION}</span> + </div> + </div> + ]} + /> + ) +} diff --git a/src/pages/settings/SettingsTextArea.tsx b/src/pages/settings/SettingsTextArea.tsx new file mode 100644 index 0000000..2cf4a64 --- /dev/null +++ b/src/pages/settings/SettingsTextArea.tsx @@ -0,0 +1,6 @@ +import styles from "./Settings.module.scss"; +import { TextArea, TextAreaProps } from "../../components/ui/TextArea"; + +export function SettingsTextArea(props: TextAreaProps) { + return <TextArea {...props} className={styles.textarea} padding={16} />; +} diff --git a/src/pages/settings/channel/Overview.tsx b/src/pages/settings/channel/Overview.tsx new file mode 100644 index 0000000..1efaf59 --- /dev/null +++ b/src/pages/settings/channel/Overview.tsx @@ -0,0 +1,89 @@ +import { Text } from "preact-i18n"; +import styles from "./Panes.module.scss"; +import Button from "../../../components/ui/Button"; +import { Channels } from "revolt.js/dist/api/objects"; +import InputBox from "../../../components/ui/InputBox"; +import { SettingsTextArea } from "../SettingsTextArea"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import { FileUploader } from "../../../context/revoltjs/FileUploads"; + +interface Props { + channel: Channels.GroupChannel | Channels.TextChannel; +} + +export function Overview({ channel }: Props) { + const client = useContext(AppContext); + + const [name, setName] = useState(channel.name); + const [description, setDescription] = useState(channel.description ?? ''); + + useEffect(() => setName(channel.name), [ channel.name ]); + useEffect(() => setDescription(channel.description ?? ''), [ channel.description ]); + + const [ changed, setChanged ] = useState(false); + function save() { + let changes: any = {}; + if (name !== channel.name) changes.name = name; + if (description !== channel.description) + changes.description = description; + + client.channels.edit(channel._id, changes); + setChanged(false); + } + + return ( + <div className={styles.overview}> + <div className={styles.row}> + <FileUploader + width={80} + height={80} + style="icon" + fileType="icons" + behaviour="upload" + maxFileSize={2_500_000} + onUpload={icon => client.channels.edit(channel._id, { icon })} + previewURL={client.channels.getIconURL(channel._id, { max_side: 256 }, true)} + remove={() => client.channels.edit(channel._id, { remove: 'Icon' })} + defaultPreview={channel.channel_type === 'Group' ? "/assets/group.png" : undefined} + /> + <div className={styles.name}> + <h3> + { channel.channel_type === 'Group' ? + <Text id="app.main.groups.name" /> : + <Text id="app.main.servers.channel_name" /> } + </h3> + <InputBox + contrast + value={name} + maxLength={32} + onChange={e => { + setName(e.currentTarget.value) + if (!changed) setChanged(true) + }} + /> + </div> + </div> + + <h3> + { channel.channel_type === 'Group' ? + <Text id="app.main.groups.description" /> : + <Text id="app.main.servers.channel_description" /> } + </h3> + <SettingsTextArea + maxRows={10} + minHeight={60} + maxLength={1024} + value={description} + placeholder={"Add a description..."} + onChange={content => { + setDescription(content); + if (!changed) setChanged(true) + }} + /> + <Button onClick={save} style="contrast" disabled={!changed}> + <Text id="app.special.modals.actions.save" /> + </Button> + </div> + ); +} diff --git a/src/pages/settings/channel/Panes.module.scss b/src/pages/settings/channel/Panes.module.scss new file mode 100644 index 0000000..281eab2 --- /dev/null +++ b/src/pages/settings/channel/Panes.module.scss @@ -0,0 +1,14 @@ +.overview { + .row { + gap: 20px; + display: flex; + + .name { + flex-grow: 1; + + input { + width: 100%; + } + } + } +} diff --git a/src/pages/settings/panes/Account.tsx b/src/pages/settings/panes/Account.tsx new file mode 100644 index 0000000..6147c25 --- /dev/null +++ b/src/pages/settings/panes/Account.tsx @@ -0,0 +1,95 @@ +import { Text } from "preact-i18n"; +import styles from "./Panes.module.scss"; +import Tip from "../../../components/ui/Tip"; +import Button from "../../../components/ui/Button"; +import { Users } from "revolt.js/dist/api/objects"; +import { Link, useHistory } from "react-router-dom"; +import Overline from "../../../components/ui/Overline"; +import { AtSign, Key, Mail } from "@styled-icons/feather"; +import { useForceUpdate, useSelf } from "../../../context/revoltjs/hooks"; +import UserIcon from "../../../components/common/UserIcon"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient"; +import { useIntermediate } from "../../../context/intermediate/Intermediate"; + +export function Account() { + const { openScreen } = useIntermediate(); + const status = useContext(StatusContext); + + const ctx = useForceUpdate(); + const user = useSelf(ctx); + if (!user) return null; + + const [email, setEmail] = useState("..."); + const [profile, setProfile] = useState<undefined | Users.Profile>( + undefined + ); + const history = useHistory(); + + function switchPage(to: string) { + history.replace(`/settings/${to}`); + } + + useEffect(() => { + if (email === "..." && status === ClientStatus.ONLINE) { + ctx.client + .req("GET", "/auth/user") + .then(account => setEmail(account.email)); + } + + if (profile === undefined && status === ClientStatus.ONLINE) { + ctx.client.users + .fetchProfile(user._id) + .then(profile => setProfile(profile ?? {})); + } + }, [status]); + + return ( + <div className={styles.user}> + <div className={styles.banner}> + <Link to="/settings/profile"> + <UserIcon target={user} size={72} /> + </Link> + <div className={styles.username}>@{user.username}</div> + </div> + <div className={styles.details}> + {[ + ["username", user.username, <AtSign size={24} />], + ["email", email, <Mail size={24} />], + ["password", "*****", <Key size={24} />] + ].map(([field, value, icon]) => ( + <div> + {icon} + <div className={styles.detail}> + <Overline> + <Text id={`login.${field}`} /> + </Overline> + <p>{value}</p> + </div> + <div> + <Button + onClick={() => + openScreen({ + id: "modify_account", + field: field as any + }) + } + contrast + > + <Text id="app.settings.pages.account.change_field" /> + </Button> + </div> + </div> + ))} + </div> + <Tip> + <span> + <Text id="app.settings.tips.account.a" /> + </span>{" "} + <a onClick={() => switchPage("profile")}> + <Text id="app.settings.tips.account.b" /> + </a> + </Tip> + </div> + ); +} diff --git a/src/pages/settings/panes/Appearance.tsx b/src/pages/settings/panes/Appearance.tsx new file mode 100644 index 0000000..867d6f0 --- /dev/null +++ b/src/pages/settings/panes/Appearance.tsx @@ -0,0 +1,286 @@ +import { Text } from "preact-i18n"; +import styles from "./Panes.module.scss"; +import { debounce } from "../../../lib/debounce"; +import Button from "../../../components/ui/Button"; +import InputBox from "../../../components/ui/InputBox"; +import { SettingsTextArea } from "../SettingsTextArea"; +import { connectState } from "../../../redux/connector"; +import { WithDispatcher } from "../../../redux/reducers"; +import ColourSwatches from "../../../components/ui/ColourSwatches"; +import { EmojiPacks, Settings } from "../../../redux/reducers/settings"; +import { Theme, ThemeContext, ThemeOptions } from "../../../context/Theme"; +import { useCallback, useContext, useEffect, useState } from "preact/hooks"; +import { useIntermediate } from "../../../context/intermediate/Intermediate"; + +// @ts-ignore +import pSBC from 'shade-blend-color'; + +interface Props { + settings: Settings; +} + +// ! FIXME: code needs to be rewritten to fix jittering +export function Component(props: Props & WithDispatcher) { + const theme = useContext(ThemeContext); + const { writeClipboard, openScreen } = useIntermediate(); + + function setTheme(theme: ThemeOptions) { + props.dispatcher({ + type: "SETTINGS_SET_THEME", + theme + }); + } + + function pushOverride(custom: Partial<Theme>) { + props.dispatcher({ + type: "SETTINGS_SET_THEME_OVERRIDE", + custom + }); + } + + function setAccent(accent: string) { + setOverride({ + accent, + "sidebar-active": accent, + "scrollbar-thumb": pSBC(-0.2, accent) + }); + } + + const emojiPack = props.settings.appearance?.emojiPack ?? 'mutant'; + function setEmojiPack(emojiPack: EmojiPacks) { + props.dispatcher({ + type: 'SETTINGS_SET_APPEARANCE', + options: { + emojiPack + } + }); + } + + const setOverride = useCallback(debounce(pushOverride, 200), []) as ( + custom: Partial<Theme> + ) => void; + const [ css, setCSS ] = useState(props.settings.theme?.custom?.css ?? ''); + + useEffect(() => setOverride({ css }), [ css ]); + + const selected = props.settings.theme?.preset ?? "dark"; + return ( + <div className={styles.appearance}> + <h3> + <Text id="app.settings.pages.appearance.theme" /> + </h3> + <div className={styles.themes}> + <div className={styles.theme}> + <img + src="/assets/images/light.svg" + data-active={selected === "light"} + onClick={() => + selected !== "light" && + setTheme({ preset: "light" }) + } /> + <h4> + <Text id="app.settings.pages.appearance.color.light" /> + </h4> + </div> + <div className={styles.theme}> + <img + src="/assets/images/dark.svg" + data-active={selected === "dark"} + onClick={() => + selected !== "dark" && setTheme({ preset: "dark" }) + } /> + <h4> + <Text id="app.settings.pages.appearance.color.dark" /> + </h4> + </div> + </div> + + <h3> + <Text id="app.settings.pages.appearance.accent_selector" /> + </h3> + <ColourSwatches value={theme.accent} onChange={setAccent} /> + + {/*<h3> + <Text id="app.settings.pages.appearance.message_display" /> + </h3> + <div className={styles.display}> + <Radio + description={ + <Text id="app.settings.pages.appearance.display.default_description" /> + } + checked + > + <Text id="app.settings.pages.appearance.display.default" /> + </Radio> + <Radio + description={ + <Text id="app.settings.pages.appearance.display.compact_description" /> + } + disabled + > + <Text id="app.settings.pages.appearance.display.compact" /> + </Radio> + </div>*/} + + <h3> + <Text id="app.settings.pages.appearance.emoji_pack" /> + </h3> + <div className={styles.emojiPack}> + <div className={styles.row}> + <div> + <div className={styles.button} + onClick={() => setEmojiPack('mutant')} + data-active={emojiPack === 'mutant'}> + <img src="/assets/images/mutant_emoji.svg" draggable={false} /> + </div> + <h4>Mutant Remix <a href="https://mutant.revolt.chat" target="_blank">(by Revolt)</a></h4> + </div> + <div> + <div className={styles.button} + onClick={() => setEmojiPack('twemoji')} + data-active={emojiPack === 'twemoji'}> + <img src="/assets/images/twemoji_emoji.svg" draggable={false} /> + </div> + <h4>Twemoji</h4> + </div> + </div> + <div className={styles.row}> + <div> + <div className={styles.button} + onClick={() => setEmojiPack('openmoji')} + data-active={emojiPack === 'openmoji'}> + <img src="/assets/images/openmoji_emoji.svg" draggable={false} /> + </div> + <h4>Openmoji</h4> + </div> + <div> + <div className={styles.button} + onClick={() => setEmojiPack('noto')} + data-active={emojiPack === 'noto'}> + <img src="/assets/images/noto_emoji.svg" draggable={false} /> + </div> + <h4>Noto Emoji</h4> + </div> + </div> + </div> + + <details> + <summary> + <Text id="app.settings.pages.appearance.advanced" /> + <div className={styles.divider}></div> + </summary> + <h3> + <Text id="app.settings.pages.appearance.overrides" /> + </h3> + <div className={styles.actions}> + <Button contrast + onClick={() => setTheme({ custom: {} })}> + <Text id="app.settings.pages.appearance.reset_overrides" /> + </Button> + <Button contrast + onClick={() => writeClipboard(JSON.stringify(theme))}> + <Text id="app.settings.pages.appearance.export_clipboard" /> + </Button> + <Button contrast + onClick={async () => { + const text = await navigator.clipboard.readText(); + setOverride(JSON.parse(text)); + }}> + <Text id="app.settings.pages.appearance.import_clipboard" /> + </Button> + <Button contrast + onClick={async () => { + openScreen({ + id: "_input", + question: <Text id="app.settings.pages.appearance.import_theme" />, + field: <Text id="app.settings.pages.appearance.theme_data" />, + callback: async string => setOverride(JSON.parse(string)) + }); + }}> + <Text id="app.settings.pages.appearance.import_manual" /> + </Button> + </div> + <div className={styles.overrides}> + {[ + "accent", + "background", + "foreground", + "primary-background", + "primary-header", + "secondary-background", + "secondary-foreground", + "secondary-header", + "tertiary-background", + "tertiary-foreground", + "block", + "message-box", + "mention", + "sidebar-active", + "scrollbar-thumb", + "scrollbar-track", + "status-online", + "status-away", + "status-busy", + "status-streaming", + "status-invisible", + "success", + "warning", + "error", + "hover" + ].map(x => ( + <div className={styles.entry} key={x}> + <span>{x}</span> + <div className={styles.override}> + <div className={styles.picker} + style={{ backgroundColor: (theme as any)[x as any] }}> + <input + type="color" + value={(theme as any)[x as any]} + onChange={v => + setOverride({ + [x]: v.currentTarget.value + }) + } + /> + </div> + <InputBox + className={styles.text} + value={(theme as any)[x as any]} + onChange={y => + setOverride({ + [x]: y.currentTarget.value + }) + } + /> + </div> + </div> + ))} + </div> + <h3> + <Text id="app.settings.pages.appearance.custom_css" /> + </h3> + <SettingsTextArea + maxRows={20} + minHeight={480} + value={css} + onChange={css => setCSS(css)} + /> + </details> + + {/*<h3> + <Text id="app.settings.pages.appearance.sync" /> + </h3> + <p>Coming soon!</p>*/} + </div> + ); +} + +export const Appearance = connectState( + Component, + state => { + return { + settings: state.settings + }; + }, + true +); diff --git a/src/pages/settings/panes/Experiments.tsx b/src/pages/settings/panes/Experiments.tsx new file mode 100644 index 0000000..2922be8 --- /dev/null +++ b/src/pages/settings/panes/Experiments.tsx @@ -0,0 +1,53 @@ +import { Text } from "preact-i18n"; +import styles from "./Panes.module.scss"; +import Checkbox from "../../../components/ui/Checkbox"; +import { connectState } from "../../../redux/connector"; +import { WithDispatcher } from "../../../redux/reducers"; +import { AVAILABLE_EXPERIMENTS, ExperimentOptions } from "../../../redux/reducers/experiments"; + +interface Props { + options?: ExperimentOptions; +} + +export function Component(props: Props & WithDispatcher) { + return ( + <div className={styles.notifications}> + <h3> + <Text id="app.settings.pages.experiments.features" /> + </h3> + { + (AVAILABLE_EXPERIMENTS).map( + key => + <Checkbox + checked={(props.options?.enabled ?? []).indexOf(key) > -1} + onChange={enabled => { + props.dispatcher({ + type: enabled ? 'EXPERIMENTS_ENABLE' : 'EXPERIMENTS_DISABLE', + key + }); + }} + > + <Text id={`app.settings.pages.experiments.titles.${key}`} /> + <p> + <Text id={`app.settings.pages.experiments.descriptions.${key}`} /> + </p> + </Checkbox> + ) + } + { + AVAILABLE_EXPERIMENTS.length === 0 && + <Text id="app.settings.pages.experiments.not_available" /> + } + </div> + ); +} + +export const ExperimentsPage = connectState( + Component, + state => { + return { + options: state.experiments + }; + }, + true +); diff --git a/src/pages/settings/panes/Feedback.tsx b/src/pages/settings/panes/Feedback.tsx new file mode 100644 index 0000000..bda00cd --- /dev/null +++ b/src/pages/settings/panes/Feedback.tsx @@ -0,0 +1,95 @@ +import { useState } from "preact/hooks"; +import styles from "./Panes.module.scss"; +import { Localizer, Text } from "preact-i18n"; +import Radio from "../../../components/ui/Radio"; +import Button from "../../../components/ui/Button"; +import InputBox from "../../../components/ui/InputBox"; +import { SettingsTextArea } from "../SettingsTextArea"; +import { useSelf } from "../../../context/revoltjs/hooks"; + +export function Feedback() { + const user = useSelf(); + const [other, setOther] = useState(""); + const [description, setDescription] = useState(""); + const [state, setState] = useState<"ready" | "sending" | "sent">("ready"); + const [checked, setChecked] = useState< + "Bug" | "Feature Request" | "__other_option__" + >("Bug"); + + async function onSubmit(ev: JSX.TargetedEvent<HTMLFormElement, Event>) { + ev.preventDefault(); + setState("sending"); + + await fetch( + `https://workers.revolt.chat/feedback`, + { + method: "POST", + body: JSON.stringify({ + checked, + other, + description, + name: user?.username ?? "Unknown User" + }), + mode: 'no-cors' + } + ); + + setState("sent"); + setChecked("Bug"); + setDescription(""); + setOther(""); + } + + return ( + <form className={styles.feedback} onSubmit={onSubmit}> + <h3> + <Text id="app.settings.pages.feedback.report" /> + </h3> + <div className={styles.options}> + <Radio + checked={checked === "Bug"} + disabled={state === "sending"} + onSelect={() => setChecked("Bug")}> + <Text id="app.settings.pages.feedback.bug" /> + </Radio> + <Radio + disabled={state === "sending"} + checked={checked === "Feature Request"} + onSelect={() => setChecked("Feature Request")}> + <Text id="app.settings.pages.feedback.feature" /> + </Radio> + <Radio + disabled={state === "sending"} + checked={checked === "__other_option__"} + onSelect={() => setChecked("__other_option__")}> + <Localizer> + <InputBox + value={other} + disabled={state === "sending"} + name="entry.1151440373.other_option_response" + onChange={e => setOther(e.currentTarget.value)} + placeholder={ + ( + <Text id="app.settings.pages.feedback.other" /> + ) as any + } + /> + </Localizer> + </Radio> + </div> + <h3> + <Text id="app.settings.pages.feedback.describe" /> + </h3> + <SettingsTextArea + maxRows={10} + value={description} + id="entry.685672624" + disabled={state === "sending"} + onChange={value => setDescription(value)} + /> + <Button type="submit" contrast> + <Text id="app.settings.pages.feedback.send" /> + </Button> + </form> + ); +} diff --git a/src/pages/settings/panes/Languages.tsx b/src/pages/settings/panes/Languages.tsx new file mode 100644 index 0000000..3c58b23 --- /dev/null +++ b/src/pages/settings/panes/Languages.tsx @@ -0,0 +1,68 @@ +import { Text } from "preact-i18n"; +import styles from "./Panes.module.scss"; +import Tip from "../../../components/ui/Tip"; +import Checkbox from "../../../components/ui/Checkbox"; +import { connectState } from "../../../redux/connector"; +import { WithDispatcher } from "../../../redux/reducers"; +import { Emoji } from "../../../components/markdown/Emoji"; +import { Language, LanguageEntry, Languages as Langs } from "../../../context/Locale"; + +interface Props { + locale: Language; +} + +export function Component({ locale, dispatcher }: Props & WithDispatcher) { + return ( + <div className={styles.languages}> + <h3> + <Text id="app.settings.pages.language.select" /> + </h3> + <div className={styles.list}> + {Object.keys(Langs).map(x => { + const l = (Langs as any)[x] as LanguageEntry; + return ( + <Checkbox + key={x} + className={styles.entry} + checked={locale === x} + onChange={v => { + if (v) { + dispatcher({ + type: "SET_LOCALE", + locale: x as Language + }); + } + }} + > + <div className={styles.flag}><Emoji size={42} emoji={l.emoji} /></div> + <span className={styles.description}> + {l.display} + </span> + </Checkbox> + ); + })} + </div> + <Tip> + <span> + <Text id="app.settings.tips.languages.a" /> + </span>{" "} + <a + href="https://weblate.insrt.uk/engage/revolt/?utm_source=widget" + target="_blank" + > + <Text id="app.settings.tips.languages.b" /> + </a> + </Tip> + </div> + ); +} + +export const Languages = connectState( + Component, + state => { + return { + locale: state.locale + }; + }, + true +); diff --git a/src/pages/settings/panes/Notifications.tsx b/src/pages/settings/panes/Notifications.tsx new file mode 100644 index 0000000..37529e4 --- /dev/null +++ b/src/pages/settings/panes/Notifications.tsx @@ -0,0 +1,142 @@ +import { Text } from "preact-i18n"; +import styles from "./Panes.module.scss"; +import Checkbox from "../../../components/ui/Checkbox"; +import { connectState } from "../../../redux/connector"; +import { WithDispatcher } from "../../../redux/reducers"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { urlBase64ToUint8Array } from "../../../lib/conversion"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import { NotificationOptions } from "../../../redux/reducers/settings"; +import { useIntermediate } from "../../../context/intermediate/Intermediate"; + +interface Props { + options?: NotificationOptions; +} + +export function Component(props: Props & WithDispatcher) { + const client = useContext(AppContext); + const { openScreen } = useIntermediate(); + const [pushEnabled, setPushEnabled] = useState<undefined | boolean>( + undefined + ); + + // Load current state of pushManager. + useEffect(() => { + navigator.serviceWorker?.getRegistration().then(async registration => { + const sub = await registration?.pushManager?.getSubscription(); + setPushEnabled(sub !== null && sub !== undefined); + }); + }, []); + + return ( + <div className={styles.notifications}> + <h3> + <Text id="app.settings.pages.notifications.push_notifications" /> + </h3> + <Checkbox + disabled={!("Notification" in window)} + checked={props.options?.desktopEnabled ?? false} + onChange={async desktopEnabled => { + if (desktopEnabled) { + let permission = await Notification.requestPermission(); + if (permission !== "granted") { + return openScreen({ + id: "error", + error: "DeniedNotification" + }); + } + } + + props.dispatcher({ + type: "SETTINGS_SET_NOTIFICATION_OPTIONS", + options: { desktopEnabled } + }); + }} + > + <Text id="app.settings.pages.notifications.enable_desktop" /> + <p> + <Text id="app.settings.pages.notifications.descriptions.enable_desktop" /> + </p> + </Checkbox> + <Checkbox + disabled={typeof pushEnabled === "undefined"} + checked={pushEnabled ?? false} + onChange={async pushEnabled => { + const reg = await navigator.serviceWorker?.getRegistration(); + if (reg) { + if (pushEnabled) { + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array( + client.configuration!.vapid + ) + }); + + // tell the server we just subscribed + const json = sub.toJSON(); + if (json.keys) { + client.req("POST", "/push/subscribe", { + endpoint: sub.endpoint, + ...json.keys + } as any); + setPushEnabled(true); + } + } else { + const sub = await reg.pushManager.getSubscription(); + sub?.unsubscribe(); + setPushEnabled(false); + + client.req("POST", "/push/unsubscribe"); + } + } + }} + > + <Text id="app.settings.pages.notifications.enable_push" /> + <p> + <Text id="app.settings.pages.notifications.descriptions.enable_push" /> + </p> + </Checkbox> + <h3> + <Text id="app.settings.pages.notifications.sounds" /> + </h3> + <Checkbox + checked={props.options?.soundEnabled ?? true} + onChange={soundEnabled => + props.dispatcher({ + type: "SETTINGS_SET_NOTIFICATION_OPTIONS", + options: { soundEnabled } + }) + } + > + <Text id="app.settings.pages.notifications.enable_sound" /> + <p> + <Text id="app.settings.pages.notifications.descriptions.enable_sound" /> + </p> + </Checkbox> + <Checkbox + checked={props.options?.outgoingSoundEnabled ?? true} + onChange={outgoingSoundEnabled => + props.dispatcher({ + type: "SETTINGS_SET_NOTIFICATION_OPTIONS", + options: { outgoingSoundEnabled } + }) + } + > + <Text id="app.settings.pages.notifications.enable_outgoing_sound" /> + <p> + <Text id="app.settings.pages.notifications.descriptions.enable_outgoing_sound" /> + </p> + </Checkbox> + </div> + ); +} + +export const Notifications = connectState( + Component, + state => { + return { + options: state.settings.notification + }; + }, + true +); diff --git a/src/pages/settings/panes/Panes.module.scss b/src/pages/settings/panes/Panes.module.scss new file mode 100644 index 0000000..56ef54f --- /dev/null +++ b/src/pages/settings/panes/Panes.module.scss @@ -0,0 +1,374 @@ +.user { + .banner { + gap: 24px; + width: 100%; + padding: 1em; + display: flex; + border-radius: 6px; + align-items: center; + background: var(--secondary-header); + + .username { + font-size: 24px; + } + + a { + transition: 0.2s ease filter; + } + + a:hover { + filter: brightness(80%); + } + } + + .details { + display: flex; + margin-top: 1em; + flex-direction: column; + + > div { + gap: 12px; + padding: 4px; + display: flex; + align-items: center; + flex-direction: row; + } + + .detail { + flex-grow: 1; + } + + p { + margin: 0; + color: var(--tertiary-foreground); + } + } + + .preview { + width: 100%; + display: grid; + place-items: center; + grid-template-columns: minmax(auto, 100%); + + > div { + width: 100%; + max-width: 560px; + } + } + + .row { + gap: 20px; + display: flex; + + .pfp { + display: flex; + align-items: center; + flex-direction: column; + } + + .background { + flex-grow: 1; + } + } +} + +.appearance { + .theme { + display: flex; + flex-direction: column; + width: 100%; + } + + .themes { + gap: 8px; + width: 100%; + display: flex; + + img { + cursor: pointer; + border-radius: 8px; + transition: border 0.3s; + border: 3px solid transparent; + + &[data-active="true"] { + cursor: default; + border: 3px solid var(--accent); + &:hover { + border: 3px solid var(--accent); + } + } + + &:hover { + border: 3px solid var(--tertiary-background); + } + } + } + + details { + font-size: 14px; + font-weight: 700; + text-transform: uppercase; + color: var(--secondary-foreground); + + summary { + cursor: pointer; + } + + /*summary { + display: flex; + flex-grow: 1; + &::after { + display: flex; + align-items: flex-end; + content: "gh"; + } + }*/ + + /*summary::-webkit-details-marker, + summary::marker { + content: ""; + }*/ + } + + .emojiPack { + gap: 12px; + display: flex; + flex-direction: column; + + .row { + gap: 12px; + display: flex; + + > div { + flex: 1; + display: flex; + flex-direction: column; + } + } + + .button { + padding: 2rem 1.5rem; + display: grid; + place-items: center; + + cursor: pointer; + border-radius: 8px; + transition: border 0.3s; + background: var(--hover); + border: 3px solid transparent; + + img { + max-width: 100%; + } + + &[data-active="true"] { + cursor: default; + background: var(--secondary-background); + border: 3px solid var(--accent); + + &:hover { + border: 3px solid var(--accent); + } + } + + &:hover { + background: var(--secondary-background); + border: 3px solid var(--tertiary-background); + } + } + + h4 { + text-transform: unset; + + a { + opacity: 0.7; + color: var(--accent); + font-weight: 600; + &:hover { + text-decoration: underline; + } + } + } + } + + .display { + gap: 8px; + display: flex; + flex-direction: column; + } + + .actions { + gap: 8px; + display: flex; + flex-wrap: wrap; + margin-bottom: 8px; + } + + .overrides { + display: grid; + grid-template-columns: 1fr 1fr; + + .entry { + gap: 8px; + padding: 2px; + margin-top: 8px; + + .override { + display: flex; + } + + span { + flex: 1; + display: block; + font-size: 14px; + font-weight: 600; + margin-bottom: 4px; + text-transform: capitalize; + } + + .picker { + width: 30px; + height: 30px; + flex-shrink: 0; + border-radius: 4px; + overflow: hidden; + margin-right: 4px; + + //TOFIX - Looks wonky on Chromium + border: 1px solid black; + + input { + opacity: 0; + width: 30px; + height: 30px; + border: none; + display: block; + cursor: pointer; + } + } + + .text { + border-radius: 4px; + padding: 0 4px 0; + } + } + } +} + +.sessions { + .session { + display: flex; + } + + .entry { + margin: 8px 0; + padding: 16px; + display: flex; + border-radius: 6px; + flex-direction: column; + background: var(--secondary-header); + + &[data-active="true"] { + color: var(--primary-background); + background: var(--accent); + margin-bottom: 20px; + } + + &[data-deleting="true"] { + opacity: 0.5; + } + + .name { + font-weight: 600; + } + + .icon { + gap: 8px; + display: flex; + padding-right: 12px; + align-items: center; + + svg { + height: 42px; + } + + div svg { + height: 24px; + } + } + + .label { + margin: 0 0 6px 0; + color: var(--primary-text); + font-size: 12px; + font-weight: 600; + } + + .info { + flex-grow: 1; + display: flex; + flex-direction: column; + justify-content: center; + + .name { + text-transform: capitalize; + } + + .time { + font-size: 12px; + color: var(--teriary-text); + } + } + } +} + +.notifications { + label { + margin-top: 12px; + } + + p { + margin-top: 0; + font-size: 0.9em; + color: var(--secondary-foreground); + } +} + +.languages { + .list { + .entry { + padding: 2px 8px; + height: 50px; + border-radius: 4px; + } + + .entry > span > span { + gap: 8px; + display: flex; + align-items: center; + flex-direction: row; + + .flag { + display: flex; + font-size: 42px; + line-height: 48px; + + > div { + display: flex; + align-items: center; + justify-content: center; + } + } + + .description { + color: var(--primary-text); + } + } + } +} + +.feedback .options { + gap: 10px; + display: flex; + flex-direction: column; +} diff --git a/src/pages/settings/panes/Profile.tsx b/src/pages/settings/panes/Profile.tsx new file mode 100644 index 0000000..c87287f --- /dev/null +++ b/src/pages/settings/panes/Profile.tsx @@ -0,0 +1,126 @@ +import styles from "./Panes.module.scss"; +import Button from "../../../components/ui/Button"; +import { Users } from "revolt.js/dist/api/objects"; +import { SettingsTextArea } from "../SettingsTextArea"; +import { IntlContext, Text, translate } from "preact-i18n"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { FileUploader } from "../../../context/revoltjs/FileUploads"; +import { useForceUpdate, useSelf } from "../../../context/revoltjs/hooks"; +import { UserProfile } from "../../../context/intermediate/popovers/UserProfile"; +import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient"; + +export function Profile() { + const { intl } = useContext(IntlContext) as any; + const status = useContext(StatusContext); + + const ctx = useForceUpdate(); + const user = useSelf(); + if (!user) return null; + + const [profile, setProfile] = useState<undefined | Users.Profile>( + undefined + ); + + // ! FIXME: temporary solution + // ! we should just announce profile changes through WS + function refreshProfile() { + ctx.client.users + .fetchProfile(user!._id) + .then(profile => setProfile(profile ?? {})); + } + + useEffect(() => { + if (profile === undefined && status === ClientStatus.ONLINE) { + refreshProfile(); + } + }, [status]); + + const [ changed, setChanged ] = useState(false); + + return ( + <div className={styles.user}> + <h3> + <Text id="app.special.modals.actions.preview" /> + </h3> + <div className={styles.preview}> + <UserProfile + user_id={user._id} + dummy={true} + dummyProfile={profile} + onClose={() => {}} + /> + </div> + <div className={styles.row}> + <div className={styles.pfp}> + <h3> + <Text id="app.settings.pages.profile.profile_picture" /> + </h3> + <FileUploader + width={80} + height={80} + style="icon" + fileType="avatars" + behaviour="upload" + maxFileSize={4_000_000} + onUpload={avatar => ctx.client.users.editUser({ avatar })} + remove={() => ctx.client.users.editUser({ remove: 'Avatar' })} + defaultPreview={ctx.client.users.getAvatarURL(user._id, { max_side: 256 }, true)} + previewURL={ctx.client.users.getAvatarURL(user._id, { max_side: 256 }, true, true)} + /> + </div> + <div className={styles.background}> + <h3> + <Text id="app.settings.pages.profile.custom_background" /> + </h3> + <FileUploader + height={92} + style="banner" + behaviour="upload" + fileType="backgrounds" + maxFileSize={6_000_000} + onUpload={async background => { + await ctx.client.users.editUser({ profile: { background } }); + refreshProfile(); + }} + remove={async () => { + await ctx.client.users.editUser({ remove: 'ProfileBackground' }); + setProfile({ ...profile, background: undefined }); + }} + previewURL={profile?.background ? ctx.client.users.getBackgroundURL(profile, { width: 1000 }, true) : undefined} + /> + </div> + </div> + <h3> + <Text id="app.settings.pages.profile.info" /> + </h3> + <SettingsTextArea + maxRows={10} + minHeight={200} + maxLength={2000} + value={profile?.content ?? ""} + disabled={typeof profile === "undefined"} + onChange={content => { + setProfile({ ...profile, content }) + if (!changed) setChanged(true) + }} + placeholder={translate( + `app.settings.pages.profile.${ + typeof profile === "undefined" + ? "fetching" + : "placeholder" + }`, + "", + intl.dictionary + )} + /> + <Button contrast + onClick={() => { + setChanged(false); + ctx.client.users.editUser({ profile: { content: profile?.content } }) + }} + disabled={!changed}> + <Text id="app.special.modals.actions.save" /> + </Button> + </div> + ); +} diff --git a/src/pages/settings/panes/Sessions.tsx b/src/pages/settings/panes/Sessions.tsx new file mode 100644 index 0000000..dff8026 --- /dev/null +++ b/src/pages/settings/panes/Sessions.tsx @@ -0,0 +1,186 @@ +import dayjs from "dayjs"; +import { decodeTime } from "ulid"; +import { Text } from "preact-i18n"; +import styles from "./Panes.module.scss"; +import Tip from "../../../components/ui/Tip"; +import { useHistory } from "react-router-dom"; +import Button from "../../../components/ui/Button"; +import Preloader from "../../../components/ui/Preloader"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; + +import { HelpCircle } from "@styled-icons/feather"; +import { + Android, + Firefoxbrowser, + Googlechrome, + Ios, + Linux, + Macos, + Microsoftedge, + Safari, + Windows +} from "@styled-icons/simple-icons"; + +import relativeTime from "dayjs/plugin/relativeTime"; +dayjs.extend(relativeTime); + +interface Session { + id: string; + friendly_name: string; +} + +export function Sessions() { + const client = useContext(AppContext); + const deviceId = client.session?.id; + + const [sessions, setSessions] = useState<Session[] | undefined>(undefined); + const [attemptingDelete, setDelete] = useState<string[]>([]); + const history = useHistory(); + + function switchPage(to: string) { + history.replace(`/settings/${to}`); + } + + useEffect(() => { + client.req("GET", "/auth/sessions").then(data => { + data.sort( + (a, b) => + (b.id === deviceId ? 1 : 0) - (a.id === deviceId ? 1 : 0) + ); + setSessions(data); + }); + }, []); + + if (typeof sessions === "undefined") { + return ( + <div className={styles.loader}> + <Preloader /> + </div> + ); + } + + function getIcon(session: Session) { + const name = session.friendly_name; + switch (true) { + case /firefox/i.test(name): + return <Firefoxbrowser />; + case /chrome/i.test(name): + return <Googlechrome />; + case /safari/i.test(name): + return <Safari />; + case /edge/i.test(name): + return <Microsoftedge />; + default: + return <HelpCircle />; + } + } + + function getSystemIcon(session: Session) { + const name = session.friendly_name; + switch (true) { + case /linux/i.test(name): + return <Linux />; + case /android/i.test(name): + return <Android />; + case /mac.*os/i.test(name): + return <Macos />; + case /ios/i.test(name): + return <Ios />; + case /windows/i.test(name): + return <Windows />; + default: + return null; + } + } + + const mapped = sessions.map(session => { + return { + ...session, + timestamp: decodeTime(session.id) + }; + }); + + mapped.sort((a, b) => b.timestamp - a.timestamp); + let id = mapped.findIndex(x => x.id === deviceId); + + const render = [ + mapped[id], + ...mapped.slice(0, id), + ...mapped.slice(id + 1, mapped.length) + ]; + + return ( + <div className={styles.sessions}> + <h3> + <Text id="app.settings.pages.sessions.active_sessions" /> + </h3> + {render.map(session => ( + <div + className={styles.entry} + data-active={session.id === deviceId} + data-deleting={attemptingDelete.indexOf(session.id) > -1} + > + {deviceId === session.id && ( + <span className={styles.label}> + <Text id="app.settings.pages.sessions.this_device" />{" "} + </span> + )} + <div className={styles.session}> + <div className={styles.icon}> + {getIcon(session)} + <div>{getSystemIcon(session)}</div> + </div> + <div className={styles.info}> + <span className={styles.name}> + {session.friendly_name} + </span> + <span className={styles.time}> + <Text + id="app.settings.pages.sessions.created" + fields={{ + time_ago: dayjs( + session.timestamp + ).fromNow() + }} + /> + </span> + </div> + {deviceId !== session.id && ( + <Button + onClick={async () => { + setDelete([ + ...attemptingDelete, + session.id + ]); + await client.req( + "DELETE", + `/auth/sessions/${session.id}` as any + ); + setSessions( + sessions?.filter( + x => x.id !== session.id + ) + ); + }} + disabled={ + attemptingDelete.indexOf(session.id) > -1 + } + > + <Text id="app.settings.pages.logOut" /> + </Button> + )} + </div> + </div> + ))} + <Tip> + <span> + <Text id="app.settings.tips.sessions.a" /> + </span>{" "} + <a onClick={() => switchPage("account")}> + <Text id="app.settings.tips.sessions.b" /> + </a> + </Tip> + </div> + ); +} diff --git a/src/pages/settings/panes/Sync.tsx b/src/pages/settings/panes/Sync.tsx new file mode 100644 index 0000000..e563128 --- /dev/null +++ b/src/pages/settings/panes/Sync.tsx @@ -0,0 +1,53 @@ +import { Text } from "preact-i18n"; +import styles from "./Panes.module.scss"; +import Checkbox from "../../../components/ui/Checkbox"; +import { connectState } from "../../../redux/connector"; +import { WithDispatcher } from "../../../redux/reducers"; +import { SyncKeys, SyncOptions } from "../../../redux/reducers/sync"; + +interface Props { + options?: SyncOptions; +} + +export function Component(props: Props & WithDispatcher) { + return ( + <div className={styles.notifications}> + <h3> + <Text id="app.settings.pages.sync.categories" /> + </h3> + { + ([ + ['appearance', 'appearance.title'], + ['theme', 'appearance.theme'], + ['locale', 'language.title'] + ] as [ SyncKeys, string ][]).map( + ([ key, title ]) => + <Checkbox + checked={(props.options?.disabled ?? []).indexOf(key) === -1} + onChange={enabled => { + props.dispatcher({ + type: enabled ? 'SYNC_ENABLE_KEY' : 'SYNC_DISABLE_KEY', + key + }); + }} + > + <Text id={`app.settings.pages.${title}`} /> + <p> + <Text id={`app.settings.pages.sync.descriptions.${key}`} /> + </p> + </Checkbox> + ) + } + </div> + ); +} + +export const Sync = connectState( + Component, + state => { + return { + options: state.sync + }; + }, + true +); diff --git a/src/pages/settings/server/Bans.tsx b/src/pages/settings/server/Bans.tsx new file mode 100644 index 0000000..7d8a159 --- /dev/null +++ b/src/pages/settings/server/Bans.tsx @@ -0,0 +1,23 @@ +import { Servers } from "revolt.js/dist/api/objects"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; + +interface Props { + server: Servers.Server; +} + +export function Bans({ server }: Props) { + const client = useContext(AppContext); + const [bans, setBans] = useState<Servers.Ban[] | undefined>(undefined); + + useEffect(() => { + client.servers.fetchBans(server._id) + .then(bans => setBans(bans)) + }, [ ]); + + return ( + <div> + { bans?.map(x => <div>{x._id.user}: {x.reason ?? 'no reason'} <button onClick={() => client.servers.unbanUser(server._id, x._id.user)}>unban</button></div>) } + </div> + ); +} diff --git a/src/pages/settings/server/Invites.tsx b/src/pages/settings/server/Invites.tsx new file mode 100644 index 0000000..115677c --- /dev/null +++ b/src/pages/settings/server/Invites.tsx @@ -0,0 +1,70 @@ +import styles from './Panes.module.scss'; +import { XCircle } from "@styled-icons/feather"; +import { useEffect, useState } from "preact/hooks"; +import Preloader from "../../../components/ui/Preloader"; +import UserIcon from "../../../components/common/UserIcon"; +import IconButton from "../../../components/ui/IconButton"; +import { getChannelName } from "../../../context/revoltjs/util"; +import { Invites as InvitesNS, Servers } from "revolt.js/dist/api/objects"; +import { useChannels, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks"; + +interface Props { + server: Servers.Server; +} + +export function Invites({ server }: Props) { + const [invites, setInvites] = useState<InvitesNS.ServerInvite[] | undefined>(undefined); + + const ctx = useForceUpdate(); + const [deleting, setDelete] = useState<string[]>([]); + const users = useUsers(invites?.map(x => x.creator) ?? [], ctx); + const channels = useChannels(invites?.map(x => x.channel) ?? [], ctx); + + useEffect(() => { + ctx.client.servers.fetchInvites(server._id) + .then(invites => setInvites(invites)) + }, [ ]); + + return ( + <div className={styles.invites}> + { typeof invites === 'undefined' && <Preloader /> } + { + invites?.map( + invite => { + let creator = users.find(x => x?._id === invite.creator); + let channel = channels.find(x => x?._id === invite.channel); + + return ( + <div className={styles.invite} + data-deleting={deleting.indexOf(invite._id) > -1}> + <code>{ invite._id }</code> + <span> + <UserIcon target={creator} size={24} /> {creator?.username ?? 'unknown'} + </span> + <span>{ (channel && creator) ? getChannelName(ctx.client, channel, [ creator ], true) : '#unknown' }</span> + <IconButton + onClick={async () => { + setDelete([ + ...deleting, + invite._id + ]); + + await ctx.client.deleteInvite(invite._id); + + setInvites( + invites?.filter( + x => x._id !== invite._id + ) + ); + }} + disabled={deleting.indexOf(invite._id) > -1}> + <XCircle size={24} /> + </IconButton> + </div> + ) + } + ) + } + </div> + ); +} diff --git a/src/pages/settings/server/Members.tsx b/src/pages/settings/server/Members.tsx new file mode 100644 index 0000000..d4fb3c8 --- /dev/null +++ b/src/pages/settings/server/Members.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from "preact/hooks"; +import { Servers } from "revolt.js/dist/api/objects"; +import { useForceUpdate, useUsers } from "../../../context/revoltjs/hooks"; + +interface Props { + server: Servers.Server; +} + +export function Members({ server }: Props) { + const [members, setMembers] = useState<Servers.Member[] | undefined>(undefined); + const ctx = useForceUpdate(); + const users = useUsers(members?.map(x => x._id.user) ?? [], ctx); + + useEffect(() => { + ctx.client.servers.members.fetchMembers(server._id) + .then(members => setMembers(members)) + }, [ ]); + + return ( + <div> + { members && members.length > 0 && users?.map(x => x && <div>@{x.username}</div>) } + </div> + ); +} diff --git a/src/pages/settings/server/Overview.tsx b/src/pages/settings/server/Overview.tsx new file mode 100644 index 0000000..a6d4234 --- /dev/null +++ b/src/pages/settings/server/Overview.tsx @@ -0,0 +1,98 @@ +import { Text } from "preact-i18n"; +import styles from './Panes.module.scss'; +import Button from "../../../components/ui/Button"; +import { Servers } from "revolt.js/dist/api/objects"; +import { SettingsTextArea } from "../SettingsTextArea"; +import InputBox from "../../../components/ui/InputBox"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import { FileUploader } from "../../../context/revoltjs/FileUploads"; + +interface Props { + server: Servers.Server; +} + +export function Overview({ server }: Props) { + const client = useContext(AppContext); + + const [name, setName] = useState(server.name); + const [description, setDescription] = useState(server.description ?? ''); + + useEffect(() => setName(server.name), [ server.name ]); + useEffect(() => setDescription(server.description ?? ''), [ server.description ]); + + const [ changed, setChanged ] = useState(false); + function save() { + let changes: any = {}; + if (name !== server.name) changes.name = name; + if (description !== server.description) + changes.description = description; + + client.servers.edit(server._id, changes); + setChanged(false); + } + + return ( + <div className={styles.overview}> + <div className={styles.row}> + <FileUploader + width={80} + height={80} + style="icon" + fileType="icons" + behaviour="upload" + maxFileSize={2_500_000} + onUpload={icon => client.servers.edit(server._id, { icon })} + previewURL={client.servers.getIconURL(server._id, { max_side: 256 }, true)} + remove={() => client.servers.edit(server._id, { remove: 'Icon' })} + /> + <div className={styles.name}> + <h3> + <Text id="app.main.servers.name" /> + </h3> + <InputBox + contrast + value={name} + maxLength={32} + onChange={e => { + setName(e.currentTarget.value) + if (!changed) setChanged(true) + }} + /> + </div> + </div> + + <h3> + <Text id="app.main.servers.description" /> + </h3> + <SettingsTextArea + maxRows={10} + minHeight={60} + maxLength={1024} + value={description} + placeholder={"Add a topic..."} + onChange={content => { + setDescription(content); + if (!changed) setChanged(true) + }} + /> + <Button onClick={save} style="contrast" disabled={!changed}> + <Text id="app.special.modals.actions.save" /> + </Button> + + <h3> + <Text id="app.main.servers.custom_banner" /> + </h3> + <FileUploader + height={160} + style="banner" + fileType="banners" + behaviour="upload" + maxFileSize={6_000_000} + onUpload={banner => client.servers.edit(server._id, { banner })} + previewURL={client.servers.getBannerURL(server._id, { width: 1000 }, true)} + remove={() => client.servers.edit(server._id, { remove: 'Banner' })} + /> + </div> + ); +} diff --git a/src/pages/settings/server/Panes.module.scss b/src/pages/settings/server/Panes.module.scss new file mode 100644 index 0000000..7f518a6 --- /dev/null +++ b/src/pages/settings/server/Panes.module.scss @@ -0,0 +1,48 @@ +.overview { + .row { + gap: 20px; + display: flex; + + .name { + flex-grow: 1; + + input { + width: 100%; + } + } + } +} + +.invites { + gap: 8px; + display: flex; + flex-direction: column; + + .invite { + gap: 8px; + padding: 8px; + display: flex; + align-items: center; + flex-direction: row; + background: var(--secondary-background); + + code, span { + flex: 1; + } + + code { + font-size: 1.4em; + user-select: all; + } + + span { + gap: 8px; + display: flex; + color: var(--secondary-foreground); + } + + &[data-deleting="true"] { + opacity: 0.5; + } + } +} diff --git a/src/version.ts b/src/version.ts index 0f4ec86..4c86db3 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = "0.1.9-alpha.7"; +export const APP_VERSION = "1.0.0-vite"; diff --git a/yarn.lock b/yarn.lock index effb671..0bf7315 100644 --- a/yarn.lock +++ b/yarn.lock @@ -836,7 +836,7 @@ "@babel/types" "^7.4.4" esutils "^2.0.2" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.14.0", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.0", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.14.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.6.tgz#535203bc0892efc7dec60bdc27b2ecf6e409062d" integrity sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg== @@ -1098,7 +1098,15 @@ "@babel/runtime" "^7.14.0" "@styled-icons/styled-icon" "^10.6.3" -"@styled-icons/styled-icon@^10.6.3": +"@styled-icons/simple-icons@^10.33.0": + version "10.33.0" + resolved "https://registry.yarnpkg.com/@styled-icons/simple-icons/-/simple-icons-10.33.0.tgz#aad04f445083ab08332f90221545629f78a94bca" + integrity sha512-kNCBcbl3LTsknb7rMXj3DsK4WPQXRhXpYxRYu3Nw0PC+rnATZ+7J4VajFps1x6Eeq+J/c2ZLB+SAQra9QDOuFw== + dependencies: + "@babel/runtime" "^7.13.10" + "@styled-icons/styled-icon" "^10.6.0" + +"@styled-icons/styled-icon@^10.6.0", "@styled-icons/styled-icon@^10.6.3": version "10.6.3" resolved "https://registry.yarnpkg.com/@styled-icons/styled-icon/-/styled-icon-10.6.3.tgz#eae0e5e18fd601ac47e821bb9c2e099810e86403" integrity sha512-/A95L3peioLoWFiy+/eKRhoQ9r/oRrN/qzbSX4hXU1nGP2rUXcX3LWUhoBNAOp9Rw38ucc/4ralY427UUNtcGQ== @@ -3421,6 +3429,11 @@ serialize-javascript@^4.0.0: dependencies: randombytes "^2.1.0" +shade-blend-color@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shade-blend-color/-/shade-blend-color-1.0.0.tgz#cfa10d3673a22ba31d552a0e793b708bc24be0bc" + integrity sha512-Tnp/ppF5h3YhPCpeHiZJ2DRnvmo4luu9qpMhuksCT+QInIXJ9alA3Vd9klfEi+RY8Oh7MaK5vzH/qcLo892L1g== + shallowequal@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" -- GitLab