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 1269 additions and 588 deletions
import { Channels } from "revolt.js/dist/api/objects"; import { observer } from "mobx-react-lite";
import { ChannelPermission } from "revolt.js/dist/api/permissions"; import {
ChannelPermission,
DEFAULT_PERMISSION_DM,
} from "revolt.js/dist/api/permissions";
import { Channel } from "revolt.js/dist/maps/Channels";
import { useContext, useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { useServer } from "../../../context/revoltjs/hooks";
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
import Checkbox from "../../../components/ui/Checkbox"; import Checkbox from "../../../components/ui/Checkbox";
import Tip from "../../../components/ui/Tip"; import Tip from "../../../components/ui/Tip";
// ! 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: channel: Channel;
| Channels.GroupChannel
| Channels.TextChannel
| Channels.VoiceChannel;
} }
// ! 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],
...@@ -73,19 +60,22 @@ export default function Permissions({ channel }: Props) { ...@@ -73,19 +60,22 @@ export default function Permissions({ channel }: Props) {
<h2>select role</h2> <h2>select role</h2>
{selected} {selected}
{keys.map((id) => { {keys.map((id) => {
let role: R = id === "default" ? defaultRole : roles[id]; const role: R = id === "default" ? defaultRole : roles[id];
return ( return (
<Checkbox <Checkbox
key={id}
checked={selected === id} checked={selected === id}
onChange={(selected) => selected && setSelected(id)}> onChange={(selected) => selected && setSelected(id)}>
{role.name} {role.name}
</Checkbox> </Checkbox>
); );
})} })}
<h2>channel per??issions</h2> <h2>channel permissions</h2>
{Object.keys(ChannelPermission).map((perm) => { {Object.keys(ChannelPermission).map((perm) => {
let value = if (perm === "View") return null;
const value =
ChannelPermission[perm as keyof typeof ChannelPermission]; ChannelPermission[perm as keyof typeof ChannelPermission];
if (value & DEFAULT_PERMISSION_DM) { if (value & DEFAULT_PERMISSION_DM) {
return ( return (
...@@ -102,10 +92,10 @@ export default function Permissions({ channel }: Props) { ...@@ -102,10 +92,10 @@ export default function Permissions({ channel }: Props) {
<Button <Button
contrast contrast
onClick={() => { onClick={() => {
client.channels.setPermissions(channel._id, selected, p); channel.setPermissions(selected, p);
}}> }}>
click here to save permissions for role click here to save permissions for role
</Button> </Button>
</div> </div>
); );
} });
import { At } from "@styled-icons/boxicons-regular"; import { At, Key, Block } from "@styled-icons/boxicons-regular";
import { Envelope, Key, HelpCircle } from "@styled-icons/boxicons-solid"; import {
import { Link, useHistory } from "react-router-dom"; Envelope,
import { Users } from "revolt.js/dist/api/objects"; 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 { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import { stopPropagation } from "../../../lib/stopPropagation";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { import {
ClientStatus, ClientStatus,
StatusContext, StatusContext,
useClient,
} from "../../../context/revoltjs/RevoltClient"; } from "../../../context/revoltjs/RevoltClient";
import { useForceUpdate, useSelf } from "../../../context/revoltjs/hooks";
import Tooltip from "../../../components/common/Tooltip"; import Tooltip from "../../../components/common/Tooltip";
import UserIcon from "../../../components/common/user/UserIcon"; import UserIcon from "../../../components/common/user/UserIcon";
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
import Overline from "../../../components/ui/Overline";
import Tip from "../../../components/ui/Tip"; import Tip from "../../../components/ui/Tip";
import CategoryButton from "../../../components/ui/fluent/CategoryButton";
export function Account() { export const Account = observer(() => {
const { openScreen, writeClipboard } = 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 [revealEmail, setRevealEmail] = useState(false); const [revealEmail, setRevealEmail] = useState(false);
const [profile, setProfile] = useState<undefined | Users.Profile>( const [profile, setProfile] = useState<undefined | Profile>(undefined);
undefined,
);
const history = useHistory(); const history = useHistory();
function switchPage(to: string) { function switchPage(to: string) {
...@@ -41,134 +45,161 @@ export function Account() { ...@@ -41,134 +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}>
<UserIcon <div className={styles.container}>
className={styles.avatar} <UserIcon
target={user} className={styles.avatar}
size={72} target={client.user!}
onClick={() => switchPage("profile")} size={72}
/> onClick={() => switchPage("profile")}
<div className={styles.userDetail}> />
<div className={styles.username}>@{user.username}</div> <div className={styles.userDetail}>
<div className={styles.userid}> @{client.user!.username}
<Tooltip <div className={styles.userid}>
content={ <Tooltip
<Text id="app.settings.pages.account.unique_id" /> content={
}> <Text id="app.settings.pages.account.unique_id" />
<HelpCircle size={16} /> }>
</Tooltip> <HelpCircle size={16} />
<Tooltip content={<Text id="app.special.copy" />}> </Tooltip>
<a onClick={() => writeClipboard(user._id)}> <Tooltip content={<Text id="app.special.copy" />}>
{user._id} <a
</a> onClick={() =>
</Tooltip> writeClipboard(client.user!._id)
}>
{client.user!._id}
</a>
</Tooltip>
</div>
</div> </div>
</div> </div>
<Button onClick={() => switchPage("profile")} contrast>
<Text id="app.settings.pages.profile.edit_profile" />
</Button>
</div> </div>
<div className={styles.details}> <div>
{( {(
[ [
["username", user.username, <At size={24} />], [
["email", email, <Envelope size={24} />], "username",
["password", "***********", <Key size={24} />], client.user!.username,
<At key="at" size={24} />,
],
["email", email, <Envelope key="envelope" size={24} />],
["password", "•••••••••", <Key key="key" size={24} />],
] as const ] as const
).map(([field, value, icon]) => ( ).map(([field, value, icon]) => (
<div> <CategoryButton
{icon} key={field}
<div className={styles.detail}> icon={icon}
<div className={styles.subtext}> description={
<Text id={`login.${field}`} /> field === "email" ? (
</div> revealEmail ? (
<p> <>
{field === "email" ? ( {value}{" "}
revealEmail ? ( <a
value onClick={(ev) =>
) : ( stopPropagation(
<> ev,
***********@{value.split("@").pop()}{" "} setRevealEmail(false),
<a )
onClick={() => }>
setRevealEmail(true) <Text id="app.special.modals.actions.hide" />
}> </a>
<Text id="app.special.modals.actions.reveal" /> </>
</a>
</>
)
) : ( ) : (
value <>
)} •••••••••••@{value.split("@").pop()}{" "}
</p> <a
</div> onClick={(ev) =>
<div> stopPropagation(
<Button ev,
onClick={() => setRevealEmail(true),
openScreen({ )
id: "modify_account", }>
field: field, <Text id="app.special.modals.actions.reveal" />
}) </a>
} </>
contrast> )
<Text id="app.settings.pages.account.change_field" /> ) : (
</Button> value
</div> )
</div> }
account
action="chevron"
onClick={() =>
openScreen({
id: "modify_account",
field,
})
}>
<Text id={`login.${field}`} />
</CategoryButton>
))} ))}
</div> </div>
<h3>
<Text id="app.settings.pages.account.account_management.title" />
</h3>
<h5>
<Text id="app.settings.pages.account.account_management.description" />
</h5>
<h3> <h3>
<Text id="app.settings.pages.account.2fa.title" /> <Text id="app.settings.pages.account.2fa.title" />
</h3> </h3>
<h5> <h5>
Currently work in progress, see{" "} {/*<Text id="app.settings.pages.account.2fa.description" />*/}
Two-factor authentication is currently work-in-progress, see{" "}
{` `}
<a <a
href="https://gitlab.insrt.uk/insert/rauth/-/issues/2" href="https://gitlab.insrt.uk/insert/rauth/-/issues/2"
target="_blank"> target="_blank"
rel="noreferrer">
tracking issue here tracking issue here
</a> </a>
. .
</h5> </h5>
{/*<h5><Text id="app.settings.pages.account.two_factor_auth.description" /></h5> <CategoryButton
<Button accent compact> icon={<Lock size={24} color="var(--error)" />}
<Text id="app.settings.pages.account.two_factor_auth.add_auth" /> description={"Set up 2FA Authentication on your account."}
</Button>*/} disabled
action="chevron">
Set up Two-factor authentication
</CategoryButton>
<h3> <h3>
<Text id="app.settings.pages.account.manage.title" /> <Text id="app.settings.pages.account.manage.title" />
</h3> </h3>
<h5> <h5>
<Text id="app.settings.pages.account.manage.description" /> <Text id="app.settings.pages.account.manage.description" />
</h5> </h5>
<div className={styles.buttons}> <CategoryButton
{/* <Button contrast> icon={<Block size={24} color="var(--error)" />}
<Text id="app.settings.pages.account.manage.disable" /> description={
</Button> */} "Disable your account. You won't be able to access it unless you log back in."
<a href="mailto:contact@revolt.chat?subject=Delete%20my%20account"> }
<Button error compact> disabled
<Text id="app.settings.pages.account.manage.delete" /> action={<Text id="general.unavailable" />}>
</Button> <Text id="app.settings.pages.account.manage.disable" />
</a> </CategoryButton>
</div> <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" />
...@@ -179,4 +210,4 @@ export function Account() { ...@@ -179,4 +210,4 @@ export function Account() {
</Tip> </Tip>
</div> </div>
); );
} });
// @ts-ignore 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 pSBC from "shade-blend-color";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
...@@ -15,10 +17,12 @@ import { EmojiPacks, Settings } from "../../../redux/reducers/settings"; ...@@ -15,10 +17,12 @@ import { EmojiPacks, Settings } from "../../../redux/reducers/settings";
import { import {
DEFAULT_FONT, DEFAULT_FONT,
DEFAULT_MONO_FONT, DEFAULT_MONO_FONT,
Fonts,
FONTS, FONTS,
FONT_KEYS, FONT_KEYS,
MONOSCAPE_FONTS, MonospaceFonts,
MONOSCAPE_FONT_KEYS, MONOSPACE_FONTS,
MONOSPACE_FONT_KEYS,
Theme, Theme,
ThemeContext, ThemeContext,
ThemeOptions, ThemeOptions,
...@@ -26,6 +30,7 @@ import { ...@@ -26,6 +30,7 @@ import {
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import CollapsibleSection from "../../../components/common/CollapsibleSection"; import CollapsibleSection from "../../../components/common/CollapsibleSection";
import Tooltip from "../../../components/common/Tooltip";
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
import Checkbox from "../../../components/ui/Checkbox"; import Checkbox from "../../../components/ui/Checkbox";
import ColourSwatches from "../../../components/ui/ColourSwatches"; import ColourSwatches from "../../../components/ui/ColourSwatches";
...@@ -54,12 +59,12 @@ export function Component(props: Props) { ...@@ -54,12 +59,12 @@ export function Component(props: Props) {
}); });
} }
function pushOverride(custom: Partial<Theme>) { const pushOverride = useCallback((custom: Partial<Theme>) => {
dispatch({ dispatch({
type: "SETTINGS_SET_THEME_OVERRIDE", type: "SETTINGS_SET_THEME_OVERRIDE",
custom, custom,
}); });
} }, []);
function setAccent(accent: string) { function setAccent(accent: string) {
setOverride({ setOverride({
...@@ -78,12 +83,14 @@ export function Component(props: Props) { ...@@ -78,12 +83,14 @@ export function Component(props: Props) {
}); });
} }
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),
[pushOverride],
) as (custom: Partial<Theme>) => void;
const [css, setCSS] = useState(props.settings.theme?.custom?.css ?? ""); 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 (
...@@ -94,12 +101,15 @@ export function Component(props: Props) { ...@@ -94,12 +101,15 @@ export function Component(props: Props) {
<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" />
...@@ -107,11 +117,14 @@ export function Component(props: Props) { ...@@ -107,11 +117,14 @@ export function Component(props: Props) {
</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" />
...@@ -161,14 +174,15 @@ export function Component(props: Props) { ...@@ -161,14 +174,15 @@ export function Component(props: Props) {
<ComboBox <ComboBox
value={theme.font ?? DEFAULT_FONT} value={theme.font ?? DEFAULT_FONT}
onChange={(e) => onChange={(e) =>
pushOverride({ font: e.currentTarget.value as any }) pushOverride({ font: e.currentTarget.value as Fonts })
}> }>
{FONT_KEYS.map((key) => ( {FONT_KEYS.map((key) => (
<option value={key}> <option value={key} key={key}>
{FONTS[key as keyof typeof FONTS].name} {FONTS[key as keyof typeof FONTS].name}
</option> </option>
))} ))}
</ComboBox> </ComboBox>
{/* TOFIX: Only show when a font with ligature support is selected, i.e.: Inter.*/}
<p> <p>
<Checkbox <Checkbox
checked={props.settings.theme?.ligatures === true} checked={props.settings.theme?.ligatures === true}
...@@ -194,13 +208,19 @@ export function Component(props: Props) { ...@@ -194,13 +208,19 @@ export function Component(props: Props) {
className={styles.button} className={styles.button}
onClick={() => setEmojiPack("mutant")} onClick={() => setEmojiPack("mutant")}
data-active={emojiPack === "mutant"}> data-active={emojiPack === "mutant"}>
<img src={mutantSVG} draggable={false} /> <img
loading="eager"
src={mutantSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div> </div>
<h4> <h4>
Mutant Remix{" "} Mutant Remix{" "}
<a <a
href="https://mutant.revolt.chat" href="https://mutant.revolt.chat"
target="_blank"> target="_blank"
rel="noreferrer">
(by Revolt) (by Revolt)
</a> </a>
</h4> </h4>
...@@ -210,7 +230,12 @@ export function Component(props: Props) { ...@@ -210,7 +230,12 @@ export function Component(props: Props) {
className={styles.button} className={styles.button}
onClick={() => setEmojiPack("twemoji")} onClick={() => setEmojiPack("twemoji")}
data-active={emojiPack === "twemoji"}> data-active={emojiPack === "twemoji"}>
<img src={twemojiSVG} draggable={false} /> <img
loading="eager"
src={twemojiSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div> </div>
<h4>Twemoji</h4> <h4>Twemoji</h4>
</div> </div>
...@@ -221,7 +246,12 @@ export function Component(props: Props) { ...@@ -221,7 +246,12 @@ export function Component(props: Props) {
className={styles.button} className={styles.button}
onClick={() => setEmojiPack("openmoji")} onClick={() => setEmojiPack("openmoji")}
data-active={emojiPack === "openmoji"}> data-active={emojiPack === "openmoji"}>
<img src={openmojiSVG} draggable={false} /> <img
loading="eager"
src={openmojiSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div> </div>
<h4>Openmoji</h4> <h4>Openmoji</h4>
</div> </div>
...@@ -230,7 +260,12 @@ export function Component(props: Props) { ...@@ -230,7 +260,12 @@ export function Component(props: Props) {
className={styles.button} className={styles.button}
onClick={() => setEmojiPack("noto")} onClick={() => setEmojiPack("noto")}
data-active={emojiPack === "noto"}> data-active={emojiPack === "noto"}>
<img src={notoSVG} draggable={false} /> <img
loading="eager"
src={notoSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div> </div>
<h4>Noto Emoji</h4> <h4>Noto Emoji</h4>
</div> </div>
...@@ -238,47 +273,61 @@ export function Component(props: Props) { ...@@ -238,47 +273,61 @@ export function Component(props: Props) {
</div> </div>
<CollapsibleSection <CollapsibleSection
id="settings_advanced_appearance"
defaultValue={false} defaultValue={false}
summary={<Text id="app.settings.pages.appearance.advanced" />}> id="settings_overrides"
<h3> summary={<Text id="app.settings.pages.appearance.overrides" />}>
<Text id="app.settings.pages.appearance.overrides" />
</h3>
<div className={styles.actions}> <div className={styles.actions}>
<Button contrast onClick={() => setTheme({ custom: {} })}> <Tooltip
<Text id="app.settings.pages.appearance.reset_overrides" /> content={
</Button> <Text id="app.settings.pages.appearance.reset_overrides" />
<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 {/*TOFIX: Try to put the tooltip above the .code div without messing up the css challenge */}
contrast {JSON.stringify(theme)}
onClick={async () => { </Tooltip>
const text = await navigator.clipboard.readText(); </div>
setOverride(JSON.parse(text)); <Tooltip
}}> content={
<Text id="app.settings.pages.appearance.import_clipboard" /> <Text id="app.settings.pages.appearance.import" />
</Button> }>
<Button <Button
contrast contrast
onClick={async () => { iconbutton
openScreen({ onClick={async () => {
id: "_input", try {
question: ( const text =
<Text id="app.settings.pages.appearance.import_theme" /> await navigator.clipboard.readText();
), setOverride(JSON.parse(text));
field: ( } catch (err) {
<Text id="app.settings.pages.appearance.theme_data" /> openScreen({
), id: "_input",
callback: async (string) => question: (
setOverride(JSON.parse(string)), <Text id="app.settings.pages.appearance.import_theme" />
}); ),
}}> field: (
<Text id="app.settings.pages.appearance.import_manual" /> <Text id="app.settings.pages.appearance.theme_data" />
</Button> ),
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}>
{( {(
[ [
...@@ -308,23 +357,34 @@ export function Component(props: Props) { ...@@ -308,23 +357,34 @@ export function Component(props: Props) {
"hover", "hover",
] as const ] as const
).map((x) => ( ).map((x) => (
<div className={styles.entry} key={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 <div
className={styles.picker} className={styles.picker}
style={{ backgroundColor: theme[x] }}> onClick={(e) =>
<input e.currentTarget.parentElement?.parentElement
type="color" ?.querySelector("input")
value={theme[x]} ?.click()
onChange={(v) => }>
setOverride({ <Pencil size={24} />
[x]: v.currentTarget.value,
})
}
/>
</div> </div>
<InputBox <InputBox
type="text"
className={styles.text} className={styles.text}
value={theme[x]} value={theme[x]}
onChange={(y) => onChange={(y) =>
...@@ -337,22 +397,28 @@ export function Component(props: Props) { ...@@ -337,22 +397,28 @@ export function Component(props: Props) {
</div> </div>
))} ))}
</div> </div>
</CollapsibleSection>
<CollapsibleSection
id="settings_advanced_appearance"
defaultValue={false}
summary={<Text id="app.settings.pages.appearance.advanced" />}>
<h3> <h3>
<Text id="app.settings.pages.appearance.mono_font" /> <Text id="app.settings.pages.appearance.mono_font" />
</h3> </h3>
<ComboBox <ComboBox
value={theme.monoscapeFont ?? DEFAULT_MONO_FONT} value={theme.monospaceFont ?? DEFAULT_MONO_FONT}
onChange={(e) => onChange={(e) =>
pushOverride({ pushOverride({
monoscapeFont: e.currentTarget.value as any, monospaceFont: e.currentTarget
.value as MonospaceFonts,
}) })
}> }>
{MONOSCAPE_FONT_KEYS.map((key) => ( {MONOSPACE_FONT_KEYS.map((key) => (
<option value={key}> <option value={key} key={key}>
{ {
MONOSCAPE_FONTS[ MONOSPACE_FONTS[
key as keyof typeof MONOSCAPE_FONTS key as keyof typeof MONOSPACE_FONTS
].name ].name
} }
</option> </option>
......
...@@ -6,6 +6,7 @@ import { connectState } from "../../../redux/connector"; ...@@ -6,6 +6,7 @@ import { connectState } from "../../../redux/connector";
import { import {
AVAILABLE_EXPERIMENTS, AVAILABLE_EXPERIMENTS,
ExperimentOptions, ExperimentOptions,
EXPERIMENTS,
} from "../../../redux/reducers/experiments"; } from "../../../redux/reducers/experiments";
import Checkbox from "../../../components/ui/Checkbox"; import Checkbox from "../../../components/ui/Checkbox";
...@@ -22,6 +23,7 @@ export function Component(props: Props) { ...@@ -22,6 +23,7 @@ export function Component(props: Props) {
</h3> </h3>
{AVAILABLE_EXPERIMENTS.map((key) => ( {AVAILABLE_EXPERIMENTS.map((key) => (
<Checkbox <Checkbox
key={key}
checked={(props.options?.enabled ?? []).indexOf(key) > -1} checked={(props.options?.enabled ?? []).indexOf(key) > -1}
onChange={(enabled) => onChange={(enabled) =>
dispatch({ dispatch({
...@@ -30,13 +32,9 @@ export function Component(props: Props) { ...@@ -30,13 +32,9 @@ export function Component(props: Props) {
: "EXPERIMENTS_DISABLE", : "EXPERIMENTS_DISABLE",
key, 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}`}
/>
</p>
</Checkbox> </Checkbox>
))} ))}
{AVAILABLE_EXPERIMENTS.length === 0 && ( {AVAILABLE_EXPERIMENTS.length === 0 && (
......
...@@ -2,7 +2,7 @@ import styles from "./Panes.module.scss"; ...@@ -2,7 +2,7 @@ import styles from "./Panes.module.scss";
import { Localizer, Text } from "preact-i18n"; import { Localizer, Text } from "preact-i18n";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { useSelf } from "../../../context/revoltjs/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";
...@@ -10,7 +10,7 @@ import Radio from "../../../components/ui/Radio"; ...@@ -10,7 +10,7 @@ import Radio from "../../../components/ui/Radio";
import TextArea from "../../../components/ui/TextArea"; import TextArea from "../../../components/ui/TextArea";
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");
...@@ -28,7 +28,7 @@ export function Feedback() { ...@@ -28,7 +28,7 @@ export function Feedback() {
checked, checked,
other, other,
description, description,
name: user?.username ?? "Unknown User", name: client.user!.username,
}), }),
mode: "no-cors", mode: "no-cors",
}); });
...@@ -57,23 +57,9 @@ export function Feedback() { ...@@ -57,23 +57,9 @@ 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={checked === "__other_option__"}
checked === "__other_option__" && other !== "Revite"
}
onSelect={() => setChecked("__other_option__")}> onSelect={() => setChecked("__other_option__")}>
<Localizer> <Localizer>
<InputBox <InputBox
...@@ -84,7 +70,7 @@ export function Feedback() { ...@@ -84,7 +70,7 @@ export function Feedback() {
placeholder={ placeholder={
( (
<Text id="app.settings.pages.feedback.other" /> <Text id="app.settings.pages.feedback.other" />
) as any ) as unknown as string
} }
/> />
</Localizer> </Localizer>
......
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
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 Tip from "../../../components/ui/Tip"; import Tip from "../../../components/ui/Tip";
import tokiponaSVG from "../assets/toki_pona.svg";
type Props = { type Props = {
locale: Language; locale: Language;
...@@ -35,7 +36,11 @@ function Entry({ entry: [x, lang], locale }: { entry: Key } & Props) { ...@@ -35,7 +36,11 @@ function Entry({ entry: [x, lang], locale }: { entry: Key } & Props) {
} }
}}> }}>
<div className={styles.flag}> <div className={styles.flag}>
<Emoji size={42} emoji={lang.emoji} /> {lang.emoji === "🙂" ? (
<img src={tokiponaSVG} width={42} />
) : (
<Emoji size={42} emoji={lang.emoji} />
)}
</div> </div>
<span className={styles.description}>{lang.display}</span> <span className={styles.description}>{lang.display}</span>
</Checkbox> </Checkbox>
...@@ -55,7 +60,17 @@ export function Component(props: Props) { ...@@ -55,7 +60,17 @@ 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} />
))}
</div>
<h3>
<Text id="app.settings.pages.language.const" />
</h3>
<div className={styles.list}>
{languages
.filter(([, lang]) => lang.cat === "const")
.map(([x, lang]) => ( .map(([x, lang]) => (
<Entry key={x} entry={[x, lang]} {...props} /> <Entry key={x} entry={[x, lang]} {...props} />
))} ))}
...@@ -65,7 +80,7 @@ export function Component(props: Props) { ...@@ -65,7 +80,7 @@ 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 === "alt")
.map(([x, lang]) => ( .map(([x, lang]) => (
<Entry key={x} entry={[x, lang]} {...props} /> <Entry key={x} entry={[x, lang]} {...props} />
))} ))}
...@@ -76,7 +91,8 @@ export function Component(props: Props) { ...@@ -76,7 +91,8 @@ export function Component(props: Props) {
</span>{" "} </span>{" "}
<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>
......
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>
);
}
...@@ -59,7 +59,8 @@ export function Component({ options }: Props) { ...@@ -59,7 +59,8 @@ export function Component({ options }: Props) {
} }
onChange={async (desktopEnabled) => { 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",
...@@ -126,7 +127,8 @@ export function Component({ options }: Props) { ...@@ -126,7 +127,8 @@ export function Component({ options }: Props) {
</h3> </h3>
{SOUNDS_ARRAY.map((key) => ( {SOUNDS_ARRAY.map((key) => (
<Checkbox <Checkbox
checked={enabledSounds[key] ? true : false} key={key}
checked={!!enabledSounds[key]}
onChange={(enabled) => onChange={(enabled) =>
dispatch({ dispatch({
type: "SETTINGS_SET_NOTIFICATION_OPTIONS", type: "SETTINGS_SET_NOTIFICATION_OPTIONS",
......
.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;
align-items: center;
background: var(--secondary-header);
overflow: hidden; overflow: hidden;
align-items: center;
border-radius: var(--border-radius);
.container {
display: flex;
gap: 24px;
align-items: center;
flex-direction: row;
width: 100%;
}
.userDetail {
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;
}
.avatar { .avatar {
cursor: pointer; cursor: pointer;
...@@ -18,13 +40,8 @@ ...@@ -18,13 +40,8 @@
} }
} }
.username {
font-size: 1.5rem;
font-weight: 600;
}
.userid { .userid {
font-size: .875rem; font-size: 12px;
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -40,57 +57,28 @@ ...@@ -40,57 +57,28 @@
.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;
margin-bottom: 5px; background: var(--secondary-header);
border-radius: 6px;
> svg { > svg {
flex-shrink: 0; flex-shrink: 0;
} }
} }
.detail {
flex-grow: 1;
min-width: 0;
display: flex;
flex-direction: column;
.subtext {
display: inline;
font-size: .875rem;
font-weight: 600;
color: var(--foreground);
text-transform: uppercase;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
a {
font-size: .875rem;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
p {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
p { p {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
...@@ -103,7 +91,7 @@ ...@@ -103,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;
...@@ -131,6 +119,20 @@ ...@@ -131,6 +119,20 @@
} }
} }
@media only screen and (max-width: 800px) {
.user {
.banner {
gap: 18px;
padding: 0;
flex-direction: column;
> button {
width: 100%;
}
}
}
}
.appearance { .appearance {
.theme { .theme {
min-width: 0; min-width: 0;
...@@ -141,12 +143,14 @@ ...@@ -141,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;
...@@ -163,14 +167,13 @@ ...@@ -163,14 +167,13 @@
} }
details { details {
summary { summary {
font-size: .8125rem; font-size: 0.8125rem;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
color: var(--secondary-foreground); color: var(--secondary-foreground);
cursor: pointer; cursor: pointer;
} }
} }
.emojiPack { .emojiPack {
...@@ -190,15 +193,15 @@ ...@@ -190,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%;
...@@ -224,15 +227,12 @@ ...@@ -224,15 +227,12 @@
text-transform: unset; text-transform: unset;
a { a {
opacity: 0.7; opacity: 0.7;
color: var(--accent); color: var(--accent);
font-weight: 600; font-weight: 600;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
} }
@media only screen and (max-width: 800px) { @media only screen and (max-width: 800px) {
...@@ -252,56 +252,92 @@ ...@@ -252,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: .875rem;
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);
}
input[type="text"] {
width: 0;
min-width: 0;
flex-grow: 1;
}
}
//TOFIX - Looks wonky on Chromium .input {
border: 1px solid black; 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;
} }
} }
} }
...@@ -318,17 +354,19 @@ ...@@ -318,17 +354,19 @@
display: flex; display: flex;
gap: 12px; gap: 12px;
flex-grow: 1; flex-grow: 1;
}
svg {
margin-top: 1px;
}
}
} }
.entry { .entry {
margin: 10px 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"] {
...@@ -341,7 +379,6 @@ ...@@ -341,7 +379,6 @@
border-bottom: 2px solid var(--primary-background); border-bottom: 2px solid var(--primary-background);
} }
} }
} }
&[data-deleting="true"] { &[data-deleting="true"] {
...@@ -376,7 +413,7 @@ ...@@ -376,7 +413,7 @@
.label { .label {
margin-bottom: 8px; margin-bottom: 8px;
color: var(--primary-text); color: var(--primary-text);
font-size: .75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
} }
...@@ -396,7 +433,7 @@ ...@@ -396,7 +433,7 @@
} }
.time { .time {
font-size: .75rem; font-size: 0.75rem;
color: var(--teriary-text); color: var(--teriary-text);
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
...@@ -408,12 +445,12 @@ ...@@ -408,12 +445,12 @@
margin-top: 20px; margin-top: 20px;
} }
@media only screen and (max-width: 900px) { @media only screen and (max-width: 800px) {
.session { .session {
align-items: unset; align-items: unset;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
> button { > button {
width: 100%; width: 100%;
} }
...@@ -427,28 +464,42 @@ ...@@ -427,28 +464,42 @@
.languages { .languages {
.list { .list {
display: flex;
flex-direction: column;
margin-bottom: 1em; margin-bottom: 1em;
gap: 8px;
.entry { .entry {
height: 50px; display: flex;
height: 45px;
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: 20px; 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: 2.625rem;
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 {
...@@ -464,12 +515,16 @@ ...@@ -464,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 { Users } from "revolt.js/dist/api/objects"; import { Profile as ProfileI } from "revolt-api/types/Users";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import { IntlContext, Text, translate } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks"; import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { useTranslation } from "../../../lib/i18n";
import { UserProfile } from "../../../context/intermediate/popovers/UserProfile"; import { UserProfile } from "../../../context/intermediate/popovers/UserProfile";
import { FileUploader } from "../../../context/revoltjs/FileUploads"; import { FileUploader } from "../../../context/revoltjs/FileUploads";
import { import {
ClientStatus, ClientStatus,
StatusContext, StatusContext,
useClient,
} from "../../../context/revoltjs/RevoltClient"; } from "../../../context/revoltjs/RevoltClient";
import { useForceUpdate, useSelf } from "../../../context/revoltjs/hooks";
import AutoComplete, { import AutoComplete, {
useAutoComplete, useAutoComplete,
...@@ -20,30 +21,25 @@ import AutoComplete, { ...@@ -20,30 +21,25 @@ import AutoComplete, {
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
export function Profile() { export function Profile() {
const { intl } = useContext(IntlContext);
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); const [changed, setChanged] = useState(false);
function setContent(content?: string) { function setContent(content?: string) {
...@@ -69,10 +65,9 @@ export function Profile() { ...@@ -69,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}>
...@@ -87,22 +82,15 @@ export function Profile() { ...@@ -87,22 +82,15 @@ export function Profile() {
fileType="avatars" fileType="avatars"
behaviour="upload" behaviour="upload"
maxFileSize={4_000_000} maxFileSize={4_000_000}
onUpload={(avatar) => onUpload={(avatar) => client.users.edit({ avatar })}
ctx.client.users.editUser({ avatar }) remove={() => client.users.edit({ remove: "Avatar" })}
} defaultPreview={client.user!.generateAvatarURL(
remove={() =>
ctx.client.users.editUser({ remove: "Avatar" })
}
defaultPreview={ctx.client.users.getAvatarURL(
user._id,
{ max_side: 256 }, { max_side: 256 },
true, true,
)} )}
previewURL={ctx.client.users.getAvatarURL( previewURL={client.user!.generateAvatarURL(
user._id,
{ max_side: 256 }, { max_side: 256 },
true, true,
true,
)} )}
/> />
</div> </div>
...@@ -117,21 +105,21 @@ export function Profile() { ...@@ -117,21 +105,21 @@ export function Profile() {
fileType="backgrounds" fileType="backgrounds"
maxFileSize={6_000_000} maxFileSize={6_000_000}
onUpload={async (background) => { onUpload={async (background) => {
await ctx.client.users.editUser({ await client.users.edit({
profile: { background }, profile: { background },
}); });
refreshProfile(); refreshProfile();
}} }}
remove={async () => { remove={async () => {
await ctx.client.users.editUser({ await client.users.edit({
remove: "ProfileBackground", remove: "ProfileBackground",
}); });
setProfile({ ...profile, background: undefined }); setProfile({ ...profile, background: undefined });
}} }}
previewURL={ previewURL={
profile?.background profile?.background
? ctx.client.users.getBackgroundURL( ? client.generateFileURL(
profile, profile.background,
{ width: 1000 }, { width: 1000 },
true, true,
) )
...@@ -160,8 +148,6 @@ export function Profile() { ...@@ -160,8 +148,6 @@ export function Profile() {
? "fetching" ? "fetching"
: "placeholder" : "placeholder"
}`, }`,
"",
(intl as any).dictionary as Record<string, unknown>,
)} )}
onKeyUp={onKeyUp} onKeyUp={onKeyUp}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
...@@ -173,7 +159,7 @@ export function Profile() { ...@@ -173,7 +159,7 @@ export function Profile() {
contrast contrast
onClick={() => { onClick={() => {
setChanged(false); setChanged(false);
ctx.client.users.editUser({ client.users.edit({
profile: { content: profile?.content }, profile: { content: profile?.content },
}); });
}} }}
......
import { HelpCircle } from "@styled-icons/boxicons-regular"; import { Chrome, Android, Apple, Windows } from "@styled-icons/boxicons-logos";
import { HelpCircle, Desktop } from "@styled-icons/boxicons-regular";
import {
Safari,
Firefoxbrowser,
Microsoftedge,
Linux,
Macos,
Opera,
} from "@styled-icons/simple-icons";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { decodeTime } from "ulid"; import { decodeTime } from "ulid";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { Safari, Firefoxbrowser, Microsoftedge, Linux, Macos } from "@styled-icons/simple-icons";
import { Chrome, Android, Apple, Windows } from "@styled-icons/boxicons-logos";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import { dayjs } from "../../../context/Locale"; import { dayjs } from "../../../context/Locale";
...@@ -43,7 +50,7 @@ export function Sessions() { ...@@ -43,7 +50,7 @@ export function Sessions() {
); );
setSessions(data); setSessions(data);
}); });
}, []); }, [client, setSessions, deviceId]);
if (typeof sessions === "undefined") { if (typeof sessions === "undefined") {
return ( return (
...@@ -64,6 +71,10 @@ export function Sessions() { ...@@ -64,6 +71,10 @@ export function Sessions() {
return <Safari size={32} />; return <Safari size={32} />;
case /edge/i.test(name): case /edge/i.test(name):
return <Microsoftedge size={32} />; 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 size={32} />; return <HelpCircle size={32} />;
} }
...@@ -95,7 +106,7 @@ export function Sessions() { ...@@ -95,7 +106,7 @@ export function Sessions() {
}); });
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],
...@@ -112,9 +123,12 @@ export function Sessions() { ...@@ -112,9 +123,12 @@ export function Sessions() {
const systemIcon = getSystemIcon(session); const systemIcon = getSystemIcon(session);
return ( return (
<div <div
key={session.id}
className={styles.entry} className={styles.entry}
data-active={session.id === deviceId} data-active={session.id === deviceId}
data-deleting={attemptingDelete.indexOf(session.id) > -1}> data-deleting={
attemptingDelete.indexOf(session.id) > -1
}>
{deviceId === session.id && ( {deviceId === session.id && (
<span className={styles.label}> <span className={styles.label}>
<Text id="app.settings.pages.sessions.this_device" />{" "} <Text id="app.settings.pages.sessions.this_device" />{" "}
...@@ -122,18 +136,25 @@ export function Sessions() { ...@@ -122,18 +136,25 @@ export function Sessions() {
)} )}
<div className={styles.session}> <div className={styles.session}>
<div className={styles.detail}> <div className={styles.detail}>
<svg width={42} height={42} <svg width={42} height={42} viewBox="0 0 32 32">
viewBox="0 0 32 32">
<foreignObject <foreignObject
x="0" x="0"
y="0" y="0"
width="32" width="32"
height="32" height="32"
mask={systemIcon ? "url(#session)": undefined}> mask={
systemIcon
? "url(#session)"
: undefined
}>
{getIcon(session)} {getIcon(session)}
</foreignObject> </foreignObject>
<foreignObject x="18" y="18" width="14" height="14"> <foreignObject
{ systemIcon } x="18"
y="18"
width="14"
height="14">
{systemIcon}
</foreignObject> </foreignObject>
</svg> </svg>
<div className={styles.info}> <div className={styles.info}>
...@@ -142,7 +163,8 @@ export function Sessions() { ...@@ -142,7 +163,8 @@ export function Sessions() {
className={styles.name} className={styles.name}
value={session.friendly_name} value={session.friendly_name}
autocomplete="off" autocomplete="off"
style={{ pointerEvents: 'none' }} /> style={{ pointerEvents: "none" }}
/>
<span className={styles.time}> <span className={styles.time}>
<Text <Text
id="app.settings.pages.sessions.created" id="app.settings.pages.sessions.created"
...@@ -155,7 +177,7 @@ export function Sessions() { ...@@ -155,7 +177,7 @@ export function Sessions() {
</span> </span>
</div> </div>
</div> </div>
{deviceId !== session.id && ( {deviceId !== session.id && (
<Button <Button
onClick={async () => { onClick={async () => {
setDelete([ setDelete([
...@@ -173,16 +195,38 @@ export function Sessions() { ...@@ -173,16 +195,38 @@ export function Sessions() {
); );
}} }}
disabled={ disabled={
attemptingDelete.indexOf(session.id) > -1 attemptingDelete.indexOf(session.id) >
-1
}> }>
<Text id="app.settings.pages.logOut" /> <Text id="app.settings.pages.logOut" />
</Button> </Button>
)} )}
</div> </div>
</div> </div>
) );
})} })}
<Button error> <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" /> <Text id="app.settings.pages.sessions.logout" />
</Button> </Button>
<Tip> <Tip>
......
...@@ -26,6 +26,7 @@ export function Component(props: Props) { ...@@ -26,6 +26,7 @@ export function Component(props: Props) {
] as [SyncKeys, string][] ] as [SyncKeys, string][]
).map(([key, title]) => ( ).map(([key, title]) => (
<Checkbox <Checkbox
key={key}
checked={ checked={
(props.options?.disabled ?? []).indexOf(key) === -1 (props.options?.disabled ?? []).indexOf(key) === -1
} }
......
import { Servers } from "revolt.js/dist/api/objects"; import { XCircle } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { Route } from "revolt.js/dist/api/routes";
import { Server } from "revolt.js/dist/maps/Servers";
import { useContext, useEffect, useState } from "preact/hooks"; import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import UserIcon from "../../../components/common/user/UserIcon";
import IconButton from "../../../components/ui/IconButton";
import Tip from "../../../components/ui/Tip"; 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).then((bans) => setBans(bans)); server.fetchBans().then(setData);
}, []); }, [server, setData]);
return ( return (
<div> <div className={styles.userList}>
<Tip warning>This section is under construction.</Tip> <div className={styles.subtitle}>
{bans?.map((x) => ( <span>
<div> <Text id="app.settings.server_pages.bans.user" />
{x._id.user}: {x.reason ?? "no reason"}{" "} </span>
<button <span class={styles.reason}>
onClick={() => <Text id="app.settings.server_pages.bans.reason" />
client.servers.unbanUser(server._id, x._id.user) </span>
}> <span>
unban <Text id="app.settings.server_pages.bans.revoke" />
</button> </span>
</div> </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 { XCircle } from "@styled-icons/boxicons-regular"; import { XCircle } from "@styled-icons/boxicons-regular";
import { Invites as InvitesNS, Servers } from "revolt.js/dist/api/objects"; 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 styles from "./Panes.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { import { useClient } from "../../../context/revoltjs/RevoltClient";
useChannels,
useForceUpdate,
useUsers,
} from "../../../context/revoltjs/hooks";
import { getChannelName } from "../../../context/revoltjs/util"; import { getChannelName } from "../../../context/revoltjs/util";
import UserIcon from "../../../components/common/user/UserIcon"; import UserIcon from "../../../components/common/user/UserIcon";
...@@ -17,57 +15,68 @@ import IconButton from "../../../components/ui/IconButton"; ...@@ -17,57 +15,68 @@ import IconButton from "../../../components/ui/IconButton";
import Preloader from "../../../components/ui/Preloader"; 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 server.fetchInvites().then(setInvites);
.fetchInvites(server._id) }, [server, setInvites]);
.then((invites) => setInvites(invites));
}, []);
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) => { {invites?.map((invite, index) => {
let creator = users.find((x) => x?._id === invite.creator); const creator = users![index];
let channel = channels.find((x) => x?._id === invite.channel); const channel = channels![index];
return ( return (
<div <div
key={invite._id}
className={styles.invite} className={styles.invite}
data-deleting={deleting.indexOf(invite._id) > -1}> data-deleting={deleting.indexOf(invite._id) > -1}>
<code>{invite._id}</code> <code>{invite._id}</code>
<span> <span>
<UserIcon target={creator} size={24} />{" "} <UserIcon target={creator} size={24} />{" "}
{creator?.username ?? "unknown"} {creator?.username ?? (
<Text id="app.main.channel.unknown_user" />
)}
</span> </span>
<span> <span>
{channel && creator {channel && creator
? getChannelName(ctx.client, channel, true) ? getChannelName(channel, true)
: "#unknown"} : "#??"}
</span> </span>
<IconButton <IconButton
onClick={async () => { onClick={async () => {
setDelete([...deleting, invite._id]); setDelete([...deleting, invite._id]);
await ctx.client.deleteInvite(invite._id); await client.deleteInvite(invite._id);
setInvites( setInvites(
invites?.filter( invites?.filter(
...@@ -83,4 +92,4 @@ export function Invites({ server }: Props) { ...@@ -83,4 +92,4 @@ export function Invites({ server }: Props) {
})} })}
</div> </div>
); );
} });
import { Servers } from "revolt.js/dist/api/objects"; 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 styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { useForceUpdate, useUsers } from "../../../context/revoltjs/hooks"; import UserIcon from "../../../components/common/user/UserIcon";
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>( const [data, setData] = useState<
undefined, { 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 if (selected) {
.fetchMembers(server._id) setRoles(
.then((members) => setMembers(members)); 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}>
{members?.length ?? 0} Members {data?.members.length ?? 0} Members
</div> </div>
{members && {data &&
members.length > 0 && data.members.length > 0 &&
users?.map( data.members
(x) => .map((member) => {
x && ( return {
<div className={styles.member}> member,
<div>@{x.username}</div> 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> </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 isEqual from "lodash.isequal";
import { Servers, Server } from "revolt.js/dist/api/objects"; import { observer } from "mobx-react-lite";
import { Server } from "revolt.js/dist/maps/Servers";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { FileUploader } from "../../../context/revoltjs/FileUploads"; import { FileUploader } from "../../../context/revoltjs/FileUploads";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { getChannelName } from "../../../context/revoltjs/util"; import { getChannelName } from "../../../context/revoltjs/util";
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
...@@ -16,12 +16,10 @@ import ComboBox from "../../../components/ui/ComboBox"; ...@@ -16,12 +16,10 @@ import ComboBox from "../../../components/ui/ComboBox";
import InputBox from "../../../components/ui/InputBox"; 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( const [systemMessages, setSystemMessages] = useState(
...@@ -40,16 +38,14 @@ export function Overview({ server }: Props) { ...@@ -40,16 +38,14 @@ export function Overview({ server }: Props) {
const [changed, setChanged] = useState(false); const [changed, setChanged] = useState(false);
function save() { function save() {
let changes: Partial< const changes: Record<string, unknown> = {};
Pick<Servers.Server, "name" | "description" | "system_messages">
> = {};
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)) if (!isEqual(systemMessages, server.system_messages))
changes.system_messages = systemMessages; changes.system_messages = systemMessages ?? undefined;
client.servers.edit(server._id, changes); server.edit(changes);
setChanged(false); setChanged(false);
} }
...@@ -63,17 +59,9 @@ export function Overview({ server }: Props) { ...@@ -63,17 +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) => onUpload={(icon) => server.edit({ icon })}
client.servers.edit(server._id, { icon }) previewURL={server.generateIconURL({ max_side: 256 }, true)}
} remove={() => server.edit({ remove: "Icon" })}
previewURL={client.servers.getIconURL(
server._id,
{ max_side: 256 },
true,
)}
remove={() =>
client.servers.edit(server._id, { remove: "Icon" })
}
/> />
<div className={styles.name}> <div className={styles.name}>
<h3> <h3>
...@@ -115,17 +103,9 @@ export function Overview({ server }: Props) { ...@@ -115,17 +103,9 @@ export function Overview({ server }: Props) {
fileType="banners" fileType="banners"
behaviour="upload" behaviour="upload"
maxFileSize={6_000_000} maxFileSize={6_000_000}
onUpload={(banner) => onUpload={(banner) => server.edit({ banner })}
client.servers.edit(server._id, { banner }) previewURL={server.generateBannerURL({ width: 1000 }, true)}
} remove={() => server.edit({ remove: "Banner" })}
previewURL={client.servers.getBannerURL(
server._id,
{ width: 1000 },
true,
)}
remove={() =>
client.servers.edit(server._id, { remove: "Banner" })
}
/> />
<h3> <h3>
...@@ -139,6 +119,7 @@ export function Overview({ server }: Props) { ...@@ -139,6 +119,7 @@ export function Overview({ server }: Props) {
].map(([i18n, key]) => ( ].map(([i18n, key]) => (
// ! FIXME: temporary code just so we can expose the options // ! FIXME: temporary code just so we can expose the options
<p <p
key={key}
style={{ style={{
display: "flex", display: "flex",
gap: "8px", gap: "8px",
...@@ -170,15 +151,13 @@ export function Overview({ server }: Props) { ...@@ -170,15 +151,13 @@ export function Overview({ server }: Props) {
<option value="disabled"> <option value="disabled">
<Text id="general.disabled" /> <Text id="general.disabled" />
</option> </option>
{server.channels.map((id) => { {server.channels
const channel = client.channels.get(id); .filter((x) => typeof x !== "undefined")
if (!channel) return null; .map((channel) => (
return ( <option key={channel!._id} value={channel!._id}>
<option value={id}> {getChannelName(channel!, true)}
{getChannelName(client, channel, true)}
</option> </option>
); ))}
})}
</ComboBox> </ComboBox>
</p> </p>
))} ))}
...@@ -190,4 +169,4 @@ export function Overview({ server }: Props) { ...@@ -190,4 +169,4 @@ export function Overview({ server }: Props) {
</p> </p>
</div> </div>
); );
} });
...@@ -17,21 +17,32 @@ ...@@ -17,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;
}
}
.reason {
flex: 2;
} }
.invite { .invite,
.ban,
.member {
gap: 8px; gap: 8px;
padding: 10px; padding: 10px;
display: flex; display: flex;
...@@ -39,7 +50,8 @@ ...@@ -39,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;
} }
...@@ -58,25 +70,23 @@ ...@@ -58,25 +70,23 @@
opacity: 0.5; opacity: 0.5;
} }
} }
}
.members {
.subtitle {
display: flex;
justify-content: space-between;
font-size: 13px;
text-transform: uppercase;
color: var(--secondary-foreground);
font-weight: 700;
}
.member { .member {
gap: 8px; cursor: pointer;
.chevron {
transition: 0.2s ease all;
}
&:not([data-open="true"]) .chevron {
transform: rotateZ(90deg);
}
}
.memberView {
padding: 10px; padding: 10px;
display: flex; margin: 0 10px;
align-items: center; background: var(--background);
flex-direction: row;
background: var(--secondary-background);
} }
} }
...@@ -95,10 +105,6 @@ ...@@ -95,10 +105,6 @@
flex-grow: 1; flex-grow: 1;
padding: 0 8px; padding: 0 8px;
overflow-y: scroll; overflow-y: scroll;
section {
margin-bottom: 1em;
}
} }
.title { .title {
...@@ -107,7 +113,8 @@ ...@@ -107,7 +113,8 @@
margin-bottom: 1em; margin-bottom: 1em;
align-items: center; align-items: center;
h1, h2 { h1,
h2 {
margin: 0; margin: 0;
min-width: 0; min-width: 0;
flex-grow: 1; flex-grow: 1;
...@@ -126,4 +133,4 @@ ...@@ -126,4 +133,4 @@
display: flex; display: flex;
padding: 8px 0; padding: 8px 0;
} }
} }
\ No newline at end of file
import { Plus } from "@styled-icons/boxicons-regular"; import { Plus } from "@styled-icons/boxicons-regular";
import isEqual from "lodash.isequal"; import isEqual from "lodash.isequal";
import { Servers } from "revolt.js/dist/api/objects"; import { observer } from "mobx-react-lite";
import { import { ChannelPermission, ServerPermission } from "revolt.js";
ChannelPermission, import { Server } from "revolt.js/dist/maps/Servers";
ServerPermission,
} from "revolt.js/dist/api/permissions";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks"; import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
import Checkbox from "../../../components/ui/Checkbox"; import Checkbox from "../../../components/ui/Checkbox";
import IconButton from "../../../components/ui/IconButton"; import ColourSwatches from "../../../components/ui/ColourSwatches";
import InputBox from "../../../components/ui/InputBox"; import InputBox from "../../../components/ui/InputBox";
import Overline from "../../../components/ui/Overline"; import Overline from "../../../components/ui/Overline";
import Tip from "../../../components/ui/Tip";
import ButtonItem from "../../../components/navigation/items/ButtonItem"; import ButtonItem from "../../../components/navigation/items/ButtonItem";
interface Props { interface Props {
server: Servers.Server; server: Server;
} }
const I32ToU32 = (arr: number[]) => arr.map((x) => x >>> 0); const I32ToU32 = (arr: number[]) => arr.map((x) => x >>> 0);
// ! FIXME: bad code :) // ! FIXME: bad code :)
export function Roles({ server }: Props) { export const Roles = observer(({ server }: Props) => {
const [role, setRole] = useState("default"); const [role, setRole] = useState("default");
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const client = useContext(AppContext); const roles = useMemo(() => server.roles ?? {}, [server]);
const roles = server.roles ?? {};
if (role !== "default" && typeof roles[role] === "undefined") { if (role !== "default" && typeof roles[role] === "undefined") {
useEffect(() => setRole("default")); useEffect(() => setRole("default"), [role]);
return null; return null;
} }
const v = (id: string) => const {
I32ToU32( name: roleName,
id === "default" colour: roleColour,
? server.default_permissions permissions,
: roles[id].permissions, } = roles[role] ?? {};
);
const [perm, setPerm] = useState(v(role)); const getPermissions = useCallback(
useEffect(() => setPerm(v(role)), [role, roles[role]?.permissions]); (id: string) => {
return I32ToU32(
const modified = !isEqual(perm, v(role)); id === "default"
const save = () => ? server.default_permissions
client.servers.setPermissions(server._id, role, { : roles[id].permissions,
server: perm[0], );
channel: perm[1], },
}); [roles, server],
);
const [perm, setPerm] = useState(getPermissions(role));
const [name, setName] = useState(roleName);
const [colour, setColour] = useState(roleColour);
useEffect(
() => setPerm(getPermissions(role)),
[getPermissions, role, permissions],
);
useEffect(() => setName(roleName), [role, roleName]);
useEffect(() => setColour(roleColour), [role, roleColour]);
const modified =
!isEqual(perm, getPermissions(role)) ||
!isEqual(name, roleName) ||
!isEqual(colour, roleColour);
const save = () => {
if (!isEqual(perm, getPermissions(role))) {
server.setPermissions(role, {
server: perm[0],
channel: perm[1],
});
}
if (!isEqual(name, roleName) || !isEqual(colour, roleColour)) {
server.editRole(role, { name, colour });
}
};
const deleteRole = () => { const deleteRole = () => {
setRole("default"); setRole("default");
client.servers.deleteRole(server._id, role); server.deleteRole(role);
}; };
return ( return (
...@@ -73,7 +100,7 @@ export function Roles({ server }: Props) { ...@@ -73,7 +100,7 @@ export function Roles({ server }: Props) {
openScreen({ openScreen({
id: "special_input", id: "special_input",
type: "create_role", type: "create_role",
server: server._id, server,
callback: (id) => setRole(id), callback: (id) => setRole(id),
}) })
} }
...@@ -88,15 +115,16 @@ export function Roles({ server }: Props) { ...@@ -88,15 +115,16 @@ export function Roles({ server }: Props) {
<Text id="app.settings.permissions.default_role" /> <Text id="app.settings.permissions.default_role" />
</ButtonItem> </ButtonItem>
); );
} else {
return (
<ButtonItem
active={role === id}
onClick={() => setRole(id)}>
{roles[id].name}
</ButtonItem>
);
} }
return (
<ButtonItem
key={id}
active={role === id}
onClick={() => setRole(id)}
style={{ color: roles[id].colour }}>
{roles[id].name}
</ButtonItem>
);
})} })}
</div> </div>
<div className={styles.permissions}> <div className={styles.permissions}>
...@@ -112,19 +140,45 @@ export function Roles({ server }: Props) { ...@@ -112,19 +140,45 @@ export function Roles({ server }: Props) {
Save Save
</Button> </Button>
</div> </div>
{role !== "default" && (
<>
<section>
<Overline type="subtle">Role Name</Overline>
<p>
<InputBox
value={name}
onChange={(e) =>
setName(e.currentTarget.value)
}
contrast
/>
</p>
</section>
<section>
<Overline type="subtle">Role Colour</Overline>
<p>
<ColourSwatches
value={colour ?? "gray"}
onChange={(value) => setColour(value)}
/>
</p>
</section>
</>
)}
<section> <section>
<Overline type="subtle"> <Overline type="subtle">
<Text id="app.settings.permissions.server" /> <Text id="app.settings.permissions.server" />
</Overline> </Overline>
{Object.keys(ServerPermission).map((key) => { {Object.keys(ServerPermission).map((key) => {
if (key === "View") return; if (key === "View") return;
let value = const value =
ServerPermission[ ServerPermission[
key as keyof typeof ServerPermission key as keyof typeof ServerPermission
]; ];
return ( return (
<Checkbox <Checkbox
key={key}
checked={(perm[0] & value) > 0} checked={(perm[0] & value) > 0}
onChange={() => onChange={() =>
setPerm([perm[0] ^ value, perm[1]]) setPerm([perm[0] ^ value, perm[1]])
...@@ -143,13 +197,14 @@ export function Roles({ server }: Props) { ...@@ -143,13 +197,14 @@ export function Roles({ server }: Props) {
</Overline> </Overline>
{Object.keys(ChannelPermission).map((key) => { {Object.keys(ChannelPermission).map((key) => {
if (key === "ManageChannel") return; if (key === "ManageChannel") return;
let value = const value =
ChannelPermission[ ChannelPermission[
key as keyof typeof ChannelPermission key as keyof typeof ChannelPermission
]; ];
return ( return (
<Checkbox <Checkbox
key={key}
checked={((perm[1] >>> 0) & value) > 0} checked={((perm[1] >>> 0) & value) > 0}
onChange={() => onChange={() =>
setPerm([perm[0], perm[1] ^ value]) setPerm([perm[0], perm[1] ^ value])
...@@ -176,4 +231,4 @@ export function Roles({ server }: Props) { ...@@ -176,4 +231,4 @@ export function Roles({ server }: Props) {
</div> </div>
</div> </div>
); );
} });
// eslint-disable-next-line @typescript-eslint/no-unused-vars /* eslint-disable */
import JSX = preact.JSX; import JSX = preact.JSX;