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 1065 additions and 194 deletions
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 styles from './Panes.module.scss'; import { XCircle } from "@styled-icons/boxicons-regular";
import { XCircle } from "@styled-icons/feather"; import { observer } from "mobx-react-lite";
import { ServerInvite } from "revolt-api/types/Invites";
import { Server } from "revolt.js/dist/maps/Servers";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import Preloader from "../../../components/ui/Preloader";
import IconButton from "../../../components/ui/IconButton"; import { useClient } from "../../../context/revoltjs/RevoltClient";
import UserIcon from "../../../components/common/user/UserIcon";
import { getChannelName } from "../../../context/revoltjs/util"; import { getChannelName } from "../../../context/revoltjs/util";
import { Invites as InvitesNS, Servers } from "revolt.js/dist/api/objects";
import { useChannels, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks"; import UserIcon from "../../../components/common/user/UserIcon";
import IconButton from "../../../components/ui/IconButton";
import Preloader from "../../../components/ui/Preloader";
interface Props { interface Props {
server: Servers.Server; server: Server;
} }
export function Invites({ server }: Props) { export const Invites = observer(({ server }: Props) => {
const [invites, setInvites] = useState<InvitesNS.ServerInvite[] | undefined>(undefined);
const ctx = useForceUpdate();
const [deleting, setDelete] = useState<string[]>([]); const [deleting, setDelete] = useState<string[]>([]);
const users = useUsers(invites?.map(x => x.creator) ?? [], ctx); const [invites, setInvites] = useState<ServerInvite[] | undefined>(
const channels = useChannels(invites?.map(x => x.channel) ?? [], ctx); undefined,
);
const client = useClient();
const users = invites?.map((invite) => client.users.get(invite.creator));
const channels = invites?.map((invite) =>
client.channels.get(invite.channel),
);
useEffect(() => { useEffect(() => {
ctx.client.servers.fetchInvites(server._id) server.fetchInvites().then(setInvites);
.then(invites => setInvites(invites)) }, [server, setInvites]);
}, [ ]);
return ( return (
<div className={styles.invites}> <div className={styles.userList}>
{ typeof invites === 'undefined' && <Preloader /> } <div className={styles.subtitle}>
{ <span>
invites?.map( <Text id="app.settings.server_pages.invites.code" />
invite => { </span>
let creator = users.find(x => x?._id === invite.creator); <span>
let channel = channels.find(x => x?._id === invite.channel); <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>
{typeof invites === "undefined" && <Preloader type="ring" />}
{invites?.map((invite, index) => {
const creator = users![index];
const channel = channels![index];
return (
<div
key={invite._id}
className={styles.invite}
data-deleting={deleting.indexOf(invite._id) > -1}>
<code>{invite._id}</code>
<span>
<UserIcon target={creator} size={24} />{" "}
{creator?.username ?? (
<Text id="app.main.channel.unknown_user" />
)}
</span>
<span>
{channel && creator
? getChannelName(channel, true)
: "#??"}
</span>
<IconButton
onClick={async () => {
setDelete([...deleting, invite._id]);
return ( await client.deleteInvite(invite._id);
<div className={styles.invite}
data-deleting={deleting.indexOf(invite._id) > -1}>
<code>{ invite._id }</code>
<span>
<UserIcon target={creator} size={24} /> {creator?.username ?? 'unknown'}
</span>
<span>{ (channel && creator) ? getChannelName(ctx.client, channel, true) : '#unknown' }</span>
<IconButton
onClick={async () => {
setDelete([
...deleting,
invite._id
]);
await ctx.client.deleteInvite(invite._id); setInvites(
invites?.filter(
setInvites( (x) => x._id !== invite._id,
invites?.filter( ),
x => x._id !== invite._id );
) }}
); disabled={deleting.indexOf(invite._id) > -1}>
}} <XCircle size={24} />
disabled={deleting.indexOf(invite._id) > -1}> </IconButton>
<XCircle size={24} /> </div>
</IconButton> );
</div> })}
)
}
)
}
</div> </div>
); );
} });
import { ChevronDown } from "@styled-icons/boxicons-regular";
import { isEqual } from "lodash";
import { observer } from "mobx-react-lite";
import { Member } from "revolt.js/dist/maps/Members";
import { Server } from "revolt.js/dist/maps/Servers";
import { User } from "revolt.js/dist/maps/Users";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Servers } from "revolt.js/dist/api/objects";
import { 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;
} }
export function Members({ server }: Props) { export const Members = observer(({ server }: Props) => {
const [members, setMembers] = useState<Servers.Member[] | undefined>(undefined); const [selected, setSelected] = useState<undefined | string>();
const ctx = useForceUpdate(); const [data, setData] = useState<
const users = useUsers(members?.map(x => x._id.user) ?? [], ctx); { members: Member[]; users: User[] } | undefined
>(undefined);
useEffect(() => { useEffect(() => {
ctx.client.servers.members.fetchMembers(server._id) server.fetchMembers().then(setData);
.then(members => setMembers(members)) }, [server, setData]);
}, [ ]);
const [roles, setRoles] = useState<string[]>([]);
useEffect(() => {
if (selected) {
setRoles(
data!.members.find((x) => x._id.user === selected)?.roles ?? [],
);
}
}, [setRoles, selected, data]);
return ( return (
<div> <div className={styles.userList}>
{ members && members.length > 0 && users?.map(x => x && <div>@{x.username}</div>) } <div className={styles.subtitle}>
{data?.members.length ?? 0} Members
</div>
{data &&
data.members.length > 0 &&
data.members
.map((member) => {
return {
member,
user: data.users.find(
(x) => x._id === member._id.user,
),
};
})
.map(({ member, user }) => (
// @ts-expect-error brokey
// eslint-disable-next-line react/jsx-no-undef
<Fragment key={member._id.user}>
<div
className={styles.member}
data-open={selected === member._id.user}
onClick={() =>
setSelected(
selected === member._id.user
? undefined
: member._id.user,
)
}>
<span>
<UserIcon target={user} size={24} />{" "}
{user?.username ?? (
<Text id="app.main.channel.unknown_user" />
)}
</span>
<IconButton className={styles.chevron}>
<ChevronDown size={24} />
</IconButton>
</div>
{selected === member._id.user && (
<div
key={`drop_${member._id.user}`}
className={styles.memberView}>
<Overline type="subtle">Roles</Overline>
{Object.keys(server.roles ?? {}).map(
(key) => {
const role = server.roles![key];
return (
<Checkbox
key={key}
checked={
roles.includes(key) ??
false
}
onChange={(v) => {
if (v) {
setRoles([
...roles,
key,
]);
} else {
setRoles(
roles.filter(
(x) =>
x !==
key,
),
);
}
}}>
<span
style={{
color: role.colour,
}}>
{role.name}
</span>
</Checkbox>
);
},
)}
<Button
compact
disabled={isEqual(
member.roles ?? [],
roles,
)}
onClick={() =>
member.edit({
roles,
})
}>
<Text id="app.special.modals.actions.save" />
</Button>
</div>
)}
</Fragment>
))}
</div> </div>
); );
} });
import isEqual from "lodash.isequal";
import { observer } from "mobx-react-lite";
import { Server } from "revolt.js/dist/maps/Servers";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import styles from './Panes.module.scss'; import { useEffect, useState } from "preact/hooks";
import Button from "../../../components/ui/Button";
import { Servers } from "revolt.js/dist/api/objects";
import InputBox from "../../../components/ui/InputBox";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize"; import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { useContext, useEffect, useState } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { FileUploader } from "../../../context/revoltjs/FileUploads"; import { FileUploader } from "../../../context/revoltjs/FileUploads";
import { getChannelName } from "../../../context/revoltjs/util";
import Button from "../../../components/ui/Button";
import ComboBox from "../../../components/ui/ComboBox";
import InputBox from "../../../components/ui/InputBox";
interface Props { interface Props {
server: Servers.Server; server: Server;
} }
export function Overview({ server }: Props) { export const Overview = observer(({ server }: Props) => {
const client = useContext(AppContext);
const [name, setName] = useState(server.name); const [name, setName] = useState(server.name);
const [description, setDescription] = useState(server.description ?? ''); const [description, setDescription] = useState(server.description ?? "");
const [systemMessages, setSystemMessages] = useState(
server.system_messages,
);
useEffect(() => setName(server.name), [ server.name ]); useEffect(() => setName(server.name), [server.name]);
useEffect(() => setDescription(server.description ?? ''), [ server.description ]); useEffect(
() => setDescription(server.description ?? ""),
[server.description],
);
useEffect(
() => setSystemMessages(server.system_messages),
[server.system_messages],
);
const [ changed, setChanged ] = useState(false); const [changed, setChanged] = useState(false);
function save() { function save() {
let changes: any = {}; const changes: Record<string, unknown> = {};
if (name !== server.name) changes.name = name; if (name !== server.name) changes.name = name;
if (description !== server.description) if (description !== server.description)
changes.description = description; changes.description = description;
if (!isEqual(systemMessages, server.system_messages))
client.servers.edit(server._id, changes); changes.system_messages = systemMessages ?? undefined;
server.edit(changes);
setChanged(false); setChanged(false);
} }
...@@ -42,9 +59,9 @@ export function Overview({ server }: Props) { ...@@ -42,9 +59,9 @@ export function Overview({ server }: Props) {
fileType="icons" fileType="icons"
behaviour="upload" behaviour="upload"
maxFileSize={2_500_000} maxFileSize={2_500_000}
onUpload={icon => client.servers.edit(server._id, { icon })} onUpload={(icon) => server.edit({ icon })}
previewURL={client.servers.getIconURL(server._id, { max_side: 256 }, true)} previewURL={server.generateIconURL({ max_side: 256 }, true)}
remove={() => client.servers.edit(server._id, { remove: 'Icon' })} remove={() => server.edit({ remove: "Icon" })}
/> />
<div className={styles.name}> <div className={styles.name}>
<h3> <h3>
...@@ -54,9 +71,9 @@ export function Overview({ server }: Props) { ...@@ -54,9 +71,9 @@ export function Overview({ server }: Props) {
contrast contrast
value={name} value={name}
maxLength={32} maxLength={32}
onChange={e => { onChange={(e) => {
setName(e.currentTarget.value) setName(e.currentTarget.value);
if (!changed) setChanged(true) if (!changed) setChanged(true);
}} }}
/> />
</div> </div>
...@@ -71,14 +88,11 @@ export function Overview({ server }: Props) { ...@@ -71,14 +88,11 @@ export function Overview({ server }: Props) {
maxLength={1024} maxLength={1024}
value={description} value={description}
placeholder={"Add a topic..."} placeholder={"Add a topic..."}
onChange={ev => { onChange={(ev) => {
setDescription(ev.currentTarget.value); setDescription(ev.currentTarget.value);
if (!changed) setChanged(true) if (!changed) setChanged(true);
}} }}
/> />
<Button onClick={save} contrast disabled={!changed}>
<Text id="app.special.modals.actions.save" />
</Button>
<h3> <h3>
<Text id="app.main.servers.custom_banner" /> <Text id="app.main.servers.custom_banner" />
...@@ -89,10 +103,70 @@ export function Overview({ server }: Props) { ...@@ -89,10 +103,70 @@ export function Overview({ server }: Props) {
fileType="banners" fileType="banners"
behaviour="upload" behaviour="upload"
maxFileSize={6_000_000} maxFileSize={6_000_000}
onUpload={banner => client.servers.edit(server._id, { banner })} onUpload={(banner) => server.edit({ banner })}
previewURL={client.servers.getBannerURL(server._id, { width: 1000 }, true)} previewURL={server.generateBannerURL({ width: 1000 }, true)}
remove={() => client.servers.edit(server._id, { remove: 'Banner' })} remove={() => server.edit({ remove: "Banner" })}
/> />
<h3>
<Text id="app.settings.server_pages.overview.system_messages" />
</h3>
{[
["User Joined", "user_joined"],
["User Left", "user_left"],
["User Kicked", "user_kicked"],
["User Banned", "user_banned"],
].map(([i18n, key]) => (
// ! FIXME: temporary code just so we can expose the options
<p
key={key}
style={{
display: "flex",
gap: "8px",
alignItems: "center",
}}>
<span style={{ flexShrink: "0", flex: `25%` }}>{i18n}</span>
<ComboBox
value={
systemMessages?.[
key as keyof typeof systemMessages
] ?? "disabled"
}
onChange={(e) => {
if (!changed) setChanged(true);
const v = e.currentTarget.value;
if (v === "disabled") {
const {
[key as keyof typeof systemMessages]: _,
...other
} = systemMessages;
setSystemMessages(other);
} else {
setSystemMessages({
...systemMessages,
[key]: v,
});
}
}}>
<option value="disabled">
<Text id="general.disabled" />
</option>
{server.channels
.filter((x) => typeof x !== "undefined")
.map((channel) => (
<option key={channel!._id} value={channel!._id}>
{getChannelName(channel!, true)}
</option>
))}
</ComboBox>
</p>
))}
<p>
<Button onClick={save} contrast disabled={!changed}>
<Text id="app.special.modals.actions.save" />
</Button>
</p>
</div> </div>
); );
} });
...@@ -6,6 +6,10 @@ ...@@ -6,6 +6,10 @@
.name { .name {
flex-grow: 1; flex-grow: 1;
h3 {
margin-top: 0;
}
input { input {
width: 100%; width: 100%;
} }
...@@ -13,20 +17,41 @@ ...@@ -13,20 +17,41 @@
} }
} }
.invites { .userList {
gap: 8px; gap: 8px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.invite { .subtitle {
gap: 8px;
display: flex;
justify-content: space-between;
font-size: 13px;
text-transform: uppercase;
color: var(--secondary-foreground);
font-weight: 700;
.reason {
text-align: center;
}
}
.reason {
flex: 2;
}
.invite,
.ban,
.member {
gap: 8px; gap: 8px;
padding: 8px; padding: 10px;
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
background: var(--secondary-background); background: var(--secondary-background);
code, span { span,
code {
flex: 1; flex: 1;
} }
...@@ -45,4 +70,67 @@ ...@@ -45,4 +70,67 @@
opacity: 0.5; opacity: 0.5;
} }
} }
.member {
cursor: pointer;
.chevron {
transition: 0.2s ease all;
}
&:not([data-open="true"]) .chevron {
transform: rotateZ(90deg);
}
}
.memberView {
padding: 10px;
margin: 0 10px;
background: var(--background);
}
}
.roles {
gap: 12px;
height: 100%;
display: flex;
.list {
width: 160px;
flex-shrink: 0;
overflow-y: scroll;
}
.permissions {
flex-grow: 1;
padding: 0 8px;
overflow-y: scroll;
}
.title {
gap: 8px;
display: flex;
margin-bottom: 1em;
align-items: center;
h1,
h2 {
margin: 0;
min-width: 0;
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
svg {
cursor: pointer;
}
}
.actions {
gap: 8px;
display: flex;
padding: 8px 0;
}
} }
import { Plus } from "@styled-icons/boxicons-regular";
import isEqual from "lodash.isequal";
import { observer } from "mobx-react-lite";
import { ChannelPermission, ServerPermission } from "revolt.js";
import { Server } from "revolt.js/dist/maps/Servers";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import Button from "../../../components/ui/Button";
import Checkbox from "../../../components/ui/Checkbox";
import ColourSwatches from "../../../components/ui/ColourSwatches";
import InputBox from "../../../components/ui/InputBox";
import Overline from "../../../components/ui/Overline";
import ButtonItem from "../../../components/navigation/items/ButtonItem";
interface Props {
server: Server;
}
const I32ToU32 = (arr: number[]) => arr.map((x) => x >>> 0);
// ! FIXME: bad code :)
export const Roles = observer(({ server }: Props) => {
const [role, setRole] = useState("default");
const { openScreen } = useIntermediate();
const roles = useMemo(() => server.roles ?? {}, [server]);
if (role !== "default" && typeof roles[role] === "undefined") {
useEffect(() => setRole("default"), [role]);
return null;
}
const {
name: roleName,
colour: roleColour,
permissions,
} = roles[role] ?? {};
const getPermissions = useCallback(
(id: string) => {
return I32ToU32(
id === "default"
? server.default_permissions
: roles[id].permissions,
);
},
[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 = () => {
setRole("default");
server.deleteRole(role);
};
return (
<div className={styles.roles}>
<div className={styles.list}>
<div className={styles.title}>
<h1>
<Text id="app.settings.server_pages.roles.title" />
</h1>
<Plus
size={22}
onClick={() =>
openScreen({
id: "special_input",
type: "create_role",
server,
callback: (id) => setRole(id),
})
}
/>
</div>
{["default", ...Object.keys(roles)].map((id) => {
if (id === "default") {
return (
<ButtonItem
active={role === "default"}
onClick={() => setRole("default")}>
<Text id="app.settings.permissions.default_role" />
</ButtonItem>
);
}
return (
<ButtonItem
key={id}
active={role === id}
onClick={() => setRole(id)}
style={{ color: roles[id].colour }}>
{roles[id].name}
</ButtonItem>
);
})}
</div>
<div className={styles.permissions}>
<div className={styles.title}>
<h2>
{role === "default" ? (
<Text id="app.settings.permissions.default_role" />
) : (
roles[role].name
)}
</h2>
<Button contrast disabled={!modified} onClick={save}>
Save
</Button>
</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>
<Overline type="subtle">
<Text id="app.settings.permissions.server" />
</Overline>
{Object.keys(ServerPermission).map((key) => {
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;
import { store } from ".";
import localForage from "localforage"; import localForage from "localforage";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { Children } from "../types/Preact";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { dispatch, State, store } from ".";
import { Children } from "../types/Preact";
interface Props { interface Props {
children: Children; children: Children;
} }
export default function State(props: Props) { export default function StateLoader(props: Props) {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
useEffect(() => { useEffect(() => {
localForage.getItem("state") localForage.getItem("state").then((state) => {
.then(state => { if (state !== null) {
if (state !== null) { dispatch({ type: "__INIT", state: state as State });
store.dispatch({ type: "__INIT", state }); }
}
setLoaded(true);
setLoaded(true); });
});
}, []); }, []);
if (!loaded) return null; if (!loaded) return null;
......
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { connect, ConnectedComponent } from "react-redux";
import { State } from ".";
import { h } from "preact"; import { h } from "preact";
import { memo } from "preact/compat"; import { memo } from "preact/compat";
import { connect, ConnectedComponent } from "react-redux";
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,
useDispatcher?: boolean, memoize?: boolean,
memoize?: boolean
): ConnectedComponent<(props: any) => h.JSX.Element | null, T> { ): ConnectedComponent<(props: any) => h.JSX.Element | null, T> {
let c = ( const c = connect(mapKeys)(component);
useDispatcher
? connect(mapKeys, (dispatcher) => {
return { dispatcher };
})
: connect(mapKeys)
)(component);
return memoize ? memo(c) : c; return memoize ? memo(c) : c;
} }
import { createStore } from "redux";
import rootReducer from "./reducers";
import localForage from "localforage"; import localForage from "localforage";
import { createStore } from "redux";
import { RevoltConfiguration } from "revolt-api/types/Core";
import { Core } from "revolt.js/dist/api/objects";
import { Typing } from "./reducers/typing";
import { Drafts } from "./reducers/drafts";
import { AuthState } from "./reducers/auth";
import { Language } from "../context/Locale"; import { Language } from "../context/Locale";
import { Unreads } from "./reducers/unreads";
import { SyncOptions } from "./reducers/sync"; import rootReducer, { Action } from "./reducers";
import { Settings } from "./reducers/settings"; import { AuthState } from "./reducers/auth";
import { QueuedMessage } from "./reducers/queue"; import { Drafts } from "./reducers/drafts";
import { ExperimentOptions } from "./reducers/experiments"; import { ExperimentOptions } from "./reducers/experiments";
import { LastOpened } from "./reducers/last_opened";
import { Notifications } from "./reducers/notifications";
import { QueuedMessage } from "./reducers/queue";
import { SectionToggle } from "./reducers/section_toggle";
import { Settings } from "./reducers/settings";
import { SyncOptions } from "./reducers/sync";
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;
notifications: Notifications;
sectionToggle: SectionToggle;
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
...@@ -51,6 +56,9 @@ store.subscribe(() => { ...@@ -51,6 +56,9 @@ store.subscribe(() => {
drafts, drafts,
sync, sync,
experiments, experiments,
lastOpened,
notifications,
sectionToggle,
} = store.getState() as State; } = store.getState() as State;
localForage.setItem("state", { localForage.setItem("state", {
...@@ -63,5 +71,16 @@ store.subscribe(() => { ...@@ -63,5 +71,16 @@ store.subscribe(() => {
drafts, drafts,
sync, sync,
experiments, experiments,
lastOpened,
notifications,
sectionToggle,
}); });
}); });
export function dispatch(action: Action) {
store.dispatch(action);
}
export function getState(): State {
return store.getState();
}
import { 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;
...@@ -13,7 +13,7 @@ export type AuthAction = ...@@ -13,7 +13,7 @@ export type AuthAction =
| { type: undefined } | { type: undefined }
| { | {
type: "LOGIN"; type: "LOGIN";
session: Auth.Session; session: Session;
} }
| { | {
type: "LOGOUT"; type: "LOGOUT";
...@@ -22,7 +22,7 @@ export type AuthAction = ...@@ -22,7 +22,7 @@ export type AuthAction =
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":
......
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[];
...@@ -18,7 +26,7 @@ export type ExperimentsAction = ...@@ -18,7 +26,7 @@ export type ExperimentsAction =
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":
......
import { State } from "..";
import { combineReducers } from "redux"; import { combineReducers } from "redux";
import { config, ConfigAction } from "./server_config"; import { State } from "..";
import { settings, SettingsAction } from "./settings";
import { locale, LocaleAction } from "./locale";
import { auth, AuthAction } from "./auth"; import { auth, AuthAction } from "./auth";
import { unreads, UnreadsAction } from "./unreads";
import { queue, QueueAction } from "./queue";
import { typing, TypingAction } from "./typing";
import { drafts, DraftAction } from "./drafts"; import { drafts, DraftAction } from "./drafts";
import { sync, SyncAction } from "./sync";
import { experiments, ExperimentsAction } from "./experiments"; import { experiments, ExperimentsAction } from "./experiments";
import { lastOpened, LastOpenedAction } from "./last_opened";
import { locale, LocaleAction } from "./locale";
import { notifications, NotificationsAction } from "./notifications";
import { queue, QueueAction } from "./queue";
import { sectionToggle, SectionToggleAction } from "./section_toggle";
import { config, ConfigAction } from "./server_config";
import { settings, SettingsAction } from "./settings";
import { sync, SyncAction } from "./sync";
import { unreads, UnreadsAction } from "./unreads";
export default combineReducers({ export default combineReducers({
config, config,
...@@ -19,10 +21,12 @@ export default combineReducers({ ...@@ -19,10 +21,12 @@ export default combineReducers({
settings, settings,
unreads, unreads,
queue, queue,
typing,
drafts, drafts,
sync, sync,
experiments, experiments,
lastOpened,
notifications,
sectionToggle,
}); });
export type Action = export type Action =
...@@ -32,22 +36,10 @@ export type Action = ...@@ -32,22 +36,10 @@ export type Action =
| SettingsAction | SettingsAction
| UnreadsAction | UnreadsAction
| QueueAction | QueueAction
| TypingAction
| DraftAction | DraftAction
| SyncAction | SyncAction
| ExperimentsAction | ExperimentsAction
| LastOpenedAction
| NotificationsAction
| SectionToggleAction
| { type: "__INIT"; state: State }; | { type: "__INIT"; state: State };
export type WithDispatcher = { dispatcher: (action: Action) => void };
// 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 {
[key: string]: string;
}
export type LastOpenedAction =
| { type: undefined }
| {
type: "LAST_OPENED_SET";
parent: string;
child: string;
}
| {
type: "RESET";
};
export function lastOpened(
state = {} as LastOpened,
action: LastOpenedAction,
): LastOpened {
switch (action.type) {
case "LAST_OPENED_SET": {
return {
...state,
[action.parent]: action.child,
};
}
case "RESET":
return {};
default:
return state;
}
}
import { Language } from "../../context/Locale"; import { Language } from "../../context/Locale";
import { SyncUpdateAction } from "./sync";
import type { SyncUpdateAction } from "./sync";
export type LocaleAction = export type LocaleAction =
| { type: undefined } | { type: undefined }
......
import { Channel } from "revolt.js/dist/maps/Channels";
import { Message } from "revolt.js/dist/maps/Messages";
import type { SyncUpdateAction } from "./sync";
export type NotificationState = "all" | "mention" | "none" | "muted";
export type Notifications = {
[key: string]: NotificationState;
};
export const DEFAULT_STATES: {
[key in Channel["channel_type"]]: NotificationState;
} = {
SavedMessages: "all",
DirectMessage: "all",
Group: "all",
TextChannel: "mention",
VoiceChannel: "mention",
};
export function getNotificationState(
notifications: Notifications,
channel: Channel,
) {
return notifications[channel._id] ?? DEFAULT_STATES[channel.channel_type];
}
export function shouldNotify(
state: NotificationState,
message: Message,
user_id: string,
) {
switch (state) {
case "muted":
case "none":
return false;
case "mention": {
if (!message.mention_ids?.includes(user_id)) return false;
}
}
return true;
}
export type NotificationsAction =
| { type: undefined }
| {
type: "NOTIFICATIONS_SET";
key: string;
state: NotificationState;
}
| {
type: "NOTIFICATIONS_REMOVE";
key: string;
}
| SyncUpdateAction
| {
type: "RESET";
};
export function notifications(
state = {} as Notifications,
action: NotificationsAction,
): Notifications {
switch (action.type) {
case "NOTIFICATIONS_SET":
return {
...state,
[action.key]: action.state,
};
case "NOTIFICATIONS_REMOVE": {
const { [action.key]: _, ...newState } = state;
return newState;
}
case "SYNC_UPDATE":
return action.update.notifications?.[1] ?? state;
case "RESET":
return {};
default:
return state;
}
}
import { MessageObject } from "../../context/revoltjs/util";
export enum QueueStatus { export enum QueueStatus {
SENDING = "sending", SENDING = "sending",
ERRORED = "errored", ERRORED = "errored",
} }
export interface Reply {
id: string;
mention: boolean;
}
export type QueuedMessageData = {
_id: string;
author: string;
channel: string;
content: string;
replies: Reply[];
};
export interface QueuedMessage { export interface QueuedMessage {
id: string; id: string;
channel: string; channel: string;
data: MessageObject; data: QueuedMessageData;
status: QueueStatus; status: QueueStatus;
error?: string; error?: string;
} }
...@@ -19,7 +31,7 @@ export type QueueAction = ...@@ -19,7 +31,7 @@ export type QueueAction =
type: "QUEUE_ADD"; type: "QUEUE_ADD";
nonce: string; nonce: string;
channel: string; channel: string;
message: MessageObject; message: QueuedMessageData;
} }
| { | {
type: "QUEUE_FAIL"; type: "QUEUE_FAIL";
...@@ -46,7 +58,7 @@ export type QueueAction = ...@@ -46,7 +58,7 @@ export type QueueAction =
export function queue( export function queue(
state: QueuedMessage[] = [], state: QueuedMessage[] = [],
action: QueueAction action: QueueAction,
): QueuedMessage[] { ): QueuedMessage[] {
switch (action.type) { switch (action.type) {
case "QUEUE_ADD": { case "QUEUE_ADD": {
...@@ -62,7 +74,7 @@ export function queue( ...@@ -62,7 +74,7 @@ export function queue(
} }
case "QUEUE_FAIL": { case "QUEUE_FAIL": {
const entry = state.find( const entry = state.find(
(x) => x.id === action.nonce (x) => x.id === action.nonce,
) as QueuedMessage; ) as QueuedMessage;
return [ return [
...state.filter((x) => x.id !== action.nonce), ...state.filter((x) => x.id !== action.nonce),
...@@ -75,7 +87,7 @@ export function queue( ...@@ -75,7 +87,7 @@ export function queue(
} }
case "QUEUE_START": { case "QUEUE_START": {
const entry = state.find( const entry = state.find(
(x) => x.id === action.nonce (x) => x.id === action.nonce,
) as QueuedMessage; ) as QueuedMessage;
return [ return [
...state.filter((x) => x.id !== action.nonce), ...state.filter((x) => x.id !== action.nonce),
......
export interface SectionToggle {
[key: string]: boolean;
}
export type SectionToggleAction =
| { type: undefined }
| {
type: "SECTION_TOGGLE_SET";
id: string;
state: boolean;
}
| {
type: "SECTION_TOGGLE_UNSET";
id: string;
}
| {
type: "RESET";
};
export function sectionToggle(
state = {} as SectionToggle,
action: SectionToggleAction,
): SectionToggle {
switch (action.type) {
case "SECTION_TOGGLE_SET": {
return {
...state,
[action.id]: action.state,
};
}
case "SECTION_TOGGLE_UNSET": {
const { [action.id]: _, ...newState } = state;
return newState;
}
case "RESET":
return {};
default:
return state;
}
}
import { Core } from "revolt.js/dist/api/objects"; import type { RevoltConfiguration } from "revolt-api/types/Core";
export type ConfigAction = export type ConfigAction =
| { type: undefined } | { type: undefined }
| { | {
type: "SET_CONFIG"; type: "SET_CONFIG";
config: Core.RevoltNodeConfiguration; config: RevoltConfiguration;
}; };
export function config( export function config(
state = { } as Core.RevoltNodeConfiguration, state = {} as RevoltConfiguration,
action: ConfigAction action: ConfigAction,
): Core.RevoltNodeConfiguration { ): RevoltConfiguration {
switch (action.type) { switch (action.type) {
case "SET_CONFIG": case "SET_CONFIG":
return action.config; return action.config;
......
import { filter } from "."; import type { Theme, ThemeOptions } from "../../context/Theme";
import { SyncUpdateAction } from "./sync";
import { Theme, ThemeOptions } from "../../context/Theme"; import { setEmojiPack } from "../../components/common/Emoji";
import type { Sounds } from "../../assets/sounds/Audio";
import type { SyncUpdateAction } from "./sync";
export type SoundOptions = {
[key in Sounds]?: boolean;
};
export const DEFAULT_SOUNDS: SoundOptions = {
message: true,
outbound: false,
call_join: true,
call_leave: true,
};
export interface NotificationOptions { export interface NotificationOptions {
desktopEnabled?: boolean; desktopEnabled?: boolean;
soundEnabled?: boolean; sounds?: SoundOptions;
outgoingSoundEnabled?: boolean;
} }
export type EmojiPacks = "mutant" | "twemoji" | "noto" | "openmoji"; export type EmojiPacks = "mutant" | "twemoji" | "noto" | "openmoji";
...@@ -44,16 +57,16 @@ export type SettingsAction = ...@@ -44,16 +57,16 @@ export type SettingsAction =
export function settings( export function settings(
state = {} as Settings, state = {} as Settings,
action: SettingsAction action: SettingsAction,
): Settings { ): Settings {
// setEmojiPack(state.appearance?.emojiPack ?? 'mutant'); setEmojiPack(state.appearance?.emojiPack ?? "mutant");
switch (action.type) { switch (action.type) {
case "SETTINGS_SET_THEME": case "SETTINGS_SET_THEME":
return { return {
...state, ...state,
theme: { theme: {
...filter(state.theme, ["custom", "preset"]), ...state.theme,
...action.theme, ...action.theme,
}, },
}; };
...@@ -80,7 +93,7 @@ export function settings( ...@@ -80,7 +93,7 @@ export function settings(
return { return {
...state, ...state,
appearance: { appearance: {
...filter(state.appearance, ["emojiPack"]), ...state.appearance,
...action.options, ...action.options,
}, },
}; };
......