Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
No results found
Show changes
Showing
with 2081 additions and 881 deletions
import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js/dist/maps/Channels";
import styled from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import styles from "./Panes.module.scss"; import { useEffect, useState } from "preact/hooks";
import Button from "../../../components/ui/Button";
import { Channels } from "revolt.js/dist/api/objects";
import InputBox from "../../../components/ui/InputBox";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { useContext, useEffect, useState } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { FileUploader } from "../../../context/revoltjs/FileUploads"; import { FileUploader } from "../../../context/revoltjs/FileUploads";
import Button from "../../../components/ui/Button";
import InputBox from "../../../components/ui/InputBox";
interface Props { interface Props {
channel: Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel; channel: Channel;
} }
export default function Overview({ channel }: Props) { const Row = styled.div`
const client = useContext(AppContext); gap: 20px;
display: flex;
.name {
flex-grow: 1;
input {
width: 100%;
}
}
`;
const [name, setName] = useState(channel.name); export default observer(({ channel }: Props) => {
const [description, setDescription] = useState(channel.description ?? ''); const [name, setName] = useState(channel.name ?? undefined);
const [description, setDescription] = useState(channel.description ?? "");
useEffect(() => setName(channel.name), [ channel.name ]); useEffect(() => setName(channel.name ?? undefined), [channel.name]);
useEffect(() => setDescription(channel.description ?? ''), [ channel.description ]); useEffect(
() => setDescription(channel.description ?? ""),
[channel.description],
);
const [ changed, setChanged ] = useState(false); const [changed, setChanged] = useState(false);
function save() { function save() {
let changes: any = {}; const changes: Record<string, string | undefined> = {};
if (name !== channel.name) changes.name = name; if (name !== channel.name) changes.name = name;
if (description !== channel.description) if (description !== channel.description)
changes.description = description; changes.description = description;
client.channels.edit(channel._id, changes); channel.edit(changes);
setChanged(false); setChanged(false);
} }
return ( return (
<div className={styles.overview}> <div className="overview">
<div className={styles.row}> <Row>
<FileUploader <FileUploader
width={80} width={80}
height={80} height={80}
...@@ -42,33 +60,44 @@ export default function Overview({ channel }: Props) { ...@@ -42,33 +60,44 @@ export default function Overview({ channel }: Props) {
fileType="icons" fileType="icons"
behaviour="upload" behaviour="upload"
maxFileSize={2_500_000} maxFileSize={2_500_000}
onUpload={icon => client.channels.edit(channel._id, { icon })} onUpload={(icon) => channel.edit({ icon })}
previewURL={client.channels.getIconURL(channel._id, { max_side: 256 }, true)} previewURL={channel.generateIconURL(
remove={() => client.channels.edit(channel._id, { remove: 'Icon' })} { max_side: 256 },
defaultPreview={channel.channel_type === 'Group' ? "/assets/group.png" : undefined} true,
)}
remove={() => channel.edit({ remove: "Icon" })}
defaultPreview={
channel.channel_type === "Group"
? "/assets/group.png"
: undefined
}
/> />
<div className={styles.name}> <div className="name">
<h3> <h3>
{ channel.channel_type === 'Group' ? {channel.channel_type === "Group" ? (
<Text id="app.main.groups.name" /> : <Text id="app.main.groups.name" />
<Text id="app.main.servers.channel_name" /> } ) : (
<Text id="app.main.servers.channel_name" />
)}
</h3> </h3>
<InputBox <InputBox
contrast contrast
value={name} value={name}
maxLength={32} maxLength={32}
onChange={e => { onChange={(e) => {
setName(e.currentTarget.value) setName(e.currentTarget.value);
if (!changed) setChanged(true) if (!changed) setChanged(true);
}} }}
/> />
</div> </div>
</div> </Row>
<h3> <h3>
{ channel.channel_type === 'Group' ? {channel.channel_type === "Group" ? (
<Text id="app.main.groups.description" /> : <Text id="app.main.groups.description" />
<Text id="app.main.servers.channel_description" /> } ) : (
<Text id="app.main.servers.channel_description" />
)}
</h3> </h3>
<TextAreaAutoSize <TextAreaAutoSize
maxRows={10} maxRows={10}
...@@ -76,9 +105,9 @@ export default function Overview({ channel }: Props) { ...@@ -76,9 +105,9 @@ export default function Overview({ channel }: Props) {
maxLength={1024} maxLength={1024}
value={description} value={description}
placeholder={"Add a description..."} placeholder={"Add a description..."}
onChange={ev => { onChange={(ev) => {
setDescription(ev.currentTarget.value); setDescription(ev.currentTarget.value);
if (!changed) setChanged(true) if (!changed) setChanged(true);
}} }}
/> />
<p> <p>
...@@ -88,4 +117,4 @@ export default function Overview({ channel }: Props) { ...@@ -88,4 +117,4 @@ export default function Overview({ channel }: Props) {
</p> </p>
</div> </div>
); );
} });
.overview {
.row {
gap: 20px;
display: flex;
.name {
flex-grow: 1;
input {
width: 100%;
}
}
}
}
import Tip from "../../../components/ui/Tip"; import { observer } from "mobx-react-lite";
import {
ChannelPermission,
DEFAULT_PERMISSION_DM,
} from "revolt.js/dist/api/permissions";
import { Channel } from "revolt.js/dist/maps/Channels";
import { useEffect, useState } from "preact/hooks";
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
import { Channels } from "revolt.js/dist/api/objects";
import Checkbox from "../../../components/ui/Checkbox"; import Checkbox from "../../../components/ui/Checkbox";
import { useServer } from "../../../context/revoltjs/hooks"; import Tip from "../../../components/ui/Tip";
import { useContext, useEffect, useState } from "preact/hooks";
import { ChannelPermission } from "revolt.js/dist/api/permissions";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
// ! FIXME: export from revolt.js
const DEFAULT_PERMISSION_DM = ChannelPermission.View
+ ChannelPermission.SendMessage
+ ChannelPermission.ManageChannel
+ ChannelPermission.VoiceCall
+ ChannelPermission.InviteOthers
+ ChannelPermission.EmbedLinks
+ ChannelPermission.UploadFiles;
interface Props { interface Props {
channel: Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel; channel: Channel;
} }
// ! FIXME: bad code :) // ! FIXME: bad code :)
export default function Permissions({ channel }: Props) { export default observer(({ channel }: Props) => {
const [ selected, setSelected ] = useState('default'); const [selected, setSelected] = useState("default");
const client = useContext(AppContext);
type R = { name: string, permissions: number }; type R = { name: string; permissions: number };
let roles: { [key: string]: R } = {}; const roles: { [key: string]: R } = {};
if (channel.channel_type !== 'Group') { if (channel.channel_type !== "Group") {
const server = useServer(channel.server); const server = channel.server;
const a = server?.roles ?? {}; const a = server?.roles ?? {};
for (let b of Object.keys(a)) { for (const b of Object.keys(a)) {
roles[b] = { roles[b] = {
name: a[b].name, name: a[b].name,
permissions: a[b].permissions[1] permissions: a[b].permissions[1],
}; };
} }
} }
const keys = [ 'default', ...Object.keys(roles) ]; const keys = ["default", ...Object.keys(roles)];
const defaultRole = { name: 'Default', permissions: (channel.channel_type === 'Group' ? channel.permissions : channel.default_permissions) ?? DEFAULT_PERMISSION_DM }; const defaultRole = {
const selectedRole = selected === 'default' ? defaultRole : roles[selected]; name: "Default",
permissions:
(channel.channel_type === "Group"
? channel.permissions
: channel.default_permissions) ?? DEFAULT_PERMISSION_DM,
};
const selectedRole = selected === "default" ? defaultRole : roles[selected];
if (!selectedRole) { if (!selectedRole) {
useEffect(() => setSelected('default'), [ ]); useEffect(() => setSelected("default"), []);
return null; return null;
} }
const [ p, setPerm ] = useState(selectedRole.permissions >>> 0); const [p, setPerm] = useState(selectedRole.permissions >>> 0);
useEffect(() => { useEffect(() => {
setPerm(selectedRole.permissions >>> 0); setPerm(selectedRole.permissions >>> 0);
}, [ selected, selectedRole.permissions ]); }, [selected, selectedRole.permissions]);
return ( return (
<div> <div>
<Tip warning>This section is under construction.</Tip> <Tip warning>This section is under construction.</Tip>
<h2>select role</h2> <h2>select role</h2>
{ selected } {selected}
{ keys {keys.map((id) => {
.map(id => { const role: R = id === "default" ? defaultRole : roles[id];
let role: R = id === 'default' ? defaultRole : roles[id];
return (
<Checkbox
key={id}
checked={selected === id}
onChange={(selected) => selected && setSelected(id)}>
{role.name}
</Checkbox>
);
})}
<h2>channel permissions</h2>
{Object.keys(ChannelPermission).map((perm) => {
if (perm === "View") return null;
const value =
ChannelPermission[perm as keyof typeof ChannelPermission];
if (value & DEFAULT_PERMISSION_DM) {
return ( return (
<Checkbox checked={selected === id} onChange={selected => selected && setSelected(id)}> <Checkbox
{ role.name } checked={(p & value) > 0}
onChange={(c) =>
setPerm(c ? p | value : p ^ value)
}>
{perm}
</Checkbox> </Checkbox>
) );
}) }
} })}
<h2>channel per??issions</h2> <Button
{ Object.keys(ChannelPermission) contrast
.map(perm => { onClick={() => {
let value = ChannelPermission[perm as keyof typeof ChannelPermission]; channel.setPermissions(selected, p);
if (value & DEFAULT_PERMISSION_DM) { }}>
return ( click here to save permissions for role
<Checkbox checked={(p & value) > 0} onChange={c => setPerm(c ? (p | value) : (p ^ value))}> </Button>
{ perm }
</Checkbox>
)
}
})
}
<Button contrast onClick={() => {
client.channels.setPermissions(channel._id, selected, p);
}}>click here to save permissions for role</Button>
</div> </div>
); );
} });
import { Text } from "preact-i18n"; import { At, Key, Block } from "@styled-icons/boxicons-regular";
import {
Envelope,
HelpCircle,
Lock,
Trash,
} from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom";
import { Profile } from "revolt-api/types/Users";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import Tip from "../../../components/ui/Tip"; import { Text } from "preact-i18n";
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 { At, Key, Envelope } from "@styled-icons/boxicons-regular";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import UserIcon from "../../../components/common/user/UserIcon";
import { useForceUpdate, useSelf } from "../../../context/revoltjs/hooks"; import { stopPropagation } from "../../../lib/stopPropagation";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient"; import {
ClientStatus,
StatusContext,
useClient,
} from "../../../context/revoltjs/RevoltClient";
import Tooltip from "../../../components/common/Tooltip";
import UserIcon from "../../../components/common/user/UserIcon";
import Button from "../../../components/ui/Button";
import Tip from "../../../components/ui/Tip";
import CategoryButton from "../../../components/ui/fluent/CategoryButton";
export function Account() { export const Account = observer(() => {
const { openScreen } = useIntermediate(); const { openScreen, writeClipboard } = useIntermediate();
const status = useContext(StatusContext); const status = useContext(StatusContext);
const ctx = useForceUpdate(); const client = useClient();
const user = useSelf(ctx);
if (!user) return null;
const [email, setEmail] = useState("..."); const [email, setEmail] = useState("...");
const [profile, setProfile] = useState<undefined | Users.Profile>( const [revealEmail, setRevealEmail] = useState(false);
undefined const [profile, setProfile] = useState<undefined | Profile>(undefined);
);
const history = useHistory(); const history = useHistory();
function switchPage(to: string) { function switchPage(to: string) {
...@@ -32,56 +45,161 @@ export function Account() { ...@@ -32,56 +45,161 @@ export function Account() {
useEffect(() => { useEffect(() => {
if (email === "..." && status === ClientStatus.ONLINE) { if (email === "..." && status === ClientStatus.ONLINE) {
ctx.client client
.req("GET", "/auth/user") .req("GET", "/auth/user")
.then(account => setEmail(account.email)); .then((account) => setEmail(account.email));
} }
if (profile === undefined && status === ClientStatus.ONLINE) { if (profile === undefined && status === ClientStatus.ONLINE) {
ctx.client.users client
.fetchProfile(user._id) .user!.fetchProfile()
.then(profile => setProfile(profile ?? {})); .then((profile) => setProfile(profile ?? {}));
} }
}, [status]); }, [client, email, profile, status]);
return ( return (
<div className={styles.user}> <div className={styles.user}>
<div className={styles.banner}> <div className={styles.banner}>
<Link to="/settings/profile"> <div className={styles.container}>
<UserIcon target={user} size={72} /> <UserIcon
</Link> className={styles.avatar}
<div className={styles.username}>@{user.username}</div> target={client.user!}
</div> size={72}
<div className={styles.details}> onClick={() => switchPage("profile")}
{[ />
["username", user.username, <At size={24} />], <div className={styles.userDetail}>
["email", email, <Envelope size={24} />], @{client.user!.username}
["password", "*****", <Key size={24} />] <div className={styles.userid}>
].map(([field, value, icon]) => ( <Tooltip
<div> content={
{icon} <Text id="app.settings.pages.account.unique_id" />
<div className={styles.detail}> }>
<Overline> <HelpCircle size={16} />
<Text id={`login.${field}`} /> </Tooltip>
</Overline> <Tooltip content={<Text id="app.special.copy" />}>
<p>{value}</p> <a
</div> onClick={() =>
<div> writeClipboard(client.user!._id)
<Button }>
onClick={() => {client.user!._id}
openScreen({ </a>
id: "modify_account", </Tooltip>
field: field as any
})
}
contrast
>
<Text id="app.settings.pages.account.change_field" />
</Button>
</div> </div>
</div> </div>
</div>
<Button onClick={() => switchPage("profile")} contrast>
<Text id="app.settings.pages.profile.edit_profile" />
</Button>
</div>
<div>
{(
[
[
"username",
client.user!.username,
<At key="at" size={24} />,
],
["email", email, <Envelope key="envelope" size={24} />],
["password", "•••••••••", <Key key="key" size={24} />],
] as const
).map(([field, value, icon]) => (
<CategoryButton
key={field}
icon={icon}
description={
field === "email" ? (
revealEmail ? (
<>
{value}{" "}
<a
onClick={(ev) =>
stopPropagation(
ev,
setRevealEmail(false),
)
}>
<Text id="app.special.modals.actions.hide" />
</a>
</>
) : (
<>
•••••••••••@{value.split("@").pop()}{" "}
<a
onClick={(ev) =>
stopPropagation(
ev,
setRevealEmail(true),
)
}>
<Text id="app.special.modals.actions.reveal" />
</a>
</>
)
) : (
value
)
}
account
action="chevron"
onClick={() =>
openScreen({
id: "modify_account",
field,
})
}>
<Text id={`login.${field}`} />
</CategoryButton>
))} ))}
</div> </div>
<h3>
<Text id="app.settings.pages.account.2fa.title" />
</h3>
<h5>
{/*<Text id="app.settings.pages.account.2fa.description" />*/}
Two-factor authentication is currently work-in-progress, see{" "}
{` `}
<a
href="https://gitlab.insrt.uk/insert/rauth/-/issues/2"
target="_blank"
rel="noreferrer">
tracking issue here
</a>
.
</h5>
<CategoryButton
icon={<Lock size={24} color="var(--error)" />}
description={"Set up 2FA Authentication on your account."}
disabled
action="chevron">
Set up Two-factor authentication
</CategoryButton>
<h3>
<Text id="app.settings.pages.account.manage.title" />
</h3>
<h5>
<Text id="app.settings.pages.account.manage.description" />
</h5>
<CategoryButton
icon={<Block size={24} color="var(--error)" />}
description={
"Disable your account. You won't be able to access it unless you log back in."
}
disabled
action={<Text id="general.unavailable" />}>
<Text id="app.settings.pages.account.manage.disable" />
</CategoryButton>
<a href="mailto:contact@revolt.chat?subject=Delete%20my%20account">
<CategoryButton
icon={<Trash size={24} color="var(--error)" />}
description={
"Delete your account, including all of your data."
}
hover
action="external">
<Text id="app.settings.pages.account.manage.delete" />
</CategoryButton>
</a>
<Tip> <Tip>
<span> <span>
<Text id="app.settings.tips.account.a" /> <Text id="app.settings.tips.account.a" />
...@@ -92,4 +210,4 @@ export function Account() { ...@@ -92,4 +210,4 @@ export function Account() {
</Tip> </Tip>
</div> </div>
); );
} });
import { Text } from "preact-i18n"; import { Reset, Import } from "@styled-icons/boxicons-regular";
import { Pencil } from "@styled-icons/boxicons-solid";
// @ts-expect-error shade-blend-color does not have typings.
import pSBC from "shade-blend-color";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { debounce } from "../../../lib/debounce"; import { debounce } from "../../../lib/debounce";
import Button from "../../../components/ui/Button";
import InputBox from "../../../components/ui/InputBox"; import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector"; import { connectState } from "../../../redux/connector";
import { WithDispatcher } from "../../../redux/reducers";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import ColourSwatches from "../../../components/ui/ColourSwatches";
import { EmojiPacks, Settings } from "../../../redux/reducers/settings"; 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 {
import pSBC from 'shade-blend-color'; DEFAULT_FONT,
DEFAULT_MONO_FONT,
import lightSVG from '../assets/light.svg'; Fonts,
import darkSVG from '../assets/dark.svg'; FONTS,
FONT_KEYS,
MonospaceFonts,
MONOSPACE_FONTS,
MONOSPACE_FONT_KEYS,
Theme,
ThemeContext,
ThemeOptions,
} from "../../../context/Theme";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import mutantSVG from '../assets/mutant_emoji.svg'; import CollapsibleSection from "../../../components/common/CollapsibleSection";
import notoSVG from '../assets/noto_emoji.svg'; import Tooltip from "../../../components/common/Tooltip";
import openmojiSVG from '../assets/openmoji_emoji.svg'; import Button from "../../../components/ui/Button";
import twemojiSVG from '../assets/twemoji_emoji.svg'; import Checkbox from "../../../components/ui/Checkbox";
import ColourSwatches from "../../../components/ui/ColourSwatches";
import ComboBox from "../../../components/ui/ComboBox";
import InputBox from "../../../components/ui/InputBox";
import darkSVG from "../assets/dark.svg";
import lightSVG from "../assets/light.svg";
import mutantSVG from "../assets/mutant_emoji.svg";
import notoSVG from "../assets/noto_emoji.svg";
import openmojiSVG from "../assets/openmoji_emoji.svg";
import twemojiSVG from "../assets/twemoji_emoji.svg";
interface Props { interface Props {
settings: Settings; settings: Settings;
} }
// ! FIXME: code needs to be rewritten to fix jittering // ! FIXME: code needs to be rewritten to fix jittering
export function Component(props: Props & WithDispatcher) { export function Component(props: Props) {
const theme = useContext(ThemeContext); const theme = useContext(ThemeContext);
const { writeClipboard, openScreen } = useIntermediate(); const { writeClipboard, openScreen } = useIntermediate();
function setTheme(theme: ThemeOptions) { function setTheme(theme: ThemeOptions) {
props.dispatcher({ dispatch({
type: "SETTINGS_SET_THEME", type: "SETTINGS_SET_THEME",
theme theme,
}); });
} }
function pushOverride(custom: Partial<Theme>) { const pushOverride = useCallback((custom: Partial<Theme>) => {
props.dispatcher({ dispatch({
type: "SETTINGS_SET_THEME_OVERRIDE", type: "SETTINGS_SET_THEME_OVERRIDE",
custom custom,
}); });
} }, []);
function setAccent(accent: string) { function setAccent(accent: string) {
setOverride({ setOverride({
accent, accent,
"scrollbar-thumb": pSBC(-0.2, accent) "scrollbar-thumb": pSBC(-0.2, accent),
}); });
} }
const emojiPack = props.settings.appearance?.emojiPack ?? 'mutant'; const emojiPack = props.settings.appearance?.emojiPack ?? "mutant";
function setEmojiPack(emojiPack: EmojiPacks) { function setEmojiPack(emojiPack: EmojiPacks) {
props.dispatcher({ dispatch({
type: 'SETTINGS_SET_APPEARANCE', type: "SETTINGS_SET_APPEARANCE",
options: { options: {
emojiPack emojiPack,
} },
}); });
} }
const setOverride = useCallback(debounce(pushOverride, 200), []) as ( // eslint-disable-next-line react-hooks/exhaustive-deps
custom: Partial<Theme> const setOverride = useCallback(
) => void; debounce(pushOverride as (...args: unknown[]) => void, 200),
const [ css, setCSS ] = useState(props.settings.theme?.custom?.css ?? ''); [pushOverride],
) as (custom: Partial<Theme>) => void;
const [css, setCSS] = useState(props.settings.theme?.custom?.css ?? "");
useEffect(() => setOverride({ css }), [ css ]); useEffect(() => setOverride({ css }), [setOverride, css]);
const selected = props.settings.theme?.preset ?? "dark"; const selected = props.settings.theme?.preset ?? "dark";
return ( return (
...@@ -79,28 +101,45 @@ export function Component(props: Props & WithDispatcher) { ...@@ -79,28 +101,45 @@ export function Component(props: Props & WithDispatcher) {
<div className={styles.themes}> <div className={styles.themes}>
<div className={styles.theme}> <div className={styles.theme}>
<img <img
loading="eager"
src={lightSVG} src={lightSVG}
draggable={false}
data-active={selected === "light"} data-active={selected === "light"}
onClick={() => onClick={() =>
selected !== "light" && selected !== "light" &&
setTheme({ preset: "light" }) setTheme({ preset: "light" })
} /> }
onContextMenu={(e) => e.preventDefault()}
/>
<h4> <h4>
<Text id="app.settings.pages.appearance.color.light" /> <Text id="app.settings.pages.appearance.color.light" />
</h4> </h4>
</div> </div>
<div className={styles.theme}> <div className={styles.theme}>
<img <img
loading="eager"
src={darkSVG} src={darkSVG}
draggable={false}
data-active={selected === "dark"} data-active={selected === "dark"}
onClick={() => onClick={() =>
selected !== "dark" && setTheme({ preset: "dark" }) selected !== "dark" && setTheme({ preset: "dark" })
} /> }
onContextMenu={(e) => e.preventDefault()}
/>
<h4> <h4>
<Text id="app.settings.pages.appearance.color.dark" /> <Text id="app.settings.pages.appearance.color.dark" />
</h4> </h4>
</div> </div>
</div> </div>
{/*<Checkbox
checked={props.settings.theme?.ligatures === true}
onChange={() =>
setTheme({
ligatures: !props.settings.theme?.ligatures,
})
}>
Use the system theme
</Checkbox>*/}
<h3> <h3>
<Text id="app.settings.pages.appearance.accent_selector" /> <Text id="app.settings.pages.appearance.accent_selector" />
...@@ -129,133 +168,228 @@ export function Component(props: Props & WithDispatcher) { ...@@ -129,133 +168,228 @@ export function Component(props: Props & WithDispatcher) {
</Radio> </Radio>
</div>*/} </div>*/}
<h3>
<Text id="app.settings.pages.appearance.font" />
</h3>
<ComboBox
value={theme.font ?? DEFAULT_FONT}
onChange={(e) =>
pushOverride({ font: e.currentTarget.value as Fonts })
}>
{FONT_KEYS.map((key) => (
<option value={key} key={key}>
{FONTS[key as keyof typeof FONTS].name}
</option>
))}
</ComboBox>
{/* TOFIX: Only show when a font with ligature support is selected, i.e.: Inter.*/}
<p>
<Checkbox
checked={props.settings.theme?.ligatures === true}
onChange={() =>
setTheme({
ligatures: !props.settings.theme?.ligatures,
})
}
description={
<Text id="app.settings.pages.appearance.ligatures_desc" />
}>
<Text id="app.settings.pages.appearance.ligatures" />
</Checkbox>
</p>
<h3> <h3>
<Text id="app.settings.pages.appearance.emoji_pack" /> <Text id="app.settings.pages.appearance.emoji_pack" />
</h3> </h3>
<div className={styles.emojiPack}> <div className={styles.emojiPack}>
<div className={styles.row}> <div className={styles.row}>
<div> <div>
<div className={styles.button} <div
onClick={() => setEmojiPack('mutant')} className={styles.button}
data-active={emojiPack === 'mutant'}> onClick={() => setEmojiPack("mutant")}
<img src={mutantSVG} draggable={false} /> data-active={emojiPack === "mutant"}>
<img
loading="eager"
src={mutantSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div> </div>
<h4>Mutant Remix <a href="https://mutant.revolt.chat" target="_blank">(by Revolt)</a></h4> <h4>
Mutant Remix{" "}
<a
href="https://mutant.revolt.chat"
target="_blank"
rel="noreferrer">
(by Revolt)
</a>
</h4>
</div> </div>
<div> <div>
<div className={styles.button} <div
onClick={() => setEmojiPack('twemoji')} className={styles.button}
data-active={emojiPack === 'twemoji'}> onClick={() => setEmojiPack("twemoji")}
<img src={twemojiSVG} draggable={false} /> data-active={emojiPack === "twemoji"}>
<img
loading="eager"
src={twemojiSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div> </div>
<h4>Twemoji</h4> <h4>Twemoji</h4>
</div> </div>
</div> </div>
<div className={styles.row}> <div className={styles.row}>
<div> <div>
<div className={styles.button} <div
onClick={() => setEmojiPack('openmoji')} className={styles.button}
data-active={emojiPack === 'openmoji'}> onClick={() => setEmojiPack("openmoji")}
<img src={openmojiSVG} draggable={false} /> data-active={emojiPack === "openmoji"}>
<img
loading="eager"
src={openmojiSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div> </div>
<h4>Openmoji</h4> <h4>Openmoji</h4>
</div> </div>
<div> <div>
<div className={styles.button} <div
onClick={() => setEmojiPack('noto')} className={styles.button}
data-active={emojiPack === 'noto'}> onClick={() => setEmojiPack("noto")}
<img src={notoSVG} draggable={false} /> data-active={emojiPack === "noto"}>
<img
loading="eager"
src={notoSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div> </div>
<h4>Noto Emoji</h4> <h4>Noto Emoji</h4>
</div> </div>
</div> </div>
</div> </div>
<details> <CollapsibleSection
<summary> defaultValue={false}
<Text id="app.settings.pages.appearance.advanced" /> id="settings_overrides"
<div className={styles.divider}></div> summary={<Text id="app.settings.pages.appearance.overrides" />}>
</summary>
<h3>
<Text id="app.settings.pages.appearance.overrides" />
</h3>
<div className={styles.actions}> <div className={styles.actions}>
<Button contrast <Tooltip
onClick={() => setTheme({ custom: {} })}> content={
<Text id="app.settings.pages.appearance.reset_overrides" /> <Text id="app.settings.pages.appearance.reset_overrides" />
</Button> }>
<Button contrast <Button
contrast
iconbutton
onClick={() => setTheme({ custom: {} })}>
<Reset size={22} />
</Button>
</Tooltip>
<div
className={styles.code}
onClick={() => writeClipboard(JSON.stringify(theme))}> onClick={() => writeClipboard(JSON.stringify(theme))}>
<Text id="app.settings.pages.appearance.export_clipboard" /> <Tooltip content={<Text id="app.special.copy" />}>
</Button> {" "}
<Button contrast {/*TOFIX: Try to put the tooltip above the .code div without messing up the css challenge */}
onClick={async () => { {JSON.stringify(theme)}
const text = await navigator.clipboard.readText(); </Tooltip>
setOverride(JSON.parse(text)); </div>
}}> <Tooltip
<Text id="app.settings.pages.appearance.import_clipboard" /> content={
</Button> <Text id="app.settings.pages.appearance.import" />
<Button contrast }>
onClick={async () => { <Button
openScreen({ contrast
id: "_input", iconbutton
question: <Text id="app.settings.pages.appearance.import_theme" />, onClick={async () => {
field: <Text id="app.settings.pages.appearance.theme_data" />, try {
callback: async string => setOverride(JSON.parse(string)) const text =
}); await navigator.clipboard.readText();
}}> setOverride(JSON.parse(text));
<Text id="app.settings.pages.appearance.import_manual" /> } catch (err) {
</Button> 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)),
});
}
}}>
<Import size={22} />
</Button>
</Tooltip>
</div> </div>
<h3>App</h3>
<div className={styles.overrides}> <div className={styles.overrides}>
{[ {(
"accent", [
"background", "accent",
"foreground", "background",
"primary-background", "foreground",
"primary-header", "primary-background",
"secondary-background", "primary-header",
"secondary-foreground", "secondary-background",
"secondary-header", "secondary-foreground",
"tertiary-background", "secondary-header",
"tertiary-foreground", "tertiary-background",
"block", "tertiary-foreground",
"message-box", "block",
"mention", "message-box",
"sidebar-active", "mention",
"scrollbar-thumb", "scrollbar-thumb",
"scrollbar-track", "scrollbar-track",
"status-online", "status-online",
"status-away", "status-away",
"status-busy", "status-busy",
"status-streaming", "status-streaming",
"status-invisible", "status-invisible",
"success", "success",
"warning", "warning",
"error", "error",
"hover" "hover",
].map(x => ( ] as const
<div className={styles.entry} key={x}> ).map((x) => (
<div
className={styles.entry}
key={x}
style={{ backgroundColor: theme[x] }}>
<div className={styles.input}>
<input
type="color"
value={theme[x]}
onChange={(v) =>
setOverride({
[x]: v.currentTarget.value,
})
}
/>
</div>
<span>{x}</span> <span>{x}</span>
<div className={styles.override}> <div className={styles.override}>
<div className={styles.picker} <div
style={{ backgroundColor: (theme as any)[x as any] }}> className={styles.picker}
<input onClick={(e) =>
type="color" e.currentTarget.parentElement?.parentElement
value={(theme as any)[x as any]} ?.querySelector("input")
onChange={v => ?.click()
setOverride({ }>
[x]: v.currentTarget.value <Pencil size={24} />
})
}
/>
</div> </div>
<InputBox <InputBox
type="text"
className={styles.text} className={styles.text}
value={(theme as any)[x as any]} value={theme[x]}
onChange={y => onChange={(y) =>
setOverride({ setOverride({
[x]: y.currentTarget.value [x]: y.currentTarget.value,
}) })
} }
/> />
...@@ -263,6 +397,34 @@ export function Component(props: Props & WithDispatcher) { ...@@ -263,6 +397,34 @@ export function Component(props: Props & WithDispatcher) {
</div> </div>
))} ))}
</div> </div>
</CollapsibleSection>
<CollapsibleSection
id="settings_advanced_appearance"
defaultValue={false}
summary={<Text id="app.settings.pages.appearance.advanced" />}>
<h3>
<Text id="app.settings.pages.appearance.mono_font" />
</h3>
<ComboBox
value={theme.monospaceFont ?? DEFAULT_MONO_FONT}
onChange={(e) =>
pushOverride({
monospaceFont: e.currentTarget
.value as MonospaceFonts,
})
}>
{MONOSPACE_FONT_KEYS.map((key) => (
<option value={key} key={key}>
{
MONOSPACE_FONTS[
key as keyof typeof MONOSPACE_FONTS
].name
}
</option>
))}
</ComboBox>
<h3> <h3>
<Text id="app.settings.pages.appearance.custom_css" /> <Text id="app.settings.pages.appearance.custom_css" />
</h3> </h3>
...@@ -271,18 +433,15 @@ export function Component(props: Props & WithDispatcher) { ...@@ -271,18 +433,15 @@ export function Component(props: Props & WithDispatcher) {
minHeight={480} minHeight={480}
code code
value={css} value={css}
onChange={ev => setCSS(ev.currentTarget.value)} /> onChange={(ev) => setCSS(ev.currentTarget.value)}
</details> />
</CollapsibleSection>
</div> </div>
); );
} }
export const Appearance = connectState( export const Appearance = connectState(Component, (state) => {
Component, return {
state => { settings: state.settings,
return { };
settings: state.settings });
};
},
true
);
import { Text } from "preact-i18n";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import Checkbox from "../../../components/ui/Checkbox"; import { Text } from "preact-i18n";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector"; import { connectState } from "../../../redux/connector";
import { WithDispatcher } from "../../../redux/reducers"; import {
import { AVAILABLE_EXPERIMENTS, ExperimentOptions } from "../../../redux/reducers/experiments"; AVAILABLE_EXPERIMENTS,
ExperimentOptions,
EXPERIMENTS,
} from "../../../redux/reducers/experiments";
import Checkbox from "../../../components/ui/Checkbox";
interface Props { interface Props {
options?: ExperimentOptions; options?: ExperimentOptions;
} }
export function Component(props: Props & WithDispatcher) { export function Component(props: Props) {
return ( return (
<div className={styles.experiments}> <div className={styles.experiments}>
<h3> <h3>
<Text id="app.settings.pages.experiments.features" /> <Text id="app.settings.pages.experiments.features" />
</h3> </h3>
{ {AVAILABLE_EXPERIMENTS.map((key) => (
(AVAILABLE_EXPERIMENTS).map( <Checkbox
key => key={key}
<Checkbox checked={(props.options?.enabled ?? []).indexOf(key) > -1}
checked={(props.options?.enabled ?? []).indexOf(key) > -1} onChange={(enabled) =>
onChange={enabled => { dispatch({
props.dispatcher({ type: enabled
type: enabled ? 'EXPERIMENTS_ENABLE' : 'EXPERIMENTS_DISABLE', ? "EXPERIMENTS_ENABLE"
key : "EXPERIMENTS_DISABLE",
}); key,
}} })
> }
<Text id={`app.settings.pages.experiments.titles.${key}`} /> description={EXPERIMENTS[key].description}>
<p> {EXPERIMENTS[key].title}
<Text id={`app.settings.pages.experiments.descriptions.${key}`} /> </Checkbox>
</p> ))}
</Checkbox> {AVAILABLE_EXPERIMENTS.length === 0 && (
)
}
{
AVAILABLE_EXPERIMENTS.length === 0 &&
<div className={styles.empty}> <div className={styles.empty}>
<Text id="app.settings.pages.experiments.not_available" /> <Text id="app.settings.pages.experiments.not_available" />
</div> </div>
)}
}
</div> </div>
); );
} }
export const ExperimentsPage = connectState( export const ExperimentsPage = connectState(Component, (state) => {
Component, return {
state => { options: state.experiments,
return { };
options: state.experiments });
};
},
true
);
import { useState } from "preact/hooks";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import { Localizer, Text } from "preact-i18n"; import { Localizer, Text } from "preact-i18n";
import Radio from "../../../components/ui/Radio"; import { useState } from "preact/hooks";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
import InputBox from "../../../components/ui/InputBox"; import InputBox from "../../../components/ui/InputBox";
import Radio from "../../../components/ui/Radio";
import TextArea from "../../../components/ui/TextArea"; import TextArea from "../../../components/ui/TextArea";
import { useSelf } from "../../../context/revoltjs/hooks";
export function Feedback() { export function Feedback() {
const user = useSelf(); const client = useClient();
const [other, setOther] = useState(""); const [other, setOther] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [state, setState] = useState<"ready" | "sending" | "sent">("ready"); const [state, setState] = useState<"ready" | "sending" | "sent">("ready");
...@@ -20,19 +22,16 @@ export function Feedback() { ...@@ -20,19 +22,16 @@ export function Feedback() {
ev.preventDefault(); ev.preventDefault();
setState("sending"); setState("sending");
await fetch( await fetch(`https://workers.revolt.chat/feedback`, {
`https://workers.revolt.chat/feedback`, method: "POST",
{ body: JSON.stringify({
method: "POST", checked,
body: JSON.stringify({ other,
checked, description,
other, name: client.user!.username,
description, }),
name: user?.username ?? "Unknown User" mode: "no-cors",
}), });
mode: 'no-cors'
}
);
setState("sent"); setState("sent");
setChecked("Bug"); setChecked("Bug");
...@@ -58,29 +57,20 @@ export function Feedback() { ...@@ -58,29 +57,20 @@ export function Feedback() {
onSelect={() => setChecked("Feature Request")}> onSelect={() => setChecked("Feature Request")}>
<Text id="app.settings.pages.feedback.feature" /> <Text id="app.settings.pages.feedback.feature" />
</Radio> </Radio>
{ (location.hostname === 'vite.revolt.chat' || location.hostname === 'local.revolt.chat') && <Radio
disabled={state === "sending"}
checked={other === "Revite"}
onSelect={() => {
setChecked("__other_option__");
setOther("Revite");
}}>
Issues with Revite
</Radio> }
<Radio <Radio
disabled={state === "sending"} disabled={state === "sending"}
checked={checked === "__other_option__" && other !== "Revite"} checked={checked === "__other_option__"}
onSelect={() => setChecked("__other_option__")}> onSelect={() => setChecked("__other_option__")}>
<Localizer> <Localizer>
<InputBox <InputBox
value={other} value={other}
disabled={state === "sending"} disabled={state === "sending"}
name="entry.1151440373.other_option_response" name="entry.1151440373.other_option_response"
onChange={e => setOther(e.currentTarget.value)} onChange={(e) => setOther(e.currentTarget.value)}
placeholder={ placeholder={
( (
<Text id="app.settings.pages.feedback.other" /> <Text id="app.settings.pages.feedback.other" />
) as any ) as unknown as string
} }
/> />
</Localizer> </Localizer>
...@@ -94,7 +84,7 @@ export function Feedback() { ...@@ -94,7 +84,7 @@ export function Feedback() {
value={description} value={description}
id="entry.685672624" id="entry.685672624"
disabled={state === "sending"} disabled={state === "sending"}
onChange={ev => setDescription(ev.currentTarget.value)} onChange={(ev) => setDescription(ev.currentTarget.value)}
/> />
<p> <p>
<Button type="submit" contrast> <Button type="submit" contrast>
......
import { Text } from "preact-i18n";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import Tip from "../../../components/ui/Tip"; import { Text } from "preact-i18n";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector";
import {
Language,
LanguageEntry,
Languages as Langs,
} from "../../../context/Locale";
import Emoji from "../../../components/common/Emoji"; import Emoji from "../../../components/common/Emoji";
import Checkbox from "../../../components/ui/Checkbox"; import Checkbox from "../../../components/ui/Checkbox";
import { connectState } from "../../../redux/connector"; import Tip from "../../../components/ui/Tip";
import { WithDispatcher } from "../../../redux/reducers"; import tokiponaSVG from "../assets/toki_pona.svg";
import { Language, LanguageEntry, Languages as Langs } from "../../../context/Locale";
type Props = WithDispatcher & { type Props = {
locale: Language; locale: Language;
} };
type Key = [ string, LanguageEntry ]; type Key = [string, LanguageEntry];
function Entry({ entry: [ x, lang ], locale, dispatcher }: { entry: Key } & Props) { function Entry({ entry: [x, lang], locale }: { entry: Key } & Props) {
return ( return (
<Checkbox <Checkbox
key={x} key={x}
className={styles.entry} className={styles.entry}
checked={locale === x} checked={locale === x}
onChange={v => { onChange={(v) => {
if (v) { if (v) {
dispatcher({ dispatch({
type: "SET_LOCALE", type: "SET_LOCALE",
locale: x as Language locale: x as Language,
}); });
} }
}} }}>
> <div className={styles.flag}>
<div className={styles.flag}><Emoji size={42} emoji={lang.emoji} /></div> {lang.emoji === "🙂" ? (
<span className={styles.description}> <img src={tokiponaSVG} width={42} />
{lang.display} ) : (
</span> <Emoji size={42} emoji={lang.emoji} />
)}
</div>
<span className={styles.description}>{lang.display}</span>
</Checkbox> </Checkbox>
); );
} }
export function Component(props: Props) { export function Component(props: Props) {
const languages = Object const languages = Object.keys(Langs).map((x) => [
.keys(Langs) x,
.map(x => [ x, Langs[x as keyof typeof Langs] ]) as Key[]; Langs[x as keyof typeof Langs],
]) as Key[];
return ( return (
<div className={styles.languages}> <div className={styles.languages}>
...@@ -48,16 +60,30 @@ export function Component(props: Props) { ...@@ -48,16 +60,30 @@ export function Component(props: Props) {
</h3> </h3>
<div className={styles.list}> <div className={styles.list}>
{languages {languages
.filter(([, lang]) => !lang.alt) .filter(([, lang]) => !lang.cat)
.map(([x, lang]) => <Entry key={x} entry={[x, lang]} {...props} />)} .map(([x, lang]) => (
<Entry key={x} entry={[x, lang]} {...props} />
))}
</div>
<h3>
<Text id="app.settings.pages.language.const" />
</h3>
<div className={styles.list}>
{languages
.filter(([, lang]) => lang.cat === "const")
.map(([x, lang]) => (
<Entry key={x} entry={[x, lang]} {...props} />
))}
</div> </div>
<h3> <h3>
<Text id="app.settings.pages.language.other" /> <Text id="app.settings.pages.language.other" />
</h3> </h3>
<div className={styles.list}> <div className={styles.list}>
{languages {languages
.filter(([, lang]) => lang.alt) .filter(([, lang]) => lang.cat === "alt")
.map(([x, lang]) => <Entry key={x} entry={[x, lang]} {...props} />)} .map(([x, lang]) => (
<Entry key={x} entry={[x, lang]} {...props} />
))}
</div> </div>
<Tip> <Tip>
<span> <span>
...@@ -66,7 +92,7 @@ export function Component(props: Props) { ...@@ -66,7 +92,7 @@ export function Component(props: Props) {
<a <a
href="https://weblate.insrt.uk/engage/revolt/?utm_source=widget" href="https://weblate.insrt.uk/engage/revolt/?utm_source=widget"
target="_blank" target="_blank"
> rel="noreferrer">
<Text id="app.settings.tips.languages.b" /> <Text id="app.settings.tips.languages.b" />
</a> </a>
</Tip> </Tip>
...@@ -74,12 +100,8 @@ export function Component(props: Props) { ...@@ -74,12 +100,8 @@ export function Component(props: Props) {
); );
} }
export const Languages = connectState( export const Languages = connectState(Component, (state) => {
Component, return {
state => { locale: state.locale,
return { };
locale: state.locale });
};
},
true
);
import { useEffect, useState } from "preact/hooks";
import Button from "../../../components/ui/Button";
import Checkbox from "../../../components/ui/Checkbox";
export function Native() {
const [config, setConfig] = useState(window.native.getConfig());
const [autoStart, setAutoStart] = useState<boolean | undefined>();
const fetchValue = () => window.native.getAutoStart().then(setAutoStart);
const [hintReload, setHintReload] = useState(false);
const [hintRelaunch, setHintRelaunch] = useState(false);
const [confirmDev, setConfirmDev] = useState(false);
useEffect(() => {
fetchValue();
}, []);
return (
<div>
<h3>App Behavior</h3>
<h5>Some options might require a restart.</h5>
<Checkbox
checked={autoStart ?? false}
disabled={typeof autoStart === "undefined"}
onChange={async (v) => {
if (v) {
await window.native.enableAutoStart();
} else {
await window.native.disableAutoStart();
}
setAutoStart(v);
}}
description="Launch Revolt when you log into your computer.">
Start with computer
</Checkbox>
<Checkbox
checked={config.discordRPC}
onChange={(discordRPC) => {
window.native.set("discordRPC", discordRPC);
setConfig({
...config,
discordRPC,
});
}}
description="Rep Revolt on your Discord status.">
Enable Discord status
</Checkbox>
<Checkbox
checked={config.build === "nightly"}
onChange={(nightly) => {
const build = nightly ? "nightly" : "stable";
window.native.set("build", build);
setHintReload(true);
setConfig({
...config,
build,
});
}}
description="Use the beta branch of Revolt.">
Revolt Nightly
</Checkbox>
<h3>Titlebar</h3>
<Checkbox
checked={!config.frame}
onChange={(frame) => {
window.native.set("frame", !frame);
setHintRelaunch(true);
setConfig({
...config,
frame: !frame,
});
}}
description={<>Let Revolt use its own window frame.</>}>
Custom window frame
</Checkbox>
<Checkbox //FIXME: In Titlebar.tsx, enable .quick css
disabled={true}
checked={!config.frame}
onChange={(frame) => {
window.native.set("frame", !frame);
setHintRelaunch(true);
setConfig({
...config,
frame: !frame,
});
}}
description="Show mute/deafen buttons on the titlebar.">
Enable quick action buttons
</Checkbox>
<h3>Advanced</h3>
<Checkbox
checked={config.hardwareAcceleration}
onChange={async (hardwareAcceleration) => {
window.native.set(
"hardwareAcceleration",
hardwareAcceleration,
);
setHintRelaunch(true);
setConfig({
...config,
hardwareAcceleration,
});
}}
description="Uses your GPU to render the app, disable if you run into visual issues.">
Hardware Acceleration
</Checkbox>
<p style={{ display: "flex", gap: "8px" }}>
<Button
contrast
compact
disabled={!hintReload}
onClick={window.native.reload}>
Reload Page
</Button>
<Button
contrast
compact
disabled={!hintRelaunch}
onClick={window.native.relaunch}>
Reload App
</Button>
</p>
<h3 style={{ marginTop: "4em" }}>Local Development Mode</h3>
{config.build === "dev" ? (
<>
<h5>Development mode is currently on.</h5>
<Button
contrast
compact
onClick={() => {
window.native.set("build", "stable");
window.native.reload();
}}>
Exit Development Mode
</Button>
</>
) : (
<>
<Checkbox
checked={confirmDev}
onChange={setConfirmDev}
description={
<>
This will change the app to the 'dev' branch,
instead loading the app from a local server on
your machine.
<br />
<b>
Without a server running,{" "}
<span style={{ color: "var(--error)" }}>
the app will not load!
</span>
</b>
</>
}>
I understand there's no going back.
</Checkbox>
<p>
<Button
error
compact
disabled={!confirmDev}
onClick={() => {
window.native.set("build", "dev");
window.native.reload();
}}>
Enter Development Mode
</Button>
</p>
</>
)}
</div>
);
}
import { Text } from "preact-i18n";
import styles from "./Panes.module.scss";
import defaultsDeep from "lodash.defaultsdeep"; import defaultsDeep from "lodash.defaultsdeep";
import Checkbox from "../../../components/ui/Checkbox";
import { connectState } from "../../../redux/connector"; import styles from "./Panes.module.scss";
import { WithDispatcher } from "../../../redux/reducers"; import { Text } from "preact-i18n";
import { SOUNDS_ARRAY } from "../../../assets/sounds/Audio";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import { urlBase64ToUint8Array } from "../../../lib/conversion"; import { urlBase64ToUint8Array } from "../../../lib/conversion";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector";
import {
DEFAULT_SOUNDS,
NotificationOptions,
SoundOptions,
} from "../../../redux/reducers/settings";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { DEFAULT_SOUNDS, NotificationOptions, SoundOptions } from "../../../redux/reducers/settings"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import Checkbox from "../../../components/ui/Checkbox";
import { SOUNDS_ARRAY } from "../../../assets/sounds/Audio";
interface Props { interface Props {
options?: NotificationOptions; options?: NotificationOptions;
} }
export function Component({ options, dispatcher }: Props & WithDispatcher) { export function Component({ options }: Props) {
const client = useContext(AppContext); const client = useContext(AppContext);
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const [pushEnabled, setPushEnabled] = useState<undefined | boolean>( const [pushEnabled, setPushEnabled] = useState<undefined | boolean>(
undefined undefined,
); );
// Load current state of pushManager. // Load current state of pushManager.
useEffect(() => { useEffect(() => {
navigator.serviceWorker?.getRegistration().then(async registration => { navigator.serviceWorker
const sub = await registration?.pushManager?.getSubscription(); ?.getRegistration()
setPushEnabled(sub !== null && sub !== undefined); .then(async (registration) => {
}); const sub = await registration?.pushManager?.getSubscription();
setPushEnabled(sub !== null && sub !== undefined);
});
}, []); }, []);
const enabledSounds: SoundOptions = defaultsDeep(options?.sounds ?? {}, DEFAULT_SOUNDS); const enabledSounds: SoundOptions = defaultsDeep(
options?.sounds ?? {},
DEFAULT_SOUNDS,
);
return ( return (
<div className={styles.notifications}> <div className={styles.notifications}>
<h3> <h3>
...@@ -39,41 +54,45 @@ export function Component({ options, dispatcher }: Props & WithDispatcher) { ...@@ -39,41 +54,45 @@ export function Component({ options, dispatcher }: Props & WithDispatcher) {
<Checkbox <Checkbox
disabled={!("Notification" in window)} disabled={!("Notification" in window)}
checked={options?.desktopEnabled ?? false} checked={options?.desktopEnabled ?? false}
onChange={async desktopEnabled => { description={
<Text id="app.settings.pages.notifications.descriptions.enable_desktop" />
}
onChange={async (desktopEnabled) => {
if (desktopEnabled) { if (desktopEnabled) {
let permission = await Notification.requestPermission(); const permission =
await Notification.requestPermission();
if (permission !== "granted") { if (permission !== "granted") {
return openScreen({ return openScreen({
id: "error", id: "error",
error: "DeniedNotification" error: "DeniedNotification",
}); });
} }
} }
dispatcher({ dispatch({
type: "SETTINGS_SET_NOTIFICATION_OPTIONS", type: "SETTINGS_SET_NOTIFICATION_OPTIONS",
options: { desktopEnabled } options: { desktopEnabled },
}); });
}} }}>
>
<Text id="app.settings.pages.notifications.enable_desktop" /> <Text id="app.settings.pages.notifications.enable_desktop" />
<p>
<Text id="app.settings.pages.notifications.descriptions.enable_desktop" />
</p>
</Checkbox> </Checkbox>
<Checkbox <Checkbox
disabled={typeof pushEnabled === "undefined"} disabled={typeof pushEnabled === "undefined"}
checked={pushEnabled ?? false} checked={pushEnabled ?? false}
onChange={async pushEnabled => { description={
<Text id="app.settings.pages.notifications.descriptions.enable_push" />
}
onChange={async (pushEnabled) => {
try { try {
const reg = await navigator.serviceWorker?.getRegistration(); const reg =
await navigator.serviceWorker?.getRegistration();
if (reg) { if (reg) {
if (pushEnabled) { if (pushEnabled) {
const sub = await reg.pushManager.subscribe({ const sub = await reg.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array( applicationServerKey: urlBase64ToUint8Array(
client.configuration!.vapid client.configuration!.vapid,
) ),
}); });
// tell the server we just subscribed // tell the server we just subscribed
...@@ -81,12 +100,16 @@ export function Component({ options, dispatcher }: Props & WithDispatcher) { ...@@ -81,12 +100,16 @@ export function Component({ options, dispatcher }: Props & WithDispatcher) {
if (json.keys) { if (json.keys) {
client.req("POST", "/push/subscribe", { client.req("POST", "/push/subscribe", {
endpoint: sub.endpoint, endpoint: sub.endpoint,
...json.keys ...(json.keys as {
} as any); p256dh: string;
auth: string;
}),
});
setPushEnabled(true); setPushEnabled(true);
} }
} else { } else {
const sub = await reg.pushManager.getSubscription(); const sub =
await reg.pushManager.getSubscription();
sub?.unsubscribe(); sub?.unsubscribe();
setPushEnabled(false); setPushEnabled(false);
...@@ -94,47 +117,40 @@ export function Component({ options, dispatcher }: Props & WithDispatcher) { ...@@ -94,47 +117,40 @@ export function Component({ options, dispatcher }: Props & WithDispatcher) {
} }
} }
} catch (err) { } catch (err) {
console.error('Failed to enable push!', err); console.error("Failed to enable push!", err);
} }
}} }}>
>
<Text id="app.settings.pages.notifications.enable_push" /> <Text id="app.settings.pages.notifications.enable_push" />
<p>
<Text id="app.settings.pages.notifications.descriptions.enable_push" />
</p>
</Checkbox> </Checkbox>
<h3> <h3>
<Text id="app.settings.pages.notifications.sounds" /> <Text id="app.settings.pages.notifications.sounds" />
</h3> </h3>
{ {SOUNDS_ARRAY.map((key) => (
SOUNDS_ARRAY.map(key => <Checkbox
<Checkbox key={key}
checked={enabledSounds[key] ? true : false} checked={!!enabledSounds[key]}
onChange={enabled => onChange={(enabled) =>
dispatcher({ dispatch({
type: "SETTINGS_SET_NOTIFICATION_OPTIONS", type: "SETTINGS_SET_NOTIFICATION_OPTIONS",
options: { options: {
sounds: { sounds: {
...options?.sounds, ...options?.sounds,
[key]: enabled [key]: enabled,
} },
} },
}) })
}> }>
<Text id={`app.settings.pages.notifications.sound.${key}`} /> <Text
</Checkbox> id={`app.settings.pages.notifications.sound.${key}`}
) />
} </Checkbox>
))}
</div> </div>
); );
} }
export const Notifications = connectState( export const Notifications = connectState(Component, (state) => {
Component, return {
state => { options: state.settings.notification,
return { };
options: state.settings.notification });
};
},
true
);
.user { .user {
.banner { .banner {
gap: 24px; position: relative;
margin-top: 8px;
margin-bottom: 15px;
gap: 16px;
width: 100%; width: 100%;
padding: 1em; padding: 12px 10px;
display: flex; display: flex;
border-radius: 6px; overflow: hidden;
align-items: center; align-items: center;
background: var(--secondary-header); border-radius: var(--border-radius);
.container {
display: flex;
gap: 24px;
align-items: center;
flex-direction: row;
width: 100%;
}
.username { .userDetail {
font-size: 24px; display: flex;
flex-grow: 1;
gap: 2px;
flex-direction: column;
font-size: 1.5rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
} }
a { .avatar {
cursor: pointer;
transition: 0.2s ease filter; transition: 0.2s ease filter;
&:hover {
filter: brightness(80%);
}
} }
a:hover { .userid {
filter: brightness(80%); font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
gap: 4px;
color: var(--tertiary-foreground);
a {
color: inherit;
cursor: pointer;
}
} }
} }
.details { .details {
display: flex; display: flex;
margin-top: 1em; padding: 1em 0;
gap: 10px;
flex-direction: column; flex-direction: column;
/*border-top: 1px solid var(--secondary-header);
border-width: 100%;*/
> div { > div {
gap: 12px; gap: 12px;
padding: 4px; /*padding: 4px;*/
padding: 8px 12px;
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
} background: var(--secondary-header);
border-radius: 6px;
.detail { > svg {
flex-grow: 1; flex-shrink: 0;
}
} }
p { p {
margin: 0; margin: 0;
font-size: 1rem;
color: var(--tertiary-foreground); color: var(--tertiary-foreground);
} }
} }
...@@ -49,7 +91,7 @@ ...@@ -49,7 +91,7 @@
display: grid; display: grid;
place-items: center; place-items: center;
grid-template-columns: minmax(auto, 100%); grid-template-columns: minmax(auto, 100%);
> div { > div {
width: 100%; width: 100%;
max-width: 560px; max-width: 560px;
...@@ -70,6 +112,25 @@ ...@@ -70,6 +112,25 @@
flex-grow: 1; flex-grow: 1;
} }
} }
.buttons {
display: flex;
gap: 12px;
}
}
@media only screen and (max-width: 800px) {
.user {
.banner {
gap: 18px;
padding: 0;
flex-direction: column;
> button {
width: 100%;
}
}
}
} }
.appearance { .appearance {
...@@ -82,12 +143,14 @@ ...@@ -82,12 +143,14 @@
.themes { .themes {
gap: 8px; gap: 8px;
display: flex; display: flex;
width: 100%;
img { img {
cursor: pointer; cursor: pointer;
border-radius: 8px; border-radius: var(--border-radius);
transition: border 0.3s; transition: border 0.3s;
border: 3px solid transparent; border: 3px solid transparent;
width: 100%;
&[data-active="true"] { &[data-active="true"] {
cursor: default; cursor: default;
...@@ -104,29 +167,13 @@ ...@@ -104,29 +167,13 @@
} }
details { details {
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
color: var(--secondary-foreground);
summary { summary {
font-size: 0.8125rem;
font-weight: 700;
text-transform: uppercase;
color: var(--secondary-foreground);
cursor: pointer; 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 { .emojiPack {
...@@ -146,15 +193,15 @@ ...@@ -146,15 +193,15 @@
} }
.button { .button {
padding: 2rem 1.5rem; padding: 2rem 1.2rem;
display: grid; display: grid;
place-items: center; place-items: center;
cursor: pointer; cursor: pointer;
border-radius: 8px;
transition: border 0.3s; transition: border 0.3s;
background: var(--hover); background: var(--hover);
border: 3px solid transparent; border: 3px solid transparent;
border-radius: var(--border-radius);
img { img {
max-width: 100%; max-width: 100%;
...@@ -187,6 +234,12 @@ ...@@ -187,6 +234,12 @@
text-decoration: underline; text-decoration: underline;
} }
} }
@media only screen and (max-width: 800px) {
a {
display: block;
}
}
} }
} }
...@@ -199,56 +252,92 @@ ...@@ -199,56 +252,92 @@
.actions { .actions {
gap: 8px; gap: 8px;
display: flex; display: flex;
flex-wrap: wrap; margin: 18px 0 8px 0;
margin-bottom: 8px;
.code {
cursor: pointer;
display: flex;
align-items: center;
font-size: 0.875rem;
min-width: 0;
flex-grow: 1;
padding: 8px;
font-family: var(--codeblock-font);
border-radius: var(--border-radius);
background: var(--secondary-background);
> div {
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
} }
.overrides { .overrides {
row-gap: 8px;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; column-gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
margin-bottom: 20px;
.entry { .entry {
gap: 8px; padding: 12px;
padding: 2px;
margin-top: 8px; margin-top: 8px;
border: 1px solid black;
.override { border-radius: var(--border-radius);
display: flex;
}
span { span {
flex: 1; flex: 1;
display: block; display: block;
font-size: 14px;
font-weight: 600; font-weight: 600;
margin-bottom: 4px; font-size: 0.875rem;
margin-bottom: 8px;
text-transform: capitalize; text-transform: capitalize;
color: transparent;
background: inherit;
background-clip: text;
-webkit-background-clip: text;
filter: sepia(1) invert(1) contrast(9) grayscale(1);
} }
.picker { .override {
width: 30px; gap: 8px;
height: 30px; display: flex;
flex-shrink: 0;
border-radius: 4px; .picker {
overflow: hidden; width: 38px;
margin-inline-end: 4px; height: 38px;
display: grid;
cursor: pointer;
place-items: center;
border-radius: var(--border-radius);
background: var(--primary-background);
}
//TOFIX - Looks wonky on Chromium input[type="text"] {
border: 1px solid black; width: 0;
min-width: 0;
flex-grow: 1;
}
}
.input {
width: 0;
height: 0;
position: relative;
input { input {
opacity: 0; opacity: 0;
width: 30px;
height: 30px;
border: none; border: none;
display: block; display: block;
cursor: pointer; cursor: pointer;
} position: relative;
}
.text { top: 48px;
border-radius: 4px; }
padding: 0 4px 0;
} }
} }
} }
...@@ -257,20 +346,39 @@ ...@@ -257,20 +346,39 @@
.sessions { .sessions {
.session { .session {
display: flex; display: flex;
align-items: center;
gap: 12px;
flex-direction: row;
.detail {
display: flex;
gap: 12px;
flex-grow: 1;
svg {
margin-top: 1px;
}
}
} }
.entry { .entry {
margin: 8px 0;
padding: 16px; padding: 16px;
display: flex; display: flex;
border-radius: 6px; margin: 10px 0;
flex-direction: column; flex-direction: column;
border-radius: var(--border-radius);
background: var(--secondary-header); background: var(--secondary-header);
&[data-active="true"] { &[data-active="true"] {
color: var(--primary-background); color: var(--primary-background);
background: var(--accent); background: var(--accent);
margin-bottom: 20px; margin-bottom: 20px;
.session .detail .info > input {
&:focus {
border-bottom: 2px solid var(--primary-background);
}
}
} }
&[data-deleting="true"] { &[data-deleting="true"] {
...@@ -279,27 +387,33 @@ ...@@ -279,27 +387,33 @@
.name { .name {
font-weight: 600; font-weight: 600;
border-bottom: 2px solid transparent;
} }
.icon { input {
gap: 8px; background: transparent;
display: flex; border: 0;
padding-right: 12px; font-family: inherit;
align-items: center; font-size: 1rem;
padding: 0;
outline: 0;
border-radius: 0;
color: inherit;
width: 100%;
svg { &:focus {
height: 42px; border-bottom: 2px solid var(--accent);
} }
div svg { &[data-active="true"] {
height: 24px; border-bottom: 2px solid inherit;
} }
} }
.label { .label {
margin: 0 0 6px 0; margin-bottom: 8px;
color: var(--primary-text); color: var(--primary-text);
font-size: 12px; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
} }
...@@ -309,56 +423,83 @@ ...@@ -309,56 +423,83 @@
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
.name { .name {
text-transform: capitalize; text-transform: capitalize;
text-overflow: ellipsis;
} }
.time { .time {
font-size: 12px; font-size: 0.75rem;
color: var(--teriary-text); color: var(--teriary-text);
text-overflow: ellipsis;
overflow: hidden;
} }
} }
} }
}
.notifications { > button {
label { margin-top: 20px;
margin-top: 12px;
} }
p { @media only screen and (max-width: 800px) {
margin-top: 0; .session {
font-size: 0.9em; align-items: unset;
color: var(--secondary-foreground); flex-direction: column;
gap: 20px;
> button {
width: 100%;
}
}
> button {
width: 100%;
}
} }
} }
.languages { .languages {
.list { .list {
display: flex;
flex-direction: column;
margin-bottom: 1em; margin-bottom: 1em;
gap: 8px;
.entry { .entry {
padding: 2px 8px; display: flex;
height: 50px; height: 45px;
border-radius: 4px; padding: 0 8px;
background: var(--secondary-header);
border-radius: var(--border-radius);
margin-top: 0;
&:hover {
background: var(--secondary-background);
}
} }
.entry > span > span { .entry > span > span {
gap: 8px; gap: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
.flag { .flag {
display: flex; display: flex;
font-size: 42px;
line-height: 48px;
> div { > div {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
> img {
height: 32px !important;
}
} }
.description { .description {
...@@ -374,12 +515,16 @@ ...@@ -374,12 +515,16 @@
flex-direction: column; flex-direction: column;
} }
.experiments { /* TOFIX: Center the "No new experiments available at this time" text without having a scrollbar */ .experiments {
height: 100%; height: calc(100% - 40px);
.empty { .empty {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100%;
} }
} }
\ No newline at end of file
section {
margin-bottom: 20px;
}
import { Profile as ProfileI } from "revolt-api/types/Users";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import Button from "../../../components/ui/Button"; import { Text } from "preact-i18n";
import { Users } from "revolt.js/dist/api/objects"; import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { IntlContext, Text, translate } from "preact-i18n";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { useContext, useEffect, useState } from "preact/hooks"; import { useTranslation } from "../../../lib/i18n";
import { FileUploader } from "../../../context/revoltjs/FileUploads";
import { useForceUpdate, useSelf } from "../../../context/revoltjs/hooks";
import { UserProfile } from "../../../context/intermediate/popovers/UserProfile"; import { UserProfile } from "../../../context/intermediate/popovers/UserProfile";
import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient"; import { FileUploader } from "../../../context/revoltjs/FileUploads";
import {
ClientStatus,
StatusContext,
useClient,
} from "../../../context/revoltjs/RevoltClient";
import AutoComplete, {
useAutoComplete,
} from "../../../components/common/AutoComplete";
import Button from "../../../components/ui/Button";
export function Profile() { export function Profile() {
const { intl } = useContext(IntlContext) as any;
const status = useContext(StatusContext); const status = useContext(StatusContext);
const translate = useTranslation();
const client = useClient();
const ctx = useForceUpdate(); const [profile, setProfile] = useState<undefined | ProfileI>(undefined);
const user = useSelf();
if (!user) return null;
const [profile, setProfile] = useState<undefined | Users.Profile>(
undefined
);
// ! FIXME: temporary solution // ! FIXME: temporary solution
// ! we should just announce profile changes through WS // ! we should just announce profile changes through WS
function refreshProfile() { const refreshProfile = useCallback(() => {
ctx.client.users client
.fetchProfile(user!._id) .user!.fetchProfile()
.then(profile => setProfile(profile ?? {})); .then((profile) => setProfile(profile ?? {}));
} }, [client.user, setProfile]);
useEffect(() => { useEffect(() => {
if (profile === undefined && status === ClientStatus.ONLINE) { if (profile === undefined && status === ClientStatus.ONLINE) {
refreshProfile(); refreshProfile();
} }
}, [status]); }, [profile, status, refreshProfile]);
const [changed, setChanged] = useState(false);
function setContent(content?: string) {
setProfile({ ...profile, content });
if (!changed) setChanged(true);
}
const [ changed, setChanged ] = useState(false); const {
onChange,
onKeyUp,
onKeyDown,
onFocus,
onBlur,
...autoCompleteProps
} = useAutoComplete(setContent, {
users: { type: "all" },
});
return ( return (
<div className={styles.user}> <div className={styles.user}>
...@@ -44,10 +65,9 @@ export function Profile() { ...@@ -44,10 +65,9 @@ export function Profile() {
</h3> </h3>
<div className={styles.preview}> <div className={styles.preview}>
<UserProfile <UserProfile
user_id={user._id} user_id={client.user!._id}
dummy={true} dummy={true}
dummyProfile={profile} dummyProfile={profile}
onClose={() => {}}
/> />
</div> </div>
<div className={styles.row}> <div className={styles.row}>
...@@ -62,10 +82,16 @@ export function Profile() { ...@@ -62,10 +82,16 @@ export function Profile() {
fileType="avatars" fileType="avatars"
behaviour="upload" behaviour="upload"
maxFileSize={4_000_000} maxFileSize={4_000_000}
onUpload={avatar => ctx.client.users.editUser({ avatar })} onUpload={(avatar) => client.users.edit({ avatar })}
remove={() => ctx.client.users.editUser({ remove: 'Avatar' })} remove={() => client.users.edit({ remove: "Avatar" })}
defaultPreview={ctx.client.users.getAvatarURL(user._id, { max_side: 256 }, true)} defaultPreview={client.user!.generateAvatarURL(
previewURL={ctx.client.users.getAvatarURL(user._id, { max_side: 256 }, true, true)} { max_side: 256 },
true,
)}
previewURL={client.user!.generateAvatarURL(
{ max_side: 256 },
true,
)}
/> />
</div> </div>
<div className={styles.background}> <div className={styles.background}>
...@@ -78,30 +104,43 @@ export function Profile() { ...@@ -78,30 +104,43 @@ export function Profile() {
behaviour="upload" behaviour="upload"
fileType="backgrounds" fileType="backgrounds"
maxFileSize={6_000_000} maxFileSize={6_000_000}
onUpload={async background => { onUpload={async (background) => {
await ctx.client.users.editUser({ profile: { background } }); await client.users.edit({
profile: { background },
});
refreshProfile(); refreshProfile();
}} }}
remove={async () => { remove={async () => {
await ctx.client.users.editUser({ remove: 'ProfileBackground' }); await client.users.edit({
remove: "ProfileBackground",
});
setProfile({ ...profile, background: undefined }); setProfile({ ...profile, background: undefined });
}} }}
previewURL={profile?.background ? ctx.client.users.getBackgroundURL(profile, { width: 1000 }, true) : undefined} previewURL={
profile?.background
? client.generateFileURL(
profile.background,
{ width: 1000 },
true,
)
: undefined
}
/> />
</div> </div>
</div> </div>
<h3> <h3>
<Text id="app.settings.pages.profile.info" /> <Text id="app.settings.pages.profile.info" />
</h3> </h3>
<AutoComplete detached {...autoCompleteProps} />
<TextAreaAutoSize <TextAreaAutoSize
maxRows={10} maxRows={10}
minHeight={200} minHeight={200}
maxLength={2000} maxLength={2000}
value={profile?.content ?? ""} value={profile?.content ?? ""}
disabled={typeof profile === "undefined"} disabled={typeof profile === "undefined"}
onChange={ev => { onChange={(ev) => {
setProfile({ ...profile, content: ev.currentTarget.value }) onChange(ev);
if (!changed) setChanged(true) setContent(ev.currentTarget.value);
}} }}
placeholder={translate( placeholder={translate(
`app.settings.pages.profile.${ `app.settings.pages.profile.${
...@@ -109,15 +148,20 @@ export function Profile() { ...@@ -109,15 +148,20 @@ export function Profile() {
? "fetching" ? "fetching"
: "placeholder" : "placeholder"
}`, }`,
"",
intl.dictionary
)} )}
onKeyUp={onKeyUp}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
/> />
<p> <p>
<Button contrast <Button
contrast
onClick={() => { onClick={() => {
setChanged(false); setChanged(false);
ctx.client.users.editUser({ profile: { content: profile?.content } }) client.users.edit({
profile: { content: profile?.content },
});
}} }}
disabled={!changed}> disabled={!changed}>
<Text id="app.special.modals.actions.save" /> <Text id="app.special.modals.actions.save" />
......
import dayjs from "dayjs"; import { Chrome, Android, Apple, Windows } from "@styled-icons/boxicons-logos";
import { decodeTime } from "ulid"; import { HelpCircle, Desktop } from "@styled-icons/boxicons-regular";
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/boxicons-regular";
import { import {
Android, Safari,
Firefoxbrowser, Firefoxbrowser,
Googlechrome, Microsoftedge,
Ios,
Linux, Linux,
Macos, Macos,
Microsoftedge, Opera,
Safari,
Windows
} from "@styled-icons/simple-icons"; } from "@styled-icons/simple-icons";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import { useHistory } from "react-router-dom";
import { decodeTime } from "ulid";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks";
import { dayjs } from "../../../context/Locale";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import Button from "../../../components/ui/Button";
import Preloader from "../../../components/ui/Preloader";
import Tip from "../../../components/ui/Tip";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface Session { interface Session {
...@@ -43,14 +43,14 @@ export function Sessions() { ...@@ -43,14 +43,14 @@ export function Sessions() {
} }
useEffect(() => { useEffect(() => {
client.req("GET", "/auth/sessions").then(data => { client.req("GET", "/auth/sessions").then((data) => {
data.sort( data.sort(
(a, b) => (a, b) =>
(b.id === deviceId ? 1 : 0) - (a.id === deviceId ? 1 : 0) (b.id === deviceId ? 1 : 0) - (a.id === deviceId ? 1 : 0),
); );
setSessions(data); setSessions(data);
}); });
}, []); }, [client, setSessions, deviceId]);
if (typeof sessions === "undefined") { if (typeof sessions === "undefined") {
return ( return (
...@@ -64,15 +64,19 @@ export function Sessions() { ...@@ -64,15 +64,19 @@ export function Sessions() {
const name = session.friendly_name; const name = session.friendly_name;
switch (true) { switch (true) {
case /firefox/i.test(name): case /firefox/i.test(name):
return <Firefoxbrowser />; return <Firefoxbrowser size={32} />;
case /chrome/i.test(name): case /chrome/i.test(name):
return <Googlechrome />; return <Chrome size={32} />;
case /safari/i.test(name): case /safari/i.test(name):
return <Safari />; return <Safari size={32} />;
case /edge/i.test(name): case /edge/i.test(name):
return <Microsoftedge />; return <Microsoftedge size={32} />;
case /opera/i.test(name):
return <Opera size={32} />;
case /desktop/i.test(name):
return <Desktop size={32} />;
default: default:
return <HelpCircle />; return <HelpCircle size={32} />;
} }
} }
...@@ -80,34 +84,34 @@ export function Sessions() { ...@@ -80,34 +84,34 @@ export function Sessions() {
const name = session.friendly_name; const name = session.friendly_name;
switch (true) { switch (true) {
case /linux/i.test(name): case /linux/i.test(name):
return <Linux />; return <Linux size={14} />;
case /android/i.test(name): case /android/i.test(name):
return <Android />; return <Android size={14} />;
case /mac.*os/i.test(name): case /mac.*os/i.test(name):
return <Macos />; return <Macos size={14} />;
case /ios/i.test(name): case /ios/i.test(name):
return <Ios />; return <Apple size={14} />;
case /windows/i.test(name): case /windows/i.test(name):
return <Windows />; return <Windows size={14} />;
default: default:
return null; return null;
} }
} }
const mapped = sessions.map(session => { const mapped = sessions.map((session) => {
return { return {
...session, ...session,
timestamp: decodeTime(session.id) timestamp: decodeTime(session.id),
}; };
}); });
mapped.sort((a, b) => b.timestamp - a.timestamp); mapped.sort((a, b) => b.timestamp - a.timestamp);
let id = mapped.findIndex(x => x.id === deviceId); const id = mapped.findIndex((x) => x.id === deviceId);
const render = [ const render = [
mapped[id], mapped[id],
...mapped.slice(0, id), ...mapped.slice(0, id),
...mapped.slice(id + 1, mapped.length) ...mapped.slice(id + 1, mapped.length),
]; ];
return ( return (
...@@ -115,64 +119,116 @@ export function Sessions() { ...@@ -115,64 +119,116 @@ export function Sessions() {
<h3> <h3>
<Text id="app.settings.pages.sessions.active_sessions" /> <Text id="app.settings.pages.sessions.active_sessions" />
</h3> </h3>
{render.map(session => ( {render.map((session) => {
<div const systemIcon = getSystemIcon(session);
className={styles.entry} return (
data-active={session.id === deviceId} <div
data-deleting={attemptingDelete.indexOf(session.id) > -1} key={session.id}
> className={styles.entry}
{deviceId === session.id && ( data-active={session.id === deviceId}
<span className={styles.label}> data-deleting={
<Text id="app.settings.pages.sessions.this_device" />{" "} attemptingDelete.indexOf(session.id) > -1
</span> }>
)} {deviceId === session.id && (
<div className={styles.session}> <span className={styles.label}>
<div className={styles.icon}> <Text id="app.settings.pages.sessions.this_device" />{" "}
{getIcon(session)}
<div>{getSystemIcon(session)}</div>
</div>
<div className={styles.info}>
<span className={styles.name}>
{session.friendly_name}
</span> </span>
<span className={styles.time}> )}
<Text <div className={styles.session}>
id="app.settings.pages.sessions.created" <div className={styles.detail}>
fields={{ <svg width={42} height={42} viewBox="0 0 32 32">
time_ago: dayjs( <foreignObject
session.timestamp x="0"
).fromNow() y="0"
width="32"
height="32"
mask={
systemIcon
? "url(#session)"
: undefined
}>
{getIcon(session)}
</foreignObject>
<foreignObject
x="18"
y="18"
width="14"
height="14">
{systemIcon}
</foreignObject>
</svg>
<div className={styles.info}>
<input
type="text"
className={styles.name}
value={session.friendly_name}
autocomplete="off"
style={{ pointerEvents: "none" }}
/>
<span className={styles.time}>
<Text
id="app.settings.pages.sessions.created"
fields={{
time_ago: dayjs(
session.timestamp,
).fromNow(),
}}
/>
</span>
</div>
</div>
{deviceId !== session.id && (
<Button
onClick={async () => {
setDelete([
...attemptingDelete,
session.id,
]);
await client.req(
"DELETE",
`/auth/sessions/${session.id}` as "/auth/sessions",
);
setSessions(
sessions?.filter(
(x) => x.id !== session.id,
),
);
}} }}
/> disabled={
</span> attemptingDelete.indexOf(session.id) >
-1
}>
<Text id="app.settings.pages.logOut" />
</Button>
)}
</div> </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>
</div> );
))} })}
<Button
error
onClick={async () => {
// ! FIXME: add to rAuth
const del: string[] = [];
render.forEach((session) => {
if (deviceId !== session.id) {
del.push(session.id);
}
});
setDelete(del);
for (const id of del) {
await client.req(
"DELETE",
`/auth/sessions/${id}` as "/auth/sessions",
);
}
setSessions(sessions.filter((x) => x.id === deviceId));
}}>
<Text id="app.settings.pages.sessions.logout" />
</Button>
<Tip> <Tip>
<span> <span>
<Text id="app.settings.tips.sessions.a" /> <Text id="app.settings.tips.sessions.a" />
......
import { Text } from "preact-i18n";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import Checkbox from "../../../components/ui/Checkbox"; import { Text } from "preact-i18n";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector"; import { connectState } from "../../../redux/connector";
import { WithDispatcher } from "../../../redux/reducers";
import { SyncKeys, SyncOptions } from "../../../redux/reducers/sync"; import { SyncKeys, SyncOptions } from "../../../redux/reducers/sync";
import Checkbox from "../../../components/ui/Checkbox";
interface Props { interface Props {
options?: SyncOptions; options?: SyncOptions;
} }
export function Component(props: Props & WithDispatcher) { export function Component(props: Props) {
return ( return (
<div className={styles.notifications}> <div className={styles.notifications}>
<h3> <h3>
<Text id="app.settings.pages.sync.categories" /> <Text id="app.settings.pages.sync.categories" />
</h3> </h3>
{ {(
([ [
['appearance', 'appearance.title'], ["appearance", "appearance.title"],
['theme', 'appearance.theme'], ["theme", "appearance.theme"],
['locale', 'language.title'] ["locale", "language.title"],
// notifications sync is always-on // notifications sync is always-on
] as [ SyncKeys, string ][]).map( ] as [SyncKeys, string][]
([ key, title ]) => ).map(([key, title]) => (
<Checkbox <Checkbox
checked={(props.options?.disabled ?? []).indexOf(key) === -1} key={key}
onChange={enabled => { checked={
props.dispatcher({ (props.options?.disabled ?? []).indexOf(key) === -1
type: enabled ? 'SYNC_ENABLE_KEY' : 'SYNC_DISABLE_KEY', }
key description={
}); <Text
}} id={`app.settings.pages.sync.descriptions.${key}`}
> />
<Text id={`app.settings.pages.${title}`} /> }
<p> onChange={(enabled) =>
<Text id={`app.settings.pages.sync.descriptions.${key}`} /> dispatch({
</p> type: enabled
</Checkbox> ? "SYNC_ENABLE_KEY"
) : "SYNC_DISABLE_KEY",
} key,
})
}>
<Text id={`app.settings.pages.${title}`} />
</Checkbox>
))}
</div> </div>
); );
} }
export const Sync = connectState( export const Sync = connectState(Component, (state) => {
Component, return {
state => { options: state.sync,
return { };
options: state.sync });
};
},
true
);
import Tip from "../../../components/ui/Tip"; import { XCircle } from "@styled-icons/boxicons-regular";
import { Servers } from "revolt.js/dist/api/objects"; import { observer } from "mobx-react-lite";
import { useContext, useEffect, useState } from "preact/hooks"; import { Route } from "revolt.js/dist/api/routes";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { Server } from "revolt.js/dist/maps/Servers";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks";
import UserIcon from "../../../components/common/user/UserIcon";
import IconButton from "../../../components/ui/IconButton";
import Preloader from "../../../components/ui/Preloader";
interface Props { interface Props {
server: Servers.Server; server: Server;
} }
export function Bans({ server }: Props) { export const Bans = observer(({ server }: Props) => {
const client = useContext(AppContext); const [deleting, setDelete] = useState<string[]>([]);
const [bans, setBans] = useState<Servers.Ban[] | undefined>(undefined); const [data, setData] = useState<
Route<"GET", "/servers/id/bans">["response"] | undefined
>(undefined);
useEffect(() => { useEffect(() => {
client.servers.fetchBans(server._id) server.fetchBans().then(setData);
.then(bans => setBans(bans)) }, [server, setData]);
}, [ ]);
return ( return (
<div> <div className={styles.userList}>
<Tip warning>This section is under construction.</Tip> <div className={styles.subtitle}>
{ bans?.map(x => <div>{x._id.user}: {x.reason ?? 'no reason'} <button onClick={() => client.servers.unbanUser(server._id, x._id.user)}>unban</button></div>) } <span>
<Text id="app.settings.server_pages.bans.user" />
</span>
<span class={styles.reason}>
<Text id="app.settings.server_pages.bans.reason" />
</span>
<span>
<Text id="app.settings.server_pages.bans.revoke" />
</span>
</div>
{typeof data === "undefined" && <Preloader type="ring" />}
{data?.bans.map((x) => {
const user = data.users.find((y) => y._id === x._id.user);
return (
<div
key={x._id.user}
className={styles.ban}
data-deleting={deleting.indexOf(x._id.user) > -1}>
<span>
<UserIcon attachment={user?.avatar} size={24} />
{user?.username}
</span>
<div className={styles.reason}>
{x.reason ?? (
<Text id="app.settings.server_pages.bans.no_reason" />
)}
</div>
<IconButton
onClick={async () => {
setDelete([...deleting, x._id.user]);
await server.unbanUser(x._id.user);
setData({
...data,
bans: data.bans.filter(
(y) => y._id.user !== x._id.user,
),
});
}}
disabled={deleting.indexOf(x._id.user) > -1}>
<XCircle size={24} />
</IconButton>
</div>
);
})}
</div> </div>
); );
} });
import isEqual from "lodash.isequal";
import { observer } from "mobx-react-lite";
import { Category } from "revolt-api/types/Servers";
import { Server } from "revolt.js/dist/maps/Servers";
import { ulid } from "ulid";
import { useState } from "preact/hooks";
import ChannelIcon from "../../../components/common/ChannelIcon";
import Button from "../../../components/ui/Button";
import ComboBox from "../../../components/ui/ComboBox";
import InputBox from "../../../components/ui/InputBox";
import Tip from "../../../components/ui/Tip";
interface Props {
server: Server;
}
// ! FIXME: really bad code
export const Categories = observer(({ server }: Props) => {
const channels = server.channels.filter((x) => typeof x !== "undefined");
const [cats, setCats] = useState<Category[]>(server.categories ?? []);
const [name, setName] = useState("");
return (
<div>
<Tip warning>This section is under construction.</Tip>
<p>
<Button
contrast
disabled={isEqual(server.categories ?? [], cats)}
onClick={() => server.edit({ categories: cats })}>
save categories
</Button>
</p>
<h2>categories</h2>
{cats.map((category) => (
<div style={{ background: "var(--hover)" }} key={category.id}>
<InputBox
value={category.title}
onChange={(e) =>
setCats(
cats.map((y) =>
y.id === category.id
? {
...y,
title: e.currentTarget.value,
}
: y,
),
)
}
contrast
/>
<Button
contrast
onClick={() =>
setCats(cats.filter((x) => x.id !== category.id))
}>
delete {category.title}
</Button>
</div>
))}
<h2>create new</h2>
<p>
<InputBox
value={name}
onChange={(e) => setName(e.currentTarget.value)}
contrast
/>
<Button
contrast
onClick={() => {
setName("");
setCats([
...cats,
{
id: ulid(),
title: name,
channels: [],
},
]);
}}>
create
</Button>
</p>
<h2>channels</h2>
{channels.map((channel) => {
return (
<div
key={channel!._id}
style={{
display: "flex",
gap: "12px",
alignItems: "center",
}}>
<div style={{ flexShrink: 0 }}>
<ChannelIcon target={channel} size={24} />{" "}
<span>{channel!.name}</span>
</div>
<ComboBox
style={{ flexGrow: 1 }}
value={
cats.find((x) =>
x.channels.includes(channel!._id),
)?.id ?? "none"
}
onChange={(e) =>
setCats(
cats.map((x) => {
return {
...x,
channels: [
...x.channels.filter(
(y) => y !== channel!._id,
),
...(e.currentTarget.value ===
x.id
? [channel!._id]
: []),
],
};
}),
)
}>
<option value="none">Uncategorised</option>
{cats.map((x) => (
<option key={x.id} value={x.id}>
{x.title}
</option>
))}
</ComboBox>
</div>
);
})}
</div>
);
});
import { Text } from "preact-i18n";
import styles from './Panes.module.scss';
import { XCircle } from "@styled-icons/boxicons-regular"; import { XCircle } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { ServerInvite } from "revolt-api/types/Invites";
import { Server } from "revolt.js/dist/maps/Servers";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import Preloader from "../../../components/ui/Preloader";
import IconButton from "../../../components/ui/IconButton"; import { useClient } from "../../../context/revoltjs/RevoltClient";
import UserIcon from "../../../components/common/user/UserIcon";
import { getChannelName } from "../../../context/revoltjs/util"; 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"; import UserIcon from "../../../components/common/user/UserIcon";
import IconButton from "../../../components/ui/IconButton";
import Preloader from "../../../components/ui/Preloader";
interface Props { interface Props {
server: Servers.Server; server: Server;
} }
export function Invites({ server }: Props) { export const Invites = observer(({ server }: Props) => {
const [invites, setInvites] = useState<InvitesNS.ServerInvite[] | undefined>(undefined);
const ctx = useForceUpdate();
const [deleting, setDelete] = useState<string[]>([]); const [deleting, setDelete] = useState<string[]>([]);
const users = useUsers(invites?.map(x => x.creator) ?? [], ctx); const [invites, setInvites] = useState<ServerInvite[] | undefined>(
const channels = useChannels(invites?.map(x => x.channel) ?? [], ctx); undefined,
);
const client = useClient();
const users = invites?.map((invite) => client.users.get(invite.creator));
const channels = invites?.map((invite) =>
client.channels.get(invite.channel),
);
useEffect(() => { useEffect(() => {
ctx.client.servers.fetchInvites(server._id) server.fetchInvites().then(setInvites);
.then(invites => setInvites(invites)) }, [server, setInvites]);
}, [ ]);
return ( return (
<div className={styles.invites}> <div className={styles.userList}>
<div className={styles.subtitle}> <div className={styles.subtitle}>
<span>Invite Code</span> <span>
<span>Invitor</span> <Text id="app.settings.server_pages.invites.code" />
<span>Channel</span> </span>
<span>Revoke</span> <span>
<Text id="app.settings.server_pages.invites.invitor" />
</span>
<span>
<Text id="app.settings.server_pages.invites.channel" />
</span>
<span>
<Text id="app.settings.server_pages.invites.revoke" />
</span>
</div> </div>
{ typeof invites === 'undefined' && <Preloader type="ring" /> } {typeof invites === "undefined" && <Preloader type="ring" />}
{ {invites?.map((invite, index) => {
invites?.map( const creator = users![index];
invite => { const channel = channels![index];
let creator = users.find(x => x?._id === invite.creator);
let channel = channels.find(x => x?._id === invite.channel);
return ( return (
<div className={styles.invite} <div
data-deleting={deleting.indexOf(invite._id) > -1}> key={invite._id}
<code>{ invite._id }</code> className={styles.invite}
<span> data-deleting={deleting.indexOf(invite._id) > -1}>
<UserIcon target={creator} size={24} /> {creator?.username ?? 'unknown'} <code>{invite._id}</code>
</span> <span>
<span>{ (channel && creator) ? getChannelName(ctx.client, channel, true) : '#unknown' }</span> <UserIcon target={creator} size={24} />{" "}
<IconButton {creator?.username ?? (
onClick={async () => { <Text id="app.main.channel.unknown_user" />
setDelete([ )}
...deleting, </span>
invite._id <span>
]); {channel && creator
? getChannelName(channel, true)
: "#??"}
</span>
<IconButton
onClick={async () => {
setDelete([...deleting, invite._id]);
await ctx.client.deleteInvite(invite._id); await client.deleteInvite(invite._id);
setInvites( setInvites(
invites?.filter( invites?.filter(
x => x._id !== invite._id (x) => x._id !== invite._id,
) ),
); );
}} }}
disabled={deleting.indexOf(invite._id) > -1}> disabled={deleting.indexOf(invite._id) > -1}>
<XCircle size={24} /> <XCircle size={24} />
</IconButton> </IconButton>
</div> </div>
) );
} })}
)
}
</div> </div>
); );
} });
import styles from './Panes.module.scss'; import { ChevronDown } from "@styled-icons/boxicons-regular";
import { isEqual } from "lodash";
import { observer } from "mobx-react-lite";
import { Member } from "revolt.js/dist/maps/Members";
import { Server } from "revolt.js/dist/maps/Servers";
import { User } from "revolt.js/dist/maps/Users";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Servers } from "revolt.js/dist/api/objects";
import UserIcon from "../../../components/common/user/UserIcon"; import UserIcon from "../../../components/common/user/UserIcon";
import { useForceUpdate, useUsers } from "../../../context/revoltjs/hooks"; import Button from "../../../components/ui/Button";
import Checkbox from "../../../components/ui/Checkbox";
import IconButton from "../../../components/ui/IconButton";
import Overline from "../../../components/ui/Overline";
interface Props { interface Props {
server: Servers.Server; server: Server;
} }
// ! FIXME: bad code :) export const Members = observer(({ server }: Props) => {
export function Members({ server }: Props) { const [selected, setSelected] = useState<undefined | string>();
const [members, setMembers] = useState<Servers.Member[] | undefined>(undefined); const [data, setData] = useState<
{ members: Member[]; users: User[] } | undefined
>(undefined);
const ctx = useForceUpdate(); useEffect(() => {
const users = useUsers(members?.map(x => x._id.user) ?? [], ctx); server.fetchMembers().then(setData);
}, [server, setData]);
const [roles, setRoles] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
ctx.client.servers.members.fetchMembers(server._id) if (selected) {
.then(members => setMembers(members)) setRoles(
}, [ ]); data!.members.find((x) => x._id.user === selected)?.roles ?? [],
);
}
}, [setRoles, selected, data]);
return ( return (
<div className={styles.members}> <div className={styles.userList}>
<div className={styles.subtitle}> <div className={styles.subtitle}>
X Members {data?.members.length ?? 0} Members
</div> </div>
{ members && members.length > 0 && users?.map(x => x && {data &&
<div className={styles.member}> data.members.length > 0 &&
<div>@{x.username}</div> data.members
</div>) .map((member) => {
} return {
member,
user: data.users.find(
(x) => x._id === member._id.user,
),
};
})
.map(({ member, user }) => (
// @ts-expect-error brokey
// eslint-disable-next-line react/jsx-no-undef
<Fragment key={member._id.user}>
<div
className={styles.member}
data-open={selected === member._id.user}
onClick={() =>
setSelected(
selected === member._id.user
? undefined
: member._id.user,
)
}>
<span>
<UserIcon target={user} size={24} />{" "}
{user?.username ?? (
<Text id="app.main.channel.unknown_user" />
)}
</span>
<IconButton className={styles.chevron}>
<ChevronDown size={24} />
</IconButton>
</div>
{selected === member._id.user && (
<div
key={`drop_${member._id.user}`}
className={styles.memberView}>
<Overline type="subtle">Roles</Overline>
{Object.keys(server.roles ?? {}).map(
(key) => {
const role = server.roles![key];
return (
<Checkbox
key={key}
checked={
roles.includes(key) ??
false
}
onChange={(v) => {
if (v) {
setRoles([
...roles,
key,
]);
} else {
setRoles(
roles.filter(
(x) =>
x !==
key,
),
);
}
}}>
<span
style={{
color: role.colour,
}}>
{role.name}
</span>
</Checkbox>
);
},
)}
<Button
compact
disabled={isEqual(
member.roles ?? [],
roles,
)}
onClick={() =>
member.edit({
roles,
})
}>
<Text id="app.special.modals.actions.save" />
</Button>
</div>
)}
</Fragment>
))}
</div> </div>
); );
} });
import isEqual from "lodash.isequal";
import { observer } from "mobx-react-lite";
import { Server } from "revolt.js/dist/maps/Servers";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import styles from './Panes.module.scss'; import { useEffect, useState } from "preact/hooks";
import Button from "../../../components/ui/Button";
import { Servers } from "revolt.js/dist/api/objects";
import InputBox from "../../../components/ui/InputBox";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { useContext, useEffect, useState } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { FileUploader } from "../../../context/revoltjs/FileUploads"; import { FileUploader } from "../../../context/revoltjs/FileUploads";
import { getChannelName } from "../../../context/revoltjs/util";
import Button from "../../../components/ui/Button";
import ComboBox from "../../../components/ui/ComboBox";
import InputBox from "../../../components/ui/InputBox";
interface Props { interface Props {
server: Servers.Server; server: Server;
} }
export function Overview({ server }: Props) { export const Overview = observer(({ server }: Props) => {
const client = useContext(AppContext);
const [name, setName] = useState(server.name); const [name, setName] = useState(server.name);
const [description, setDescription] = useState(server.description ?? ''); const [description, setDescription] = useState(server.description ?? "");
const [systemMessages, setSystemMessages] = useState(
server.system_messages,
);
useEffect(() => setName(server.name), [ server.name ]); useEffect(() => setName(server.name), [server.name]);
useEffect(() => setDescription(server.description ?? ''), [ server.description ]); useEffect(
() => setDescription(server.description ?? ""),
[server.description],
);
useEffect(
() => setSystemMessages(server.system_messages),
[server.system_messages],
);
const [ changed, setChanged ] = useState(false); const [changed, setChanged] = useState(false);
function save() { function save() {
let changes: any = {}; const changes: Record<string, unknown> = {};
if (name !== server.name) changes.name = name; if (name !== server.name) changes.name = name;
if (description !== server.description) if (description !== server.description)
changes.description = description; changes.description = description;
if (!isEqual(systemMessages, server.system_messages))
client.servers.edit(server._id, changes); changes.system_messages = systemMessages ?? undefined;
server.edit(changes);
setChanged(false); setChanged(false);
} }
...@@ -42,9 +59,9 @@ export function Overview({ server }: Props) { ...@@ -42,9 +59,9 @@ export function Overview({ server }: Props) {
fileType="icons" fileType="icons"
behaviour="upload" behaviour="upload"
maxFileSize={2_500_000} maxFileSize={2_500_000}
onUpload={icon => client.servers.edit(server._id, { icon })} onUpload={(icon) => server.edit({ icon })}
previewURL={client.servers.getIconURL(server._id, { max_side: 256 }, true)} previewURL={server.generateIconURL({ max_side: 256 }, true)}
remove={() => client.servers.edit(server._id, { remove: 'Icon' })} remove={() => server.edit({ remove: "Icon" })}
/> />
<div className={styles.name}> <div className={styles.name}>
<h3> <h3>
...@@ -54,9 +71,9 @@ export function Overview({ server }: Props) { ...@@ -54,9 +71,9 @@ export function Overview({ server }: Props) {
contrast contrast
value={name} value={name}
maxLength={32} maxLength={32}
onChange={e => { onChange={(e) => {
setName(e.currentTarget.value) setName(e.currentTarget.value);
if (!changed) setChanged(true) if (!changed) setChanged(true);
}} }}
/> />
</div> </div>
...@@ -71,16 +88,11 @@ export function Overview({ server }: Props) { ...@@ -71,16 +88,11 @@ export function Overview({ server }: Props) {
maxLength={1024} maxLength={1024}
value={description} value={description}
placeholder={"Add a topic..."} placeholder={"Add a topic..."}
onChange={ev => { onChange={(ev) => {
setDescription(ev.currentTarget.value); setDescription(ev.currentTarget.value);
if (!changed) setChanged(true) if (!changed) setChanged(true);
}} }}
/> />
<p>
<Button onClick={save} contrast disabled={!changed}>
<Text id="app.special.modals.actions.save" />
</Button>
</p>
<h3> <h3>
<Text id="app.main.servers.custom_banner" /> <Text id="app.main.servers.custom_banner" />
...@@ -91,10 +103,70 @@ export function Overview({ server }: Props) { ...@@ -91,10 +103,70 @@ export function Overview({ server }: Props) {
fileType="banners" fileType="banners"
behaviour="upload" behaviour="upload"
maxFileSize={6_000_000} maxFileSize={6_000_000}
onUpload={banner => client.servers.edit(server._id, { banner })} onUpload={(banner) => server.edit({ banner })}
previewURL={client.servers.getBannerURL(server._id, { width: 1000 }, true)} previewURL={server.generateBannerURL({ width: 1000 }, true)}
remove={() => client.servers.edit(server._id, { remove: 'Banner' })} remove={() => server.edit({ remove: "Banner" })}
/> />
<h3>
<Text id="app.settings.server_pages.overview.system_messages" />
</h3>
{[
["User Joined", "user_joined"],
["User Left", "user_left"],
["User Kicked", "user_kicked"],
["User Banned", "user_banned"],
].map(([i18n, key]) => (
// ! FIXME: temporary code just so we can expose the options
<p
key={key}
style={{
display: "flex",
gap: "8px",
alignItems: "center",
}}>
<span style={{ flexShrink: "0", flex: `25%` }}>{i18n}</span>
<ComboBox
value={
systemMessages?.[
key as keyof typeof systemMessages
] ?? "disabled"
}
onChange={(e) => {
if (!changed) setChanged(true);
const v = e.currentTarget.value;
if (v === "disabled") {
const {
[key as keyof typeof systemMessages]: _,
...other
} = systemMessages;
setSystemMessages(other);
} else {
setSystemMessages({
...systemMessages,
[key]: v,
});
}
}}>
<option value="disabled">
<Text id="general.disabled" />
</option>
{server.channels
.filter((x) => typeof x !== "undefined")
.map((channel) => (
<option key={channel!._id} value={channel!._id}>
{getChannelName(channel!, true)}
</option>
))}
</ComboBox>
</p>
))}
<p>
<Button onClick={save} contrast disabled={!changed}>
<Text id="app.special.modals.actions.save" />
</Button>
</p>
</div> </div>
); );
} });
...@@ -6,6 +6,10 @@ ...@@ -6,6 +6,10 @@
.name { .name {
flex-grow: 1; flex-grow: 1;
h3 {
margin-top: 0;
}
input { input {
width: 100%; width: 100%;
} }
...@@ -13,21 +17,32 @@ ...@@ -13,21 +17,32 @@
} }
} }
.invites { .userList {
gap: 8px; gap: 8px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.subtitle { .subtitle {
gap: 8px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 13px; font-size: 13px;
text-transform: uppercase; text-transform: uppercase;
color: var(--secondary-foreground); color: var(--secondary-foreground);
font-weight: 700; font-weight: 700;
.reason {
text-align: center;
}
} }
.invite { .reason {
flex: 2;
}
.invite,
.ban,
.member {
gap: 8px; gap: 8px;
padding: 10px; padding: 10px;
display: flex; display: flex;
...@@ -35,7 +50,8 @@ ...@@ -35,7 +50,8 @@
flex-direction: row; flex-direction: row;
background: var(--secondary-background); background: var(--secondary-background);
code, span { span,
code {
flex: 1; flex: 1;
} }
...@@ -54,25 +70,67 @@ ...@@ -54,25 +70,67 @@
opacity: 0.5; opacity: 0.5;
} }
} }
.member {
cursor: pointer;
.chevron {
transition: 0.2s ease all;
}
&:not([data-open="true"]) .chevron {
transform: rotateZ(90deg);
}
}
.memberView {
padding: 10px;
margin: 0 10px;
background: var(--background);
}
} }
.members { .roles {
gap: 12px;
height: 100%;
display: flex;
.subtitle { .list {
display: flex; width: 160px;
justify-content: space-between; flex-shrink: 0;
font-size: 13px; overflow-y: scroll;
text-transform: uppercase;
color: var(--secondary-foreground);
font-weight: 700;
} }
.member { .permissions {
flex-grow: 1;
padding: 0 8px;
overflow-y: scroll;
}
.title {
gap: 8px; gap: 8px;
padding: 10px;
display: flex; display: flex;
margin-bottom: 1em;
align-items: center; align-items: center;
flex-direction: row;
background: var(--secondary-background); h1,
h2 {
margin: 0;
min-width: 0;
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
svg {
cursor: pointer;
}
} }
}
\ No newline at end of file .actions {
gap: 8px;
display: flex;
padding: 8px 0;
}
}