mirror of
https://github.com/revoltchat/revite.git
synced 2024-11-25 16:40:58 -05:00
Port settings.
This commit is contained in:
parent
b4bc2262ae
commit
31d8950ea1
48 changed files with 3056 additions and 106 deletions
|
@ -31,6 +31,7 @@
|
||||||
"@preact/preset-vite": "^2.0.0",
|
"@preact/preset-vite": "^2.0.0",
|
||||||
"@styled-icons/bootstrap": "^10.34.0",
|
"@styled-icons/bootstrap": "^10.34.0",
|
||||||
"@styled-icons/feather": "^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-katex": "^3.4.3",
|
||||||
"@traptitech/markdown-it-spoiler": "^1.1.6",
|
"@traptitech/markdown-it-spoiler": "^1.1.6",
|
||||||
"@types/markdown-it": "^12.0.2",
|
"@types/markdown-it": "^12.0.2",
|
||||||
|
@ -70,6 +71,7 @@
|
||||||
"revolt.js": "4.3.0",
|
"revolt.js": "4.3.0",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"sass": "^1.35.1",
|
"sass": "^1.35.1",
|
||||||
|
"shade-blend-color": "^1.0.0",
|
||||||
"styled-components": "^5.3.0",
|
"styled-components": "^5.3.0",
|
||||||
"twemoji": "^13.1.0",
|
"twemoji": "^13.1.0",
|
||||||
"typescript": "^4.3.2",
|
"typescript": "^4.3.2",
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import styles from "./Item.module.scss";
|
import styles from "./Item.module.scss";
|
||||||
|
import Tooltip from '../../common/Tooltip';
|
||||||
|
import IconButton from '../../ui/IconButton';
|
||||||
import UserIcon from '../../common/UserIcon';
|
import UserIcon from '../../common/UserIcon';
|
||||||
import { Localizer, Text } from "preact-i18n";
|
import { Localizer, Text } from "preact-i18n";
|
||||||
import { X, Zap } from "@styled-icons/feather";
|
import { X, Zap } from "@styled-icons/feather";
|
||||||
import UserStatus from '../../common/UserStatus';
|
import UserStatus from '../../common/UserStatus';
|
||||||
import { Children } from "../../../types/Preact";
|
import { Children } from "../../../types/Preact";
|
||||||
import ChannelIcon from '../../common/ChannelIcon';
|
import ChannelIcon from '../../common/ChannelIcon';
|
||||||
|
import { attachContextMenu } from 'preact-context-menu';
|
||||||
import { Channels, Users } from "revolt.js/dist/api/objects";
|
import { Channels, Users } from "revolt.js/dist/api/objects";
|
||||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||||
|
import { useIntermediate } from '../../../context/intermediate/Intermediate';
|
||||||
|
|
||||||
interface CommonProps {
|
interface CommonProps {
|
||||||
active?: boolean
|
active?: boolean
|
||||||
|
@ -22,7 +26,7 @@ type UserProps = CommonProps & {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserButton({ active, alert, alertCount, user, context, channel }: UserProps) {
|
export function UserButton({ active, alert, alertCount, user, context, channel }: UserProps) {
|
||||||
// const { openScreen } = useContext(IntermediateContext);
|
const { openScreen } = useIntermediate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -30,13 +34,12 @@ export function UserButton({ active, alert, alertCount, user, context, channel }
|
||||||
data-active={active}
|
data-active={active}
|
||||||
data-alert={typeof alert === 'string'}
|
data-alert={typeof alert === 'string'}
|
||||||
data-online={typeof channel !== 'undefined' || (user.online && user.status?.presence !== Users.Presence.Invisible)}
|
data-online={typeof channel !== 'undefined' || (user.online && user.status?.presence !== Users.Presence.Invisible)}
|
||||||
/*onContextMenu={attachContextMenu('Menu', {
|
onContextMenu={attachContextMenu('Menu', {
|
||||||
user: user._id,
|
user: user._id,
|
||||||
channel: channel?._id,
|
channel: channel?._id,
|
||||||
unread: alert,
|
unread: alert,
|
||||||
contextualChannel: context?._id
|
contextualChannel: context?._id
|
||||||
})}*/
|
})}>
|
||||||
>
|
|
||||||
<div className={styles.avatar}>
|
<div className={styles.avatar}>
|
||||||
<UserIcon target={user} size={32} status />
|
<UserIcon target={user} size={32} status />
|
||||||
</div>
|
</div>
|
||||||
|
@ -56,24 +59,22 @@ export function UserButton({ active, alert, alertCount, user, context, channel }
|
||||||
{ context?.channel_type === "Group" &&
|
{ context?.channel_type === "Group" &&
|
||||||
context.owner === user._id && (
|
context.owner === user._id && (
|
||||||
<Localizer>
|
<Localizer>
|
||||||
{/*<Tooltip
|
<Tooltip
|
||||||
content={
|
content={
|
||||||
<Text id="app.main.groups.owner" />
|
<Text id="app.main.groups.owner" />
|
||||||
}
|
}
|
||||||
>*/}
|
>
|
||||||
<Zap size={20} />
|
<Zap size={20} />
|
||||||
{/*</Tooltip>*/}
|
</Tooltip>
|
||||||
</Localizer>
|
</Localizer>
|
||||||
)}
|
)}
|
||||||
{alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>}
|
{alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>}
|
||||||
{ !isTouchscreenDevice && channel &&
|
{ !isTouchscreenDevice && channel &&
|
||||||
/*<IconButton
|
<IconButton
|
||||||
className={styles.icon}
|
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} />
|
<X size={24} />
|
||||||
/*</IconButton>*/
|
</IconButton>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -93,15 +94,14 @@ export function ChannelButton({ active, alert, alertCount, channel, user, compac
|
||||||
return <UserButton {...{ active, alert, channel, user }} />
|
return <UserButton {...{ active, alert, channel, user }} />
|
||||||
}
|
}
|
||||||
|
|
||||||
//const { openScreen } = useContext(IntermediateContext);
|
const { openScreen } = useIntermediate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-active={active}
|
data-active={active}
|
||||||
data-alert={typeof alert === 'string'}
|
data-alert={typeof alert === 'string'}
|
||||||
className={classNames(styles.item, { [styles.compact]: compact })}
|
className={classNames(styles.item, { [styles.compact]: compact })}
|
||||||
//onContextMenu={attachContextMenu('Menu', { channel: channel._id })}>
|
onContextMenu={attachContextMenu('Menu', { channel: channel._id })}>
|
||||||
>
|
|
||||||
<div className={styles.avatar}>
|
<div className={styles.avatar}>
|
||||||
<ChannelIcon target={channel} size={compact ? 24 : 32} />
|
<ChannelIcon target={channel} size={compact ? 24 : 32} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -124,13 +124,11 @@ export function ChannelButton({ active, alert, alertCount, channel, user, compac
|
||||||
<div className={styles.button}>
|
<div className={styles.button}>
|
||||||
{alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>}
|
{alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>}
|
||||||
{!isTouchscreenDevice && channel.channel_type === "Group" && (
|
{!isTouchscreenDevice && channel.channel_type === "Group" && (
|
||||||
/*<IconButton
|
<IconButton
|
||||||
className={styles.icon}
|
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} />
|
<X size={24} />
|
||||||
/*</IconButton>*/
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,24 +1,20 @@
|
||||||
import { Localizer, Text } from "preact-i18n";
|
import { Localizer, Text } from "preact-i18n";
|
||||||
import { useContext, useLayoutEffect } from "preact/hooks";
|
import { useContext } from "preact/hooks";
|
||||||
import { Home, Users, Tool, Settings, Save } from "@styled-icons/feather";
|
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 { WithDispatcher } from "../../../redux/reducers";
|
||||||
import { Unreads } from "../../../redux/reducers/unreads";
|
import { Unreads } from "../../../redux/reducers/unreads";
|
||||||
import { connectState } from "../../../redux/connector";
|
import { connectState } from "../../../redux/connector";
|
||||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||||
import { useChannels, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
|
import { useChannels, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
|
||||||
import { User } from "revolt.js";
|
|
||||||
import { Users as UsersNS } from 'revolt.js/dist/api/objects';
|
import { Users as UsersNS } from 'revolt.js/dist/api/objects';
|
||||||
import { mapChannelWithUnread, useUnreads } from "./common";
|
import { mapChannelWithUnread, useUnreads } from "./common";
|
||||||
import { Channels } from "revolt.js/dist/api/objects";
|
import { Channels } from "revolt.js/dist/api/objects";
|
||||||
import UserIcon from '../../common/UserIcon';
|
|
||||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||||
import ConnectionStatus from '../items/ConnectionStatus';
|
import ConnectionStatus from '../items/ConnectionStatus';
|
||||||
import UserStatus from '../../common/UserStatus';
|
|
||||||
import ButtonItem, { ChannelButton } from '../items/ButtonItem';
|
import ButtonItem, { ChannelButton } from '../items/ButtonItem';
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Header from '../../ui/Header';
|
|
||||||
import UserHeader from "../../common/UserHeader";
|
import UserHeader from "../../common/UserHeader";
|
||||||
import Category from '../../ui/Category';
|
import Category from '../../ui/Category';
|
||||||
import PaintCounter from "../../../lib/PaintCounter";
|
import PaintCounter from "../../../lib/PaintCounter";
|
||||||
|
|
|
@ -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 { Channel, Servers } from "revolt.js/dist/api/objects";
|
||||||
import { Link, useLocation, useParams } from "react-router-dom";
|
import { Link, useLocation, useParams } from "react-router-dom";
|
||||||
import { useChannels, useForceUpdate, useServers } from "../../../context/revoltjs/hooks";
|
import { useChannels, useForceUpdate, useServers } from "../../../context/revoltjs/hooks";
|
||||||
|
@ -11,6 +9,7 @@ import { Children } from "../../../types/Preact";
|
||||||
import LineDivider from "../../ui/LineDivider";
|
import LineDivider from "../../ui/LineDivider";
|
||||||
import ServerIcon from "../../common/ServerIcon";
|
import ServerIcon from "../../common/ServerIcon";
|
||||||
import PaintCounter from "../../../lib/PaintCounter";
|
import PaintCounter from "../../../lib/PaintCounter";
|
||||||
|
import { attachContextMenu } from 'preact-context-menu';
|
||||||
|
|
||||||
function Icon({ children, unread, size }: { children: Children, unread?: 'mention' | 'unread', size: number }) {
|
function Icon({ children, unread, size }: { children: Children, unread?: 'mention' | 'unread', size: number }) {
|
||||||
return (
|
return (
|
||||||
|
@ -157,8 +156,7 @@ export function ServerListSidebar({ unreads }: Props) {
|
||||||
<Link to={`/server/${entry!._id}`}>
|
<Link to={`/server/${entry!._id}`}>
|
||||||
<ServerEntry
|
<ServerEntry
|
||||||
active={entry!._id === server?._id}
|
active={entry!._id === server?._id}
|
||||||
//onContextMenu={attachContextMenu('Menu', { server: entry!._id })}>
|
onContextMenu={attachContextMenu('Menu', { server: entry!._id })}>
|
||||||
>
|
|
||||||
<Icon size={36} unread={entry.unread}>
|
<Icon size={36} unread={entry.unread}>
|
||||||
<ServerIcon size={32} target={entry} />
|
<ServerIcon size={32} target={entry} />
|
||||||
</Icon>
|
</Icon>
|
||||||
|
@ -169,6 +167,7 @@ export function ServerListSidebar({ unreads }: Props) {
|
||||||
<PaintCounter small />
|
<PaintCounter small />
|
||||||
</ServerList>
|
</ServerList>
|
||||||
</ServersBase>
|
</ServersBase>
|
||||||
|
// ! FIXME: add overlay back
|
||||||
/*<div className={styles.servers}>
|
/*<div className={styles.servers}>
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
<Link to={`/`}>
|
<Link to={`/`}>
|
||||||
|
|
|
@ -12,11 +12,32 @@ import Header from '../../ui/Header';
|
||||||
import ConnectionStatus from '../items/ConnectionStatus';
|
import ConnectionStatus from '../items/ConnectionStatus';
|
||||||
import { connectState } from "../../../redux/connector";
|
import { connectState } from "../../../redux/connector";
|
||||||
import PaintCounter from "../../../lib/PaintCounter";
|
import PaintCounter from "../../../lib/PaintCounter";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { attachContextMenu } from 'preact-context-menu';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
unreads: Unreads;
|
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) {
|
function ServerSidebar(props: Props & WithDispatcher) {
|
||||||
const { server: server_id, channel: channel_id } = useParams<{ server?: string, channel?: string }>();
|
const { server: server_id, channel: channel_id } = useParams<{ server?: string, channel?: string }>();
|
||||||
const ctx = useForceUpdate();
|
const ctx = useForceUpdate();
|
||||||
|
@ -33,7 +54,7 @@ function ServerSidebar(props: Props & WithDispatcher) {
|
||||||
if (channel) useUnreads({ ...props, channel }, ctx);
|
if (channel) useUnreads({ ...props, channel }, ctx);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<ServerBase>
|
||||||
<Header placement="secondary" background style={{ background: `url('${ctx.client.servers.getBannerURL(server._id, { width: 480 }, true)}')` }}>
|
<Header placement="secondary" background style={{ background: `url('${ctx.client.servers.getBannerURL(server._id, { width: 480 }, true)}')` }}>
|
||||||
<div>
|
<div>
|
||||||
{ server.name }
|
{ server.name }
|
||||||
|
@ -45,9 +66,7 @@ function ServerSidebar(props: Props & WithDispatcher) {
|
||||||
</div> }
|
</div> }
|
||||||
</Header>
|
</Header>
|
||||||
<ConnectionStatus />
|
<ConnectionStatus />
|
||||||
<div
|
<ServerList onContextMenu={attachContextMenu('Menu', { server_list: server._id })}>
|
||||||
//onContextMenu={attachContextMenu('Menu', { server_list: server._id })}>
|
|
||||||
>
|
|
||||||
{channels.map(entry => {
|
{channels.map(entry => {
|
||||||
return (
|
return (
|
||||||
<Link to={`/server/${server._id}/channel/${entry._id}`}>
|
<Link to={`/server/${server._id}/channel/${entry._id}`}>
|
||||||
|
@ -61,9 +80,9 @@ function ServerSidebar(props: Props & WithDispatcher) {
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</ServerList>
|
||||||
<PaintCounter small />
|
<PaintCounter small />
|
||||||
</div>
|
</ServerBase>
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,14 @@ const CheckboxBase = styled.label`
|
||||||
input {
|
input {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--secondary-background);
|
||||||
|
|
||||||
|
.check {
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CheckboxContent = styled.span`
|
const CheckboxContent = styled.span`
|
||||||
|
@ -46,6 +54,7 @@ const Checkmark = styled.div<{ checked: boolean }>`
|
||||||
display: grid;
|
display: grid;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
transition: 0.2s ease all;
|
||||||
background: var(--secondary-background);
|
background: var(--secondary-background);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
@ -56,7 +65,7 @@ const Checkmark = styled.div<{ checked: boolean }>`
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.checked &&
|
props.checked &&
|
||||||
css`
|
css`
|
||||||
background: var(--accent);
|
background: var(--accent) !important;
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -71,7 +80,7 @@ export interface CheckboxProps {
|
||||||
|
|
||||||
export default function Checkbox(props: CheckboxProps) {
|
export default function Checkbox(props: CheckboxProps) {
|
||||||
return (
|
return (
|
||||||
<CheckboxBase disabled={props.disabled}>
|
<CheckboxBase disabled={props.disabled} className={props.className}>
|
||||||
<CheckboxContent>
|
<CheckboxContent>
|
||||||
<span>{props.children}</span>
|
<span>{props.children}</span>
|
||||||
{props.description && (
|
{props.description && (
|
||||||
|
@ -87,7 +96,7 @@ export default function Checkbox(props: CheckboxProps) {
|
||||||
!props.disabled && props.onChange(!props.checked)
|
!props.disabled && props.onChange(!props.checked)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Checkmark checked={props.checked}>
|
<Checkmark checked={props.checked} className="check">
|
||||||
<Check size={20} />
|
<Check size={20} />
|
||||||
</Checkmark>
|
</Checkmark>
|
||||||
</CheckboxBase>
|
</CheckboxBase>
|
||||||
|
|
|
@ -47,7 +47,7 @@ const ModalContainer = styled.div`
|
||||||
animation-timing-function: cubic-bezier(.3,.3,.18,1.1);
|
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;
|
border-radius: 8px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
@ -56,10 +56,13 @@ const ModalContent = styled.div<{ [key in 'attachment' | 'noBackground' | 'borde
|
||||||
}
|
}
|
||||||
|
|
||||||
${ props => !props.noBackground && css`
|
${ props => !props.noBackground && css`
|
||||||
padding: 1.5em;
|
|
||||||
background: var(--secondary-header);
|
background: var(--secondary-header);
|
||||||
` }
|
` }
|
||||||
|
|
||||||
|
${ props => props.padding && css`
|
||||||
|
padding: 1.5em;
|
||||||
|
` }
|
||||||
|
|
||||||
${ props => props.attachment && css`
|
${ props => props.attachment && css`
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
` }
|
` }
|
||||||
|
@ -110,7 +113,8 @@ export default function Modal(props: Props) {
|
||||||
<ModalContent
|
<ModalContent
|
||||||
attachment={!!props.actions}
|
attachment={!!props.actions}
|
||||||
noBackground={props.noBackground}
|
noBackground={props.noBackground}
|
||||||
border={props.border}>
|
border={props.border}
|
||||||
|
padding={!props.dontModal}>
|
||||||
{props.title && <h3>{props.title}</h3>}
|
{props.title && <h3>{props.title}</h3>}
|
||||||
{props.children}
|
{props.children}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
|
|
31
src/components/ui/TextArea.module.scss
Normal file
31
src/components/ui/TextArea.module.scss
Normal file
|
@ -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;
|
||||||
|
}
|
146
src/components/ui/TextArea.tsx
Normal file
146
src/components/ui/TextArea.tsx
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
|
@ -1,5 +1,6 @@
|
||||||
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
|
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
|
||||||
import { createGlobalStyle } from "styled-components";
|
import { createGlobalStyle } from "styled-components";
|
||||||
|
import { connectState } from "../redux/connector";
|
||||||
import { Children } from "../types/Preact";
|
import { Children } from "../types/Preact";
|
||||||
import { createContext } from "preact";
|
import { createContext } from "preact";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
|
@ -116,10 +117,15 @@ export const ThemeContext = createContext<Theme>({} as any);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Children;
|
children: Children;
|
||||||
|
options?: ThemeOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Theme(props: Props) {
|
function Theme(props: Props) {
|
||||||
const theme = PRESETS.dark;
|
const theme: Theme = {
|
||||||
|
...PRESETS["dark"],
|
||||||
|
...(PRESETS as any)[props.options?.preset as any],
|
||||||
|
...props.options?.custom
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={theme}>
|
<ThemeContext.Provider value={theme}>
|
||||||
|
@ -134,7 +140,16 @@ export default function Theme(props: Props) {
|
||||||
/>
|
/>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<GlobalTheme theme={theme} />
|
<GlobalTheme theme={theme} />
|
||||||
|
{theme.css && (
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: theme.css }} />
|
||||||
|
)}
|
||||||
{props.children}
|
{props.children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default connectState(Theme, state => {
|
||||||
|
return {
|
||||||
|
options: state.settings.theme
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
|
@ -7,8 +7,6 @@ import Button from "../../../components/ui/Button";
|
||||||
import FormField from "../../../pages/login/FormField";
|
import FormField from "../../../pages/login/FormField";
|
||||||
import Preloader from "../../../components/ui/Preloader";
|
import Preloader from "../../../components/ui/Preloader";
|
||||||
|
|
||||||
// import WideSvg from "../../../assets/wide.svg";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
callback: (username: string, loginAfterSuccess?: true) => Promise<void>;
|
callback: (username: string, loginAfterSuccess?: true) => Promise<void>;
|
||||||
|
@ -34,6 +32,7 @@ export function OnboardingModal({ onClose, callback }: Props) {
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h1>
|
<h1>
|
||||||
<Text id="app.special.modals.onboarding.welcome" />
|
<Text id="app.special.modals.onboarding.welcome" />
|
||||||
|
<img src="/assets/wide.svg" />
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.form}>
|
<div className={styles.form}>
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
|
background-color: var(--secondary-background);
|
||||||
|
|
||||||
&[data-force="light"] {
|
&[data-force="light"] {
|
||||||
color: white;
|
color: white;
|
||||||
|
|
82
src/context/revoltjs/FileUploads.module.scss
Normal file
82
src/context/revoltjs/FileUploads.module.scss
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
148
src/context/revoltjs/FileUploads.tsx
Normal file
148
src/context/revoltjs/FileUploads.tsx
Normal file
|
@ -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;
|
||||||
|
}
|
44
src/context/revoltjs/RequiresOnline.tsx
Normal file
44
src/context/revoltjs/RequiresOnline.tsx
Normal file
|
@ -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 }</>;
|
||||||
|
}
|
9
src/lib/conversion.ts
Normal file
9
src/lib/conversion.ts
Normal file
|
@ -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)));
|
||||||
|
}
|
15
src/lib/debounce.ts
Normal file
15
src/lib/debounce.ts
Normal file
|
@ -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);
|
||||||
|
};
|
||||||
|
}
|
9
src/lib/fileSize.ts
Normal file
9
src/lib/fileSize.ts
Normal file
|
@ -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`;
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import { render } from "preact";
|
import { render } from "preact";
|
||||||
import "./styles/index.scss";
|
import "./styles/index.scss";
|
||||||
import { App } from "./app";
|
import { App } from "./pages/app";
|
||||||
|
|
||||||
import { registerSW } from 'virtual:pwa-register'
|
import { registerSW } from 'virtual:pwa-register'
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,10 @@ import RightSidebar from "../components/navigation/RightSidebar";
|
||||||
|
|
||||||
import Home from './home/Home';
|
import Home from './home/Home';
|
||||||
import Friends from "./friends/Friends";
|
import Friends from "./friends/Friends";
|
||||||
|
import Settings from './settings/Settings';
|
||||||
import Developer from "./developer/Developer";
|
import Developer from "./developer/Developer";
|
||||||
|
import ServerSettings from "./settings/ServerSettings";
|
||||||
|
import ChannelSettings from "./settings/ChannelSettings";
|
||||||
|
|
||||||
const Routes = styled.div`
|
const Routes = styled.div`
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
@ -31,17 +34,19 @@ export default function App() {
|
||||||
docked={isTouchscreenDevice ? Docked.None : Docked.Left}>
|
docked={isTouchscreenDevice ? Docked.None : Docked.Left}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/dev">
|
<Route path="/server/:server/channel/:channel/settings/:page" component={ChannelSettings} />
|
||||||
<Developer />
|
<Route path="/server/:server/channel/:channel/settings" component={ChannelSettings} />
|
||||||
</Route>
|
<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="/friends">
|
<Route path="/settings/:page" component={Settings} />
|
||||||
<Friends />
|
<Route path="/settings" component={Settings} />
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/">
|
<Route path="/dev" component={Developer} />
|
||||||
<Home />
|
<Route path="/friends" component={Friends} />
|
||||||
</Route>
|
<Route path="/" component={Home} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</Routes>
|
</Routes>
|
||||||
<ContextMenus />
|
<ContextMenus />
|
||||||
|
@ -55,31 +60,6 @@ export default function App() {
|
||||||
* <Route path="/channel/:channel/message/:message">
|
* <Route path="/channel/:channel/message/:message">
|
||||||
<ChannelWrapper />
|
<ChannelWrapper />
|
||||||
</Route>
|
</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">
|
<Route path="/server/:server/channel/:channel">
|
||||||
<ChannelWrapper />
|
<ChannelWrapper />
|
||||||
|
@ -89,21 +69,10 @@ export default function App() {
|
||||||
<ChannelWrapper />
|
<ChannelWrapper />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/friends">
|
|
||||||
<Friends />
|
|
||||||
</Route>
|
|
||||||
<Route path="/dev">
|
|
||||||
<Developer />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/open/:id">
|
<Route path="/open/:id">
|
||||||
<Open />
|
<Open />
|
||||||
</Route>
|
</Route>
|
||||||
{/*<Route path="/invite/:code">
|
{/*<Route path="/invite/:code">
|
||||||
<OpenInvite />
|
<OpenInvite />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/">
|
|
||||||
<Home />
|
|
||||||
</Route>
|
|
||||||
*/
|
*/
|
|
@ -1,11 +1,11 @@
|
||||||
import { CheckAuth } from "./context/revoltjs/CheckAuth";
|
import { CheckAuth } from "../context/revoltjs/CheckAuth";
|
||||||
import Preloader from "./components/ui/Preloader";
|
import Preloader from "../components/ui/Preloader";
|
||||||
import { Route, Switch } from "react-router-dom";
|
import { Route, Switch } from "react-router-dom";
|
||||||
import Context from "./context";
|
import Context from "../context";
|
||||||
|
|
||||||
import { lazy, Suspense } from "preact/compat";
|
import { lazy, Suspense } from "preact/compat";
|
||||||
const Login = lazy(() => import('./pages/login/Login'));
|
const Login = lazy(() => import('./login/Login'));
|
||||||
const RevoltApp = lazy(() => import('./pages/App'));
|
const RevoltApp = lazy(() => import('./RevoltApp'));
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
|
@ -3,14 +3,13 @@ import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
import Header from "../../components/ui/Header";
|
import Header from "../../components/ui/Header";
|
||||||
// import WideLogo from "../../../../../assets/wide.svg";
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.home}>
|
<div className={styles.home}>
|
||||||
<Header placement="primary"><Text id="app.navigation.tabs.home" /></Header>
|
<Header placement="primary"><Text id="app.navigation.tabs.home" /></Header>
|
||||||
<h3>
|
<h3>
|
||||||
<Text id="app.special.modals.onboarding.welcome" /> {/*<WideLogo />*/}
|
<Text id="app.special.modals.onboarding.welcome" /> <img src="/assets/wide.svg" />
|
||||||
</h3>
|
</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Form } from "./Form";
|
import { Form } from "./Form";
|
||||||
|
import { detect } from "detect-browser";
|
||||||
import { useContext } from "preact/hooks";
|
import { useContext } from "preact/hooks";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import { deviceDetect } from "react-device-detect";
|
|
||||||
import { OperationsContext } from "../../../context/revoltjs/RevoltClient";
|
import { OperationsContext } from "../../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
export function FormLogin() {
|
export function FormLogin() {
|
||||||
|
@ -12,7 +12,7 @@ export function FormLogin() {
|
||||||
<Form
|
<Form
|
||||||
page="login"
|
page="login"
|
||||||
callback={async data => {
|
callback={async data => {
|
||||||
const browser = deviceDetect();
|
const browser = detect();
|
||||||
let device_name;
|
let device_name;
|
||||||
if (browser) {
|
if (browser) {
|
||||||
const { name, os } = browser;
|
const { name, os } = browser;
|
||||||
|
|
46
src/pages/settings/ChannelSettings.tsx
Normal file
46
src/pages/settings/ChannelSettings.tsx
Normal file
|
@ -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
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
120
src/pages/settings/GenericSettings.tsx
Normal file
120
src/pages/settings/GenericSettings.tsx
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
65
src/pages/settings/ServerSettings.tsx
Normal file
65
src/pages/settings/ServerSettings.tsx
Normal file
|
@ -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
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
209
src/pages/settings/Settings.module.scss
Normal file
209
src/pages/settings/Settings.module.scss
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
153
src/pages/settings/Settings.tsx
Normal file
153
src/pages/settings/Settings.tsx
Normal file
|
@ -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>
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
6
src/pages/settings/SettingsTextArea.tsx
Normal file
6
src/pages/settings/SettingsTextArea.tsx
Normal file
|
@ -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} />;
|
||||||
|
}
|
89
src/pages/settings/channel/Overview.tsx
Normal file
89
src/pages/settings/channel/Overview.tsx
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
14
src/pages/settings/channel/Panes.module.scss
Normal file
14
src/pages/settings/channel/Panes.module.scss
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
.overview {
|
||||||
|
.row {
|
||||||
|
gap: 20px;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.name {
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
95
src/pages/settings/panes/Account.tsx
Normal file
95
src/pages/settings/panes/Account.tsx
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
286
src/pages/settings/panes/Appearance.tsx
Normal file
286
src/pages/settings/panes/Appearance.tsx
Normal file
|
@ -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
|
||||||
|
);
|
53
src/pages/settings/panes/Experiments.tsx
Normal file
53
src/pages/settings/panes/Experiments.tsx
Normal file
|
@ -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
|
||||||
|
);
|
95
src/pages/settings/panes/Feedback.tsx
Normal file
95
src/pages/settings/panes/Feedback.tsx
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
68
src/pages/settings/panes/Languages.tsx
Normal file
68
src/pages/settings/panes/Languages.tsx
Normal file
|
@ -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
|
||||||
|
);
|
142
src/pages/settings/panes/Notifications.tsx
Normal file
142
src/pages/settings/panes/Notifications.tsx
Normal file
|
@ -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
|
||||||
|
);
|
374
src/pages/settings/panes/Panes.module.scss
Normal file
374
src/pages/settings/panes/Panes.module.scss
Normal file
|
@ -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;
|
||||||
|
}
|
126
src/pages/settings/panes/Profile.tsx
Normal file
126
src/pages/settings/panes/Profile.tsx
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
186
src/pages/settings/panes/Sessions.tsx
Normal file
186
src/pages/settings/panes/Sessions.tsx
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
53
src/pages/settings/panes/Sync.tsx
Normal file
53
src/pages/settings/panes/Sync.tsx
Normal file
|
@ -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
|
||||||
|
);
|
23
src/pages/settings/server/Bans.tsx
Normal file
23
src/pages/settings/server/Bans.tsx
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
70
src/pages/settings/server/Invites.tsx
Normal file
70
src/pages/settings/server/Invites.tsx
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
24
src/pages/settings/server/Members.tsx
Normal file
24
src/pages/settings/server/Members.tsx
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
98
src/pages/settings/server/Overview.tsx
Normal file
98
src/pages/settings/server/Overview.tsx
Normal file
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
48
src/pages/settings/server/Panes.module.scss
Normal file
48
src/pages/settings/server/Panes.module.scss
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1 +1 @@
|
||||||
export const APP_VERSION = "0.1.9-alpha.7";
|
export const APP_VERSION = "1.0.0-vite";
|
||||||
|
|
17
yarn.lock
17
yarn.lock
|
@ -836,7 +836,7 @@
|
||||||
"@babel/types" "^7.4.4"
|
"@babel/types" "^7.4.4"
|
||||||
esutils "^2.0.2"
|
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"
|
version "7.14.6"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.6.tgz#535203bc0892efc7dec60bdc27b2ecf6e409062d"
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.6.tgz#535203bc0892efc7dec60bdc27b2ecf6e409062d"
|
||||||
integrity sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg==
|
integrity sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg==
|
||||||
|
@ -1098,7 +1098,15 @@
|
||||||
"@babel/runtime" "^7.14.0"
|
"@babel/runtime" "^7.14.0"
|
||||||
"@styled-icons/styled-icon" "^10.6.3"
|
"@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"
|
version "10.6.3"
|
||||||
resolved "https://registry.yarnpkg.com/@styled-icons/styled-icon/-/styled-icon-10.6.3.tgz#eae0e5e18fd601ac47e821bb9c2e099810e86403"
|
resolved "https://registry.yarnpkg.com/@styled-icons/styled-icon/-/styled-icon-10.6.3.tgz#eae0e5e18fd601ac47e821bb9c2e099810e86403"
|
||||||
integrity sha512-/A95L3peioLoWFiy+/eKRhoQ9r/oRrN/qzbSX4hXU1nGP2rUXcX3LWUhoBNAOp9Rw38ucc/4ralY427UUNtcGQ==
|
integrity sha512-/A95L3peioLoWFiy+/eKRhoQ9r/oRrN/qzbSX4hXU1nGP2rUXcX3LWUhoBNAOp9Rw38ucc/4ralY427UUNtcGQ==
|
||||||
|
@ -3421,6 +3429,11 @@ serialize-javascript@^4.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
randombytes "^2.1.0"
|
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:
|
shallowequal@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
|
resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
|
||||||
|
|
Loading…
Reference in a new issue