Port settings.

This commit is contained in:
Paul
2021-06-19 22:37:12 +01:00
parent b4bc2262ae
commit 31d8950ea1
48 changed files with 3056 additions and 106 deletions

View File

@@ -11,7 +11,10 @@ import RightSidebar from "../components/navigation/RightSidebar";
import Home from './home/Home';
import Friends from "./friends/Friends";
import Settings from './settings/Settings';
import Developer from "./developer/Developer";
import ServerSettings from "./settings/ServerSettings";
import ChannelSettings from "./settings/ChannelSettings";
const Routes = styled.div`
min-width: 0;
@@ -31,17 +34,19 @@ export default function App() {
docked={isTouchscreenDevice ? Docked.None : Docked.Left}>
<Routes>
<Switch>
<Route path="/dev">
<Developer />
</Route>
<Route path="/server/:server/channel/:channel/settings/:page" component={ChannelSettings} />
<Route path="/server/:server/channel/:channel/settings" component={ChannelSettings} />
<Route path="/server/:server/settings/:page" component={ServerSettings} />
<Route path="/server/:server/settings" component={ServerSettings} />
<Route path="/channel/:channel/settings/:page" component={ChannelSettings} />
<Route path="/channel/:channel/settings" component={ChannelSettings} />
<Route path="/settings/:page" component={Settings} />
<Route path="/settings" component={Settings} />
<Route path="/friends">
<Friends />
</Route>
<Route path="/">
<Home />
</Route>
<Route path="/dev" component={Developer} />
<Route path="/friends" component={Friends} />
<Route path="/" component={Home} />
</Switch>
</Routes>
<ContextMenus />
@@ -55,31 +60,6 @@ export default function App() {
* <Route path="/channel/:channel/message/:message">
<ChannelWrapper />
</Route>
<Route path="/server/:server/channel/:channel/settings/:page">
<ChannelSettings key="channel_settings" />
</Route>
<Route path="/server/:server/channel/:channel/settings">
<ChannelSettings key="channel_settings" />
</Route>
<Route path="/server/:server/settings/:page">
<ServerSettings key="channel_settings" />
</Route>
<Route path="/server/:server/settings">
<ServerSettings key="channel_settings" />
</Route>
<Route path="/channel/:channel/settings/:page">
<ChannelSettings key="channel_settings" />
</Route>
<Route path="/channel/:channel/settings">
<ChannelSettings key="channel_settings" />
</Route>
<Route path="/settings/:page">
<Settings key="settings" />
</Route>
<Route path="/settings">
<Settings key="settings" />
</Route>
<Route path="/server/:server/channel/:channel">
<ChannelWrapper />
@@ -89,21 +69,10 @@ export default function App() {
<ChannelWrapper />
</Route>
<Route path="/friends">
<Friends />
</Route>
<Route path="/dev">
<Developer />
</Route>
<Route path="/open/:id">
<Open />
</Route>
{/*<Route path="/invite/:code">
<OpenInvite />
</Route>
<Route path="/">
<Home />
</Route>
*/

31
src/pages/app.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { CheckAuth } from "../context/revoltjs/CheckAuth";
import Preloader from "../components/ui/Preloader";
import { Route, Switch } from "react-router-dom";
import Context from "../context";
import { lazy, Suspense } from "preact/compat";
const Login = lazy(() => import('./login/Login'));
const RevoltApp = lazy(() => import('./RevoltApp'));
export function App() {
return (
<Context>
{/*
// @ts-expect-error */}
<Suspense fallback={<Preloader />}>
<Switch>
<Route path="/login">
<CheckAuth>
<Login />
</CheckAuth>
</Route>
<Route path="/">
<CheckAuth auth>
<RevoltApp />
</CheckAuth>
</Route>
</Switch>
</Suspense>
</Context>
);
}

View File

@@ -3,14 +3,13 @@ import { Link } from "react-router-dom";
import { Text } from "preact-i18n";
import Header from "../../components/ui/Header";
// import WideLogo from "../../../../../assets/wide.svg";
export default function Home() {
return (
<div className={styles.home}>
<Header placement="primary"><Text id="app.navigation.tabs.home" /></Header>
<h3>
<Text id="app.special.modals.onboarding.welcome" /> {/*<WideLogo />*/}
<Text id="app.special.modals.onboarding.welcome" /> <img src="/assets/wide.svg" />
</h3>
<ul>
<li>

View File

@@ -1,7 +1,7 @@
import { Form } from "./Form";
import { detect } from "detect-browser";
import { useContext } from "preact/hooks";
import { useHistory } from "react-router-dom";
import { deviceDetect } from "react-device-detect";
import { OperationsContext } from "../../../context/revoltjs/RevoltClient";
export function FormLogin() {
@@ -12,7 +12,7 @@ export function FormLogin() {
<Form
page="login"
callback={async data => {
const browser = deviceDetect();
const browser = detect();
let device_name;
if (browser) {
const { name, os } = browser;

View 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
/>
)
}

View 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>
);
}

View 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
/>
)
}

View 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);
}
}
}

View 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>
]}
/>
)
}

View 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} />;
}

View 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>
);
}

View File

@@ -0,0 +1,14 @@
.overview {
.row {
gap: 20px;
display: flex;
.name {
flex-grow: 1;
input {
width: 100%;
}
}
}
}

View 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>
);
}

View 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
);

View 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
);

View 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>
);
}

View 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
);

View 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
);

View 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;
}

View 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>
);
}

View 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>
);
}

View 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
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}
}
}