merge: branch 'quark/permissions'

This commit is contained in:
Paul Makles
2022-04-29 13:48:38 +01:00
117 changed files with 10609 additions and 6253 deletions

View File

@@ -347,7 +347,9 @@ export default observer(() => {
<Trash
size={24}
onClick={() =>
client.users.edit({ remove: "StatusText" })
client.users.edit({
remove: ["StatusText"],
})
}
/>
)}

View File

@@ -1,5 +1,5 @@
import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js/dist/maps/Channels";
import { Channel } from "revolt.js";
import styled from "styled-components/macro";
import { Text } from "preact-i18n";
@@ -69,7 +69,7 @@ export default observer(({ channel }: Props) => {
{ max_side: 256 },
true,
)}
remove={() => channel.edit({ remove: "Icon" })}
remove={() => channel.edit({ remove: ["Icon"] })}
defaultPreview={
channel.channel_type === "Group"
? "/assets/group.png"

View File

@@ -1,102 +1,119 @@
import isEqual from "lodash.isequal";
import { observer } from "mobx-react-lite";
import {
ChannelPermission,
DEFAULT_PERMISSION_DM,
} from "revolt.js/dist/api/permissions";
import { Channel } from "revolt.js/dist/maps/Channels";
import { Channel, API } from "revolt.js";
import { DEFAULT_PERMISSION_DIRECT_MESSAGE } from "revolt.js";
import { useEffect, useState } from "preact/hooks";
import { Text } from "preact-i18n";
import { useState } from "preact/hooks";
import Button from "../../../components/ui/Button";
import Checkbox from "../../../components/ui/Checkbox";
import Tip from "../../../components/ui/Tip";
import { TextReact } from "../../../lib/i18n";
import { PermissionsLayout, Button, SpaceBetween, H1 } from "@revoltchat/ui";
import { PermissionList } from "../../../components/settings/roles/PermissionList";
import { RoleOrDefault } from "../../../components/settings/roles/RoleSelection";
import { useRoles } from "../server/Roles";
interface Props {
channel: Channel;
}
// ! FIXME: bad code :)
export default observer(({ channel }: Props) => {
const [selected, setSelected] = useState("default");
type R = { name: string; permissions: number };
const roles: { [key: string]: R } = {};
if (channel.channel_type !== "Group") {
const server = channel.server;
const a = server?.roles ?? {};
for (const b of Object.keys(a)) {
roles[b] = {
name: a[b].name,
permissions:
channel.role_permissions?.[b] ?? a[b].permissions[1],
};
}
}
const keys = ["default", ...Object.keys(roles)];
const defaultRole = {
name: "Default",
permissions:
(channel.channel_type === "Group"
? channel.permissions
: channel.default_permissions) ?? DEFAULT_PERMISSION_DM,
};
const selectedRole = selected === "default" ? defaultRole : roles[selected];
if (!selectedRole) {
useEffect(() => setSelected("default"), []);
return null;
}
const [p, setPerm] = useState(selectedRole.permissions >>> 0);
useEffect(() => {
setPerm(selectedRole.permissions >>> 0);
}, [selected, selectedRole.permissions]);
// Consolidate all permissions that we can change right now.
const currentRoles =
channel.channel_type === "Group"
? ([
{
id: "default",
name: "Default",
permissions:
channel.permissions ??
DEFAULT_PERMISSION_DIRECT_MESSAGE,
},
] as RoleOrDefault[])
: (useRoles(channel.server! as any).map((role) => {
return {
...role,
permissions: (role.id === "default"
? channel.default_permissions
: channel.role_permissions?.[role.id]) ?? {
a: 0,
d: 0,
},
};
}) as RoleOrDefault[]);
return (
<div>
<Tip warning>This section is under construction.</Tip>
<h2>select role</h2>
{selected}
{keys.map((id) => {
const role: R = id === "default" ? defaultRole : roles[id];
<PermissionsLayout
channel={channel}
editor={({ selected }) => {
const currentRole = currentRoles.find(
(x) => x.id === selected,
)!;
return (
<Checkbox
key={id}
checked={selected === id}
onChange={(selected) => selected && setSelected(id)}>
{role.name}
</Checkbox>
);
})}
<h2>channel permissions</h2>
{Object.keys(ChannelPermission).map((perm) => {
if (perm === "View") return null;
if (!currentRole) return null;
const value =
ChannelPermission[perm as keyof typeof ChannelPermission];
if (value & DEFAULT_PERMISSION_DM) {
return (
<Checkbox
checked={(p & value) > 0}
onChange={(c) =>
setPerm(c ? p | value : p ^ value)
}>
{perm}
</Checkbox>
// Keep track of whatever role we're editing right now.
const [value, setValue] = useState<
API.OverrideField | number | undefined
>(undefined);
const currentPermission = currentRoles.find(
(x) => x.id === selected,
)!.permissions;
const currentValue = value ?? currentPermission;
// Upload new role information to server.
function save() {
channel.setPermissions(
selected,
typeof currentValue === "number"
? currentValue
: ({
allow: currentValue.a,
deny: currentValue.d,
} as any),
);
}
})}
<Button
contrast
onClick={() => {
channel.setPermissions(selected, p);
}}>
click here to save permissions for role
</Button>
</div>
return (
<div>
<SpaceBetween>
<H1>
<TextReact
id="app.settings.permissions.title"
fields={{ role: currentRole.name }}
/>
</H1>
<Button
palette="secondary"
disabled={isEqual(
currentPermission,
currentValue,
)}
onClick={save}>
<Text id="app.special.modals.actions.save" />
</Button>
</SpaceBetween>
<PermissionList
value={currentValue}
onChange={setValue}
filter={[
...(channel.channel_type === "Group"
? []
: ["ViewChannel" as "ViewChannel"]),
"ReadMessageHistory",
"SendMessage",
"ManageMessages",
"ManageWebhooks",
"InviteOthers",
"SendEmbeds",
"UploadFiles",
"Masquerade",
]}
/>
</div>
);
}}
/>
);
});

View File

@@ -1,4 +1,4 @@
import { At, Key, Block, ListOl } from "@styled-icons/boxicons-regular";
import { At, Key, Block } from "@styled-icons/boxicons-regular";
import {
Envelope,
HelpCircle,
@@ -8,7 +8,7 @@ import {
} from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom";
import { Profile } from "revolt-api/types/Users";
import { API } from "revolt.js";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
@@ -37,7 +37,9 @@ export const Account = observer(() => {
const [email, setEmail] = useState("...");
const [revealEmail, setRevealEmail] = useState(false);
const [profile, setProfile] = useState<undefined | Profile>(undefined);
const [profile, setProfile] = useState<undefined | API.UserProfile>(
undefined,
);
const history = useHistory();
function switchPage(to: string) {
@@ -46,8 +48,8 @@ export const Account = observer(() => {
useEffect(() => {
if (email === "..." && status === ClientStatus.ONLINE) {
client
.req("GET", "/auth/account")
client.api
.get("/auth/account/")
.then((account) => setEmail(account.email));
}

View File

@@ -1,10 +1,10 @@
// ! FIXME: this code is garbage, need to replace
import { Key, Clipboard, Globe, Plus } from "@styled-icons/boxicons-regular";
import { LockAlt, HelpCircle } from "@styled-icons/boxicons-solid";
import type { AxiosError } from "axios";
import { observer } from "mobx-react-lite";
import { Bot } from "revolt-api/types/Bots";
import { Profile as ProfileI } from "revolt-api/types/Users";
import { User } from "revolt.js/dist/maps/Users";
import { API } from "revolt.js";
import { User } from "revolt.js";
import styled from "styled-components/macro";
import styles from "./Panes.module.scss";
@@ -43,7 +43,7 @@ interface Changes {
name?: string;
public?: boolean;
interactions_url?: string;
remove?: "InteractionsURL";
remove?: "InteractionsURL"[];
}
const BotBadge = styled.div`
@@ -62,7 +62,7 @@ const BotBadge = styled.div`
`;
interface Props {
bot: Bot;
bot: API.Bot;
onDelete(): void;
onUpdate(changes: Changes): void;
}
@@ -75,7 +75,7 @@ function BotCard({ bot, onDelete, onUpdate }: Props) {
_id: bot._id,
username: user.username,
public: bot.public,
interactions_url: bot.interactions_url,
interactions_url: bot.interactions_url as any,
});
const [error, setError] = useState<string | JSX.Element>("");
const [saving, setSaving] = useState(false);
@@ -87,23 +87,21 @@ function BotCard({ bot, onDelete, onUpdate }: Props) {
useState<HTMLInputElement | null>(null);
const { writeClipboard, openScreen } = useIntermediate();
const [profile, setProfile] = useState<undefined | ProfileI>(undefined);
const [profile, setProfile] = useState<undefined | API.UserProfile>(
undefined,
);
const refreshProfile = useCallback(() => {
client
.request(
"GET",
`/users/${bot._id}/profile` as "/users/id/profile",
{
headers: { "x-bot-token": bot.token },
transformRequest: (data, headers) => {
// Remove user headers for this request
delete headers["x-user-id"];
delete headers["x-session-token"];
return data;
},
client.api
.get(`/users/${bot._id as ""}/profile`, undefined, {
headers: { "x-bot-token": bot.token },
transformRequest: (data, headers) => {
// Remove user headers for this request
delete headers?.["x-user-id"];
delete headers?.["x-session-token"];
return data;
},
)
})
.then((profile) => setProfile(profile ?? {}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, setProfile]);
@@ -122,14 +120,14 @@ function BotCard({ bot, onDelete, onUpdate }: Props) {
const changes: Changes = {};
if (data.username !== user!.username) changes.name = data.username;
if (data.public !== bot.public) changes.public = data.public;
if (data.interactions_url === "") changes.remove = "InteractionsURL";
if (data.interactions_url === "") changes.remove = ["InteractionsURL"];
else if (data.interactions_url !== bot.interactions_url)
changes.interactions_url = data.interactions_url;
setSaving(true);
setError("");
try {
await client.bots.edit(bot._id, changes);
if (changed) await editBotContent(profile?.content);
if (changed) await editBotContent(profile?.content ?? undefined);
onUpdate(changes);
setChanged(false);
setEditMode(false);
@@ -152,19 +150,22 @@ function BotCard({ bot, onDelete, onUpdate }: Props) {
async function editBotAvatar(avatar?: string) {
setSaving(true);
setError("");
await client.request("PATCH", "/users/id", {
headers: { "x-bot-token": bot.token },
transformRequest: (data, headers) => {
// Remove user headers for this request
delete headers["x-user-id"];
delete headers["x-session-token"];
return data;
await client.api.patch(
"/users/@me",
avatar ? { avatar } : { remove: ["Avatar"] },
{
headers: { "x-bot-token": bot.token },
transformRequest: (data, headers) => {
// Remove user headers for this request
delete headers?.["x-user-id"];
delete headers?.["x-session-token"];
return data;
},
},
data: JSON.stringify(avatar ? { avatar } : { remove: "Avatar" }),
});
);
const res = await client.bots.fetch(bot._id);
if (!avatar) res.user.update({}, "Avatar");
if (!avatar) res.user.update({}, ["Avatar"]);
setUser(res.user);
setSaving(false);
}
@@ -172,20 +173,21 @@ function BotCard({ bot, onDelete, onUpdate }: Props) {
async function editBotBackground(background?: string) {
setSaving(true);
setError("");
await client.request("PATCH", "/users/id", {
headers: { "x-bot-token": bot.token },
transformRequest: (data, headers) => {
// Remove user headers for this request
delete headers["x-user-id"];
delete headers["x-session-token"];
return data;
await client.api.patch(
"/users/@me",
background
? { profile: { background } }
: { remove: ["ProfileBackground"] },
{
headers: { "x-bot-token": bot.token },
transformRequest: (data, headers) => {
// Remove user headers for this request
delete headers?.["x-user-id"];
delete headers?.["x-session-token"];
return data;
},
},
data: JSON.stringify(
background
? { profile: { background } }
: { remove: "ProfileBackground" },
),
});
);
if (!background) setProfile({ ...profile, background: undefined });
else refreshProfile();
@@ -195,20 +197,19 @@ function BotCard({ bot, onDelete, onUpdate }: Props) {
async function editBotContent(content?: string) {
setSaving(true);
setError("");
await client.request("PATCH", "/users/id", {
headers: { "x-bot-token": bot.token },
transformRequest: (data, headers) => {
// Remove user headers for this request
delete headers["x-user-id"];
delete headers["x-session-token"];
return data;
await client.api.patch(
"/users/@me",
content ? { profile: { content } } : { remove: ["ProfileContent"] },
{
headers: { "x-bot-token": bot.token },
transformRequest: (data, headers) => {
// Remove user headers for this request
delete headers?.["x-user-id"];
delete headers?.["x-session-token"];
return data;
},
},
data: JSON.stringify(
content
? { profile: { content } }
: { remove: "ProfileContent" },
),
});
);
if (!content) setProfile({ ...profile, content: undefined });
else refreshProfile();
@@ -333,7 +334,7 @@ function BotCard({ bot, onDelete, onUpdate }: Props) {
_id: bot._id,
username: user!.username,
public: bot.public,
interactions_url: bot.interactions_url,
interactions_url: bot.interactions_url as any,
});
usernameRef!.value = user!.username;
interactionsRef!.value = bot.interactions_url || "";
@@ -521,7 +522,7 @@ function BotCard({ bot, onDelete, onUpdate }: Props) {
export const MyBots = observer(() => {
const client = useClient();
const [bots, setBots] = useState<Bot[] | undefined>(undefined);
const [bots, setBots] = useState<API.Bot[] | undefined>(undefined);
useEffect(() => {
client.bots.fetchOwned().then(({ bots }) => setBots(bots));
@@ -582,7 +583,7 @@ export const MyBots = observer(() => {
changes.interactions_url;
if (
changes.remove ===
"InteractionsURL"
["InteractionsURL"]
)
x.interactions_url = undefined;
}

View File

@@ -81,7 +81,7 @@ export const Notifications = observer(() => {
// tell the server we just subscribed
const json = sub.toJSON();
if (json.keys) {
client.req("POST", "/push/subscribe", {
client.api.post("/push/subscribe", {
endpoint: sub.endpoint,
...(json.keys as {
p256dh: string;
@@ -96,7 +96,7 @@ export const Notifications = observer(() => {
sub?.unsubscribe();
setPushEnabled(false);
client.req("POST", "/push/unsubscribe");
client.api.post("/push/unsubscribe");
}
}
} catch (err) {

View File

@@ -1,7 +1,7 @@
import { Markdown } from "@styled-icons/boxicons-logos";
import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom";
import { Profile as ProfileI } from "revolt-api/types/Users";
import { API } from "revolt.js";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
@@ -30,7 +30,9 @@ export const Profile = observer(() => {
const client = useClient();
const history = useHistory();
const [profile, setProfile] = useState<undefined | ProfileI>(undefined);
const [profile, setProfile] = useState<undefined | API.UserProfile>(
undefined,
);
// ! FIXME: temporary solution
// ! we should just announce profile changes through WS
@@ -103,7 +105,7 @@ export const Profile = observer(() => {
behaviour="upload"
maxFileSize={4_000_000}
onUpload={(avatar) => client.users.edit({ avatar })}
remove={() => client.users.edit({ remove: "Avatar" })}
remove={() => client.users.edit({ remove: ["Avatar"] })}
defaultPreview={client.user!.generateAvatarURL(
{ max_side: 256 },
true,
@@ -132,7 +134,7 @@ export const Profile = observer(() => {
}}
remove={async () => {
await client.users.edit({
remove: "ProfileBackground",
remove: ["ProfileBackground"],
});
setProfile({ ...profile, background: undefined });
}}

View File

@@ -11,7 +11,7 @@ import {
} from "@styled-icons/simple-icons";
import relativeTime from "dayjs/plugin/relativeTime";
import { useHistory } from "react-router-dom";
import { SessionInfo } from "revolt-api/types/Auth";
import { API } from "revolt.js";
import { decodeTime } from "ulid";
import styles from "./Panes.module.scss";
@@ -33,7 +33,7 @@ export function Sessions() {
const deviceId =
typeof client.session === "object" ? client.session._id : undefined;
const [sessions, setSessions] = useState<SessionInfo[] | undefined>(
const [sessions, setSessions] = useState<API.SessionInfo[] | undefined>(
undefined,
);
const [attemptingDelete, setDelete] = useState<string[]>([]);
@@ -44,7 +44,7 @@ export function Sessions() {
}
useEffect(() => {
client.req("GET", "/auth/session/all").then((data) => {
client.api.get("/auth/session/all").then((data) => {
data.sort(
(a, b) =>
(b._id === deviceId ? 1 : 0) - (a._id === deviceId ? 1 : 0),
@@ -61,7 +61,7 @@ export function Sessions() {
);
}
function getIcon(session: SessionInfo) {
function getIcon(session: API.SessionInfo) {
const name = session.name;
switch (true) {
case /firefox/i.test(name):
@@ -83,7 +83,7 @@ export function Sessions() {
}
}
function getSystemIcon(session: SessionInfo) {
function getSystemIcon(session: API.SessionInfo) {
const name = session.name;
switch (true) {
case /linux/i.test(name):
@@ -187,9 +187,10 @@ export function Sessions() {
...attemptingDelete,
session._id,
]);
await client.req(
"DELETE",
`/auth/session/${session._id}` as "/auth/session/id",
await client.api.delete(
`/auth/session/${
session._id as ""
}`,
);
setSessions(
sessions?.filter(
@@ -222,10 +223,7 @@ export function Sessions() {
setDelete(del);
for (const id of del) {
await client.req(
"DELETE",
`/auth/session/${id}` as "/auth/session/id",
);
await client.api.delete(`/auth/session/${id as ""}`);
}
setSessions(sessions.filter((x) => x._id === deviceId));

View File

@@ -1,10 +1,8 @@
import { XCircle } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { Virtuoso } from "react-virtuoso";
import { Ban } from "revolt-api/types/Servers";
import { User } from "revolt-api/types/Users";
import { Route } from "revolt.js/dist/api/routes";
import { Server } from "revolt.js/dist/maps/Servers";
import { API } from "revolt.js";
import { Server } from "revolt.js";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
@@ -15,8 +13,8 @@ import IconButton from "../../../components/ui/IconButton";
import Preloader from "../../../components/ui/Preloader";
interface InnerProps {
ban: Ban;
users: Pick<User, "username" | "avatar" | "_id">[];
ban: API.ServerBan;
users: Pick<API.User, "username" | "avatar" | "_id">[];
server: Server;
removeSelf: () => void;
}
@@ -53,9 +51,7 @@ interface Props {
}
export const Bans = observer(({ server }: Props) => {
const [data, setData] = useState<
Route<"GET", "/servers/id/bans">["response"] | undefined
>(undefined);
const [data, setData] = useState<API.BanListResult | undefined>(undefined);
useEffect(() => {
server.fetchBans().then(setData);

View File

@@ -1,9 +1,7 @@
import { Plus, X } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { DragDropContext } from "react-beautiful-dnd";
import { TextChannel, VoiceChannel } from "revolt-api/types/Channels";
import { Category } from "revolt-api/types/Servers";
import { Server } from "revolt.js/dist/maps/Servers";
import { Channel, Server, API } from "revolt.js";
import styled, { css } from "styled-components/macro";
import { ulid } from "ulid";
@@ -135,7 +133,7 @@ interface Props {
export const Categories = observer(({ server }: Props) => {
const [status, setStatus] = useState<EditStatus>("saved");
const [categories, setCategories] = useState<Category[]>(
const [categories, setCategories] = useState<API.Category[]>(
server.categories ?? [],
);
@@ -327,12 +325,14 @@ function ListElement({
addChannel,
draggable,
}: {
category: Category;
category: API.Category;
server: Server;
index: number;
setTitle?: (title: string) => void;
deleteSelf?: () => void;
addChannel: (channel: TextChannel | VoiceChannel) => void;
addChannel: (
channel: Channel & { channel_type: "TextChannel" | "VoiceChannel" },
) => void;
draggable?: boolean;
}) {
const { openScreen } = useIntermediate();

View File

@@ -1,8 +1,7 @@
import { XCircle } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { Virtuoso } from "react-virtuoso";
import { Invite, ServerInvite } from "revolt-api/types/Invites";
import { Server } from "revolt.js/dist/maps/Servers";
import { API, Server } from "revolt.js";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
@@ -16,7 +15,7 @@ import IconButton from "../../../components/ui/IconButton";
import Preloader from "../../../components/ui/Preloader";
interface InnerProps {
invite: Invite;
invite: API.Invite;
server: Server;
removeSelf: () => void;
}
@@ -52,12 +51,10 @@ interface Props {
}
export const Invites = ({ server }: Props) => {
const [invites, setInvites] = useState<ServerInvite[] | undefined>(
undefined,
);
const [invites, setInvites] = useState<API.Invite[] | undefined>(undefined);
useEffect(() => {
server.fetchInvites().then(setInvites);
server.fetchInvites().then((v) => setInvites(v));
}, [server, setInvites]);
return (

View File

@@ -2,8 +2,8 @@ import { ChevronDown } from "@styled-icons/boxicons-regular";
import { isEqual } from "lodash";
import { observer } from "mobx-react-lite";
import { Virtuoso } from "react-virtuoso";
import { Member } from "revolt.js/dist/maps/Members";
import { Server } from "revolt.js/dist/maps/Servers";
import { Member } from "revolt.js";
import { Server } from "revolt.js";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";

View File

@@ -1,13 +1,14 @@
import { Markdown } from "@styled-icons/boxicons-logos";
import isEqual from "lodash.isequal";
import { observer } from "mobx-react-lite";
import { Server } from "revolt.js/dist/maps/Servers";
import { Server } from "revolt.js";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { noop } from "../../../lib/js";
import { FileUploader } from "../../../context/revoltjs/FileUploads";
import { getChannelName } from "../../../context/revoltjs/util";
@@ -60,9 +61,9 @@ export const Overview = observer(({ server }: Props) => {
fileType="icons"
behaviour="upload"
maxFileSize={2_500_000}
onUpload={(icon) => server.edit({ icon })}
onUpload={(icon) => server.edit({ icon }).then(noop)}
previewURL={server.generateIconURL({ max_side: 256 }, true)}
remove={() => server.edit({ remove: "Icon" })}
remove={() => server.edit({ remove: ["Icon"] }).then(noop)}
/>
<div className={styles.name}>
<h3>
@@ -117,9 +118,9 @@ export const Overview = observer(({ server }: Props) => {
fileType="banners"
behaviour="upload"
maxFileSize={6_000_000}
onUpload={(banner) => server.edit({ banner })}
onUpload={(banner) => server.edit({ banner }).then(noop)}
previewURL={server.generateBannerURL({ width: 1000 }, true)}
remove={() => server.edit({ remove: "Banner" })}
remove={() => server.edit({ remove: ["Banner"] }).then(noop)}
/>
<hr />
<h3>

View File

@@ -116,48 +116,3 @@
flex-grow: 1;
}
}
.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;
}
}

View File

@@ -1,289 +1,237 @@
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 { Server } from "revolt.js";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import {
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "preact/hooks";
import { useMemo, useState } from "preact/hooks";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
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 { Button, PermissionsLayout, SpaceBetween, H1 } from "@revoltchat/ui";
import ButtonItem from "../../../components/navigation/items/ButtonItem";
import { PermissionList } from "../../../components/settings/roles/PermissionList";
import { RoleOrDefault } from "../../../components/settings/roles/RoleSelection";
interface Props {
server: Server;
}
const I32ToU32 = (arr: number[]) => arr.map((x) => x >>> 0);
/**
* Hook to memo-ize role information.
* @param server Target server
* @returns Role array
*/
export function useRoles(server: Server) {
return useMemo(
() =>
[
// Pull in known server roles.
...server.orderedRoles,
// Include the default server permissions.
{
id: "default",
name: "Default",
permissions: server.default_permissions,
},
] as RoleOrDefault[],
[server.roles, server.default_permissions],
);
}
// ! FIXME: bad code :)
/**
* Roles settings menu
*/
export const Roles = observer(({ server }: Props) => {
const client = useContext(AppContext);
const [role, setRole] = useState("default");
// Consolidate all permissions that we can change right now.
const currentRoles = useRoles(server);
// Pull in modal context.
const { openScreen } = useIntermediate();
const roles = server.roles || {};
if (role !== "default" && typeof roles[role] === "undefined") {
useEffect(() => setRole("default"), [role]);
return null;
}
const clientPermissions = client.servers.get(server._id)!.permission;
const {
name: roleName,
colour: roleColour,
hoist: roleHoist,
rank: roleRank,
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 [hoist, setHoist] = useState(roleHoist);
const [rank, setRank] = useState(roleRank);
const [colour, setColour] = useState(roleColour);
useEffect(
() => setPerm(getPermissions(role)),
[getPermissions, role, permissions],
);
useEffect(() => setName(roleName), [role, roleName]);
useEffect(() => setHoist(roleHoist), [role, roleHoist]);
useEffect(() => setRank(roleRank), [role, roleRank]);
useEffect(() => setColour(roleColour), [role, roleColour]);
const modified =
!isEqual(perm, getPermissions(role)) ||
!isEqual(name, roleName) ||
!isEqual(colour, roleColour) ||
!isEqual(hoist, roleHoist) ||
!isEqual(rank, roleRank);
const save = () => {
if (!isEqual(perm, getPermissions(role))) {
server.setPermissions(role, {
server: perm[0],
channel: perm[1],
});
}
if (
!isEqual(name, roleName) ||
!isEqual(colour, roleColour) ||
!isEqual(hoist, roleHoist) ||
!isEqual(rank, roleRank)
) {
server.editRole(role, { name, colour, hoist, rank });
}
};
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) =>
id === "default" ? (
<ButtonItem
active={role === "default"}
onClick={() => setRole("default")}>
<Text id="app.settings.permissions.default_role" />
</ButtonItem>
) : (
<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
<PermissionsLayout
server={server}
onCreateRole={(callback) =>
openScreen({
id: "special_input",
type: "create_role",
server: server as any,
callback,
})
}
editor={({ selected }) => {
const currentRole = currentRoles.find(
(x) => x.id === selected,
)!;
if (!currentRole) return null;
// Keep track of whatever role we're editing right now.
const [value, setValue] = useState<Partial<RoleOrDefault>>({});
const currentRoleValue = { ...currentRole, ...value };
// Calculate permissions we have access to on this server.
const current = server.permission;
// Upload new role information to server.
function save() {
const { permissions: permsCurrent, ...current } =
currentRole;
const { permissions: permsValue, ...value } =
currentRoleValue;
if (!isEqual(permsCurrent, permsValue)) {
server.setPermissions(
selected,
typeof permsValue === "number"
? permsValue
: {
allow: permsValue.a,
deny: permsValue.d,
},
);
}
if (!isEqual(current, value)) {
server.editRole(selected, value);
}
}
// Delete the role from this server.
function deleteRole() {
server.deleteRole(selected);
}
return (
<div>
<SpaceBetween>
<H1>
<Text
id="app.settings.actions.edit"
fields={{ name: currentRole.name }}
/>
</H1>
<Button
palette="secondary"
disabled={isEqual(
currentRole,
currentRoleValue,
)}
onClick={save}>
<Text id="app.special.modals.actions.save" />
</Button>
</SpaceBetween>
<hr />
{selected !== "default" && (
<>
<section>
<Overline type="subtle">
<Text id="app.settings.permissions.role_name" />
</Overline>
<p>
<InputBox
value={currentRoleValue.name}
onChange={(e) =>
setValue({
...value,
name: e.currentTarget.value,
})
}
contrast
/>
</p>
</section>
<section>
<Overline type="subtle">
<Text id="app.settings.permissions.role_colour" />
</Overline>
<p>
<ColourSwatches
value={
currentRoleValue.colour ??
"gray"
}
onChange={(colour) =>
setValue({ ...value, colour })
}
/>
</p>
</section>
<section>
<Overline type="subtle">
<Text id="app.settings.permissions.role_options" />
</Overline>
<p>
<Checkbox
checked={
currentRoleValue.hoist ?? false
}
onChange={(hoist) =>
setValue({ ...value, hoist })
}
description={
<Text id="app.settings.permissions.hoist_desc" />
}>
<Text id="app.settings.permissions.hoist_role" />
</Checkbox>
</p>
</section>
<section>
<Overline type="subtle">
<Text id="app.settings.permissions.role_ranking" />
</Overline>
<p>
<InputBox
type="number"
value={currentRoleValue.rank ?? 0}
onChange={(e) =>
setValue({
...value,
rank: parseInt(
e.currentTarget.value,
),
})
}
contrast
/>
</p>
</section>
</>
)}
</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">Role Options</Overline>
<p>
<Checkbox
checked={hoist ?? false}
onChange={(v) => setHoist(v)}
description="Display this role above others.">
Hoist Role
</Checkbox>
</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]])
}
disabled={!(clientPermissions & value)}
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" ||
!(clientPermissions & value)
}
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>
{role !== "default" && (
<>
<section>
<Overline type="subtle">
Experimental Role Ranking
</Overline>
<p>
<InputBox
value={rank ?? 0}
onChange={(e) =>
setRank(parseInt(e.currentTarget.value))
}
contrast
/>
</p>
</section>
</>
)}
</div>
</div>
<h1>
<Text id="app.settings.permissions.edit_title" />
</h1>
<PermissionList
value={currentRoleValue.permissions}
onChange={(permissions) =>
setValue({
...value,
permissions,
} as RoleOrDefault)
}
/>
{selected !== "default" && (
<>
<hr />
<h1>
<Text id="app.settings.categories.danger_zone" />
</h1>
<Button
palette="error"
compact
onClick={deleteRole}>
<Text id="app.settings.permissions.delete_role" />
</Button>
</>
)}
</div>
);
}}
/>
);
});