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 1692 additions and 1168 deletions
.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%;
}
.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;
...@@ -17,35 +40,48 @@ ...@@ -17,35 +40,48 @@
} }
} }
.username { .userid {
font-size: 24px; font-size: 12px;
font-weight: 600; 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;
> svg { > svg {
flex-shrink: 0; flex-shrink: 0;
} }
} }
.detail {
flex-grow: 1;
}
p { p {
margin: 0; margin: 0;
font-size: 1rem;
color: var(--tertiary-foreground); color: var(--tertiary-foreground);
} }
} }
...@@ -55,7 +91,7 @@ ...@@ -55,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;
...@@ -76,6 +112,25 @@ ...@@ -76,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 {
...@@ -88,12 +143,14 @@ ...@@ -88,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;
...@@ -110,29 +167,13 @@ ...@@ -110,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 {
...@@ -152,15 +193,15 @@ ...@@ -152,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%;
...@@ -193,6 +234,12 @@ ...@@ -193,6 +234,12 @@
text-decoration: underline; text-decoration: underline;
} }
} }
@media only screen and (max-width: 800px) {
a {
display: block;
}
}
} }
} }
...@@ -205,56 +252,92 @@ ...@@ -205,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);
}
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;
} }
} }
} }
...@@ -263,20 +346,39 @@ ...@@ -263,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"] {
...@@ -285,27 +387,33 @@ ...@@ -285,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;
} }
...@@ -315,42 +423,83 @@ ...@@ -315,42 +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;
}
}
}
> button {
margin-top: 20px;
}
@media only screen and (max-width: 800px) {
.session {
align-items: unset;
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 {
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: 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 {
...@@ -366,12 +515,16 @@ ...@@ -366,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,
} from "../../../components/common/AutoComplete"; } from "../../../components/common/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>( // ! FIXME: temporary solution
undefined, // ! we should just announce profile changes through WS
); const refreshProfile = useCallback(() => {
client
.user!.fetchProfile()
.then((profile) => setProfile(profile ?? {}));
}, [client.user, setProfile]);
// ! FIXME: temporary solution useEffect(() => {
// ! we should just announce profile changes through WS if (profile === undefined && status === ClientStatus.ONLINE) {
function refreshProfile() { refreshProfile();
ctx.client.users }
.fetchProfile(user!._id) }, [profile, status, refreshProfile]);
.then((profile) => setProfile(profile ?? {}));
}
useEffect(() => { const [changed, setChanged] = useState(false);
if (profile === undefined && status === ClientStatus.ONLINE) { function setContent(content?: string) {
refreshProfile(); setProfile({ ...profile, content });
} if (!changed) setChanged(true);
}, [status]); }
const [changed, setChanged] = useState(false); const {
function setContent(content?: string) { onChange,
setProfile({ ...profile, content }); onKeyUp,
if (!changed) setChanged(true); onKeyDown,
} onFocus,
onBlur,
...autoCompleteProps
} = useAutoComplete(setContent, {
users: { type: "all" },
});
const { return (
onChange, <div className={styles.user}>
onKeyUp, <h3>
onKeyDown, <Text id="app.special.modals.actions.preview" />
onFocus, </h3>
onBlur, <div className={styles.preview}>
...autoCompleteProps <UserProfile
} = useAutoComplete(setContent, { user_id={client.user!._id}
users: { type: "all" }, dummy={true}
}); dummyProfile={profile}
/>
return ( </div>
<div className={styles.user}> <div className={styles.row}>
<h3> <div className={styles.pfp}>
<Text id="app.special.modals.actions.preview" /> <h3>
</h3> <Text id="app.settings.pages.profile.profile_picture" />
<div className={styles.preview}> </h3>
<UserProfile <FileUploader
user_id={user._id} width={80}
dummy={true} height={80}
dummyProfile={profile} style="icon"
onClose={() => {}} fileType="avatars"
/> behaviour="upload"
</div> maxFileSize={4_000_000}
<div className={styles.row}> onUpload={(avatar) => client.users.edit({ avatar })}
<div className={styles.pfp}> remove={() => client.users.edit({ remove: "Avatar" })}
<h3> defaultPreview={client.user!.generateAvatarURL(
<Text id="app.settings.pages.profile.profile_picture" /> { max_side: 256 },
</h3> true,
<FileUploader )}
width={80} previewURL={client.user!.generateAvatarURL(
height={80} { max_side: 256 },
style="icon" true,
fileType="avatars" )}
behaviour="upload" />
maxFileSize={4_000_000} </div>
onUpload={(avatar) => <div className={styles.background}>
ctx.client.users.editUser({ avatar }) <h3>
} <Text id="app.settings.pages.profile.custom_background" />
remove={() => </h3>
ctx.client.users.editUser({ remove: "Avatar" }) <FileUploader
} height={92}
defaultPreview={ctx.client.users.getAvatarURL( style="banner"
user._id, behaviour="upload"
{ max_side: 256 }, fileType="backgrounds"
true, maxFileSize={6_000_000}
)} onUpload={async (background) => {
previewURL={ctx.client.users.getAvatarURL( await client.users.edit({
user._id, profile: { background },
{ max_side: 256 }, });
true, refreshProfile();
true, }}
)} remove={async () => {
/> await client.users.edit({
</div> remove: "ProfileBackground",
<div className={styles.background}> });
<h3> setProfile({ ...profile, background: undefined });
<Text id="app.settings.pages.profile.custom_background" /> }}
</h3> previewURL={
<FileUploader profile?.background
height={92} ? client.generateFileURL(
style="banner" profile.background,
behaviour="upload" { width: 1000 },
fileType="backgrounds" true,
maxFileSize={6_000_000} )
onUpload={async (background) => { : undefined
await ctx.client.users.editUser({ }
profile: { background }, />
}); </div>
refreshProfile(); </div>
}} <h3>
remove={async () => { <Text id="app.settings.pages.profile.info" />
await ctx.client.users.editUser({ </h3>
remove: "ProfileBackground", <AutoComplete detached {...autoCompleteProps} />
}); <TextAreaAutoSize
setProfile({ ...profile, background: undefined }); maxRows={10}
}} minHeight={200}
previewURL={ maxLength={2000}
profile?.background value={profile?.content ?? ""}
? ctx.client.users.getBackgroundURL( disabled={typeof profile === "undefined"}
profile, onChange={(ev) => {
{ width: 1000 }, onChange(ev);
true, setContent(ev.currentTarget.value);
) }}
: undefined placeholder={translate(
} `app.settings.pages.profile.${
/> typeof profile === "undefined"
</div> ? "fetching"
</div> : "placeholder"
<h3> }`,
<Text id="app.settings.pages.profile.info" /> )}
</h3> onKeyUp={onKeyUp}
<AutoComplete detached {...autoCompleteProps} /> onKeyDown={onKeyDown}
<TextAreaAutoSize onFocus={onFocus}
maxRows={10} onBlur={onBlur}
minHeight={200} />
maxLength={2000} <p>
value={profile?.content ?? ""} <Button
disabled={typeof profile === "undefined"} contrast
onChange={(ev) => { onClick={() => {
onChange(ev); setChanged(false);
setContent(ev.currentTarget.value); client.users.edit({
}} profile: { content: profile?.content },
placeholder={translate( });
`app.settings.pages.profile.${ }}
typeof profile === "undefined" disabled={!changed}>
? "fetching" <Text id="app.special.modals.actions.save" />
: "placeholder" </Button>
}`, </p>
"", </div>
(intl as any).dictionary as Record<string, unknown>, );
)}
onKeyUp={onKeyUp}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
/>
<p>
<Button
contrast
onClick={() => {
setChanged(false);
ctx.client.users.editUser({
profile: { content: profile?.content },
});
}}
disabled={!changed}>
<Text id="app.special.modals.actions.save" />
</Button>
</p>
</div>
);
} }
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 { import {
Android, Safari,
Firefoxbrowser, Firefoxbrowser,
Googlechrome, Microsoftedge,
Ios, Linux,
Linux, Macos,
Macos, Opera,
Microsoftedge,
Safari,
Windows,
} from "@styled-icons/simple-icons"; } from "@styled-icons/simple-icons";
import dayjs from "dayjs";
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";
...@@ -19,6 +16,7 @@ import styles from "./Panes.module.scss"; ...@@ -19,6 +16,7 @@ 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 { dayjs } from "../../../context/Locale";
import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { AppContext } from "../../../context/revoltjs/RevoltClient";
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
...@@ -28,159 +26,217 @@ import Tip from "../../../components/ui/Tip"; ...@@ -28,159 +26,217 @@ import Tip from "../../../components/ui/Tip";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
interface Session { interface Session {
id: string; id: string;
friendly_name: string; friendly_name: string;
} }
export function Sessions() { export function Sessions() {
const client = useContext(AppContext); const client = useContext(AppContext);
const deviceId = client.session?.id; const deviceId = client.session?.id;
const [sessions, setSessions] = useState<Session[] | undefined>(undefined); const [sessions, setSessions] = useState<Session[] | undefined>(undefined);
const [attemptingDelete, setDelete] = useState<string[]>([]); const [attemptingDelete, setDelete] = useState<string[]>([]);
const history = useHistory(); const history = useHistory();
function switchPage(to: string) { function switchPage(to: string) {
history.replace(`/settings/${to}`); history.replace(`/settings/${to}`);
} }
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 (
<div className={styles.loader}> <div className={styles.loader}>
<Preloader type="ring" /> <Preloader type="ring" />
</div> </div>
); );
} }
function getIcon(session: Session) { function getIcon(session: Session) {
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} />;
default: case /opera/i.test(name):
return <HelpCircle />; return <Opera size={32} />;
} case /desktop/i.test(name):
} return <Desktop size={32} />;
default:
function getSystemIcon(session: Session) { return <HelpCircle size={32} />;
const name = session.friendly_name; }
switch (true) { }
case /linux/i.test(name):
return <Linux />; function getSystemIcon(session: Session) {
case /android/i.test(name): const name = session.friendly_name;
return <Android />; switch (true) {
case /mac.*os/i.test(name): case /linux/i.test(name):
return <Macos />; return <Linux size={14} />;
case /ios/i.test(name): case /android/i.test(name):
return <Ios />; return <Android size={14} />;
case /windows/i.test(name): case /mac.*os/i.test(name):
return <Windows />; return <Macos size={14} />;
default: case /ios/i.test(name):
return null; return <Apple size={14} />;
} case /windows/i.test(name):
} return <Windows size={14} />;
default:
const mapped = sessions.map((session) => { return null;
return { }
...session, }
timestamp: decodeTime(session.id),
}; const mapped = sessions.map((session) => {
}); return {
...session,
mapped.sort((a, b) => b.timestamp - a.timestamp); timestamp: decodeTime(session.id),
let id = mapped.findIndex((x) => x.id === deviceId); };
});
const render = [
mapped[id], mapped.sort((a, b) => b.timestamp - a.timestamp);
...mapped.slice(0, id), const id = mapped.findIndex((x) => x.id === deviceId);
...mapped.slice(id + 1, mapped.length),
]; const render = [
mapped[id],
return ( ...mapped.slice(0, id),
<div className={styles.sessions}> ...mapped.slice(id + 1, mapped.length),
<h3> ];
<Text id="app.settings.pages.sessions.active_sessions" />
</h3> return (
{render.map((session) => ( <div className={styles.sessions}>
<div <h3>
className={styles.entry} <Text id="app.settings.pages.sessions.active_sessions" />
data-active={session.id === deviceId} </h3>
data-deleting={attemptingDelete.indexOf(session.id) > -1}> {render.map((session) => {
{deviceId === session.id && ( const systemIcon = getSystemIcon(session);
<span className={styles.label}> return (
<Text id="app.settings.pages.sessions.this_device" />{" "} <div
</span> key={session.id}
)} className={styles.entry}
<div className={styles.session}> data-active={session.id === deviceId}
<div className={styles.icon}> data-deleting={
{getIcon(session)} attemptingDelete.indexOf(session.id) > -1
<div>{getSystemIcon(session)}</div> }>
</div> {deviceId === session.id && (
<div className={styles.info}> <span className={styles.label}>
<span className={styles.name}> <Text id="app.settings.pages.sessions.this_device" />{" "}
{session.friendly_name} </span>
</span> )}
<span className={styles.time}> <div className={styles.session}>
<Text <div className={styles.detail}>
id="app.settings.pages.sessions.created" <svg width={42} height={42} viewBox="0 0 32 32">
fields={{ <foreignObject
time_ago: dayjs( x="0"
session.timestamp, y="0"
).fromNow(), width="32"
}} height="32"
/> mask={
</span> systemIcon
</div> ? "url(#session)"
{deviceId !== session.id && ( : undefined
<Button }>
onClick={async () => { {getIcon(session)}
setDelete([ </foreignObject>
...attemptingDelete, <foreignObject
session.id, x="18"
]); y="18"
await client.req( width="14"
"DELETE", height="14">
`/auth/sessions/${session.id}` as "/auth/sessions", {systemIcon}
); </foreignObject>
setSessions( </svg>
sessions?.filter( <div className={styles.info}>
(x) => x.id !== session.id, <input
), type="text"
); className={styles.name}
}} value={session.friendly_name}
disabled={ autocomplete="off"
attemptingDelete.indexOf(session.id) > -1 style={{ pointerEvents: "none" }}
}> />
<Text id="app.settings.pages.logOut" /> <span className={styles.time}>
</Button> <Text
)} id="app.settings.pages.sessions.created"
</div> fields={{
</div> time_ago: dayjs(
))} session.timestamp,
<Tip> ).fromNow(),
<span> }}
<Text id="app.settings.tips.sessions.a" /> />
</span>{" "} </span>
<a onClick={() => switchPage("account")}> </div>
<Text id="app.settings.tips.sessions.b" /> </div>
</a> {deviceId !== session.id && (
</Tip> <Button
</div> 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={
attemptingDelete.indexOf(session.id) >
-1
}>
<Text id="app.settings.pages.logOut" />
</Button>
)}
</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>
<span>
<Text id="app.settings.tips.sessions.a" />
</span>{" "}
<a onClick={() => switchPage("account")}>
<Text id="app.settings.tips.sessions.b" />
</a>
</Tip>
</div>
);
} }
...@@ -8,49 +8,50 @@ import { SyncKeys, SyncOptions } from "../../../redux/reducers/sync"; ...@@ -8,49 +8,50 @@ import { SyncKeys, SyncOptions } from "../../../redux/reducers/sync";
import Checkbox from "../../../components/ui/Checkbox"; import Checkbox from "../../../components/ui/Checkbox";
interface Props { interface Props {
options?: SyncOptions; options?: SyncOptions;
} }
export function Component(props: Props) { 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][] ] as [SyncKeys, string][]
).map(([key, title]) => ( ).map(([key, title]) => (
<Checkbox <Checkbox
checked={ key={key}
(props.options?.disabled ?? []).indexOf(key) === -1 checked={
} (props.options?.disabled ?? []).indexOf(key) === -1
description={ }
<Text description={
id={`app.settings.pages.sync.descriptions.${key}`} <Text
/> id={`app.settings.pages.sync.descriptions.${key}`}
} />
onChange={(enabled) => }
dispatch({ onChange={(enabled) =>
type: enabled dispatch({
? "SYNC_ENABLE_KEY" type: enabled
: "SYNC_DISABLE_KEY", ? "SYNC_ENABLE_KEY"
key, : "SYNC_DISABLE_KEY",
}) key,
}> })
<Text id={`app.settings.pages.${title}`} /> }>
</Checkbox> <Text id={`app.settings.pages.${title}`} />
))} </Checkbox>
</div> ))}
); </div>
);
} }
export const Sync = connectState(Component, (state) => { export const Sync = connectState(Component, (state) => {
return { return {
options: state.sync, options: state.sync,
}; };
}); });
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
useEffect(() => { >(undefined);
client.servers.fetchBans(server._id).then((bans) => setBans(bans));
}, []); useEffect(() => {
server.fetchBans().then(setData);
return ( }, [server, setData]);
<div>
<Tip warning>This section is under construction.</Tip> return (
{bans?.map((x) => ( <div className={styles.userList}>
<div> <div className={styles.subtitle}>
{x._id.user}: {x.reason ?? "no reason"}{" "} <span>
<button <Text id="app.settings.server_pages.bans.user" />
onClick={() => </span>
client.servers.unbanUser(server._id, x._id.user) <span class={styles.reason}>
}> <Text id="app.settings.server_pages.bans.reason" />
unban </span>
</button> <span>
</div> <Text id="app.settings.server_pages.bans.revoke" />
))} </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>
);
});
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,70 +15,81 @@ import IconButton from "../../../components/ui/IconButton"; ...@@ -17,70 +15,81 @@ 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< const [deleting, setDelete] = useState<string[]>([]);
InvitesNS.ServerInvite[] | undefined const [invites, setInvites] = useState<ServerInvite[] | undefined>(
>(undefined); undefined,
);
const ctx = useForceUpdate(); const client = useClient();
const [deleting, setDelete] = useState<string[]>([]); const users = invites?.map((invite) => client.users.get(invite.creator));
const users = useUsers(invites?.map((x) => x.creator) ?? [], ctx); const channels = invites?.map((invite) =>
const channels = useChannels(invites?.map((x) => x.channel) ?? [], ctx); 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>
</div> <Text id="app.settings.server_pages.invites.invitor" />
{typeof invites === "undefined" && <Preloader type="ring" />} </span>
{invites?.map((invite) => { <span>
let creator = users.find((x) => x?._id === invite.creator); <Text id="app.settings.server_pages.invites.channel" />
let channel = channels.find((x) => x?._id === invite.channel); </span>
<span>
<Text id="app.settings.server_pages.invites.revoke" />
</span>
</div>
{typeof invites === "undefined" && <Preloader type="ring" />}
{invites?.map((invite, index) => {
const creator = users![index];
const channel = channels![index];
return ( return (
<div <div
className={styles.invite} key={invite._id}
data-deleting={deleting.indexOf(invite._id) > -1}> className={styles.invite}
<code>{invite._id}</code> data-deleting={deleting.indexOf(invite._id) > -1}>
<span> <code>{invite._id}</code>
<UserIcon target={creator} size={24} />{" "} <span>
{creator?.username ?? "unknown"} <UserIcon target={creator} size={24} />{" "}
</span> {creator?.username ?? (
<span> <Text id="app.main.channel.unknown_user" />
{channel && creator )}
? getChannelName(ctx.client, channel, true) </span>
: "#unknown"} <span>
</span> {channel && creator
<IconButton ? getChannelName(channel, true)
onClick={async () => { : "#??"}
setDelete([...deleting, invite._id]); </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 { 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]);
useEffect(() => { const [roles, setRoles] = useState<string[]>([]);
ctx.client.servers.members useEffect(() => {
.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}>
{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(
</div> (x) => x._id === member._id.user,
), ),
)} };
</div> })
); .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>
);
});
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,178 +16,157 @@ import ComboBox from "../../../components/ui/ComboBox"; ...@@ -16,178 +16,157 @@ 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 [description, setDescription] = useState(server.description ?? "");
const [systemMessages, setSystemMessages] = useState(
server.system_messages,
);
const [name, setName] = useState(server.name); useEffect(() => setName(server.name), [server.name]);
const [description, setDescription] = useState(server.description ?? ""); useEffect(
const [systemMessages, setSystemMessages] = useState( () => setDescription(server.description ?? ""),
server.system_messages, [server.description],
); );
useEffect(
() => setSystemMessages(server.system_messages),
[server.system_messages],
);
useEffect(() => setName(server.name), [server.name]); const [changed, setChanged] = useState(false);
useEffect( function save() {
() => setDescription(server.description ?? ""), const changes: Record<string, unknown> = {};
[server.description], if (name !== server.name) changes.name = name;
); if (description !== server.description)
useEffect( changes.description = description;
() => setSystemMessages(server.system_messages), if (!isEqual(systemMessages, server.system_messages))
[server.system_messages], changes.system_messages = systemMessages ?? undefined;
);
const [changed, setChanged] = useState(false); server.edit(changes);
function save() { setChanged(false);
let changes: Partial< }
Pick<Servers.Server, "name" | "description" | "system_messages">
> = {};
if (name !== server.name) changes.name = name;
if (description !== server.description)
changes.description = description;
if (!isEqual(systemMessages, server.system_messages))
changes.system_messages = systemMessages;
client.servers.edit(server._id, changes); return (
setChanged(false); <div className={styles.overview}>
} <div className={styles.row}>
<FileUploader
width={80}
height={80}
style="icon"
fileType="icons"
behaviour="upload"
maxFileSize={2_500_000}
onUpload={(icon) => server.edit({ icon })}
previewURL={server.generateIconURL({ max_side: 256 }, true)}
remove={() => server.edit({ remove: "Icon" })}
/>
<div className={styles.name}>
<h3>
<Text id="app.main.servers.name" />
</h3>
<InputBox
contrast
value={name}
maxLength={32}
onChange={(e) => {
setName(e.currentTarget.value);
if (!changed) setChanged(true);
}}
/>
</div>
</div>
return ( <h3>
<div className={styles.overview}> <Text id="app.main.servers.description" />
<div className={styles.row}> </h3>
<FileUploader <TextAreaAutoSize
width={80} maxRows={10}
height={80} minHeight={60}
style="icon" maxLength={1024}
fileType="icons" value={description}
behaviour="upload" placeholder={"Add a topic..."}
maxFileSize={2_500_000} onChange={(ev) => {
onUpload={(icon) => setDescription(ev.currentTarget.value);
client.servers.edit(server._id, { icon }) if (!changed) setChanged(true);
} }}
previewURL={client.servers.getIconURL( />
server._id,
{ max_side: 256 },
true,
)}
remove={() =>
client.servers.edit(server._id, { remove: "Icon" })
}
/>
<div className={styles.name}>
<h3>
<Text id="app.main.servers.name" />
</h3>
<InputBox
contrast
value={name}
maxLength={32}
onChange={(e) => {
setName(e.currentTarget.value);
if (!changed) setChanged(true);
}}
/>
</div>
</div>
<h3> <h3>
<Text id="app.main.servers.description" /> <Text id="app.main.servers.custom_banner" />
</h3> </h3>
<TextAreaAutoSize <FileUploader
maxRows={10} height={160}
minHeight={60} style="banner"
maxLength={1024} fileType="banners"
value={description} behaviour="upload"
placeholder={"Add a topic..."} maxFileSize={6_000_000}
onChange={(ev) => { onUpload={(banner) => server.edit({ banner })}
setDescription(ev.currentTarget.value); previewURL={server.generateBannerURL({ width: 1000 }, true)}
if (!changed) setChanged(true); remove={() => server.edit({ remove: "Banner" })}
}} />
/>
<h3> <h3>
<Text id="app.main.servers.custom_banner" /> <Text id="app.settings.server_pages.overview.system_messages" />
</h3> </h3>
<FileUploader {[
height={160} ["User Joined", "user_joined"],
style="banner" ["User Left", "user_left"],
fileType="banners" ["User Kicked", "user_kicked"],
behaviour="upload" ["User Banned", "user_banned"],
maxFileSize={6_000_000} ].map(([i18n, key]) => (
onUpload={(banner) => // ! FIXME: temporary code just so we can expose the options
client.servers.edit(server._id, { banner }) <p
} key={key}
previewURL={client.servers.getBannerURL( style={{
server._id, display: "flex",
{ width: 1000 }, gap: "8px",
true, alignItems: "center",
)} }}>
remove={() => <span style={{ flexShrink: "0", flex: `25%` }}>{i18n}</span>
client.servers.edit(server._id, { remove: "Banner" }) <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>
))}
<h3> <p>
<Text id="app.settings.server_pages.overview.system_messages" /> <Button onClick={save} contrast disabled={!changed}>
</h3> <Text id="app.special.modals.actions.save" />
{[ </Button>
["User Joined", "user_joined"], </p>
["User Left", "user_left"], </div>
["User Kicked", "user_kicked"], );
["User Banned", "user_banned"], });
].map(([i18n, key]) => (
// ! FIXME: temporary code just so we can expose the options
<p
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.map((id) => {
const channel = client.channels.get(id);
if (!channel) return null;
return (
<option value={id}>
{getChannelName(client, channel, true)}
</option>
);
})}
</ComboBox>
</p>
))}
<p>
<Button onClick={save} contrast disabled={!changed}>
<Text id="app.special.modals.actions.save" />
</Button>
</p>
</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;
}
}
.reason {
flex: 2;
} }
.invite { .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,23 @@ ...@@ -54,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);
} }
} }
...@@ -91,10 +105,6 @@ ...@@ -91,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 {
...@@ -103,7 +113,8 @@ ...@@ -103,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;
...@@ -122,4 +133,4 @@ ...@@ -122,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"), [role]);
useEffect(() => setRole("default")); return null;
return null; }
}
const {
const v = (id: string) => name: roleName,
I32ToU32( colour: roleColour,
id === "default" permissions,
? server.default_permissions } = roles[role] ?? {};
: roles[id].permissions,
); const getPermissions = useCallback(
const [perm, setPerm] = useState(v(role)); (id: string) => {
useEffect(() => setPerm(v(role)), [role, roles[role]?.permissions]); return I32ToU32(
id === "default"
const modified = !isEqual(perm, v(role)); ? server.default_permissions
const save = () => : roles[id].permissions,
client.servers.setPermissions(server._id, role, { );
server: perm[0], },
channel: perm[1], [roles, server],
}); );
const deleteRole = () => {
setRole("default"); const [perm, setPerm] = useState(getPermissions(role));
client.servers.deleteRole(server._id, role); const [name, setName] = useState(roleName);
}; const [colour, setColour] = useState(roleColour);
return ( useEffect(
<div className={styles.roles}> () => setPerm(getPermissions(role)),
<div className={styles.list}> [getPermissions, role, permissions],
<div className={styles.title}> );
<h1>
<Text id="app.settings.server_pages.roles.title" /> useEffect(() => setName(roleName), [role, roleName]);
</h1> useEffect(() => setColour(roleColour), [role, roleColour]);
<Plus
size={22} const modified =
onClick={() => !isEqual(perm, getPermissions(role)) ||
openScreen({ !isEqual(name, roleName) ||
id: "special_input", !isEqual(colour, roleColour);
type: "create_role",
server: server._id, const save = () => {
callback: (id) => setRole(id), if (!isEqual(perm, getPermissions(role))) {
}) server.setPermissions(role, {
} server: perm[0],
/> channel: perm[1],
</div> });
{["default", ...Object.keys(roles)].map((id) => { }
if (id === "default") {
return ( if (!isEqual(name, roleName) || !isEqual(colour, roleColour)) {
<ButtonItem server.editRole(role, { name, colour });
active={role === "default"} }
onClick={() => setRole("default")}> };
<Text id="app.settings.permissions.default_role" />
</ButtonItem> const deleteRole = () => {
); setRole("default");
} else { server.deleteRole(role);
return ( };
<ButtonItem
active={role === id} return (
onClick={() => setRole(id)}> <div className={styles.roles}>
{roles[id].name} <div className={styles.list}>
</ButtonItem> <div className={styles.title}>
); <h1>
} <Text id="app.settings.server_pages.roles.title" />
})} </h1>
</div> <Plus
<div className={styles.permissions}> size={22}
<div className={styles.title}> onClick={() =>
<h2> openScreen({
{role === "default" ? ( id: "special_input",
<Text id="app.settings.permissions.default_role" /> type: "create_role",
) : ( server,
roles[role].name callback: (id) => setRole(id),
)} })
</h2> }
<Button contrast disabled={!modified} onClick={save}> />
Save </div>
</Button> {["default", ...Object.keys(roles)].map((id) => {
</div> if (id === "default") {
<section> return (
<Overline type="subtle"> <ButtonItem
<Text id="app.settings.permissions.server" /> active={role === "default"}
</Overline> onClick={() => setRole("default")}>
{Object.keys(ServerPermission).map((key) => { <Text id="app.settings.permissions.default_role" />
if (key === "View") return; </ButtonItem>
let value = );
ServerPermission[ }
key as keyof typeof ServerPermission return (
]; <ButtonItem
key={id}
return ( active={role === id}
<Checkbox onClick={() => setRole(id)}
checked={(perm[0] & value) > 0} style={{ color: roles[id].colour }}>
onChange={() => {roles[id].name}
setPerm([perm[0] ^ value, perm[1]]) </ButtonItem>
} );
description={ })}
<Text id={`permissions.server.${key}.d`} /> </div>
}> <div className={styles.permissions}>
<Text id={`permissions.server.${key}.t`} /> <div className={styles.title}>
</Checkbox> <h2>
); {role === "default" ? (
})} <Text id="app.settings.permissions.default_role" />
</section> ) : (
<section> roles[role].name
<Overline type="subtle"> )}
<Text id="app.settings.permissions.channel" /> </h2>
</Overline> <Button contrast disabled={!modified} onClick={save}>
{Object.keys(ChannelPermission).map((key) => { Save
if (key === "ManageChannel") return; </Button>
let value = </div>
ChannelPermission[ {role !== "default" && (
key as keyof typeof ChannelPermission <>
]; <section>
<Overline type="subtle">Role Name</Overline>
return ( <p>
<Checkbox <InputBox
checked={((perm[1] >>> 0) & value) > 0} value={name}
onChange={() => onChange={(e) =>
setPerm([perm[0], perm[1] ^ value]) setName(e.currentTarget.value)
} }
disabled={key === "View"} contrast
description={ />
<Text id={`permissions.channel.${key}.d`} /> </p>
}> </section>
<Text id={`permissions.channel.${key}.t`} /> <section>
</Checkbox> <Overline type="subtle">Role Colour</Overline>
); <p>
})} <ColourSwatches
</section> value={colour ?? "gray"}
<div className={styles.actions}> onChange={(value) => setColour(value)}
<Button contrast disabled={!modified} onClick={save}> />
Save </p>
</Button> </section>
{role !== "default" && ( </>
<Button contrast error onClick={deleteRole}> )}
Delete <section>
</Button> <Overline type="subtle">
)} <Text id="app.settings.permissions.server" />
</div> </Overline>
</div> {Object.keys(ServerPermission).map((key) => {
</div> if (key === "View") return;
); const value =
} ServerPermission[
key as keyof typeof ServerPermission
];
return (
<Checkbox
key={key}
checked={(perm[0] & value) > 0}
onChange={() =>
setPerm([perm[0] ^ value, perm[1]])
}
description={
<Text id={`permissions.server.${key}.d`} />
}>
<Text id={`permissions.server.${key}.t`} />
</Checkbox>
);
})}
</section>
<section>
<Overline type="subtle">
<Text id="app.settings.permissions.channel" />
</Overline>
{Object.keys(ChannelPermission).map((key) => {
if (key === "ManageChannel") return;
const value =
ChannelPermission[
key as keyof typeof ChannelPermission
];
return (
<Checkbox
key={key}
checked={((perm[1] >>> 0) & value) > 0}
onChange={() =>
setPerm([perm[0], perm[1] ^ value])
}
disabled={key === "View"}
description={
<Text id={`permissions.channel.${key}.d`} />
}>
<Text id={`permissions.channel.${key}.t`} />
</Checkbox>
);
})}
</section>
<div className={styles.actions}>
<Button contrast disabled={!modified} onClick={save}>
Save
</Button>
{role !== "default" && (
<Button contrast error onClick={deleteRole}>
Delete
</Button>
)}
</div>
</div>
</div>
);
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars /* eslint-disable */
import JSX = preact.JSX; import JSX = preact.JSX;
...@@ -7,22 +7,22 @@ import { dispatch, State, store } from "."; ...@@ -7,22 +7,22 @@ import { dispatch, State, store } from ".";
import { Children } from "../types/Preact"; import { Children } from "../types/Preact";
interface Props { interface Props {
children: Children; children: Children;
} }
export default function StateLoader(props: Props) { export default function StateLoader(props: Props) {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
useEffect(() => { useEffect(() => {
localForage.getItem("state").then((state) => { localForage.getItem("state").then((state) => {
if (state !== null) { if (state !== null) {
dispatch({ type: "__INIT", state: state as State }); dispatch({ type: "__INIT", state: state as State });
} }
setLoaded(true); setLoaded(true);
}); });
}, []); }, []);
if (!loaded) return null; if (!loaded) return null;
return <Provider store={store}>{props.children}</Provider>; return <Provider store={store}>{props.children}</Provider>;
} }
...@@ -7,10 +7,10 @@ import { memo } from "preact/compat"; ...@@ -7,10 +7,10 @@ import { memo } from "preact/compat";
import { State } from "."; import { State } from ".";
export function connectState<T>( export function connectState<T>(
component: (props: any) => h.JSX.Element | null, component: (props: any) => h.JSX.Element | null,
mapKeys: (state: State, props: T) => any, mapKeys: (state: State, props: T) => any,
memoize?: boolean, memoize?: boolean,
): ConnectedComponent<(props: any) => h.JSX.Element | null, T> { ): ConnectedComponent<(props: any) => h.JSX.Element | null, T> {
let c = connect(mapKeys)(component); const c = connect(mapKeys)(component);
return memoize ? memo(c) : c; return memoize ? memo(c) : c;
} }
import localForage from "localforage"; import localForage from "localforage";
import { createStore } from "redux"; import { createStore } from "redux";
import { Core } from "revolt.js/dist/api/objects"; import { RevoltConfiguration } from "revolt-api/types/Core";
import { Language } from "../context/Locale"; import { Language } from "../context/Locale";
...@@ -14,71 +14,73 @@ import { QueuedMessage } from "./reducers/queue"; ...@@ -14,71 +14,73 @@ import { QueuedMessage } from "./reducers/queue";
import { SectionToggle } from "./reducers/section_toggle"; import { SectionToggle } from "./reducers/section_toggle";
import { Settings } from "./reducers/settings"; import { Settings } from "./reducers/settings";
import { SyncOptions } from "./reducers/sync"; import { SyncOptions } from "./reducers/sync";
import { Typing } from "./reducers/typing";
import { Unreads } from "./reducers/unreads"; import { Unreads } from "./reducers/unreads";
export type State = { export type State = {
config: Core.RevoltNodeConfiguration; config: RevoltConfiguration;
locale: Language; locale: Language;
auth: AuthState; auth: AuthState;
settings: Settings; settings: Settings;
unreads: Unreads; unreads: Unreads;
queue: QueuedMessage[]; queue: QueuedMessage[];
typing: Typing; drafts: Drafts;
drafts: Drafts; sync: SyncOptions;
sync: SyncOptions; experiments: ExperimentOptions;
experiments: ExperimentOptions; lastOpened: LastOpened;
lastOpened: LastOpened; notifications: Notifications;
notifications: Notifications; sectionToggle: SectionToggle;
sectionToggle: SectionToggle;
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const store = createStore((state: any, action: any) => { export const store = createStore((state: any, action: any) => {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.debug("State Update:", action); console.debug("State Update:", action);
} }
if (action.type === "__INIT") { if (action.type === "__INIT") {
return action.state; return action.state;
} }
return rootReducer(state, action); return rootReducer(state, action);
}); });
// Save state using localForage. // Save state using localForage.
store.subscribe(() => { store.subscribe(() => {
const { const {
config, config,
locale, locale,
auth, auth,
settings, settings,
unreads, unreads,
queue, queue,
drafts, drafts,
sync, sync,
experiments, experiments,
lastOpened, lastOpened,
notifications, notifications,
sectionToggle, sectionToggle,
} = store.getState() as State; } = store.getState() as State;
localForage.setItem("state", { localForage.setItem("state", {
config, config,
locale, locale,
auth, auth,
settings, settings,
unreads, unreads,
queue, queue,
drafts, drafts,
sync, sync,
experiments, experiments,
lastOpened, lastOpened,
notifications, notifications,
sectionToggle, sectionToggle,
}); });
}); });
export function dispatch(action: Action) { export function dispatch(action: Action) {
store.dispatch(action); store.dispatch(action);
}
export function getState(): State {
return store.getState();
} }
import type { Auth } from "revolt.js/dist/api/objects"; import { Session } from "revolt-api/types/Auth";
export interface AuthState { export interface AuthState {
accounts: { accounts: {
[key: string]: { [key: string]: {
session: Auth.Session; session: Session;
}; };
}; };
active?: string; active?: string;
} }
export type AuthAction = export type AuthAction =
| { type: undefined } | { type: undefined }
| { | {
type: "LOGIN"; type: "LOGIN";
session: Auth.Session; session: Session;
} }
| { | {
type: "LOGOUT"; type: "LOGOUT";
user_id?: string; user_id?: string;
}; };
export function auth( export function auth(
state = { accounts: {} } as AuthState, state = { accounts: {} } as AuthState,
action: AuthAction, action: AuthAction,
): AuthState { ): AuthState {
switch (action.type) { switch (action.type) {
case "LOGIN": case "LOGIN":
return { return {
accounts: { accounts: {
...state.accounts, ...state.accounts,
[action.session.user_id]: { [action.session.user_id]: {
session: action.session, session: action.session,
}, },
}, },
active: action.session.user_id, active: action.session.user_id,
}; };
case "LOGOUT": { case "LOGOUT": {
const accounts = Object.assign({}, state.accounts); const accounts = Object.assign({}, state.accounts);
action.user_id && delete accounts[action.user_id]; action.user_id && delete accounts[action.user_id];
return { return {
accounts, accounts,
}; };
} }
default: default:
return state; return state;
} }
} }
export type Drafts = { [key: string]: string }; export type Drafts = { [key: string]: string };
export type DraftAction = export type DraftAction =
| { type: undefined } | { type: undefined }
| { | {
type: "SET_DRAFT"; type: "SET_DRAFT";
channel: string; channel: string;
content: string; content: string;
} }
| { | {
type: "CLEAR_DRAFT"; type: "CLEAR_DRAFT";
channel: string; channel: string;
} }
| { | {
type: "RESET"; type: "RESET";
}; };
export function drafts(state: Drafts = {}, action: DraftAction): Drafts { export function drafts(state: Drafts = {}, action: DraftAction): Drafts {
switch (action.type) { switch (action.type) {
case "SET_DRAFT": case "SET_DRAFT":
return { return {
...state, ...state,
[action.channel]: action.content, [action.channel]: action.content,
}; };
case "CLEAR_DRAFT": { case "CLEAR_DRAFT": {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [action.channel]: _, ...newState } = state; const { [action.channel]: _, ...newState } = state;
return newState; return newState;
} }
case "RESET": case "RESET":
return {}; return {};
default: default:
return state; return state;
} }
} }
export type Experiments = never; export type Experiments = "search";
export const AVAILABLE_EXPERIMENTS: Experiments[] = []; export const AVAILABLE_EXPERIMENTS: Experiments[] = ["search"];
export const EXPERIMENTS: {
[key in Experiments]: { title: string; description: string };
} = {
search: {
title: "Search",
description: "Allows you to search for messages in channels.",
},
};
export interface ExperimentOptions { export interface ExperimentOptions {
enabled?: Experiments[]; enabled?: Experiments[];
} }
export type ExperimentsAction = export type ExperimentsAction =
| { type: undefined } | { type: undefined }
| { | {
type: "EXPERIMENTS_ENABLE"; type: "EXPERIMENTS_ENABLE";
key: Experiments; key: Experiments;
} }
| { | {
type: "EXPERIMENTS_DISABLE"; type: "EXPERIMENTS_DISABLE";
key: Experiments; key: Experiments;
}; };
export function experiments( export function experiments(
state = {} as ExperimentOptions, state = {} as ExperimentOptions,
action: ExperimentsAction, action: ExperimentsAction,
): ExperimentOptions { ): ExperimentOptions {
switch (action.type) { switch (action.type) {
case "EXPERIMENTS_ENABLE": case "EXPERIMENTS_ENABLE":
return { return {
...state, ...state,
enabled: [ enabled: [
...(state.enabled ?? []) ...(state.enabled ?? [])
.filter((x) => AVAILABLE_EXPERIMENTS.includes(x)) .filter((x) => AVAILABLE_EXPERIMENTS.includes(x))
.filter((v) => v !== action.key), .filter((v) => v !== action.key),
action.key, action.key,
], ],
}; };
case "EXPERIMENTS_DISABLE": case "EXPERIMENTS_DISABLE":
return { return {
...state, ...state,
enabled: state.enabled enabled: state.enabled
?.filter((v) => v !== action.key) ?.filter((v) => v !== action.key)
.filter((x) => AVAILABLE_EXPERIMENTS.includes(x)), .filter((x) => AVAILABLE_EXPERIMENTS.includes(x)),
}; };
default: default:
return state; return state;
} }
} }
...@@ -12,49 +12,34 @@ import { sectionToggle, SectionToggleAction } from "./section_toggle"; ...@@ -12,49 +12,34 @@ import { sectionToggle, SectionToggleAction } from "./section_toggle";
import { config, ConfigAction } from "./server_config"; import { config, ConfigAction } from "./server_config";
import { settings, SettingsAction } from "./settings"; import { settings, SettingsAction } from "./settings";
import { sync, SyncAction } from "./sync"; import { sync, SyncAction } from "./sync";
import { typing, TypingAction } from "./typing";
import { unreads, UnreadsAction } from "./unreads"; import { unreads, UnreadsAction } from "./unreads";
export default combineReducers({ export default combineReducers({
config, config,
locale, locale,
auth, auth,
settings, settings,
unreads, unreads,
queue, queue,
typing, drafts,
drafts, sync,
sync, experiments,
experiments, lastOpened,
lastOpened, notifications,
notifications, sectionToggle,
sectionToggle,
}); });
export type Action = export type Action =
| ConfigAction | ConfigAction
| LocaleAction | LocaleAction
| AuthAction | AuthAction
| SettingsAction | SettingsAction
| UnreadsAction | UnreadsAction
| QueueAction | QueueAction
| TypingAction | DraftAction
| DraftAction | SyncAction
| SyncAction | ExperimentsAction
| ExperimentsAction | LastOpenedAction
| LastOpenedAction | NotificationsAction
| NotificationsAction | SectionToggleAction
| SectionToggleAction | { type: "__INIT"; state: State };
| { type: "__INIT"; state: State };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function filter(obj: any, keys: string[]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newObj: any = {};
for (const key of keys) {
const v = obj[key];
if (v) newObj[key] = v;
}
return newObj;
}
export interface LastOpened { export interface LastOpened {
[key: string]: string; [key: string]: string;
} }
export type LastOpenedAction = export type LastOpenedAction =
| { type: undefined } | { type: undefined }
| { | {
type: "LAST_OPENED_SET"; type: "LAST_OPENED_SET";
parent: string; parent: string;
child: string; child: string;
} }
| { | {
type: "RESET"; type: "RESET";
}; };
export function lastOpened( export function lastOpened(
state = {} as LastOpened, state = {} as LastOpened,
action: LastOpenedAction, action: LastOpenedAction,
): LastOpened { ): LastOpened {
switch (action.type) { switch (action.type) {
case "LAST_OPENED_SET": { case "LAST_OPENED_SET": {
return { return {
...state, ...state,
[action.parent]: action.child, [action.parent]: action.child,
}; };
} }
case "RESET": case "RESET":
return {}; return {};
default: default:
return state; return state;
} }
} }