Use tabWidth 4 without actual tabs.

This commit is contained in:
Paul
2021-07-05 11:25:20 +01:00
parent 7bd33d8d34
commit b5a11d5c8f
180 changed files with 16619 additions and 16622 deletions

View File

@@ -5,79 +5,79 @@ import { useContext, useEffect } from "preact/hooks";
import { useIntermediate } from "../context/intermediate/Intermediate";
import {
AppContext,
ClientStatus,
StatusContext,
AppContext,
ClientStatus,
StatusContext,
} from "../context/revoltjs/RevoltClient";
import {
useChannels,
useForceUpdate,
useUser,
useChannels,
useForceUpdate,
useUser,
} from "../context/revoltjs/hooks";
import Header from "../components/ui/Header";
export default function Open() {
const history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext);
const { id } = useParams<{ id: string }>();
const { openScreen } = useIntermediate();
const history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext);
const { id } = useParams<{ id: string }>();
const { openScreen } = useIntermediate();
if (status !== ClientStatus.ONLINE) {
return (
<Header placement="primary">
<Text id="general.loading" />
</Header>
);
}
if (status !== ClientStatus.ONLINE) {
return (
<Header placement="primary">
<Text id="general.loading" />
</Header>
);
}
const ctx = useForceUpdate();
const channels = useChannels(undefined, ctx);
const user = useUser(id, ctx);
const ctx = useForceUpdate();
const channels = useChannels(undefined, ctx);
const user = useUser(id, ctx);
useEffect(() => {
if (id === "saved") {
for (const channel of channels) {
if (channel?.channel_type === "SavedMessages") {
history.push(`/channel/${channel._id}`);
return;
}
}
useEffect(() => {
if (id === "saved") {
for (const channel of channels) {
if (channel?.channel_type === "SavedMessages") {
history.push(`/channel/${channel._id}`);
return;
}
}
client.users
.openDM(client.user?._id as string)
.then((channel) => history.push(`/channel/${channel?._id}`))
.catch((error) => openScreen({ id: "error", error }));
client.users
.openDM(client.user?._id as string)
.then((channel) => history.push(`/channel/${channel?._id}`))
.catch((error) => openScreen({ id: "error", error }));
return;
}
return;
}
if (user) {
const channel: string | undefined = channels.find(
(channel) =>
channel?.channel_type === "DirectMessage" &&
channel.recipients.includes(id),
)?._id;
if (user) {
const channel: string | undefined = channels.find(
(channel) =>
channel?.channel_type === "DirectMessage" &&
channel.recipients.includes(id),
)?._id;
if (channel) {
history.push(`/channel/${channel}`);
} else {
client.users
.openDM(id)
.then((channel) => history.push(`/channel/${channel?._id}`))
.catch((error) => openScreen({ id: "error", error }));
}
if (channel) {
history.push(`/channel/${channel}`);
} else {
client.users
.openDM(id)
.then((channel) => history.push(`/channel/${channel?._id}`))
.catch((error) => openScreen({ id: "error", error }));
}
return;
}
return;
}
history.push("/");
}, []);
history.push("/");
}, []);
return (
<Header placement="primary">
<Text id="general.loading" />
</Header>
);
return (
<Header placement="primary">
<Text id="general.loading" />
</Header>
);
}

View File

@@ -24,97 +24,97 @@ import ServerSettings from "./settings/ServerSettings";
import Settings from "./settings/Settings";
const Routes = styled.div`
min-width: 0;
display: flex;
overflow: hidden;
flex-direction: column;
background: var(--primary-background);
min-width: 0;
display: flex;
overflow: hidden;
flex-direction: column;
background: var(--primary-background);
`;
export default function App() {
const path = useLocation().pathname;
const fixedBottomNav =
path === "/" || path === "/settings" || path.startsWith("/friends");
const inSettings = path.includes("/settings");
const inChannel = path.includes("/channel");
const inSpecial =
(path.startsWith("/friends") && isTouchscreenDevice) ||
path.startsWith("/invite") ||
path.startsWith("/settings");
const path = useLocation().pathname;
const fixedBottomNav =
path === "/" || path === "/settings" || path.startsWith("/friends");
const inSettings = path.includes("/settings");
const inChannel = path.includes("/channel");
const inSpecial =
(path.startsWith("/friends") && isTouchscreenDevice) ||
path.startsWith("/invite") ||
path.startsWith("/settings");
return (
<OverlappingPanels
width="100vw"
height="var(--app-height)"
leftPanel={
inSpecial
? undefined
: { width: 292, component: <LeftSidebar /> }
}
rightPanel={
!inSettings && inChannel
? { width: 240, component: <RightSidebar /> }
: undefined
}
bottomNav={{
component: <BottomNavigation />,
showIf: fixedBottomNav ? ShowIf.Always : ShowIf.Left,
height: 50,
}}
docked={isTouchscreenDevice ? Docked.None : Docked.Left}>
<Routes>
<Switch>
<Route
path="/server/:server/channel/:channel/settings/:page"
component={ChannelSettings}
/>
<Route
path="/server/:server/channel/:channel/settings"
component={ChannelSettings}
/>
<Route
path="/server/:server/settings/:page"
component={ServerSettings}
/>
<Route
path="/server/:server/settings"
component={ServerSettings}
/>
<Route
path="/channel/:channel/settings/:page"
component={ChannelSettings}
/>
<Route
path="/channel/:channel/settings"
component={ChannelSettings}
/>
return (
<OverlappingPanels
width="100vw"
height="var(--app-height)"
leftPanel={
inSpecial
? undefined
: { width: 292, component: <LeftSidebar /> }
}
rightPanel={
!inSettings && inChannel
? { width: 240, component: <RightSidebar /> }
: undefined
}
bottomNav={{
component: <BottomNavigation />,
showIf: fixedBottomNav ? ShowIf.Always : ShowIf.Left,
height: 50,
}}
docked={isTouchscreenDevice ? Docked.None : Docked.Left}>
<Routes>
<Switch>
<Route
path="/server/:server/channel/:channel/settings/:page"
component={ChannelSettings}
/>
<Route
path="/server/:server/channel/:channel/settings"
component={ChannelSettings}
/>
<Route
path="/server/:server/settings/:page"
component={ServerSettings}
/>
<Route
path="/server/:server/settings"
component={ServerSettings}
/>
<Route
path="/channel/:channel/settings/:page"
component={ChannelSettings}
/>
<Route
path="/channel/:channel/settings"
component={ChannelSettings}
/>
<Route
path="/channel/:channel/message/:message"
component={Channel}
/>
<Route
path="/server/:server/channel/:channel"
component={Channel}
/>
<Route path="/server/:server" />
<Route path="/channel/:channel" component={Channel} />
<Route
path="/channel/:channel/message/:message"
component={Channel}
/>
<Route
path="/server/:server/channel/:channel"
component={Channel}
/>
<Route path="/server/:server" />
<Route path="/channel/:channel" component={Channel} />
<Route path="/settings/:page" component={Settings} />
<Route path="/settings" component={Settings} />
<Route path="/settings/:page" component={Settings} />
<Route path="/settings" component={Settings} />
<Route path="/dev" component={Developer} />
<Route path="/friends" component={Friends} />
<Route path="/open/:id" component={Open} />
<Route path="/invite/:code" component={Invite} />
<Route path="/" component={Home} />
</Switch>
</Routes>
<ContextMenus />
<Popovers />
<Notifications />
<StateMonitor />
<SyncManager />
</OverlappingPanels>
);
<Route path="/dev" component={Developer} />
<Route path="/friends" component={Friends} />
<Route path="/open/:id" component={Open} />
<Route path="/invite/:code" component={Invite} />
<Route path="/" component={Home} />
</Switch>
</Routes>
<ContextMenus />
<Popovers />
<Notifications />
<StateMonitor />
<SyncManager />
</OverlappingPanels>
);
}

View File

@@ -12,25 +12,25 @@ const Login = lazy(() => import("./login/Login"));
const RevoltApp = lazy(() => import("./RevoltApp"));
export function App() {
return (
<Context>
<Masks />
{/*
return (
<Context>
<Masks />
{/*
// @ts-expect-error */}
<Suspense fallback={<Preloader type="spinner" />}>
<Switch>
<Route path="/login">
<CheckAuth>
<Login />
</CheckAuth>
</Route>
<Route path="/">
<CheckAuth auth>
<RevoltApp />
</CheckAuth>
</Route>
</Switch>
</Suspense>
</Context>
);
<Suspense fallback={<Preloader type="spinner" />}>
<Switch>
<Route path="/login">
<CheckAuth>
<Login />
</CheckAuth>
</Route>
<Route path="/">
<CheckAuth auth>
<RevoltApp />
</CheckAuth>
</Route>
</Switch>
</Suspense>
</Context>
);
}

View File

@@ -20,134 +20,134 @@ import { MessageArea } from "./messaging/MessageArea";
import VoiceHeader from "./voice/VoiceHeader";
const ChannelMain = styled.div`
flex-grow: 1;
display: flex;
min-height: 0;
overflow: hidden;
flex-direction: row;
flex-grow: 1;
display: flex;
min-height: 0;
overflow: hidden;
flex-direction: row;
`;
const ChannelContent = styled.div`
flex-grow: 1;
display: flex;
overflow: hidden;
flex-direction: column;
flex-grow: 1;
display: flex;
overflow: hidden;
flex-direction: column;
`;
const AgeGate = styled.div`
display: flex;
flex-grow: 1;
flex-direction: column;
align-items: center;
justify-content: center;
user-select: none;
padding: 12px;
display: flex;
flex-grow: 1;
flex-direction: column;
align-items: center;
justify-content: center;
user-select: none;
padding: 12px;
img {
height: 150px;
}
img {
height: 150px;
}
.subtext {
color: var(--secondary-foreground);
margin-bottom: 12px;
font-size: 14px;
}
.subtext {
color: var(--secondary-foreground);
margin-bottom: 12px;
font-size: 14px;
}
.actions {
margin-top: 20px;
display: flex;
gap: 12px;
}
.actions {
margin-top: 20px;
display: flex;
gap: 12px;
}
`;
export function Channel({ id }: { id: string }) {
const ctx = useForceUpdate();
const channel = useChannel(id, ctx);
const ctx = useForceUpdate();
const channel = useChannel(id, ctx);
if (!channel) return null;
if (!channel) return null;
if (channel.channel_type === "VoiceChannel") {
return <VoiceChannel channel={channel} />;
} else {
return <TextChannel channel={channel} />;
}
if (channel.channel_type === "VoiceChannel") {
return <VoiceChannel channel={channel} />;
} else {
return <TextChannel channel={channel} />;
}
}
function TextChannel({ channel }: { channel: Channels.Channel }) {
const [showMembers, setMembers] = useState(true);
const [showMembers, setMembers] = useState(true);
if (
(channel.channel_type === "TextChannel" ||
channel.channel_type === "Group") &&
channel.name.includes("nsfw")
) {
const goBack = useHistory();
const [consent, setConsent] = useState(false);
const [ageGate, setAgeGate] = useState(false);
if (!ageGate) {
return (
<AgeGate>
<img
src={"https://static.revolt.chat/emoji/mutant/26a0.svg"}
draggable={false}
/>
<h2>{channel.name}</h2>
<span className="subtext">
This channel is marked as NSFW.{" "}
<a href="#">Learn more</a>
</span>
if (
(channel.channel_type === "TextChannel" ||
channel.channel_type === "Group") &&
channel.name.includes("nsfw")
) {
const goBack = useHistory();
const [consent, setConsent] = useState(false);
const [ageGate, setAgeGate] = useState(false);
if (!ageGate) {
return (
<AgeGate>
<img
src={"https://static.revolt.chat/emoji/mutant/26a0.svg"}
draggable={false}
/>
<h2>{channel.name}</h2>
<span className="subtext">
This channel is marked as NSFW.{" "}
<a href="#">Learn more</a>
</span>
<Checkbox checked={consent} onChange={(v) => setConsent(v)}>
I confirm that I am at least 18 years old.
</Checkbox>
<div className="actions">
<Button contrast onClick={() => goBack}>
Go back
</Button>
<Button
contrast
onClick={() => consent && setAgeGate(true)}>
Enter Channel
</Button>
</div>
</AgeGate>
);
}
}
<Checkbox checked={consent} onChange={(v) => setConsent(v)}>
I confirm that I am at least 18 years old.
</Checkbox>
<div className="actions">
<Button contrast onClick={() => goBack}>
Go back
</Button>
<Button
contrast
onClick={() => consent && setAgeGate(true)}>
Enter Channel
</Button>
</div>
</AgeGate>
);
}
}
let id = channel._id;
return (
<>
<ChannelHeader
channel={channel}
toggleSidebar={() => setMembers(!showMembers)}
/>
<ChannelMain>
<ChannelContent>
<VoiceHeader id={id} />
<MessageArea id={id} />
<TypingIndicator id={id} />
<JumpToBottom id={id} />
<MessageBox channel={channel} />
</ChannelContent>
{!isTouchscreenDevice && showMembers && (
<MemberSidebar channel={channel} />
)}
</ChannelMain>
</>
);
let id = channel._id;
return (
<>
<ChannelHeader
channel={channel}
toggleSidebar={() => setMembers(!showMembers)}
/>
<ChannelMain>
<ChannelContent>
<VoiceHeader id={id} />
<MessageArea id={id} />
<TypingIndicator id={id} />
<JumpToBottom id={id} />
<MessageBox channel={channel} />
</ChannelContent>
{!isTouchscreenDevice && showMembers && (
<MemberSidebar channel={channel} />
)}
</ChannelMain>
</>
);
}
function VoiceChannel({ channel }: { channel: Channels.Channel }) {
return (
<>
<ChannelHeader channel={channel} />
<VoiceHeader id={channel._id} />
</>
);
return (
<>
<ChannelHeader channel={channel} />
<VoiceHeader id={channel._id} />
</>
);
}
export default function () {
const { channel } = useParams<{ channel: string }>();
return <Channel id={channel} key={channel} />;
const { channel } = useParams<{ channel: string }>();
return <Channel id={channel} key={channel} />;
}

View File

@@ -19,121 +19,121 @@ import Markdown from "../../components/markdown/Markdown";
import HeaderActions from "./actions/HeaderActions";
export interface ChannelHeaderProps {
channel: Channel;
toggleSidebar?: () => void;
channel: Channel;
toggleSidebar?: () => void;
}
const Info = styled.div`
flex-grow: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
flex-grow: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
display: flex;
gap: 8px;
align-items: center;
display: flex;
gap: 8px;
align-items: center;
* {
display: inline-block;
}
* {
display: inline-block;
}
.divider {
height: 20px;
margin: 0 5px;
padding-left: 1px;
background-color: var(--tertiary-background);
}
.divider {
height: 20px;
margin: 0 5px;
padding-left: 1px;
background-color: var(--tertiary-background);
}
.status {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-inline-end: 6px;
}
.status {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-inline-end: 6px;
}
.desc {
cursor: pointer;
margin-top: 2px;
font-size: 0.8em;
font-weight: 400;
color: var(--secondary-foreground);
}
.desc {
cursor: pointer;
margin-top: 2px;
font-size: 0.8em;
font-weight: 400;
color: var(--secondary-foreground);
}
`;
export default function ChannelHeader({
channel,
toggleSidebar,
channel,
toggleSidebar,
}: ChannelHeaderProps) {
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const name = getChannelName(client, channel);
let icon, recipient;
switch (channel.channel_type) {
case "SavedMessages":
icon = <Notepad size={24} />;
break;
case "DirectMessage":
icon = <At size={24} />;
const uid = client.channels.getRecipient(channel._id);
recipient = client.users.get(uid);
break;
case "Group":
icon = <Group size={24} />;
break;
case "TextChannel":
icon = <Hash size={24} />;
break;
}
const name = getChannelName(client, channel);
let icon, recipient;
switch (channel.channel_type) {
case "SavedMessages":
icon = <Notepad size={24} />;
break;
case "DirectMessage":
icon = <At size={24} />;
const uid = client.channels.getRecipient(channel._id);
recipient = client.users.get(uid);
break;
case "Group":
icon = <Group size={24} />;
break;
case "TextChannel":
icon = <Hash size={24} />;
break;
}
return (
<Header placement="primary">
{icon}
<Info>
<span className="name">{name}</span>
{isTouchscreenDevice &&
channel.channel_type === "DirectMessage" && (
<>
<div className="divider" />
<span className="desc">
<div
className="status"
style={{
backgroundColor: useStatusColour(
recipient as User,
),
}}
/>
<UserStatus user={recipient as User} />
</span>
</>
)}
{!isTouchscreenDevice &&
(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") &&
channel.description && (
<>
<div className="divider" />
<span
className="desc"
onClick={() =>
openScreen({
id: "channel_info",
channel_id: channel._id,
})
}>
<Markdown
content={
channel.description.split("\n")[0] ?? ""
}
disallowBigEmoji
/>
</span>
</>
)}
</Info>
<HeaderActions channel={channel} toggleSidebar={toggleSidebar} />
</Header>
);
return (
<Header placement="primary">
{icon}
<Info>
<span className="name">{name}</span>
{isTouchscreenDevice &&
channel.channel_type === "DirectMessage" && (
<>
<div className="divider" />
<span className="desc">
<div
className="status"
style={{
backgroundColor: useStatusColour(
recipient as User,
),
}}
/>
<UserStatus user={recipient as User} />
</span>
</>
)}
{!isTouchscreenDevice &&
(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") &&
channel.description && (
<>
<div className="divider" />
<span
className="desc"
onClick={() =>
openScreen({
id: "channel_info",
channel_id: channel._id,
})
}>
<Markdown
content={
channel.description.split("\n")[0] ?? ""
}
disallowBigEmoji
/>
</span>
</>
)}
</Info>
<HeaderActions channel={channel} toggleSidebar={toggleSidebar} />
</Header>
);
}

View File

@@ -1,9 +1,9 @@
import { Sidebar as SidebarIcon } from "@styled-icons/boxicons-regular";
import {
UserPlus,
Cog,
PhoneCall,
PhoneOutgoing,
UserPlus,
Cog,
PhoneCall,
PhoneOutgoing,
} from "@styled-icons/boxicons-solid";
import { useHistory } from "react-router-dom";
@@ -12,9 +12,9 @@ import { useContext } from "preact/hooks";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import {
VoiceContext,
VoiceOperationsContext,
VoiceStatus,
VoiceContext,
VoiceOperationsContext,
VoiceStatus,
} from "../../../context/Voice";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
@@ -25,88 +25,88 @@ import IconButton from "../../../components/ui/IconButton";
import { ChannelHeaderProps } from "../ChannelHeader";
export default function HeaderActions({
channel,
toggleSidebar,
channel,
toggleSidebar,
}: ChannelHeaderProps) {
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const history = useHistory();
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const history = useHistory();
return (
<>
<UpdateIndicator />
{channel.channel_type === "Group" && (
<>
<IconButton
onClick={() =>
openScreen({
id: "user_picker",
omit: channel.recipients,
callback: async (users) => {
for (const user of users) {
await client.channels.addMember(
channel._id,
user,
);
}
},
})
}>
<UserPlus size={27} />
</IconButton>
<IconButton
onClick={() =>
history.push(`/channel/${channel._id}/settings`)
}>
<Cog size={24} />
</IconButton>
</>
)}
<VoiceActions channel={channel} />
{(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") &&
!isTouchscreenDevice && (
<IconButton onClick={toggleSidebar}>
<SidebarIcon size={22} />
</IconButton>
)}
</>
);
return (
<>
<UpdateIndicator />
{channel.channel_type === "Group" && (
<>
<IconButton
onClick={() =>
openScreen({
id: "user_picker",
omit: channel.recipients,
callback: async (users) => {
for (const user of users) {
await client.channels.addMember(
channel._id,
user,
);
}
},
})
}>
<UserPlus size={27} />
</IconButton>
<IconButton
onClick={() =>
history.push(`/channel/${channel._id}/settings`)
}>
<Cog size={24} />
</IconButton>
</>
)}
<VoiceActions channel={channel} />
{(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") &&
!isTouchscreenDevice && (
<IconButton onClick={toggleSidebar}>
<SidebarIcon size={22} />
</IconButton>
)}
</>
);
}
function VoiceActions({ channel }: Pick<ChannelHeaderProps, "channel">) {
if (
channel.channel_type === "SavedMessages" ||
channel.channel_type === "TextChannel"
)
return null;
if (
channel.channel_type === "SavedMessages" ||
channel.channel_type === "TextChannel"
)
return null;
const voice = useContext(VoiceContext);
const { connect, disconnect } = useContext(VoiceOperationsContext);
const voice = useContext(VoiceContext);
const { connect, disconnect } = useContext(VoiceOperationsContext);
if (voice.status >= VoiceStatus.READY) {
if (voice.roomId === channel._id) {
return (
<IconButton onClick={disconnect}>
<PhoneOutgoing size={22} />
</IconButton>
);
} else {
return (
<IconButton
onClick={() => {
disconnect();
connect(channel._id);
}}>
<PhoneCall size={24} />
</IconButton>
);
}
} else {
return (
<IconButton>
<PhoneCall size={24} /** ! FIXME: TEMP */ color="red" />
</IconButton>
);
}
if (voice.status >= VoiceStatus.READY) {
if (voice.roomId === channel._id) {
return (
<IconButton onClick={disconnect}>
<PhoneOutgoing size={22} />
</IconButton>
);
} else {
return (
<IconButton
onClick={() => {
disconnect();
connect(channel._id);
}}>
<PhoneCall size={24} />
</IconButton>
);
}
} else {
return (
<IconButton>
<PhoneCall size={24} /** ! FIXME: TEMP */ color="red" />
</IconButton>
);
}
}

View File

@@ -6,35 +6,35 @@ import { useChannel, useForceUpdate } from "../../../context/revoltjs/hooks";
import { getChannelName } from "../../../context/revoltjs/util";
const StartBase = styled.div`
margin: 18px 16px 10px 16px;
margin: 18px 16px 10px 16px;
h1 {
font-size: 23px;
margin: 0 0 8px 0;
}
h1 {
font-size: 23px;
margin: 0 0 8px 0;
}
h4 {
font-weight: 400;
margin: 0;
font-size: 14px;
}
h4 {
font-weight: 400;
margin: 0;
font-size: 14px;
}
`;
interface Props {
id: string;
id: string;
}
export default function ConversationStart({ id }: Props) {
const ctx = useForceUpdate();
const channel = useChannel(id, ctx);
if (!channel) return null;
const ctx = useForceUpdate();
const channel = useChannel(id, ctx);
if (!channel) return null;
return (
<StartBase>
<h1>{getChannelName(ctx.client, channel, true)}</h1>
<h4>
<Text id="app.main.channel.start.group" />
</h4>
</StartBase>
);
return (
<StartBase>
<h1>{getChannelName(ctx.client, channel, true)}</h1>
<h4>
<Text id="app.main.channel.start.group" />
</h4>
</StartBase>
);
}

View File

@@ -4,11 +4,11 @@ import useResizeObserver from "use-resize-observer";
import { createContext } from "preact";
import {
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "preact/hooks";
import { defer } from "../../../lib/defer";
@@ -19,8 +19,8 @@ import { RenderState, ScrollState } from "../../../lib/renderer/types";
import { IntermediateContext } from "../../../context/intermediate/Intermediate";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import {
ClientStatus,
StatusContext,
ClientStatus,
StatusContext,
} from "../../../context/revoltjs/RevoltClient";
import Preloader from "../../../components/ui/Preloader";
@@ -29,231 +29,231 @@ import ConversationStart from "./ConversationStart";
import MessageRenderer from "./MessageRenderer";
const Area = styled.div`
height: 100%;
flex-grow: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: scroll;
word-break: break-word;
height: 100%;
flex-grow: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: scroll;
word-break: break-word;
> div {
display: flex;
min-height: 100%;
padding-bottom: 20px;
flex-direction: column;
justify-content: flex-end;
}
> div {
display: flex;
min-height: 100%;
padding-bottom: 20px;
flex-direction: column;
justify-content: flex-end;
}
`;
interface Props {
id: string;
id: string;
}
export const MessageAreaWidthContext = createContext(0);
export const MESSAGE_AREA_PADDING = 82;
export function MessageArea({ id }: Props) {
const status = useContext(StatusContext);
const { focusTaken } = useContext(IntermediateContext);
const status = useContext(StatusContext);
const { focusTaken } = useContext(IntermediateContext);
// ? This is the scroll container.
const ref = useRef<HTMLDivElement>(null);
const { width, height } = useResizeObserver<HTMLDivElement>({ ref });
// ? This is the scroll container.
const ref = useRef<HTMLDivElement>(null);
const { width, height } = useResizeObserver<HTMLDivElement>({ ref });
// ? Current channel state.
const [state, setState] = useState<RenderState>({ type: "LOADING" });
// ? Current channel state.
const [state, setState] = useState<RenderState>({ type: "LOADING" });
// ? useRef to avoid re-renders
const scrollState = useRef<ScrollState>({ type: "Free" });
// ? useRef to avoid re-renders
const scrollState = useRef<ScrollState>({ type: "Free" });
const setScrollState = (v: ScrollState) => {
if (v.type === "StayAtBottom") {
if (scrollState.current.type === "Bottom" || atBottom()) {
scrollState.current = {
type: "ScrollToBottom",
smooth: v.smooth,
};
} else {
scrollState.current = { type: "Free" };
}
} else {
scrollState.current = v;
}
const setScrollState = (v: ScrollState) => {
if (v.type === "StayAtBottom") {
if (scrollState.current.type === "Bottom" || atBottom()) {
scrollState.current = {
type: "ScrollToBottom",
smooth: v.smooth,
};
} else {
scrollState.current = { type: "Free" };
}
} else {
scrollState.current = v;
}
defer(() => {
if (scrollState.current.type === "ScrollToBottom") {
setScrollState({
type: "Bottom",
scrollingUntil: +new Date() + 150,
});
defer(() => {
if (scrollState.current.type === "ScrollToBottom") {
setScrollState({
type: "Bottom",
scrollingUntil: +new Date() + 150,
});
animateScroll.scrollToBottom({
container: ref.current,
duration: scrollState.current.smooth ? 150 : 0,
});
} else if (scrollState.current.type === "OffsetTop") {
animateScroll.scrollTo(
Math.max(
101,
ref.current.scrollTop +
(ref.current.scrollHeight -
scrollState.current.previousHeight),
),
{
container: ref.current,
duration: 0,
},
);
animateScroll.scrollToBottom({
container: ref.current,
duration: scrollState.current.smooth ? 150 : 0,
});
} else if (scrollState.current.type === "OffsetTop") {
animateScroll.scrollTo(
Math.max(
101,
ref.current.scrollTop +
(ref.current.scrollHeight -
scrollState.current.previousHeight),
),
{
container: ref.current,
duration: 0,
},
);
setScrollState({ type: "Free" });
} else if (scrollState.current.type === "ScrollTop") {
animateScroll.scrollTo(scrollState.current.y, {
container: ref.current,
duration: 0,
});
setScrollState({ type: "Free" });
} else if (scrollState.current.type === "ScrollTop") {
animateScroll.scrollTo(scrollState.current.y, {
container: ref.current,
duration: 0,
});
setScrollState({ type: "Free" });
}
});
};
setScrollState({ type: "Free" });
}
});
};
// ? Determine if we are at the bottom of the scroll container.
// -> https://stackoverflow.com/a/44893438
// By default, we assume we are at the bottom, i.e. when we first load.
const atBottom = (offset = 0) =>
ref.current
? Math.floor(ref.current.scrollHeight - ref.current.scrollTop) -
offset <=
ref.current.clientHeight
: true;
// ? Determine if we are at the bottom of the scroll container.
// -> https://stackoverflow.com/a/44893438
// By default, we assume we are at the bottom, i.e. when we first load.
const atBottom = (offset = 0) =>
ref.current
? Math.floor(ref.current.scrollHeight - ref.current.scrollTop) -
offset <=
ref.current.clientHeight
: true;
const atTop = (offset = 0) => ref.current.scrollTop <= offset;
const atTop = (offset = 0) => ref.current.scrollTop <= offset;
// ? Handle events from renderer.
useEffect(() => {
SingletonMessageRenderer.addListener("state", setState);
return () => SingletonMessageRenderer.removeListener("state", setState);
}, []);
// ? Handle events from renderer.
useEffect(() => {
SingletonMessageRenderer.addListener("state", setState);
return () => SingletonMessageRenderer.removeListener("state", setState);
}, []);
useEffect(() => {
SingletonMessageRenderer.addListener("scroll", setScrollState);
return () =>
SingletonMessageRenderer.removeListener("scroll", setScrollState);
}, [scrollState]);
useEffect(() => {
SingletonMessageRenderer.addListener("scroll", setScrollState);
return () =>
SingletonMessageRenderer.removeListener("scroll", setScrollState);
}, [scrollState]);
// ? Load channel initially.
useEffect(() => {
SingletonMessageRenderer.init(id);
}, [id]);
// ? Load channel initially.
useEffect(() => {
SingletonMessageRenderer.init(id);
}, [id]);
// ? If we are waiting for network, try again.
useEffect(() => {
switch (status) {
case ClientStatus.ONLINE:
if (state.type === "WAITING_FOR_NETWORK") {
SingletonMessageRenderer.init(id);
} else {
SingletonMessageRenderer.reloadStale(id);
}
// ? If we are waiting for network, try again.
useEffect(() => {
switch (status) {
case ClientStatus.ONLINE:
if (state.type === "WAITING_FOR_NETWORK") {
SingletonMessageRenderer.init(id);
} else {
SingletonMessageRenderer.reloadStale(id);
}
break;
case ClientStatus.OFFLINE:
case ClientStatus.DISCONNECTED:
case ClientStatus.CONNECTING:
SingletonMessageRenderer.markStale();
break;
}
}, [status, state]);
break;
case ClientStatus.OFFLINE:
case ClientStatus.DISCONNECTED:
case ClientStatus.CONNECTING:
SingletonMessageRenderer.markStale();
break;
}
}, [status, state]);
// ? When the container is scrolled.
// ? Also handle StayAtBottom
useEffect(() => {
async function onScroll() {
if (scrollState.current.type === "Free" && atBottom()) {
setScrollState({ type: "Bottom" });
} else if (scrollState.current.type === "Bottom" && !atBottom()) {
if (
scrollState.current.scrollingUntil &&
scrollState.current.scrollingUntil > +new Date()
)
return;
setScrollState({ type: "Free" });
}
}
// ? When the container is scrolled.
// ? Also handle StayAtBottom
useEffect(() => {
async function onScroll() {
if (scrollState.current.type === "Free" && atBottom()) {
setScrollState({ type: "Bottom" });
} else if (scrollState.current.type === "Bottom" && !atBottom()) {
if (
scrollState.current.scrollingUntil &&
scrollState.current.scrollingUntil > +new Date()
)
return;
setScrollState({ type: "Free" });
}
}
ref.current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll);
}, [ref, scrollState]);
ref.current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll);
}, [ref, scrollState]);
// ? Top and bottom loaders.
useEffect(() => {
async function onScroll() {
if (atTop(100)) {
SingletonMessageRenderer.loadTop(ref.current);
}
// ? Top and bottom loaders.
useEffect(() => {
async function onScroll() {
if (atTop(100)) {
SingletonMessageRenderer.loadTop(ref.current);
}
if (atBottom(100)) {
SingletonMessageRenderer.loadBottom(ref.current);
}
}
if (atBottom(100)) {
SingletonMessageRenderer.loadBottom(ref.current);
}
}
ref.current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll);
}, [ref]);
ref.current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll);
}, [ref]);
// ? Scroll down whenever the message area resizes.
function stbOnResize() {
if (!atBottom() && scrollState.current.type === "Bottom") {
animateScroll.scrollToBottom({
container: ref.current,
duration: 0,
});
// ? Scroll down whenever the message area resizes.
function stbOnResize() {
if (!atBottom() && scrollState.current.type === "Bottom") {
animateScroll.scrollToBottom({
container: ref.current,
duration: 0,
});
setScrollState({ type: "Bottom" });
}
}
setScrollState({ type: "Bottom" });
}
}
// ? Scroll down when container resized.
useLayoutEffect(() => {
stbOnResize();
}, [height]);
// ? Scroll down when container resized.
useLayoutEffect(() => {
stbOnResize();
}, [height]);
// ? Scroll down whenever the window resizes.
useLayoutEffect(() => {
document.addEventListener("resize", stbOnResize);
return () => document.removeEventListener("resize", stbOnResize);
}, [ref, scrollState]);
// ? Scroll down whenever the window resizes.
useLayoutEffect(() => {
document.addEventListener("resize", stbOnResize);
return () => document.removeEventListener("resize", stbOnResize);
}, [ref, scrollState]);
// ? Scroll to bottom when pressing 'Escape'.
useEffect(() => {
function keyUp(e: KeyboardEvent) {
if (e.key === "Escape" && !focusTaken) {
SingletonMessageRenderer.jumpToBottom(id, true);
internalEmit("TextArea", "focus", "message");
}
}
// ? Scroll to bottom when pressing 'Escape'.
useEffect(() => {
function keyUp(e: KeyboardEvent) {
if (e.key === "Escape" && !focusTaken) {
SingletonMessageRenderer.jumpToBottom(id, true);
internalEmit("TextArea", "focus", "message");
}
}
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
}, [ref, focusTaken]);
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
}, [ref, focusTaken]);
return (
<MessageAreaWidthContext.Provider
value={(width ?? 0) - MESSAGE_AREA_PADDING}>
<Area ref={ref}>
<div>
{state.type === "LOADING" && <Preloader type="ring" />}
{state.type === "WAITING_FOR_NETWORK" && (
<RequiresOnline>
<Preloader type="ring" />
</RequiresOnline>
)}
{state.type === "RENDER" && (
<MessageRenderer id={id} state={state} />
)}
{state.type === "EMPTY" && <ConversationStart id={id} />}
</div>
</Area>
</MessageAreaWidthContext.Provider>
);
return (
<MessageAreaWidthContext.Provider
value={(width ?? 0) - MESSAGE_AREA_PADDING}>
<Area ref={ref}>
<div>
{state.type === "LOADING" && <Preloader type="ring" />}
{state.type === "WAITING_FOR_NETWORK" && (
<RequiresOnline>
<Preloader type="ring" />
</RequiresOnline>
)}
{state.type === "RENDER" && (
<MessageRenderer id={id} state={state} />
)}
{state.type === "EMPTY" && <ConversationStart id={id} />}
</div>
</Area>
</MessageAreaWidthContext.Provider>
);
}

View File

@@ -6,128 +6,128 @@ import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import {
IntermediateContext,
useIntermediate,
IntermediateContext,
useIntermediate,
} from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { MessageObject } from "../../../context/revoltjs/util";
import AutoComplete, {
useAutoComplete,
useAutoComplete,
} from "../../../components/common/AutoComplete";
const EditorBase = styled.div`
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
textarea {
resize: none;
padding: 12px;
font-size: 0.875rem;
border-radius: 3px;
white-space: pre-wrap;
background: var(--secondary-header);
}
textarea {
resize: none;
padding: 12px;
font-size: 0.875rem;
border-radius: 3px;
white-space: pre-wrap;
background: var(--secondary-header);
}
.caption {
padding: 2px;
font-size: 11px;
color: var(--tertiary-foreground);
.caption {
padding: 2px;
font-size: 11px;
color: var(--tertiary-foreground);
a {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
a {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
`;
interface Props {
message: MessageObject;
finish: () => void;
message: MessageObject;
finish: () => void;
}
export default function MessageEditor({ message, finish }: Props) {
const [content, setContent] = useState((message.content as string) ?? "");
const { focusTaken } = useContext(IntermediateContext);
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const [content, setContent] = useState((message.content as string) ?? "");
const { focusTaken } = useContext(IntermediateContext);
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
async function save() {
finish();
async function save() {
finish();
if (content.length === 0) {
openScreen({
id: "special_prompt",
// @ts-expect-error
type: "delete_message",
// @ts-expect-error
target: message,
});
} else if (content !== message.content) {
await client.channels.editMessage(message.channel, message._id, {
content,
});
}
}
if (content.length === 0) {
openScreen({
id: "special_prompt",
// @ts-expect-error
type: "delete_message",
// @ts-expect-error
target: message,
});
} else if (content !== message.content) {
await client.channels.editMessage(message.channel, message._id, {
content,
});
}
}
// ? Stop editing when pressing ESC.
useEffect(() => {
function keyUp(e: KeyboardEvent) {
if (e.key === "Escape" && !focusTaken) {
finish();
}
}
// ? Stop editing when pressing ESC.
useEffect(() => {
function keyUp(e: KeyboardEvent) {
if (e.key === "Escape" && !focusTaken) {
finish();
}
}
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
}, [focusTaken]);
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
}, [focusTaken]);
const {
onChange,
onKeyUp,
onKeyDown,
onFocus,
onBlur,
...autoCompleteProps
} = useAutoComplete((v) => setContent(v ?? ""), {
users: { type: "all" },
});
const {
onChange,
onKeyUp,
onKeyDown,
onFocus,
onBlur,
...autoCompleteProps
} = useAutoComplete((v) => setContent(v ?? ""), {
users: { type: "all" },
});
return (
<EditorBase>
<AutoComplete detached {...autoCompleteProps} />
<TextAreaAutoSize
forceFocus
maxRows={3}
padding={12}
value={content}
maxLength={2000}
onChange={(ev) => {
onChange(ev);
setContent(ev.currentTarget.value);
}}
onKeyDown={(e) => {
if (onKeyDown(e)) return;
return (
<EditorBase>
<AutoComplete detached {...autoCompleteProps} />
<TextAreaAutoSize
forceFocus
maxRows={3}
padding={12}
value={content}
maxLength={2000}
onChange={(ev) => {
onChange(ev);
setContent(ev.currentTarget.value);
}}
onKeyDown={(e) => {
if (onKeyDown(e)) return;
if (
!e.shiftKey &&
e.key === "Enter" &&
!isTouchscreenDevice
) {
e.preventDefault();
save();
}
}}
onKeyUp={onKeyUp}
onFocus={onFocus}
onBlur={onBlur}
/>
<span className="caption">
escape to <a onClick={finish}>cancel</a> &middot; enter to{" "}
<a onClick={save}>save</a>
</span>
</EditorBase>
);
if (
!e.shiftKey &&
e.key === "Enter" &&
!isTouchscreenDevice
) {
e.preventDefault();
save();
}
}}
onKeyUp={onKeyUp}
onFocus={onFocus}
onBlur={onBlur}
/>
<span className="caption">
escape to <a onClick={finish}>cancel</a> &middot; enter to{" "}
<a onClick={save}>save</a>
</span>
</EditorBase>
);
}

View File

@@ -26,190 +26,190 @@ import ConversationStart from "./ConversationStart";
import MessageEditor from "./MessageEditor";
interface Props {
id: string;
state: RenderState;
queue: QueuedMessage[];
id: string;
state: RenderState;
queue: QueuedMessage[];
}
const BlockedMessage = styled.div`
font-size: 0.8em;
margin-top: 6px;
padding: 4px 64px;
color: var(--tertiary-foreground);
font-size: 0.8em;
margin-top: 6px;
padding: 4px 64px;
color: var(--tertiary-foreground);
&:hover {
background: var(--hover);
}
&:hover {
background: var(--hover);
}
`;
function MessageRenderer({ id, state, queue }: Props) {
if (state.type !== "RENDER") return null;
if (state.type !== "RENDER") return null;
const client = useContext(AppContext);
const userId = client.user!._id;
const client = useContext(AppContext);
const userId = client.user!._id;
const [editing, setEditing] = useState<string | undefined>(undefined);
const stopEditing = () => {
setEditing(undefined);
internalEmit("TextArea", "focus", "message");
};
const [editing, setEditing] = useState<string | undefined>(undefined);
const stopEditing = () => {
setEditing(undefined);
internalEmit("TextArea", "focus", "message");
};
useEffect(() => {
function editLast() {
if (state.type !== "RENDER") return;
for (let i = state.messages.length - 1; i >= 0; i--) {
if (state.messages[i].author === userId) {
setEditing(state.messages[i]._id);
return;
}
}
}
useEffect(() => {
function editLast() {
if (state.type !== "RENDER") return;
for (let i = state.messages.length - 1; i >= 0; i--) {
if (state.messages[i].author === userId) {
setEditing(state.messages[i]._id);
return;
}
}
}
const subs = [
internalSubscribe("MessageRenderer", "edit_last", editLast),
internalSubscribe("MessageRenderer", "edit_message", setEditing),
];
const subs = [
internalSubscribe("MessageRenderer", "edit_last", editLast),
internalSubscribe("MessageRenderer", "edit_message", setEditing),
];
return () => subs.forEach((unsub) => unsub());
}, [state.messages]);
return () => subs.forEach((unsub) => unsub());
}, [state.messages]);
let render: Children[] = [],
previous: MessageObject | undefined;
let render: Children[] = [],
previous: MessageObject | undefined;
if (state.atTop) {
render.push(<ConversationStart id={id} />);
} else {
render.push(
<RequiresOnline>
<Preloader type="ring" />
</RequiresOnline>,
);
}
if (state.atTop) {
render.push(<ConversationStart id={id} />);
} else {
render.push(
<RequiresOnline>
<Preloader type="ring" />
</RequiresOnline>,
);
}
let head = true;
function compare(
current: string,
curAuthor: string,
previous: string,
prevAuthor: string,
) {
const atime = decodeTime(current),
adate = new Date(atime),
btime = decodeTime(previous),
bdate = new Date(btime);
let head = true;
function compare(
current: string,
curAuthor: string,
previous: string,
prevAuthor: string,
) {
const atime = decodeTime(current),
adate = new Date(atime),
btime = decodeTime(previous),
bdate = new Date(btime);
if (
adate.getFullYear() !== bdate.getFullYear() ||
adate.getMonth() !== bdate.getMonth() ||
adate.getDate() !== bdate.getDate()
) {
render.push(<DateDivider date={adate} />);
head = true;
}
if (
adate.getFullYear() !== bdate.getFullYear() ||
adate.getMonth() !== bdate.getMonth() ||
adate.getDate() !== bdate.getDate()
) {
render.push(<DateDivider date={adate} />);
head = true;
}
head = curAuthor !== prevAuthor || Math.abs(btime - atime) >= 420000;
}
head = curAuthor !== prevAuthor || Math.abs(btime - atime) >= 420000;
}
let blocked = 0;
function pushBlocked() {
render.push(
<BlockedMessage>
<X size={16} /> {blocked} blocked messages
</BlockedMessage>,
);
blocked = 0;
}
let blocked = 0;
function pushBlocked() {
render.push(
<BlockedMessage>
<X size={16} /> {blocked} blocked messages
</BlockedMessage>,
);
blocked = 0;
}
for (const message of state.messages) {
if (previous) {
compare(message._id, message.author, previous._id, previous.author);
}
for (const message of state.messages) {
if (previous) {
compare(message._id, message.author, previous._id, previous.author);
}
if (message.author === "00000000000000000000000000") {
render.push(
<SystemMessage
key={message._id}
message={message}
attachContext
/>,
);
} else {
// ! FIXME: temp solution
if (
client.users.get(message.author)?.relationship ===
Users.Relationship.Blocked
) {
blocked++;
} else {
if (blocked > 0) pushBlocked();
if (message.author === "00000000000000000000000000") {
render.push(
<SystemMessage
key={message._id}
message={message}
attachContext
/>,
);
} else {
// ! FIXME: temp solution
if (
client.users.get(message.author)?.relationship ===
Users.Relationship.Blocked
) {
blocked++;
} else {
if (blocked > 0) pushBlocked();
render.push(
<Message
message={message}
key={message._id}
head={head}
content={
editing === message._id ? (
<MessageEditor
message={message}
finish={stopEditing}
/>
) : undefined
}
attachContext
/>,
);
}
}
render.push(
<Message
message={message}
key={message._id}
head={head}
content={
editing === message._id ? (
<MessageEditor
message={message}
finish={stopEditing}
/>
) : undefined
}
attachContext
/>,
);
}
}
previous = message;
}
previous = message;
}
if (blocked > 0) pushBlocked();
if (blocked > 0) pushBlocked();
const nonces = state.messages.map((x) => x.nonce);
if (state.atBottom) {
for (const msg of queue) {
if (msg.channel !== id) continue;
if (nonces.includes(msg.id)) continue;
const nonces = state.messages.map((x) => x.nonce);
if (state.atBottom) {
for (const msg of queue) {
if (msg.channel !== id) continue;
if (nonces.includes(msg.id)) continue;
if (previous) {
compare(msg.id, userId!, previous._id, previous.author);
if (previous) {
compare(msg.id, userId!, previous._id, previous.author);
previous = {
_id: msg.id,
data: { author: userId! },
} as any;
}
previous = {
_id: msg.id,
data: { author: userId! },
} as any;
}
render.push(
<Message
message={{
...msg.data,
replies: msg.data.replies.map((x) => x.id),
}}
key={msg.id}
queued={msg}
head={head}
attachContext
/>,
);
}
} else {
render.push(
<RequiresOnline>
<Preloader type="ring" />
</RequiresOnline>,
);
}
render.push(
<Message
message={{
...msg.data,
replies: msg.data.replies.map((x) => x.id),
}}
key={msg.id}
queued={msg}
head={head}
attachContext
/>,
);
}
} else {
render.push(
<RequiresOnline>
<Preloader type="ring" />
</RequiresOnline>,
);
}
return <>{render}</>;
return <>{render}</>;
}
export default memo(
connectState<Omit<Props, "queue">>(MessageRenderer, (state) => {
return {
queue: state.queue,
};
}),
connectState<Omit<Props, "queue">>(MessageRenderer, (state) => {
return {
queue: state.queue,
};
}),
);

View File

@@ -5,134 +5,134 @@ import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import {
VoiceContext,
VoiceOperationsContext,
VoiceStatus,
VoiceContext,
VoiceOperationsContext,
VoiceStatus,
} from "../../../context/Voice";
import {
useForceUpdate,
useSelf,
useUsers,
useForceUpdate,
useSelf,
useUsers,
} from "../../../context/revoltjs/hooks";
import UserIcon from "../../../components/common/user/UserIcon";
import Button from "../../../components/ui/Button";
interface Props {
id: string;
id: string;
}
const VoiceBase = styled.div`
padding: 20px;
background: var(--secondary-background);
padding: 20px;
background: var(--secondary-background);
.status {
position: absolute;
color: var(--success);
background: var(--primary-background);
display: flex;
align-items: center;
padding: 10px;
font-size: 14px;
font-weight: 600;
border-radius: 7px;
flex: 1 0;
user-select: none;
.status {
position: absolute;
color: var(--success);
background: var(--primary-background);
display: flex;
align-items: center;
padding: 10px;
font-size: 14px;
font-weight: 600;
border-radius: 7px;
flex: 1 0;
user-select: none;
svg {
margin-inline-end: 4px;
cursor: help;
}
}
svg {
margin-inline-end: 4px;
cursor: help;
}
}
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
.participants {
margin: 20px 0;
justify-content: center;
pointer-events: none;
user-select: none;
display: flex;
gap: 16px;
.participants {
margin: 20px 0;
justify-content: center;
pointer-events: none;
user-select: none;
display: flex;
gap: 16px;
.disconnected {
opacity: 0.5;
}
}
.disconnected {
opacity: 0.5;
}
}
.actions {
display: flex;
justify-content: center;
gap: 10px;
}
.actions {
display: flex;
justify-content: center;
gap: 10px;
}
`;
export default function VoiceHeader({ id }: Props) {
const { status, participants, roomId } = useContext(VoiceContext);
if (roomId !== id) return null;
const { status, participants, roomId } = useContext(VoiceContext);
if (roomId !== id) return null;
const { isProducing, startProducing, stopProducing, disconnect } =
useContext(VoiceOperationsContext);
const { isProducing, startProducing, stopProducing, disconnect } =
useContext(VoiceOperationsContext);
const ctx = useForceUpdate();
const self = useSelf(ctx);
const keys = participants ? Array.from(participants.keys()) : undefined;
const users = keys ? useUsers(keys, ctx) : undefined;
const ctx = useForceUpdate();
const self = useSelf(ctx);
const keys = participants ? Array.from(participants.keys()) : undefined;
const users = keys ? useUsers(keys, ctx) : undefined;
return (
<VoiceBase>
<div className="participants">
{users && users.length !== 0
? users.map((user, index) => {
const id = keys![index];
return (
<div key={id}>
<UserIcon
size={80}
target={user}
status={false}
voice={
participants!.get(id)?.audio
? undefined
: "muted"
}
/>
</div>
);
})
: self !== undefined && (
<div key={self._id} className="disconnected">
<UserIcon
size={80}
target={self}
status={false}
/>
</div>
)}
</div>
<div className="status">
<BarChart size={20} />
{status === VoiceStatus.CONNECTED && (
<Text id="app.main.channel.voice.connected" />
)}
</div>
<div className="actions">
<Button error onClick={disconnect}>
<Text id="app.main.channel.voice.leave" />
</Button>
{isProducing("audio") ? (
<Button onClick={() => stopProducing("audio")}>
<Text id="app.main.channel.voice.mute" />
</Button>
) : (
<Button onClick={() => startProducing("audio")}>
<Text id="app.main.channel.voice.unmute" />
</Button>
)}
</div>
</VoiceBase>
);
return (
<VoiceBase>
<div className="participants">
{users && users.length !== 0
? users.map((user, index) => {
const id = keys![index];
return (
<div key={id}>
<UserIcon
size={80}
target={user}
status={false}
voice={
participants!.get(id)?.audio
? undefined
: "muted"
}
/>
</div>
);
})
: self !== undefined && (
<div key={self._id} className="disconnected">
<UserIcon
size={80}
target={self}
status={false}
/>
</div>
)}
</div>
<div className="status">
<BarChart size={20} />
{status === VoiceStatus.CONNECTED && (
<Text id="app.main.channel.voice.connected" />
)}
</div>
<div className="actions">
<Button error onClick={disconnect}>
<Text id="app.main.channel.voice.leave" />
</Button>
{isProducing("audio") ? (
<Button onClick={() => stopProducing("audio")}>
<Text id="app.main.channel.voice.mute" />
</Button>
) : (
<Button onClick={() => startProducing("audio")}>
<Text id="app.main.channel.voice.unmute" />
</Button>
)}
</div>
</VoiceBase>
);
}
/**{voice.roomId === id && (

View File

@@ -11,31 +11,31 @@ import { useUserPermission } from "../../context/revoltjs/hooks";
import Header from "../../components/ui/Header";
export default function Developer() {
// const voice = useContext(VoiceContext);
const client = useContext(AppContext);
const userPermission = useUserPermission(client.user!._id);
// const voice = useContext(VoiceContext);
const client = useContext(AppContext);
const userPermission = useUserPermission(client.user!._id);
return (
<div>
<Header placement="primary">
<Wrench size="24" />
Developer Tab
</Header>
<div style={{ padding: "16px" }}>
<PaintCounter always />
</div>
<div style={{ padding: "16px" }}>
<b>User ID:</b> {client.user!._id} <br />
<b>Permission against self:</b> {userPermission} <br />
</div>
<div style={{ padding: "16px" }}>
<TextReact
id="login.open_mail_provider"
fields={{ provider: <b>GAMING!</b> }}
/>
</div>
<div style={{ padding: "16px" }}>
{/*<span>
return (
<div>
<Header placement="primary">
<Wrench size="24" />
Developer Tab
</Header>
<div style={{ padding: "16px" }}>
<PaintCounter always />
</div>
<div style={{ padding: "16px" }}>
<b>User ID:</b> {client.user!._id} <br />
<b>Permission against self:</b> {userPermission} <br />
</div>
<div style={{ padding: "16px" }}>
<TextReact
id="login.open_mail_provider"
fields={{ provider: <b>GAMING!</b> }}
/>
</div>
<div style={{ padding: "16px" }}>
{/*<span>
<b>Voice Status:</b> {VoiceStatus[voice.status]}
</span>
<br />
@@ -48,7 +48,7 @@ export default function Developer() {
{Array.from(voice.participants.keys()).join(", ")}]
</span>
<br />*/}
</div>
</div>
);
</div>
</div>
);
}

View File

@@ -13,8 +13,8 @@ import { stopPropagation } from "../../lib/stopPropagation";
import { VoiceOperationsContext } from "../../context/Voice";
import { useIntermediate } from "../../context/intermediate/Intermediate";
import {
AppContext,
OperationsContext,
AppContext,
OperationsContext,
} from "../../context/revoltjs/RevoltClient";
import UserIcon from "../../components/common/user/UserIcon";
@@ -24,113 +24,113 @@ import IconButton from "../../components/ui/IconButton";
import { Children } from "../../types/Preact";
interface Props {
user: User;
user: User;
}
export function Friend({ user }: Props) {
const client = useContext(AppContext);
const { openScreen } = useIntermediate();
const { openDM } = useContext(OperationsContext);
const { connect } = useContext(VoiceOperationsContext);
const client = useContext(AppContext);
const { openScreen } = useIntermediate();
const { openDM } = useContext(OperationsContext);
const { connect } = useContext(VoiceOperationsContext);
const actions: Children[] = [];
let subtext: Children = null;
const actions: Children[] = [];
let subtext: Children = null;
if (user.relationship === Users.Relationship.Friend) {
subtext = <UserStatus user={user} />;
actions.push(
<>
<IconButton
type="circle"
className={classNames(
styles.button,
styles.call,
styles.success,
)}
onClick={(ev) =>
stopPropagation(ev, openDM(user._id).then(connect))
}>
<PhoneCall size={20} />
</IconButton>
<IconButton
type="circle"
className={styles.button}
onClick={(ev) => stopPropagation(ev, openDM(user._id))}>
<Envelope size={20} />
</IconButton>
</>,
);
}
if (user.relationship === Users.Relationship.Friend) {
subtext = <UserStatus user={user} />;
actions.push(
<>
<IconButton
type="circle"
className={classNames(
styles.button,
styles.call,
styles.success,
)}
onClick={(ev) =>
stopPropagation(ev, openDM(user._id).then(connect))
}>
<PhoneCall size={20} />
</IconButton>
<IconButton
type="circle"
className={styles.button}
onClick={(ev) => stopPropagation(ev, openDM(user._id))}>
<Envelope size={20} />
</IconButton>
</>,
);
}
if (user.relationship === Users.Relationship.Incoming) {
actions.push(
<IconButton
type="circle"
className={styles.button}
onClick={(ev) =>
stopPropagation(ev, client.users.addFriend(user.username))
}>
<Plus size={24} />
</IconButton>,
);
if (user.relationship === Users.Relationship.Incoming) {
actions.push(
<IconButton
type="circle"
className={styles.button}
onClick={(ev) =>
stopPropagation(ev, client.users.addFriend(user.username))
}>
<Plus size={24} />
</IconButton>,
);
subtext = <Text id="app.special.friends.incoming" />;
}
subtext = <Text id="app.special.friends.incoming" />;
}
if (user.relationship === Users.Relationship.Outgoing) {
subtext = <Text id="app.special.friends.outgoing" />;
}
if (user.relationship === Users.Relationship.Outgoing) {
subtext = <Text id="app.special.friends.outgoing" />;
}
if (
user.relationship === Users.Relationship.Friend ||
user.relationship === Users.Relationship.Outgoing ||
user.relationship === Users.Relationship.Incoming
) {
actions.push(
<IconButton
type="circle"
className={classNames(styles.button, styles.error)}
onClick={(ev) =>
stopPropagation(
ev,
user.relationship === Users.Relationship.Friend
? openScreen({
id: "special_prompt",
type: "unfriend_user",
target: user,
})
: client.users.removeFriend(user._id),
)
}>
<X size={24} />
</IconButton>,
);
}
if (
user.relationship === Users.Relationship.Friend ||
user.relationship === Users.Relationship.Outgoing ||
user.relationship === Users.Relationship.Incoming
) {
actions.push(
<IconButton
type="circle"
className={classNames(styles.button, styles.error)}
onClick={(ev) =>
stopPropagation(
ev,
user.relationship === Users.Relationship.Friend
? openScreen({
id: "special_prompt",
type: "unfriend_user",
target: user,
})
: client.users.removeFriend(user._id),
)
}>
<X size={24} />
</IconButton>,
);
}
if (user.relationship === Users.Relationship.Blocked) {
actions.push(
<IconButton
type="circle"
className={classNames(styles.button, styles.error)}
onClick={(ev) =>
stopPropagation(ev, client.users.unblockUser(user._id))
}>
<X size={24} />
</IconButton>,
);
}
if (user.relationship === Users.Relationship.Blocked) {
actions.push(
<IconButton
type="circle"
className={classNames(styles.button, styles.error)}
onClick={(ev) =>
stopPropagation(ev, client.users.unblockUser(user._id))
}>
<X size={24} />
</IconButton>,
);
}
return (
<div
className={styles.friend}
onClick={() => openScreen({ id: "profile", user_id: user._id })}
onContextMenu={attachContextMenu("Menu", { user: user._id })}>
<UserIcon target={user} size={36} status />
<div className={styles.name}>
<span>@{user.username}</span>
{subtext && <span className={styles.subtext}>{subtext}</span>}
</div>
<div className={styles.actions}>{actions}</div>
</div>
);
return (
<div
className={styles.friend}
onClick={() => openScreen({ id: "profile", user_id: user._id })}
onContextMenu={attachContextMenu("Menu", { user: user._id })}>
<UserIcon target={user} size={36} status />
<div className={styles.name}>
<span>@{user.username}</span>
{subtext && <span className={styles.subtext}>{subtext}</span>}
</div>
<div className={styles.actions}>{actions}</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import {
ChevronDown,
ChevronRight,
ListPlus,
ChevronDown,
ChevronRight,
ListPlus,
} from "@styled-icons/boxicons-regular";
import { UserDetail, MessageAdd, UserPlus } from "@styled-icons/boxicons-solid";
import { User, Users } from "revolt.js/dist/api/objects";
@@ -27,91 +27,91 @@ import { Children } from "../../types/Preact";
import { Friend } from "./Friend";
export default function Friends() {
const { openScreen } = useIntermediate();
const { openScreen } = useIntermediate();
const users = useUsers() as User[];
users.sort((a, b) => a.username.localeCompare(b.username));
const users = useUsers() as User[];
users.sort((a, b) => a.username.localeCompare(b.username));
const friends = users.filter(
(x) => x.relationship === Users.Relationship.Friend,
);
const friends = users.filter(
(x) => x.relationship === Users.Relationship.Friend,
);
const lists = [
[
"",
users.filter((x) => x.relationship === Users.Relationship.Incoming),
],
[
"app.special.friends.sent",
users.filter((x) => x.relationship === Users.Relationship.Outgoing),
"outgoing",
],
[
"app.status.online",
friends.filter(
(x) =>
x.online && x.status?.presence !== Users.Presence.Invisible,
),
"online",
],
[
"app.status.offline",
friends.filter(
(x) =>
!x.online ||
x.status?.presence === Users.Presence.Invisible,
),
"offline",
],
[
"app.special.friends.blocked",
users.filter((x) => x.relationship === Users.Relationship.Blocked),
"blocked",
],
] as [string, User[], string][];
const lists = [
[
"",
users.filter((x) => x.relationship === Users.Relationship.Incoming),
],
[
"app.special.friends.sent",
users.filter((x) => x.relationship === Users.Relationship.Outgoing),
"outgoing",
],
[
"app.status.online",
friends.filter(
(x) =>
x.online && x.status?.presence !== Users.Presence.Invisible,
),
"online",
],
[
"app.status.offline",
friends.filter(
(x) =>
!x.online ||
x.status?.presence === Users.Presence.Invisible,
),
"offline",
],
[
"app.special.friends.blocked",
users.filter((x) => x.relationship === Users.Relationship.Blocked),
"blocked",
],
] as [string, User[], string][];
const incoming = lists[0][1];
const userlist: Children[] = incoming.map((x) => <b>{x.username}</b>);
for (let i = incoming.length - 1; i > 0; i--) userlist.splice(i, 0, ", ");
const incoming = lists[0][1];
const userlist: Children[] = incoming.map((x) => <b>{x.username}</b>);
for (let i = incoming.length - 1; i > 0; i--) userlist.splice(i, 0, ", ");
const isEmpty = lists.reduce((p: number, n) => p + n.length, 0) === 0;
return (
<>
<Header placement="primary">
{!isTouchscreenDevice && <UserDetail size={24} />}
<div className={styles.title}>
<Text id="app.navigation.tabs.friends" />
</div>
<div className={styles.actions}>
{/*<Tooltip content={"Create Category"} placement="bottom">
const isEmpty = lists.reduce((p: number, n) => p + n.length, 0) === 0;
return (
<>
<Header placement="primary">
{!isTouchscreenDevice && <UserDetail size={24} />}
<div className={styles.title}>
<Text id="app.navigation.tabs.friends" />
</div>
<div className={styles.actions}>
{/*<Tooltip content={"Create Category"} placement="bottom">
<IconButton onClick={() => openScreen({ id: 'special_input', type: 'create_group' })}>
<ListPlus size={28} />
</IconButton>
</Tooltip>
<div className={styles.divider} />*/}
<Tooltip content={"Create Group"} placement="bottom">
<IconButton
onClick={() =>
openScreen({
id: "special_input",
type: "create_group",
})
}>
<MessageAdd size={24} />
</IconButton>
</Tooltip>
<Tooltip content={"Add Friend"} placement="bottom">
<IconButton
onClick={() =>
openScreen({
id: "special_input",
type: "add_friend",
})
}>
<UserPlus size={27} />
</IconButton>
</Tooltip>
{/*
<Tooltip content={"Create Group"} placement="bottom">
<IconButton
onClick={() =>
openScreen({
id: "special_input",
type: "create_group",
})
}>
<MessageAdd size={24} />
</IconButton>
</Tooltip>
<Tooltip content={"Add Friend"} placement="bottom">
<IconButton
onClick={() =>
openScreen({
id: "special_input",
type: "add_friend",
})
}>
<UserPlus size={27} />
</IconButton>
</Tooltip>
{/*
<div className={styles.divider} />
<Tooltip content={"Friend Activity"} placement="bottom">
<IconButton>
@@ -119,98 +119,98 @@ export default function Friends() {
</IconButton>
</Tooltip>
*/}
</div>
</Header>
<div className={styles.list} data-empty={isEmpty}>
{isEmpty && (
<>
<img src="https://img.insrt.uk/xexu7/XOPoBUTI47.png/raw" />
<Text id="app.special.friends.nobody" />
</>
)}
</div>
</Header>
<div className={styles.list} data-empty={isEmpty}>
{isEmpty && (
<>
<img src="https://img.insrt.uk/xexu7/XOPoBUTI47.png/raw" />
<Text id="app.special.friends.nobody" />
</>
)}
{incoming.length > 0 && (
<div
className={styles.pending}
onClick={() =>
openScreen({
id: "pending_requests",
users: incoming.map((x) => x._id),
})
}>
<div className={styles.avatars}>
{incoming.map(
(x, i) =>
i < 3 && (
<UserIcon
target={x}
size={64}
mask={
i <
Math.min(incoming.length - 1, 2)
? "url(#overlap)"
: undefined
}
/>
),
)}
</div>
<div className={styles.details}>
<div>
<Text id="app.special.friends.pending" />{" "}
<span>{incoming.length}</span>
</div>
<span>
{incoming.length > 3 ? (
<TextReact
id="app.special.friends.from.several"
fields={{
userlist: userlist.slice(0, 6),
count: incoming.length - 3,
}}
/>
) : incoming.length > 1 ? (
<TextReact
id="app.special.friends.from.multiple"
fields={{
user: userlist.shift()!,
userlist: userlist.slice(1),
}}
/>
) : (
<TextReact
id="app.special.friends.from.single"
fields={{ user: userlist[0] }}
/>
)}
</span>
</div>
<ChevronRight size={28} />
</div>
)}
{incoming.length > 0 && (
<div
className={styles.pending}
onClick={() =>
openScreen({
id: "pending_requests",
users: incoming.map((x) => x._id),
})
}>
<div className={styles.avatars}>
{incoming.map(
(x, i) =>
i < 3 && (
<UserIcon
target={x}
size={64}
mask={
i <
Math.min(incoming.length - 1, 2)
? "url(#overlap)"
: undefined
}
/>
),
)}
</div>
<div className={styles.details}>
<div>
<Text id="app.special.friends.pending" />{" "}
<span>{incoming.length}</span>
</div>
<span>
{incoming.length > 3 ? (
<TextReact
id="app.special.friends.from.several"
fields={{
userlist: userlist.slice(0, 6),
count: incoming.length - 3,
}}
/>
) : incoming.length > 1 ? (
<TextReact
id="app.special.friends.from.multiple"
fields={{
user: userlist.shift()!,
userlist: userlist.slice(1),
}}
/>
) : (
<TextReact
id="app.special.friends.from.single"
fields={{ user: userlist[0] }}
/>
)}
</span>
</div>
<ChevronRight size={28} />
</div>
)}
{lists.map(([i18n, list, section_id], index) => {
if (index === 0) return;
if (list.length === 0) return;
{lists.map(([i18n, list, section_id], index) => {
if (index === 0) return;
if (list.length === 0) return;
return (
<CollapsibleSection
id={`friends_${section_id}`}
defaultValue={true}
sticky
large
summary={
<div class="title">
<Text id={i18n} /> {list.length}
</div>
}>
{list.map((x) => (
<Friend key={x._id} user={x} />
))}
</CollapsibleSection>
);
})}
</div>
</>
);
return (
<CollapsibleSection
id={`friends_${section_id}`}
defaultValue={true}
sticky
large
summary={
<div class="title">
<Text id={i18n} /> {list.length}
</div>
}>
{list.map((x) => (
<Friend key={x._id} user={x} />
))}
</CollapsibleSection>
);
})}
</div>
</>
);
}

View File

@@ -8,34 +8,34 @@ import wideSVG from "../../assets/wide.svg";
import Header from "../../components/ui/Header";
export default function Home() {
return (
<div className={styles.home}>
<Header placement="primary">
<HomeIcon size={24} />
<Text id="app.navigation.tabs.home" />
</Header>
<h3>
<Text id="app.special.modals.onboarding.welcome" />{" "}
<img src={wideSVG} />
</h3>
<ul>
<li>
Go to your <Link to="/friends">friends list</Link>.
</li>
<li>
Give <Link to="/settings/feedback">feedback</Link>.
</li>
<li>
Join <Link to="/invite/Testers">testers server</Link>.
</li>
<li>
View{" "}
<a href="https://gitlab.insrt.uk/revolt" target="_blank">
source code
</a>
.
</li>
</ul>
</div>
);
return (
<div className={styles.home}>
<Header placement="primary">
<HomeIcon size={24} />
<Text id="app.navigation.tabs.home" />
</Header>
<h3>
<Text id="app.special.modals.onboarding.welcome" />{" "}
<img src={wideSVG} />
</h3>
<ul>
<li>
Go to your <Link to="/friends">friends list</Link>.
</li>
<li>
Give <Link to="/settings/feedback">feedback</Link>.
</li>
<li>
Join <Link to="/invite/Testers">testers server</Link>.
</li>
<li>
View{" "}
<a href="https://gitlab.insrt.uk/revolt" target="_blank">
source code
</a>
.
</li>
</ul>
</div>
);
}

View File

@@ -7,9 +7,9 @@ import { useContext, useEffect, useState } from "preact/hooks";
import RequiresOnline from "../../context/revoltjs/RequiresOnline";
import {
AppContext,
ClientStatus,
StatusContext,
AppContext,
ClientStatus,
StatusContext,
} from "../../context/revoltjs/RevoltClient";
import { takeError } from "../../context/revoltjs/util";
@@ -20,109 +20,109 @@ import Overline from "../../components/ui/Overline";
import Preloader from "../../components/ui/Preloader";
export default function Invite() {
const history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext);
const { code } = useParams<{ code: string }>();
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const [invite, setInvite] = useState<Invites.RetrievedInvite | undefined>(
undefined,
);
const history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext);
const { code } = useParams<{ code: string }>();
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const [invite, setInvite] = useState<Invites.RetrievedInvite | undefined>(
undefined,
);
useEffect(() => {
if (
typeof invite === "undefined" &&
(status === ClientStatus.ONLINE || status === ClientStatus.READY)
) {
client
.fetchInvite(code)
.then((data) => setInvite(data))
.catch((err) => setError(takeError(err)));
}
}, [status]);
useEffect(() => {
if (
typeof invite === "undefined" &&
(status === ClientStatus.ONLINE || status === ClientStatus.READY)
) {
client
.fetchInvite(code)
.then((data) => setInvite(data))
.catch((err) => setError(takeError(err)));
}
}, [status]);
if (typeof invite === "undefined") {
return (
<div className={styles.preloader}>
<RequiresOnline>
{error ? (
<Overline type="error" error={error} />
) : (
<Preloader type="spinner" />
)}
</RequiresOnline>
</div>
);
}
if (typeof invite === "undefined") {
return (
<div className={styles.preloader}>
<RequiresOnline>
{error ? (
<Overline type="error" error={error} />
) : (
<Preloader type="spinner" />
)}
</RequiresOnline>
</div>
);
}
// ! FIXME: add i18n translations
return (
<div
className={styles.invite}
style={{
backgroundImage: invite.server_banner
? `url('${client.generateFileURL(invite.server_banner)}')`
: undefined,
}}>
<div className={styles.leave}>
<ArrowBack size={32} onClick={() => history.push("/")} />
</div>
// ! FIXME: add i18n translations
return (
<div
className={styles.invite}
style={{
backgroundImage: invite.server_banner
? `url('${client.generateFileURL(invite.server_banner)}')`
: undefined,
}}>
<div className={styles.leave}>
<ArrowBack size={32} onClick={() => history.push("/")} />
</div>
{!processing && (
<div className={styles.icon}>
<ServerIcon
attachment={invite.server_icon}
server_name={invite.server_name}
size={64}
/>
</div>
)}
{!processing && (
<div className={styles.icon}>
<ServerIcon
attachment={invite.server_icon}
server_name={invite.server_name}
size={64}
/>
</div>
)}
<div className={styles.details}>
{processing ? (
<Preloader type="ring" />
) : (
<>
<h1>{invite.server_name}</h1>
<h2>#{invite.channel_name}</h2>
<h3>
Invited by{" "}
<UserIcon
size={24}
attachment={invite.user_avatar}
/>{" "}
{invite.user_name}
</h3>
<Overline type="error" error={error} />
<Button
contrast
onClick={async () => {
if (status === ClientStatus.READY) {
return history.push("/");
}
<div className={styles.details}>
{processing ? (
<Preloader type="ring" />
) : (
<>
<h1>{invite.server_name}</h1>
<h2>#{invite.channel_name}</h2>
<h3>
Invited by{" "}
<UserIcon
size={24}
attachment={invite.user_avatar}
/>{" "}
{invite.user_name}
</h3>
<Overline type="error" error={error} />
<Button
contrast
onClick={async () => {
if (status === ClientStatus.READY) {
return history.push("/");
}
try {
setProcessing(true);
try {
setProcessing(true);
let result = await client.joinInvite(code);
if (result.type === "Server") {
history.push(
`/server/${result.server._id}/channel/${result.channel._id}`,
);
}
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
}}>
{status === ClientStatus.READY
? "Login to REVOLT"
: "Accept Invite"}
</Button>
</>
)}
</div>
</div>
);
let result = await client.joinInvite(code);
if (result.type === "Server") {
history.push(
`/server/${result.server._id}/channel/${result.channel._id}`,
);
}
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
}}>
{status === ClientStatus.READY
? "Login to REVOLT"
: "Accept Invite"}
</Button>
</>
)}
</div>
</div>
);
}

View File

@@ -4,68 +4,68 @@ import InputBox from "../../components/ui/InputBox";
import Overline from "../../components/ui/Overline";
interface Props {
type: "email" | "username" | "password" | "invite" | "current_password";
showOverline?: boolean;
register: Function;
error?: string;
name?: string;
type: "email" | "username" | "password" | "invite" | "current_password";
showOverline?: boolean;
register: Function;
error?: string;
name?: string;
}
export default function FormField({
type,
register,
showOverline,
error,
name,
type,
register,
showOverline,
error,
name,
}: Props) {
return (
<>
{showOverline && (
<Overline error={error}>
<Text id={`login.${type}`} />
</Overline>
)}
<Localizer>
<InputBox
// Styled uses React typing while we use Preact
// this leads to inconsistances where things need to be typed oddly
placeholder={(<Text id={`login.enter.${type}`} />) as any}
name={
type === "current_password" ? "password" : name ?? type
}
type={
type === "invite" || type === "username"
? "text"
: type === "current_password"
? "password"
: type
}
ref={register(
type === "password" || type === "current_password"
? {
validate: (value: string) =>
value.length === 0
? "RequiredField"
: value.length < 8
? "TooShort"
: value.length > 1024
? "TooLong"
: undefined,
}
: type === "email"
? {
required: "RequiredField",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "InvalidEmail",
},
}
: type === "username"
? { required: "RequiredField" }
: { required: "RequiredField" },
)}
/>
</Localizer>
</>
);
return (
<>
{showOverline && (
<Overline error={error}>
<Text id={`login.${type}`} />
</Overline>
)}
<Localizer>
<InputBox
// Styled uses React typing while we use Preact
// this leads to inconsistances where things need to be typed oddly
placeholder={(<Text id={`login.enter.${type}`} />) as any}
name={
type === "current_password" ? "password" : name ?? type
}
type={
type === "invite" || type === "username"
? "text"
: type === "current_password"
? "password"
: type
}
ref={register(
type === "password" || type === "current_password"
? {
validate: (value: string) =>
value.length === 0
? "RequiredField"
: value.length < 8
? "TooShort"
: value.length > 1024
? "TooLong"
: undefined,
}
: type === "email"
? {
required: "RequiredField",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "InvalidEmail",
},
}
: type === "username"
? { required: "RequiredField" }
: { required: "RequiredField" },
)}
/>
</Localizer>
</>
);
}

View File

@@ -19,56 +19,56 @@ import { FormResend } from "./forms/FormResend";
import { FormReset, FormSendReset } from "./forms/FormReset";
export default function Login() {
const theme = useContext(ThemeContext);
const client = useContext(AppContext);
const theme = useContext(ThemeContext);
const client = useContext(AppContext);
return (
<div className={styles.login}>
<Helmet>
<meta name="theme-color" content={theme.background} />
</Helmet>
<div className={styles.content}>
<div className={styles.attribution}>
<span>
API:{" "}
<code>{client.configuration?.revolt ?? "???"}</code>{" "}
&middot; revolt.js: <code>{LIBRARY_VERSION}</code>{" "}
&middot; App: <code>{APP_VERSION}</code>
</span>
<span>
<LocaleSelector />
</span>
</div>
<div className={styles.modal}>
<Switch>
<Route path="/login/create">
<FormCreate />
</Route>
<Route path="/login/resend">
<FormResend />
</Route>
<Route path="/login/reset/:token">
<FormReset />
</Route>
<Route path="/login/reset">
<FormSendReset />
</Route>
<Route path="/">
<FormLogin />
</Route>
</Switch>
</div>
<div className={styles.attribution}>
<span>
<Text id="general.image_by" /> &lrm;@lorenzoherrera
&rlm;· unsplash.com
</span>
</div>
</div>
<div
className={styles.bg}
style={{ background: `url('${background}')` }}
/>
</div>
);
return (
<div className={styles.login}>
<Helmet>
<meta name="theme-color" content={theme.background} />
</Helmet>
<div className={styles.content}>
<div className={styles.attribution}>
<span>
API:{" "}
<code>{client.configuration?.revolt ?? "???"}</code>{" "}
&middot; revolt.js: <code>{LIBRARY_VERSION}</code>{" "}
&middot; App: <code>{APP_VERSION}</code>
</span>
<span>
<LocaleSelector />
</span>
</div>
<div className={styles.modal}>
<Switch>
<Route path="/login/create">
<FormCreate />
</Route>
<Route path="/login/resend">
<FormResend />
</Route>
<Route path="/login/reset/:token">
<FormReset />
</Route>
<Route path="/login/reset">
<FormSendReset />
</Route>
<Route path="/">
<FormLogin />
</Route>
</Switch>
</div>
<div className={styles.attribution}>
<span>
<Text id="general.image_by" /> &lrm;@lorenzoherrera
&rlm;· unsplash.com
</span>
</div>
</div>
<div
className={styles.bg}
style={{ background: `url('${background}')` }}
/>
</div>
);
}

View File

@@ -9,33 +9,33 @@ import { AppContext } from "../../../context/revoltjs/RevoltClient";
import Preloader from "../../../components/ui/Preloader";
export interface CaptchaProps {
onSuccess: (token?: string) => void;
onCancel: () => void;
onSuccess: (token?: string) => void;
onCancel: () => void;
}
export function CaptchaBlock(props: CaptchaProps) {
const client = useContext(AppContext);
const client = useContext(AppContext);
useEffect(() => {
if (!client.configuration?.features.captcha.enabled) {
props.onSuccess();
}
}, []);
useEffect(() => {
if (!client.configuration?.features.captcha.enabled) {
props.onSuccess();
}
}, []);
if (!client.configuration?.features.captcha.enabled)
return <Preloader type="spinner" />;
if (!client.configuration?.features.captcha.enabled)
return <Preloader type="spinner" />;
return (
<div>
<HCaptcha
sitekey={client.configuration.features.captcha.key}
onVerify={(token) => props.onSuccess(token)}
/>
<div className={styles.footer}>
<a onClick={props.onCancel}>
<Text id="login.cancel" />
</a>
</div>
</div>
);
return (
<div>
<HCaptcha
sitekey={client.configuration.features.captcha.key}
onVerify={(token) => props.onSuccess(token)}
/>
<div className={styles.footer}>
<a onClick={props.onCancel}>
<Text id="login.cancel" />
</a>
</div>
</div>
);
}

View File

@@ -20,232 +20,232 @@ import { Legal } from "./Legal";
import { MailProvider } from "./MailProvider";
interface Props {
page: "create" | "login" | "send_reset" | "reset" | "resend";
callback: (fields: {
email: string;
password: string;
invite: string;
captcha?: string;
}) => Promise<void>;
page: "create" | "login" | "send_reset" | "reset" | "resend";
callback: (fields: {
email: string;
password: string;
invite: string;
captcha?: string;
}) => Promise<void>;
}
function getInviteCode() {
if (typeof window === "undefined") return "";
if (typeof window === "undefined") return "";
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get("code");
return code ?? "";
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get("code");
return code ?? "";
}
interface FormInputs {
email: string;
password: string;
invite: string;
email: string;
password: string;
invite: string;
}
export function Form({ page, callback }: Props) {
const client = useContext(AppContext);
const client = useContext(AppContext);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | undefined>(undefined);
const [error, setGlobalError] = useState<string | undefined>(undefined);
const [captcha, setCaptcha] = useState<CaptchaProps | undefined>(undefined);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | undefined>(undefined);
const [error, setGlobalError] = useState<string | undefined>(undefined);
const [captcha, setCaptcha] = useState<CaptchaProps | undefined>(undefined);
const { handleSubmit, register, errors, setError } = useForm<FormInputs>({
defaultValues: {
email: "",
password: "",
invite: getInviteCode(),
},
});
const { handleSubmit, register, errors, setError } = useForm<FormInputs>({
defaultValues: {
email: "",
password: "",
invite: getInviteCode(),
},
});
async function onSubmit(data: FormInputs) {
setGlobalError(undefined);
setLoading(true);
async function onSubmit(data: FormInputs) {
setGlobalError(undefined);
setLoading(true);
function onError(err: any) {
setLoading(false);
function onError(err: any) {
setLoading(false);
const error = takeError(err);
switch (error) {
case "email_in_use":
return setError("email", { type: "", message: error });
case "unknown_user":
return setError("email", { type: "", message: error });
case "invalid_invite":
return setError("invite", { type: "", message: error });
}
const error = takeError(err);
switch (error) {
case "email_in_use":
return setError("email", { type: "", message: error });
case "unknown_user":
return setError("email", { type: "", message: error });
case "invalid_invite":
return setError("invite", { type: "", message: error });
}
setGlobalError(error);
}
setGlobalError(error);
}
try {
if (
client.configuration?.features.captcha.enabled &&
page !== "reset"
) {
setCaptcha({
onSuccess: async (captcha) => {
setCaptcha(undefined);
try {
await callback({ ...data, captcha });
setSuccess(data.email);
} catch (err) {
onError(err);
}
},
onCancel: () => {
setCaptcha(undefined);
setLoading(false);
},
});
} else {
await callback(data);
setSuccess(data.email);
}
} catch (err) {
onError(err);
}
}
try {
if (
client.configuration?.features.captcha.enabled &&
page !== "reset"
) {
setCaptcha({
onSuccess: async (captcha) => {
setCaptcha(undefined);
try {
await callback({ ...data, captcha });
setSuccess(data.email);
} catch (err) {
onError(err);
}
},
onCancel: () => {
setCaptcha(undefined);
setLoading(false);
},
});
} else {
await callback(data);
setSuccess(data.email);
}
} catch (err) {
onError(err);
}
}
if (typeof success !== "undefined") {
return (
<div className={styles.success}>
{client.configuration?.features.email ? (
<>
<Envelope size={72} />
<h2>
<Text id="login.check_mail" />
</h2>
<p className={styles.note}>
<Text id="login.email_delay" />
</p>
<MailProvider email={success} />
</>
) : (
<>
<CheckCircle size={72} />
<h1>
<Text id="login.successful_registration" />
</h1>
</>
)}
<span className={styles.footer}>
<Link to="/login">
<a>
<Text id="login.remembered" />
</a>
</Link>
</span>
</div>
);
}
if (typeof success !== "undefined") {
return (
<div className={styles.success}>
{client.configuration?.features.email ? (
<>
<Envelope size={72} />
<h2>
<Text id="login.check_mail" />
</h2>
<p className={styles.note}>
<Text id="login.email_delay" />
</p>
<MailProvider email={success} />
</>
) : (
<>
<CheckCircle size={72} />
<h1>
<Text id="login.successful_registration" />
</h1>
</>
)}
<span className={styles.footer}>
<Link to="/login">
<a>
<Text id="login.remembered" />
</a>
</Link>
</span>
</div>
);
}
if (captcha) return <CaptchaBlock {...captcha} />;
if (loading) return <Preloader type="spinner" />;
if (captcha) return <CaptchaBlock {...captcha} />;
if (loading) return <Preloader type="spinner" />;
return (
<div className={styles.form}>
<img src={wideSVG} />
{/* Preact / React typing incompatabilities */}
<form
onSubmit={
handleSubmit(
onSubmit,
) as JSX.GenericEventHandler<HTMLFormElement>
}>
{page !== "reset" && (
<FormField
type="email"
register={register}
showOverline
error={errors.email?.message}
/>
)}
{(page === "login" ||
page === "create" ||
page === "reset") && (
<FormField
type="password"
register={register}
showOverline
error={errors.password?.message}
/>
)}
{client.configuration?.features.invite_only &&
page === "create" && (
<FormField
type="invite"
register={register}
showOverline
error={errors.invite?.message}
/>
)}
{error && (
<Overline type="error" error={error}>
<Text id={`login.error.${page}`} />
</Overline>
)}
<Button>
<Text
id={
page === "create"
? "login.register"
: page === "login"
? "login.title"
: page === "reset"
? "login.set_password"
: page === "resend"
? "login.resend"
: "login.reset"
}
/>
</Button>
</form>
{page === "create" && (
<>
<span className={styles.create}>
<Text id="login.existing" />
<Link to="/login">
<Text id="login.title" />
</Link>
</span>
<span className={styles.create}>
<Text id="login.missing_verification" />
<Link to="/login/resend">
<Text id="login.resend" />
</Link>
</span>
</>
)}
{page === "login" && (
<>
<span className={styles.create}>
<Text id="login.new" />
<Link to="/login/create">
<Text id="login.create" />
</Link>
</span>
<span className={styles.create}>
<Text id="login.forgot" />
<Link to="/login/reset">
<Text id="login.reset" />
</Link>
</span>
</>
)}
{(page === "reset" ||
page === "resend" ||
page === "send_reset") && (
<>
<span className={styles.create}>
<Link to="/login">
<Text id="login.remembered" />
</Link>
</span>
</>
)}
<Legal />
</div>
);
return (
<div className={styles.form}>
<img src={wideSVG} />
{/* Preact / React typing incompatabilities */}
<form
onSubmit={
handleSubmit(
onSubmit,
) as JSX.GenericEventHandler<HTMLFormElement>
}>
{page !== "reset" && (
<FormField
type="email"
register={register}
showOverline
error={errors.email?.message}
/>
)}
{(page === "login" ||
page === "create" ||
page === "reset") && (
<FormField
type="password"
register={register}
showOverline
error={errors.password?.message}
/>
)}
{client.configuration?.features.invite_only &&
page === "create" && (
<FormField
type="invite"
register={register}
showOverline
error={errors.invite?.message}
/>
)}
{error && (
<Overline type="error" error={error}>
<Text id={`login.error.${page}`} />
</Overline>
)}
<Button>
<Text
id={
page === "create"
? "login.register"
: page === "login"
? "login.title"
: page === "reset"
? "login.set_password"
: page === "resend"
? "login.resend"
: "login.reset"
}
/>
</Button>
</form>
{page === "create" && (
<>
<span className={styles.create}>
<Text id="login.existing" />
<Link to="/login">
<Text id="login.title" />
</Link>
</span>
<span className={styles.create}>
<Text id="login.missing_verification" />
<Link to="/login/resend">
<Text id="login.resend" />
</Link>
</span>
</>
)}
{page === "login" && (
<>
<span className={styles.create}>
<Text id="login.new" />
<Link to="/login/create">
<Text id="login.create" />
</Link>
</span>
<span className={styles.create}>
<Text id="login.forgot" />
<Link to="/login/reset">
<Text id="login.reset" />
</Link>
</span>
</>
)}
{(page === "reset" ||
page === "resend" ||
page === "send_reset") && (
<>
<span className={styles.create}>
<Link to="/login">
<Text id="login.remembered" />
</Link>
</span>
</>
)}
<Legal />
</div>
);
}

View File

@@ -5,14 +5,14 @@ import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { Form } from "./Form";
export function FormCreate() {
const client = useContext(AppContext);
const client = useContext(AppContext);
return (
<Form
page="create"
callback={async (data) => {
await client.register(import.meta.env.VITE_API_URL, data);
}}
/>
);
return (
<Form
page="create"
callback={async (data) => {
await client.register(import.meta.env.VITE_API_URL, data);
}}
/>
);
}

View File

@@ -8,25 +8,25 @@ import { OperationsContext } from "../../../context/revoltjs/RevoltClient";
import { Form } from "./Form";
export function FormLogin() {
const { login } = useContext(OperationsContext);
const history = useHistory();
const { login } = useContext(OperationsContext);
const history = useHistory();
return (
<Form
page="login"
callback={async (data) => {
const browser = detect();
let device_name;
if (browser) {
const { name, os } = browser;
device_name = `${name} on ${os}`;
} else {
device_name = "Unknown Device";
}
return (
<Form
page="login"
callback={async (data) => {
const browser = detect();
let device_name;
if (browser) {
const { name, os } = browser;
device_name = `${name} on ${os}`;
} else {
device_name = "Unknown Device";
}
await login({ ...data, device_name });
history.push("/");
}}
/>
);
await login({ ...data, device_name });
history.push("/");
}}
/>
);
}

View File

@@ -5,14 +5,14 @@ import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { Form } from "./Form";
export function FormResend() {
const client = useContext(AppContext);
const client = useContext(AppContext);
return (
<Form
page="resend"
callback={async (data) => {
await client.req("POST", "/auth/resend", data);
}}
/>
);
return (
<Form
page="resend"
callback={async (data) => {
await client.req("POST", "/auth/resend", data);
}}
/>
);
}

View File

@@ -7,33 +7,33 @@ import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { Form } from "./Form";
export function FormSendReset() {
const client = useContext(AppContext);
const client = useContext(AppContext);
return (
<Form
page="send_reset"
callback={async (data) => {
await client.req("POST", "/auth/send_reset", data);
}}
/>
);
return (
<Form
page="send_reset"
callback={async (data) => {
await client.req("POST", "/auth/send_reset", data);
}}
/>
);
}
export function FormReset() {
const { token } = useParams<{ token: string }>();
const client = useContext(AppContext);
const history = useHistory();
const { token } = useParams<{ token: string }>();
const client = useContext(AppContext);
const history = useHistory();
return (
<Form
page="reset"
callback={async (data) => {
await client.req("POST", "/auth/reset", {
token,
...data,
});
history.push("/login");
}}
/>
);
return (
<Form
page="reset"
callback={async (data) => {
await client.req("POST", "/auth/reset", {
token,
...data,
});
history.push("/login");
}}
/>
);
}

View File

@@ -2,19 +2,19 @@ import styles from "../Login.module.scss";
import { Text } from "preact-i18n";
export function Legal() {
return (
<span className={styles.footer}>
<a href="https://revolt.chat/about" target="_blank">
<Text id="general.about" />
</a>
&middot;
<a href="https://revolt.chat/terms" target="_blank">
<Text id="general.tos" />
</a>
&middot;
<a href="https://revolt.chat/privacy" target="_blank">
<Text id="general.privacy" />
</a>
</span>
);
return (
<span className={styles.footer}>
<a href="https://revolt.chat/about" target="_blank">
<Text id="general.about" />
</a>
&middot;
<a href="https://revolt.chat/terms" target="_blank">
<Text id="general.tos" />
</a>
&middot;
<a href="https://revolt.chat/privacy" target="_blank">
<Text id="general.privacy" />
</a>
</span>
);
}

View File

@@ -4,53 +4,53 @@ import { Text } from "preact-i18n";
import Button from "../../../components/ui/Button";
interface Props {
email?: string;
email?: string;
}
function mapMailProvider(email?: string): [string, string] | undefined {
if (!email) return;
if (!email) return;
const match = /@(.+)/.exec(email);
if (match === null) return;
const match = /@(.+)/.exec(email);
if (match === null) return;
const domain = match[1];
switch (domain) {
case "gmail.com":
return ["Gmail", "https://gmail.com"];
case "tuta.io":
return ["Tutanota", "https://mail.tutanota.com"];
case "outlook.com":
return ["Outlook", "https://outlook.live.com"];
case "yahoo.com":
return ["Yahoo", "https://mail.yahoo.com"];
case "wp.pl":
return ["WP Poczta", "https://poczta.wp.pl"];
case "protonmail.com":
case "protonmail.ch":
return ["ProtonMail", "https://mail.protonmail.com"];
case "seznam.cz":
case "email.cz":
case "post.cz":
return ["Seznam", "https://email.seznam.cz"];
default:
return [domain, `https://${domain}`];
}
const domain = match[1];
switch (domain) {
case "gmail.com":
return ["Gmail", "https://gmail.com"];
case "tuta.io":
return ["Tutanota", "https://mail.tutanota.com"];
case "outlook.com":
return ["Outlook", "https://outlook.live.com"];
case "yahoo.com":
return ["Yahoo", "https://mail.yahoo.com"];
case "wp.pl":
return ["WP Poczta", "https://poczta.wp.pl"];
case "protonmail.com":
case "protonmail.ch":
return ["ProtonMail", "https://mail.protonmail.com"];
case "seznam.cz":
case "email.cz":
case "post.cz":
return ["Seznam", "https://email.seznam.cz"];
default:
return [domain, `https://${domain}`];
}
}
export function MailProvider({ email }: Props) {
const provider = mapMailProvider(email);
if (!provider) return null;
const provider = mapMailProvider(email);
if (!provider) return null;
return (
<div className={styles.mailProvider}>
<a href={provider[1]} target="_blank">
<Button>
<Text
id="login.open_mail_provider"
fields={{ provider: provider[0] }}
/>
</Button>
</a>
</div>
);
return (
<div className={styles.mailProvider}>
<a href={provider[1]} target="_blank">
<Button>
<Text
id="login.open_mail_provider"
fields={{ provider: provider[0] }}
/>
</Button>
</a>
</div>
);
}

View File

@@ -13,75 +13,75 @@ import Overview from "./channel/Overview";
import Permissions from "./channel/Permissions";
export default function ChannelSettings() {
const { channel: cid } = useParams<{ channel: string }>();
const ctx = useForceUpdate();
const channel = useChannel(cid, ctx);
if (!channel) return null;
if (
channel.channel_type === "SavedMessages" ||
channel.channel_type === "DirectMessage"
)
return null;
const { channel: cid } = useParams<{ channel: string }>();
const ctx = useForceUpdate();
const channel = useChannel(cid, ctx);
if (!channel) return null;
if (
channel.channel_type === "SavedMessages" ||
channel.channel_type === "DirectMessage"
)
return null;
const history = useHistory();
function switchPage(to?: string) {
let base_url;
switch (channel?.channel_type) {
case "TextChannel":
case "VoiceChannel":
base_url = `/server/${channel.server}/channel/${cid}/settings`;
break;
default:
base_url = `/channel/${cid}/settings`;
}
const history = useHistory();
function switchPage(to?: string) {
let base_url;
switch (channel?.channel_type) {
case "TextChannel":
case "VoiceChannel":
base_url = `/server/${channel.server}/channel/${cid}/settings`;
break;
default:
base_url = `/channel/${cid}/settings`;
}
if (to) {
history.replace(`${base_url}/${to}`);
} else {
history.replace(base_url);
}
}
if (to) {
history.replace(`${base_url}/${to}`);
} else {
history.replace(base_url);
}
}
return (
<GenericSettings
pages={[
{
category: (
<Category
variant="uniform"
text={getChannelName(ctx.client, channel, true)}
/>
),
id: "overview",
icon: <ListUl size={20} />,
title: (
<Text id="app.settings.channel_pages.overview.title" />
),
},
{
id: "permissions",
icon: <ListCheck size={20} />,
title: (
<Text id="app.settings.channel_pages.permissions.title" />
),
},
]}
children={[
<Route path="/server/:server/channel/:channel/settings/permissions">
<Permissions channel={channel} />
</Route>,
<Route path="/channel/:channel/settings/permissions">
<Permissions channel={channel} />
</Route>,
return (
<GenericSettings
pages={[
{
category: (
<Category
variant="uniform"
text={getChannelName(ctx.client, channel, true)}
/>
),
id: "overview",
icon: <ListUl size={20} />,
title: (
<Text id="app.settings.channel_pages.overview.title" />
),
},
{
id: "permissions",
icon: <ListCheck size={20} />,
title: (
<Text id="app.settings.channel_pages.permissions.title" />
),
},
]}
children={[
<Route path="/server/:server/channel/:channel/settings/permissions">
<Permissions channel={channel} />
</Route>,
<Route path="/channel/:channel/settings/permissions">
<Permissions channel={channel} />
</Route>,
<Route path="/">
<Overview channel={channel} />
</Route>,
]}
category="channel_pages"
switchPage={switchPage}
defaultPage="overview"
showExitButton
/>
);
<Route path="/">
<Overview channel={channel} />
</Route>,
]}
category="channel_pages"
switchPage={switchPage}
defaultPage="overview"
showExitButton
/>
);
}

View File

@@ -19,140 +19,140 @@ import ButtonItem from "../../components/navigation/items/ButtonItem";
import { Children } from "../../types/Preact";
interface Props {
pages: {
category?: Children;
divider?: boolean;
id: string;
icon: Children;
title: Children;
hideTitle?: boolean;
}[];
custom?: Children;
children: Children;
defaultPage: string;
showExitButton?: boolean;
switchPage: (to?: string) => void;
category: "pages" | "channel_pages" | "server_pages";
pages: {
category?: Children;
divider?: boolean;
id: string;
icon: Children;
title: Children;
hideTitle?: boolean;
}[];
custom?: Children;
children: Children;
defaultPage: string;
showExitButton?: boolean;
switchPage: (to?: string) => void;
category: "pages" | "channel_pages" | "server_pages";
}
export function GenericSettings({
pages,
switchPage,
category,
custom,
children,
defaultPage,
showExitButton,
pages,
switchPage,
category,
custom,
children,
defaultPage,
showExitButton,
}: Props) {
const history = useHistory();
const theme = useContext(ThemeContext);
const { page } = useParams<{ page: string }>();
const history = useHistory();
const theme = useContext(ThemeContext);
const { page } = useParams<{ page: string }>();
function exitSettings() {
if (history.length > 0) {
history.goBack();
} else {
history.push("/");
}
}
function exitSettings() {
if (history.length > 0) {
history.goBack();
} else {
history.push("/");
}
}
useEffect(() => {
function keyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
exitSettings();
}
}
useEffect(() => {
function keyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
exitSettings();
}
}
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, []);
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, []);
return (
<div className={styles.settings} data-mobile={isTouchscreenDevice}>
<Helmet>
<meta
name="theme-color"
content={
isTouchscreenDevice
? theme["primary-header"]
: theme["secondary-background"]
}
/>
</Helmet>
{isTouchscreenDevice && (
<Header placement="primary">
{typeof page === "undefined" ? (
<>
{showExitButton && (
<IconButton onClick={exitSettings}>
<X size={24} />
</IconButton>
)}
<Text id="app.settings.title" />
</>
) : (
<>
<IconButton onClick={() => switchPage()}>
<ArrowBack size={24} />
</IconButton>
<Text
id={`app.settings.${category}.${page}.title`}
/>
</>
)}
</Header>
)}
{(!isTouchscreenDevice || typeof page === "undefined") && (
<div className={styles.sidebar}>
<div className={styles.container}>
{pages.map((entry, i) => (
<>
{entry.category && (
<Category
variant="uniform"
text={entry.category}
/>
)}
<ButtonItem
active={
page === entry.id ||
(i === 0 &&
!isTouchscreenDevice &&
typeof page === "undefined")
}
onClick={() => switchPage(entry.id)}
compact>
{entry.icon} {entry.title}
</ButtonItem>
{entry.divider && <LineDivider />}
</>
))}
{custom}
</div>
</div>
)}
{(!isTouchscreenDevice || typeof page === "string") && (
<div className={styles.content}>
{!isTouchscreenDevice &&
!pages.find((x) => x.id === page && x.hideTitle) && (
<h1>
<Text
id={`app.settings.${category}.${
page ?? defaultPage
}.title`}
/>
</h1>
)}
<Switch>{children}</Switch>
</div>
)}
{!isTouchscreenDevice && (
<div className={styles.action}>
<IconButton onClick={exitSettings}>
<XCircle size={48} />
</IconButton>
</div>
)}
</div>
);
return (
<div className={styles.settings} data-mobile={isTouchscreenDevice}>
<Helmet>
<meta
name="theme-color"
content={
isTouchscreenDevice
? theme["primary-header"]
: theme["secondary-background"]
}
/>
</Helmet>
{isTouchscreenDevice && (
<Header placement="primary">
{typeof page === "undefined" ? (
<>
{showExitButton && (
<IconButton onClick={exitSettings}>
<X size={24} />
</IconButton>
)}
<Text id="app.settings.title" />
</>
) : (
<>
<IconButton onClick={() => switchPage()}>
<ArrowBack size={24} />
</IconButton>
<Text
id={`app.settings.${category}.${page}.title`}
/>
</>
)}
</Header>
)}
{(!isTouchscreenDevice || typeof page === "undefined") && (
<div className={styles.sidebar}>
<div className={styles.container}>
{pages.map((entry, i) => (
<>
{entry.category && (
<Category
variant="uniform"
text={entry.category}
/>
)}
<ButtonItem
active={
page === entry.id ||
(i === 0 &&
!isTouchscreenDevice &&
typeof page === "undefined")
}
onClick={() => switchPage(entry.id)}
compact>
{entry.icon} {entry.title}
</ButtonItem>
{entry.divider && <LineDivider />}
</>
))}
{custom}
</div>
</div>
)}
{(!isTouchscreenDevice || typeof page === "string") && (
<div className={styles.content}>
{!isTouchscreenDevice &&
!pages.find((x) => x.id === page && x.hideTitle) && (
<h1>
<Text
id={`app.settings.${category}.${
page ?? defaultPage
}.title`}
/>
</h1>
)}
<Switch>{children}</Switch>
</div>
)}
{!isTouchscreenDevice && (
<div className={styles.action}>
<IconButton onClick={exitSettings}>
<XCircle size={48} />
</IconButton>
</div>
)}
</div>
);
}

View File

@@ -1,8 +1,8 @@
import {
ListUl,
Share,
Group,
ListCheck,
ListUl,
Share,
Group,
ListCheck,
} from "@styled-icons/boxicons-regular";
import { XSquare } from "@styled-icons/boxicons-solid";
import { Route, useHistory, useParams } from "react-router-dom";
@@ -22,85 +22,85 @@ import { Overview } from "./server/Overview";
import { Roles } from "./server/Roles";
export default function ServerSettings() {
const { server: sid } = useParams<{ server: string }>();
const server = useServer(sid);
if (!server) return null;
const { server: sid } = useParams<{ server: string }>();
const server = useServer(sid);
if (!server) return null;
const history = useHistory();
function switchPage(to?: string) {
if (to) {
history.replace(`/server/${sid}/settings/${to}`);
} else {
history.replace(`/server/${sid}/settings`);
}
}
const history = useHistory();
function switchPage(to?: string) {
if (to) {
history.replace(`/server/${sid}/settings/${to}`);
} else {
history.replace(`/server/${sid}/settings`);
}
}
return (
<GenericSettings
pages={[
{
category: <Category variant="uniform" text={server.name} />, //TOFIX: Just add the server.name as a string, otherwise it makes a duplicate category
id: "overview",
icon: <ListUl size={20} />,
title: (
<Text id="app.settings.server_pages.overview.title" />
),
},
{
id: "members",
icon: <Group size={20} />,
title: (
<Text id="app.settings.server_pages.members.title" />
),
},
{
id: "invites",
icon: <Share size={20} />,
title: (
<Text id="app.settings.server_pages.invites.title" />
),
},
{
id: "bans",
icon: <XSquare size={20} />,
title: <Text id="app.settings.server_pages.bans.title" />,
},
{
id: "roles",
icon: <ListCheck size={20} />,
title: <Text id="app.settings.server_pages.roles.title" />,
hideTitle: true,
},
]}
children={[
<Route path="/server/:server/settings/members">
<RequiresOnline>
<Members server={server} />
</RequiresOnline>
</Route>,
<Route path="/server/:server/settings/invites">
<RequiresOnline>
<Invites server={server} />
</RequiresOnline>
</Route>,
<Route path="/server/:server/settings/bans">
<RequiresOnline>
<Bans server={server} />
</RequiresOnline>
</Route>,
<Route path="/server/:server/settings/roles">
<RequiresOnline>
<Roles server={server} />
</RequiresOnline>
</Route>,
<Route path="/">
<Overview server={server} />
</Route>,
]}
category="server_pages"
switchPage={switchPage}
defaultPage="overview"
showExitButton
/>
);
return (
<GenericSettings
pages={[
{
category: <Category variant="uniform" text={server.name} />, //TOFIX: Just add the server.name as a string, otherwise it makes a duplicate category
id: "overview",
icon: <ListUl size={20} />,
title: (
<Text id="app.settings.server_pages.overview.title" />
),
},
{
id: "members",
icon: <Group size={20} />,
title: (
<Text id="app.settings.server_pages.members.title" />
),
},
{
id: "invites",
icon: <Share size={20} />,
title: (
<Text id="app.settings.server_pages.invites.title" />
),
},
{
id: "bans",
icon: <XSquare size={20} />,
title: <Text id="app.settings.server_pages.bans.title" />,
},
{
id: "roles",
icon: <ListCheck size={20} />,
title: <Text id="app.settings.server_pages.roles.title" />,
hideTitle: true,
},
]}
children={[
<Route path="/server/:server/settings/members">
<RequiresOnline>
<Members server={server} />
</RequiresOnline>
</Route>,
<Route path="/server/:server/settings/invites">
<RequiresOnline>
<Invites server={server} />
</RequiresOnline>
</Route>,
<Route path="/server/:server/settings/bans">
<RequiresOnline>
<Bans server={server} />
</RequiresOnline>
</Route>,
<Route path="/server/:server/settings/roles">
<RequiresOnline>
<Roles server={server} />
</RequiresOnline>
</Route>,
<Route path="/">
<Overview server={server} />
</Route>,
]}
category="server_pages"
switchPage={switchPage}
defaultPage="overview"
showExitButton
/>
);
}

View File

@@ -1,18 +1,18 @@
import { Gitlab } from "@styled-icons/boxicons-logos";
import {
Sync as SyncIcon,
Globe,
LogOut,
Sync as SyncIcon,
Globe,
LogOut,
} from "@styled-icons/boxicons-regular";
import {
Bell,
Palette,
Coffee,
IdCard,
CheckShield,
Flask,
User,
Megaphone,
Bell,
Palette,
Coffee,
IdCard,
CheckShield,
Flask,
User,
Megaphone,
} from "@styled-icons/boxicons-solid";
import { Route, useHistory } from "react-router-dom";
import { LIBRARY_VERSION } from "revolt.js";
@@ -23,8 +23,8 @@ import { useContext } from "preact/hooks";
import RequiresOnline from "../../context/revoltjs/RequiresOnline";
import {
AppContext,
OperationsContext,
AppContext,
OperationsContext,
} from "../../context/revoltjs/RevoltClient";
import LineDivider from "../../components/ui/LineDivider";
@@ -44,159 +44,159 @@ import { Sessions } from "./panes/Sessions";
import { Sync } from "./panes/Sync";
export default function Settings() {
const history = useHistory();
const client = useContext(AppContext);
const operations = useContext(OperationsContext);
const history = useHistory();
const client = useContext(AppContext);
const operations = useContext(OperationsContext);
function switchPage(to?: string) {
if (to) {
history.replace(`/settings/${to}`);
} else {
history.replace(`/settings`);
}
}
function switchPage(to?: string) {
if (to) {
history.replace(`/settings/${to}`);
} else {
history.replace(`/settings`);
}
}
return (
<GenericSettings
pages={[
{
category: (
<Text id="app.settings.categories.user_settings" />
),
id: "account",
icon: <User size={20} />,
title: <Text id="app.settings.pages.account.title" />,
},
{
id: "profile",
icon: <IdCard size={20} />,
title: <Text id="app.settings.pages.profile.title" />,
},
{
id: "sessions",
icon: <CheckShield size={20} />,
title: <Text id="app.settings.pages.sessions.title" />,
},
{
category: (
<Text id="app.settings.categories.client_settings" />
),
id: "appearance",
icon: <Palette size={20} />,
title: <Text id="app.settings.pages.appearance.title" />,
},
{
id: "notifications",
icon: <Bell size={20} />,
title: <Text id="app.settings.pages.notifications.title" />,
},
{
id: "language",
icon: <Globe size={20} />,
title: <Text id="app.settings.pages.language.title" />,
},
{
id: "sync",
icon: <SyncIcon size={20} />,
title: <Text id="app.settings.pages.sync.title" />,
},
{
divider: true,
id: "experiments",
icon: <Flask size={20} />,
title: <Text id="app.settings.pages.experiments.title" />,
},
{
id: "feedback",
icon: <Megaphone size={20} />,
title: <Text id="app.settings.pages.feedback.title" />,
},
]}
children={[
<Route path="/settings/profile">
<Profile />
</Route>,
<Route path="/settings/sessions">
<RequiresOnline>
<Sessions />
</RequiresOnline>
</Route>,
<Route path="/settings/appearance">
<Appearance />
</Route>,
<Route path="/settings/notifications">
<Notifications />
</Route>,
<Route path="/settings/language">
<Languages />
</Route>,
<Route path="/settings/sync">
<Sync />
</Route>,
<Route path="/settings/experiments">
<ExperimentsPage />
</Route>,
<Route path="/settings/feedback">
<Feedback />
</Route>,
<Route path="/">
<Account />
</Route>,
]}
defaultPage="account"
switchPage={switchPage}
category="pages"
custom={[
<a href="https://gitlab.insrt.uk/revolt" target="_blank">
<ButtonItem compact>
<Gitlab size={20} />
<Text id="app.settings.pages.source_code" />
</ButtonItem>
</a>,
<a href="https://ko-fi.com/insertish" target="_blank">
<ButtonItem className={styles.donate} compact>
<Coffee size={20} />
<Text id="app.settings.pages.donate.title" />
</ButtonItem>
</a>,
<LineDivider />,
<ButtonItem
onClick={() => operations.logout()}
className={styles.logOut}
compact>
<LogOut size={20} />
<Text id="app.settings.pages.logOut" />
</ButtonItem>,
<div className={styles.version}>
<div>
<span className={styles.revision}>
<a
href={`${REPO_URL}/${GIT_REVISION}`}
target="_blank">
{GIT_REVISION.substr(0, 7)}
</a>
{` `}
<a
href={
GIT_BRANCH !== "DETACHED"
? `https://gitlab.insrt.uk/revolt/client/-/tree/${GIT_BRANCH}`
: undefined
}
target="_blank">
({GIT_BRANCH})
</a>
</span>
<span>
{GIT_BRANCH === "production" ? "Stable" : "Nightly"}{" "}
{APP_VERSION}
</span>
<span>
API: {client.configuration?.revolt ?? "N/A"}
</span>
<span>revolt.js: {LIBRARY_VERSION}</span>
</div>
</div>,
]}
/>
);
return (
<GenericSettings
pages={[
{
category: (
<Text id="app.settings.categories.user_settings" />
),
id: "account",
icon: <User size={20} />,
title: <Text id="app.settings.pages.account.title" />,
},
{
id: "profile",
icon: <IdCard size={20} />,
title: <Text id="app.settings.pages.profile.title" />,
},
{
id: "sessions",
icon: <CheckShield size={20} />,
title: <Text id="app.settings.pages.sessions.title" />,
},
{
category: (
<Text id="app.settings.categories.client_settings" />
),
id: "appearance",
icon: <Palette size={20} />,
title: <Text id="app.settings.pages.appearance.title" />,
},
{
id: "notifications",
icon: <Bell size={20} />,
title: <Text id="app.settings.pages.notifications.title" />,
},
{
id: "language",
icon: <Globe size={20} />,
title: <Text id="app.settings.pages.language.title" />,
},
{
id: "sync",
icon: <SyncIcon size={20} />,
title: <Text id="app.settings.pages.sync.title" />,
},
{
divider: true,
id: "experiments",
icon: <Flask size={20} />,
title: <Text id="app.settings.pages.experiments.title" />,
},
{
id: "feedback",
icon: <Megaphone size={20} />,
title: <Text id="app.settings.pages.feedback.title" />,
},
]}
children={[
<Route path="/settings/profile">
<Profile />
</Route>,
<Route path="/settings/sessions">
<RequiresOnline>
<Sessions />
</RequiresOnline>
</Route>,
<Route path="/settings/appearance">
<Appearance />
</Route>,
<Route path="/settings/notifications">
<Notifications />
</Route>,
<Route path="/settings/language">
<Languages />
</Route>,
<Route path="/settings/sync">
<Sync />
</Route>,
<Route path="/settings/experiments">
<ExperimentsPage />
</Route>,
<Route path="/settings/feedback">
<Feedback />
</Route>,
<Route path="/">
<Account />
</Route>,
]}
defaultPage="account"
switchPage={switchPage}
category="pages"
custom={[
<a href="https://gitlab.insrt.uk/revolt" target="_blank">
<ButtonItem compact>
<Gitlab size={20} />
<Text id="app.settings.pages.source_code" />
</ButtonItem>
</a>,
<a href="https://ko-fi.com/insertish" target="_blank">
<ButtonItem className={styles.donate} compact>
<Coffee size={20} />
<Text id="app.settings.pages.donate.title" />
</ButtonItem>
</a>,
<LineDivider />,
<ButtonItem
onClick={() => operations.logout()}
className={styles.logOut}
compact>
<LogOut size={20} />
<Text id="app.settings.pages.logOut" />
</ButtonItem>,
<div className={styles.version}>
<div>
<span className={styles.revision}>
<a
href={`${REPO_URL}/${GIT_REVISION}`}
target="_blank">
{GIT_REVISION.substr(0, 7)}
</a>
{` `}
<a
href={
GIT_BRANCH !== "DETACHED"
? `https://gitlab.insrt.uk/revolt/client/-/tree/${GIT_BRANCH}`
: undefined
}
target="_blank">
({GIT_BRANCH})
</a>
</span>
<span>
{GIT_BRANCH === "production" ? "Stable" : "Nightly"}{" "}
{APP_VERSION}
</span>
<span>
API: {client.configuration?.revolt ?? "N/A"}
</span>
<span>revolt.js: {LIBRARY_VERSION}</span>
</div>
</div>,
]}
/>
);
}

View File

@@ -13,105 +13,105 @@ import Button from "../../../components/ui/Button";
import InputBox from "../../../components/ui/InputBox";
interface Props {
channel:
| Channels.GroupChannel
| Channels.TextChannel
| Channels.VoiceChannel;
channel:
| Channels.GroupChannel
| Channels.TextChannel
| Channels.VoiceChannel;
}
export default function Overview({ channel }: Props) {
const client = useContext(AppContext);
const client = useContext(AppContext);
const [name, setName] = useState(channel.name);
const [description, setDescription] = useState(channel.description ?? "");
const [name, setName] = useState(channel.name);
const [description, setDescription] = useState(channel.description ?? "");
useEffect(() => setName(channel.name), [channel.name]);
useEffect(
() => setDescription(channel.description ?? ""),
[channel.description],
);
useEffect(() => setName(channel.name), [channel.name]);
useEffect(
() => setDescription(channel.description ?? ""),
[channel.description],
);
const [changed, setChanged] = useState(false);
function save() {
let changes: any = {};
if (name !== channel.name) changes.name = name;
if (description !== channel.description)
changes.description = description;
const [changed, setChanged] = useState(false);
function save() {
let changes: any = {};
if (name !== channel.name) changes.name = name;
if (description !== channel.description)
changes.description = description;
client.channels.edit(channel._id, changes);
setChanged(false);
}
client.channels.edit(channel._id, changes);
setChanged(false);
}
return (
<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) =>
client.channels.edit(channel._id, { icon })
}
previewURL={client.channels.getIconURL(
channel._id,
{ max_side: 256 },
true,
)}
remove={() =>
client.channels.edit(channel._id, { remove: "Icon" })
}
defaultPreview={
channel.channel_type === "Group"
? "/assets/group.png"
: undefined
}
/>
<div className={styles.name}>
<h3>
{channel.channel_type === "Group" ? (
<Text id="app.main.groups.name" />
) : (
<Text id="app.main.servers.channel_name" />
)}
</h3>
<InputBox
contrast
value={name}
maxLength={32}
onChange={(e) => {
setName(e.currentTarget.value);
if (!changed) setChanged(true);
}}
/>
</div>
</div>
return (
<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) =>
client.channels.edit(channel._id, { icon })
}
previewURL={client.channels.getIconURL(
channel._id,
{ max_side: 256 },
true,
)}
remove={() =>
client.channels.edit(channel._id, { remove: "Icon" })
}
defaultPreview={
channel.channel_type === "Group"
? "/assets/group.png"
: undefined
}
/>
<div className={styles.name}>
<h3>
{channel.channel_type === "Group" ? (
<Text id="app.main.groups.name" />
) : (
<Text id="app.main.servers.channel_name" />
)}
</h3>
<InputBox
contrast
value={name}
maxLength={32}
onChange={(e) => {
setName(e.currentTarget.value);
if (!changed) setChanged(true);
}}
/>
</div>
</div>
<h3>
{channel.channel_type === "Group" ? (
<Text id="app.main.groups.description" />
) : (
<Text id="app.main.servers.channel_description" />
)}
</h3>
<TextAreaAutoSize
maxRows={10}
minHeight={60}
maxLength={1024}
value={description}
placeholder={"Add a description..."}
onChange={(ev) => {
setDescription(ev.currentTarget.value);
if (!changed) setChanged(true);
}}
/>
<p>
<Button onClick={save} contrast disabled={!changed}>
<Text id="app.special.modals.actions.save" />
</Button>
</p>
</div>
);
<h3>
{channel.channel_type === "Group" ? (
<Text id="app.main.groups.description" />
) : (
<Text id="app.main.servers.channel_description" />
)}
</h3>
<TextAreaAutoSize
maxRows={10}
minHeight={60}
maxLength={1024}
value={description}
placeholder={"Add a description..."}
onChange={(ev) => {
setDescription(ev.currentTarget.value);
if (!changed) setChanged(true);
}}
/>
<p>
<Button onClick={save} contrast disabled={!changed}>
<Text id="app.special.modals.actions.save" />
</Button>
</p>
</div>
);
}

View File

@@ -12,100 +12,100 @@ import Tip from "../../../components/ui/Tip";
// ! FIXME: export from revolt.js
const DEFAULT_PERMISSION_DM =
ChannelPermission.View +
ChannelPermission.SendMessage +
ChannelPermission.ManageChannel +
ChannelPermission.VoiceCall +
ChannelPermission.InviteOthers +
ChannelPermission.EmbedLinks +
ChannelPermission.UploadFiles;
ChannelPermission.View +
ChannelPermission.SendMessage +
ChannelPermission.ManageChannel +
ChannelPermission.VoiceCall +
ChannelPermission.InviteOthers +
ChannelPermission.EmbedLinks +
ChannelPermission.UploadFiles;
interface Props {
channel:
| Channels.GroupChannel
| Channels.TextChannel
| Channels.VoiceChannel;
channel:
| Channels.GroupChannel
| Channels.TextChannel
| Channels.VoiceChannel;
}
// ! FIXME: bad code :)
export default function Permissions({ channel }: Props) {
const [selected, setSelected] = useState("default");
const client = useContext(AppContext);
const [selected, setSelected] = useState("default");
const client = useContext(AppContext);
type R = { name: string; permissions: number };
let roles: { [key: string]: R } = {};
if (channel.channel_type !== "Group") {
const server = useServer(channel.server);
const a = server?.roles ?? {};
for (let b of Object.keys(a)) {
roles[b] = {
name: a[b].name,
permissions: a[b].permissions[1],
};
}
}
type R = { name: string; permissions: number };
let roles: { [key: string]: R } = {};
if (channel.channel_type !== "Group") {
const server = useServer(channel.server);
const a = server?.roles ?? {};
for (let b of Object.keys(a)) {
roles[b] = {
name: a[b].name,
permissions: a[b].permissions[1],
};
}
}
const keys = ["default", ...Object.keys(roles)];
const keys = ["default", ...Object.keys(roles)];
const defaultRole = {
name: "Default",
permissions:
(channel.channel_type === "Group"
? channel.permissions
: channel.default_permissions) ?? DEFAULT_PERMISSION_DM,
};
const selectedRole = selected === "default" ? defaultRole : roles[selected];
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;
}
if (!selectedRole) {
useEffect(() => setSelected("default"), []);
return null;
}
const [p, setPerm] = useState(selectedRole.permissions >>> 0);
const [p, setPerm] = useState(selectedRole.permissions >>> 0);
useEffect(() => {
setPerm(selectedRole.permissions >>> 0);
}, [selected, selectedRole.permissions]);
useEffect(() => {
setPerm(selectedRole.permissions >>> 0);
}, [selected, selectedRole.permissions]);
return (
<div>
<Tip warning>This section is under construction.</Tip>
<h2>select role</h2>
{selected}
{keys.map((id) => {
let role: R = id === "default" ? defaultRole : roles[id];
return (
<div>
<Tip warning>This section is under construction.</Tip>
<h2>select role</h2>
{selected}
{keys.map((id) => {
let role: R = id === "default" ? defaultRole : roles[id];
return (
<Checkbox
checked={selected === id}
onChange={(selected) => selected && setSelected(id)}>
{role.name}
</Checkbox>
);
})}
<h2>channel per??issions</h2>
{Object.keys(ChannelPermission).map((perm) => {
let 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>
);
}
})}
<Button
contrast
onClick={() => {
client.channels.setPermissions(channel._id, selected, p);
}}>
click here to save permissions for role
</Button>
</div>
);
return (
<Checkbox
checked={selected === id}
onChange={(selected) => selected && setSelected(id)}>
{role.name}
</Checkbox>
);
})}
<h2>channel per??issions</h2>
{Object.keys(ChannelPermission).map((perm) => {
let 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>
);
}
})}
<Button
contrast
onClick={() => {
client.channels.setPermissions(channel._id, selected, p);
}}>
click here to save permissions for role
</Button>
</div>
);
}

View File

@@ -9,8 +9,8 @@ import { useContext, useEffect, useState } from "preact/hooks";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import {
ClientStatus,
StatusContext,
ClientStatus,
StatusContext,
} from "../../../context/revoltjs/RevoltClient";
import { useForceUpdate, useSelf } from "../../../context/revoltjs/hooks";
@@ -20,87 +20,87 @@ import Overline from "../../../components/ui/Overline";
import Tip from "../../../components/ui/Tip";
export function Account() {
const { openScreen } = useIntermediate();
const status = useContext(StatusContext);
const { openScreen } = useIntermediate();
const status = useContext(StatusContext);
const ctx = useForceUpdate();
const user = useSelf(ctx);
if (!user) return null;
const ctx = useForceUpdate();
const user = useSelf(ctx);
if (!user) return null;
const [email, setEmail] = useState("...");
const [profile, setProfile] = useState<undefined | Users.Profile>(
undefined,
);
const history = useHistory();
const [email, setEmail] = useState("...");
const [profile, setProfile] = useState<undefined | Users.Profile>(
undefined,
);
const history = useHistory();
function switchPage(to: string) {
history.replace(`/settings/${to}`);
}
function switchPage(to: string) {
history.replace(`/settings/${to}`);
}
useEffect(() => {
if (email === "..." && status === ClientStatus.ONLINE) {
ctx.client
.req("GET", "/auth/user")
.then((account) => setEmail(account.email));
}
useEffect(() => {
if (email === "..." && status === ClientStatus.ONLINE) {
ctx.client
.req("GET", "/auth/user")
.then((account) => setEmail(account.email));
}
if (profile === undefined && status === ClientStatus.ONLINE) {
ctx.client.users
.fetchProfile(user._id)
.then((profile) => setProfile(profile ?? {}));
}
}, [status]);
if (profile === undefined && status === ClientStatus.ONLINE) {
ctx.client.users
.fetchProfile(user._id)
.then((profile) => setProfile(profile ?? {}));
}
}, [status]);
return (
<div className={styles.user}>
<div className={styles.banner}>
<UserIcon
className={styles.avatar}
target={user}
size={72}
onClick={() => switchPage("profile")}
/>
<div className={styles.username}>@{user.username}</div>
</div>
<div className={styles.details}>
{(
[
["username", user.username, <At size={24} />],
["email", email, <Envelope size={24} />],
["password", "*****", <Key size={24} />],
] as const
).map(([field, value, icon]) => (
<div>
{icon}
<div className={styles.detail}>
<Overline>
<Text id={`login.${field}`} />
</Overline>
<p>{value}</p>
</div>
<div>
<Button
onClick={() =>
openScreen({
id: "modify_account",
field: field,
})
}
contrast>
<Text id="app.settings.pages.account.change_field" />
</Button>
</div>
</div>
))}
</div>
<Tip>
<span>
<Text id="app.settings.tips.account.a" />
</span>{" "}
<a onClick={() => switchPage("profile")}>
<Text id="app.settings.tips.account.b" />
</a>
</Tip>
</div>
);
return (
<div className={styles.user}>
<div className={styles.banner}>
<UserIcon
className={styles.avatar}
target={user}
size={72}
onClick={() => switchPage("profile")}
/>
<div className={styles.username}>@{user.username}</div>
</div>
<div className={styles.details}>
{(
[
["username", user.username, <At size={24} />],
["email", email, <Envelope size={24} />],
["password", "*****", <Key size={24} />],
] as const
).map(([field, value, icon]) => (
<div>
{icon}
<div className={styles.detail}>
<Overline>
<Text id={`login.${field}`} />
</Overline>
<p>{value}</p>
</div>
<div>
<Button
onClick={() =>
openScreen({
id: "modify_account",
field: field,
})
}
contrast>
<Text id="app.settings.pages.account.change_field" />
</Button>
</div>
</div>
))}
</div>
<Tip>
<span>
<Text id="app.settings.tips.account.a" />
</span>{" "}
<a onClick={() => switchPage("profile")}>
<Text id="app.settings.tips.account.b" />
</a>
</Tip>
</div>
);
}

View File

@@ -13,15 +13,15 @@ import { connectState } from "../../../redux/connector";
import { EmojiPacks, Settings } from "../../../redux/reducers/settings";
import {
DEFAULT_FONT,
DEFAULT_MONO_FONT,
FONTS,
FONT_KEYS,
MONOSCAPE_FONTS,
MONOSCAPE_FONT_KEYS,
Theme,
ThemeContext,
ThemeOptions,
DEFAULT_FONT,
DEFAULT_MONO_FONT,
FONTS,
FONT_KEYS,
MONOSCAPE_FONTS,
MONOSCAPE_FONT_KEYS,
Theme,
ThemeContext,
ThemeOptions,
} from "../../../context/Theme";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
@@ -39,92 +39,92 @@ import openmojiSVG from "../assets/openmoji_emoji.svg";
import twemojiSVG from "../assets/twemoji_emoji.svg";
interface Props {
settings: Settings;
settings: Settings;
}
// ! FIXME: code needs to be rewritten to fix jittering
export function Component(props: Props) {
const theme = useContext(ThemeContext);
const { writeClipboard, openScreen } = useIntermediate();
const theme = useContext(ThemeContext);
const { writeClipboard, openScreen } = useIntermediate();
function setTheme(theme: ThemeOptions) {
dispatch({
type: "SETTINGS_SET_THEME",
theme,
});
}
function setTheme(theme: ThemeOptions) {
dispatch({
type: "SETTINGS_SET_THEME",
theme,
});
}
function pushOverride(custom: Partial<Theme>) {
dispatch({
type: "SETTINGS_SET_THEME_OVERRIDE",
custom,
});
}
function pushOverride(custom: Partial<Theme>) {
dispatch({
type: "SETTINGS_SET_THEME_OVERRIDE",
custom,
});
}
function setAccent(accent: string) {
setOverride({
accent,
"scrollbar-thumb": pSBC(-0.2, accent),
});
}
function setAccent(accent: string) {
setOverride({
accent,
"scrollbar-thumb": pSBC(-0.2, accent),
});
}
const emojiPack = props.settings.appearance?.emojiPack ?? "mutant";
function setEmojiPack(emojiPack: EmojiPacks) {
dispatch({
type: "SETTINGS_SET_APPEARANCE",
options: {
emojiPack,
},
});
}
const emojiPack = props.settings.appearance?.emojiPack ?? "mutant";
function setEmojiPack(emojiPack: EmojiPacks) {
dispatch({
type: "SETTINGS_SET_APPEARANCE",
options: {
emojiPack,
},
});
}
const setOverride = useCallback(debounce(pushOverride, 200), []) as (
custom: Partial<Theme>,
) => void;
const [css, setCSS] = useState(props.settings.theme?.custom?.css ?? "");
const setOverride = useCallback(debounce(pushOverride, 200), []) as (
custom: Partial<Theme>,
) => void;
const [css, setCSS] = useState(props.settings.theme?.custom?.css ?? "");
useEffect(() => setOverride({ css }), [css]);
useEffect(() => setOverride({ css }), [css]);
const selected = props.settings.theme?.preset ?? "dark";
return (
<div className={styles.appearance}>
<h3>
<Text id="app.settings.pages.appearance.theme" />
</h3>
<div className={styles.themes}>
<div className={styles.theme}>
<img
src={lightSVG}
data-active={selected === "light"}
onClick={() =>
selected !== "light" &&
setTheme({ preset: "light" })
}
/>
<h4>
<Text id="app.settings.pages.appearance.color.light" />
</h4>
</div>
<div className={styles.theme}>
<img
src={darkSVG}
data-active={selected === "dark"}
onClick={() =>
selected !== "dark" && setTheme({ preset: "dark" })
}
/>
<h4>
<Text id="app.settings.pages.appearance.color.dark" />
</h4>
</div>
</div>
const selected = props.settings.theme?.preset ?? "dark";
return (
<div className={styles.appearance}>
<h3>
<Text id="app.settings.pages.appearance.theme" />
</h3>
<div className={styles.themes}>
<div className={styles.theme}>
<img
src={lightSVG}
data-active={selected === "light"}
onClick={() =>
selected !== "light" &&
setTheme({ preset: "light" })
}
/>
<h4>
<Text id="app.settings.pages.appearance.color.light" />
</h4>
</div>
<div className={styles.theme}>
<img
src={darkSVG}
data-active={selected === "dark"}
onClick={() =>
selected !== "dark" && setTheme({ preset: "dark" })
}
/>
<h4>
<Text id="app.settings.pages.appearance.color.dark" />
</h4>
</div>
</div>
<h3>
<Text id="app.settings.pages.appearance.accent_selector" />
</h3>
<ColourSwatches value={theme.accent} onChange={setAccent} />
<h3>
<Text id="app.settings.pages.appearance.accent_selector" />
</h3>
<ColourSwatches value={theme.accent} onChange={setAccent} />
{/*<h3>
{/*<h3>
<Text id="app.settings.pages.appearance.message_display" />
</h3>
<div className={styles.display}>
@@ -146,229 +146,229 @@ export function Component(props: Props) {
</Radio>
</div>*/}
<h3>
<Text id="app.settings.pages.appearance.font" />
</h3>
<ComboBox
value={theme.font ?? DEFAULT_FONT}
onChange={(e) =>
setTheme({ custom: { font: e.currentTarget.value as any } })
}>
{FONT_KEYS.map((key) => (
<option value={key}>
{FONTS[key as keyof typeof FONTS].name}
</option>
))}
</ComboBox>
<p>
<Checkbox
checked={props.settings.theme?.ligatures === true}
onChange={() =>
setTheme({
ligatures: !props.settings.theme?.ligatures,
})
}
description={
<Text id="app.settings.pages.appearance.ligatures_desc" />
}>
<Text id="app.settings.pages.appearance.ligatures" />
</Checkbox>
</p>
<h3>
<Text id="app.settings.pages.appearance.font" />
</h3>
<ComboBox
value={theme.font ?? DEFAULT_FONT}
onChange={(e) =>
setTheme({ custom: { font: e.currentTarget.value as any } })
}>
{FONT_KEYS.map((key) => (
<option value={key}>
{FONTS[key as keyof typeof FONTS].name}
</option>
))}
</ComboBox>
<p>
<Checkbox
checked={props.settings.theme?.ligatures === true}
onChange={() =>
setTheme({
ligatures: !props.settings.theme?.ligatures,
})
}
description={
<Text id="app.settings.pages.appearance.ligatures_desc" />
}>
<Text id="app.settings.pages.appearance.ligatures" />
</Checkbox>
</p>
<h3>
<Text id="app.settings.pages.appearance.emoji_pack" />
</h3>
<div className={styles.emojiPack}>
<div className={styles.row}>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("mutant")}
data-active={emojiPack === "mutant"}>
<img src={mutantSVG} draggable={false} />
</div>
<h4>
Mutant Remix{" "}
<a
href="https://mutant.revolt.chat"
target="_blank">
(by Revolt)
</a>
</h4>
</div>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("twemoji")}
data-active={emojiPack === "twemoji"}>
<img src={twemojiSVG} draggable={false} />
</div>
<h4>Twemoji</h4>
</div>
</div>
<div className={styles.row}>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("openmoji")}
data-active={emojiPack === "openmoji"}>
<img src={openmojiSVG} draggable={false} />
</div>
<h4>Openmoji</h4>
</div>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("noto")}
data-active={emojiPack === "noto"}>
<img src={notoSVG} draggable={false} />
</div>
<h4>Noto Emoji</h4>
</div>
</div>
</div>
<h3>
<Text id="app.settings.pages.appearance.emoji_pack" />
</h3>
<div className={styles.emojiPack}>
<div className={styles.row}>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("mutant")}
data-active={emojiPack === "mutant"}>
<img src={mutantSVG} draggable={false} />
</div>
<h4>
Mutant Remix{" "}
<a
href="https://mutant.revolt.chat"
target="_blank">
(by Revolt)
</a>
</h4>
</div>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("twemoji")}
data-active={emojiPack === "twemoji"}>
<img src={twemojiSVG} draggable={false} />
</div>
<h4>Twemoji</h4>
</div>
</div>
<div className={styles.row}>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("openmoji")}
data-active={emojiPack === "openmoji"}>
<img src={openmojiSVG} draggable={false} />
</div>
<h4>Openmoji</h4>
</div>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("noto")}
data-active={emojiPack === "noto"}>
<img src={notoSVG} draggable={false} />
</div>
<h4>Noto Emoji</h4>
</div>
</div>
</div>
<CollapsibleSection
id="settings_advanced_appearance"
defaultValue={false}
summary={<Text id="app.settings.pages.appearance.advanced" />}>
<h3>
<Text id="app.settings.pages.appearance.overrides" />
</h3>
<div className={styles.actions}>
<Button contrast onClick={() => setTheme({ custom: {} })}>
<Text id="app.settings.pages.appearance.reset_overrides" />
</Button>
<Button
contrast
onClick={() => writeClipboard(JSON.stringify(theme))}>
<Text id="app.settings.pages.appearance.export_clipboard" />
</Button>
<Button
contrast
onClick={async () => {
const text = await navigator.clipboard.readText();
setOverride(JSON.parse(text));
}}>
<Text id="app.settings.pages.appearance.import_clipboard" />
</Button>
<Button
contrast
onClick={async () => {
openScreen({
id: "_input",
question: (
<Text id="app.settings.pages.appearance.import_theme" />
),
field: (
<Text id="app.settings.pages.appearance.theme_data" />
),
callback: async (string) =>
setOverride(JSON.parse(string)),
});
}}>
<Text id="app.settings.pages.appearance.import_manual" />
</Button>
</div>
<div className={styles.overrides}>
{(
[
"accent",
"background",
"foreground",
"primary-background",
"primary-header",
"secondary-background",
"secondary-foreground",
"secondary-header",
"tertiary-background",
"tertiary-foreground",
"block",
"message-box",
"mention",
"scrollbar-thumb",
"scrollbar-track",
"status-online",
"status-away",
"status-busy",
"status-streaming",
"status-invisible",
"success",
"warning",
"error",
"hover",
] as const
).map((x) => (
<div className={styles.entry} key={x}>
<span>{x}</span>
<div className={styles.override}>
<div
className={styles.picker}
style={{ backgroundColor: theme[x] }}>
<input
type="color"
value={theme[x]}
onChange={(v) =>
setOverride({
[x]: v.currentTarget.value,
})
}
/>
</div>
<InputBox
className={styles.text}
value={theme[x]}
onChange={(y) =>
setOverride({
[x]: y.currentTarget.value,
})
}
/>
</div>
</div>
))}
</div>
<CollapsibleSection
id="settings_advanced_appearance"
defaultValue={false}
summary={<Text id="app.settings.pages.appearance.advanced" />}>
<h3>
<Text id="app.settings.pages.appearance.overrides" />
</h3>
<div className={styles.actions}>
<Button contrast onClick={() => setTheme({ custom: {} })}>
<Text id="app.settings.pages.appearance.reset_overrides" />
</Button>
<Button
contrast
onClick={() => writeClipboard(JSON.stringify(theme))}>
<Text id="app.settings.pages.appearance.export_clipboard" />
</Button>
<Button
contrast
onClick={async () => {
const text = await navigator.clipboard.readText();
setOverride(JSON.parse(text));
}}>
<Text id="app.settings.pages.appearance.import_clipboard" />
</Button>
<Button
contrast
onClick={async () => {
openScreen({
id: "_input",
question: (
<Text id="app.settings.pages.appearance.import_theme" />
),
field: (
<Text id="app.settings.pages.appearance.theme_data" />
),
callback: async (string) =>
setOverride(JSON.parse(string)),
});
}}>
<Text id="app.settings.pages.appearance.import_manual" />
</Button>
</div>
<div className={styles.overrides}>
{(
[
"accent",
"background",
"foreground",
"primary-background",
"primary-header",
"secondary-background",
"secondary-foreground",
"secondary-header",
"tertiary-background",
"tertiary-foreground",
"block",
"message-box",
"mention",
"scrollbar-thumb",
"scrollbar-track",
"status-online",
"status-away",
"status-busy",
"status-streaming",
"status-invisible",
"success",
"warning",
"error",
"hover",
] as const
).map((x) => (
<div className={styles.entry} key={x}>
<span>{x}</span>
<div className={styles.override}>
<div
className={styles.picker}
style={{ backgroundColor: theme[x] }}>
<input
type="color"
value={theme[x]}
onChange={(v) =>
setOverride({
[x]: v.currentTarget.value,
})
}
/>
</div>
<InputBox
className={styles.text}
value={theme[x]}
onChange={(y) =>
setOverride({
[x]: y.currentTarget.value,
})
}
/>
</div>
</div>
))}
</div>
<h3>
<Text id="app.settings.pages.appearance.mono_font" />
</h3>
<ComboBox
value={theme.monoscapeFont ?? DEFAULT_MONO_FONT}
onChange={(e) =>
setTheme({
custom: {
monoscapeFont: e.currentTarget.value as any,
},
})
}>
{MONOSCAPE_FONT_KEYS.map((key) => (
<option value={key}>
{
MONOSCAPE_FONTS[
key as keyof typeof MONOSCAPE_FONTS
].name
}
</option>
))}
</ComboBox>
<h3>
<Text id="app.settings.pages.appearance.mono_font" />
</h3>
<ComboBox
value={theme.monoscapeFont ?? DEFAULT_MONO_FONT}
onChange={(e) =>
setTheme({
custom: {
monoscapeFont: e.currentTarget.value as any,
},
})
}>
{MONOSCAPE_FONT_KEYS.map((key) => (
<option value={key}>
{
MONOSCAPE_FONTS[
key as keyof typeof MONOSCAPE_FONTS
].name
}
</option>
))}
</ComboBox>
<h3>
<Text id="app.settings.pages.appearance.custom_css" />
</h3>
<TextAreaAutoSize
maxRows={20}
minHeight={480}
code
value={css}
onChange={(ev) => setCSS(ev.currentTarget.value)}
/>
</CollapsibleSection>
</div>
);
<h3>
<Text id="app.settings.pages.appearance.custom_css" />
</h3>
<TextAreaAutoSize
maxRows={20}
minHeight={480}
code
value={css}
onChange={(ev) => setCSS(ev.currentTarget.value)}
/>
</CollapsibleSection>
</div>
);
}
export const Appearance = connectState(Component, (state) => {
return {
settings: state.settings,
};
return {
settings: state.settings,
};
});

View File

@@ -4,52 +4,52 @@ import { Text } from "preact-i18n";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector";
import {
AVAILABLE_EXPERIMENTS,
ExperimentOptions,
AVAILABLE_EXPERIMENTS,
ExperimentOptions,
} from "../../../redux/reducers/experiments";
import Checkbox from "../../../components/ui/Checkbox";
interface Props {
options?: ExperimentOptions;
options?: ExperimentOptions;
}
export function Component(props: Props) {
return (
<div className={styles.experiments}>
<h3>
<Text id="app.settings.pages.experiments.features" />
</h3>
{AVAILABLE_EXPERIMENTS.map((key) => (
<Checkbox
checked={(props.options?.enabled ?? []).indexOf(key) > -1}
onChange={(enabled) =>
dispatch({
type: enabled
? "EXPERIMENTS_ENABLE"
: "EXPERIMENTS_DISABLE",
key,
})
}>
<Text id={`app.settings.pages.experiments.titles.${key}`} />
<p>
<Text
id={`app.settings.pages.experiments.descriptions.${key}`}
/>
</p>
</Checkbox>
))}
{AVAILABLE_EXPERIMENTS.length === 0 && (
<div className={styles.empty}>
<Text id="app.settings.pages.experiments.not_available" />
</div>
)}
</div>
);
return (
<div className={styles.experiments}>
<h3>
<Text id="app.settings.pages.experiments.features" />
</h3>
{AVAILABLE_EXPERIMENTS.map((key) => (
<Checkbox
checked={(props.options?.enabled ?? []).indexOf(key) > -1}
onChange={(enabled) =>
dispatch({
type: enabled
? "EXPERIMENTS_ENABLE"
: "EXPERIMENTS_DISABLE",
key,
})
}>
<Text id={`app.settings.pages.experiments.titles.${key}`} />
<p>
<Text
id={`app.settings.pages.experiments.descriptions.${key}`}
/>
</p>
</Checkbox>
))}
{AVAILABLE_EXPERIMENTS.length === 0 && (
<div className={styles.empty}>
<Text id="app.settings.pages.experiments.not_available" />
</div>
)}
</div>
);
}
export const ExperimentsPage = connectState(Component, (state) => {
return {
options: state.experiments,
};
return {
options: state.experiments,
};
});

View File

@@ -10,101 +10,101 @@ import Radio from "../../../components/ui/Radio";
import TextArea from "../../../components/ui/TextArea";
export function Feedback() {
const user = useSelf();
const [other, setOther] = useState("");
const [description, setDescription] = useState("");
const [state, setState] = useState<"ready" | "sending" | "sent">("ready");
const [checked, setChecked] = useState<
"Bug" | "Feature Request" | "__other_option__"
>("Bug");
const user = useSelf();
const [other, setOther] = useState("");
const [description, setDescription] = useState("");
const [state, setState] = useState<"ready" | "sending" | "sent">("ready");
const [checked, setChecked] = useState<
"Bug" | "Feature Request" | "__other_option__"
>("Bug");
async function onSubmit(ev: JSX.TargetedEvent<HTMLFormElement, Event>) {
ev.preventDefault();
setState("sending");
async function onSubmit(ev: JSX.TargetedEvent<HTMLFormElement, Event>) {
ev.preventDefault();
setState("sending");
await fetch(`https://workers.revolt.chat/feedback`, {
method: "POST",
body: JSON.stringify({
checked,
other,
description,
name: user?.username ?? "Unknown User",
}),
mode: "no-cors",
});
await fetch(`https://workers.revolt.chat/feedback`, {
method: "POST",
body: JSON.stringify({
checked,
other,
description,
name: user?.username ?? "Unknown User",
}),
mode: "no-cors",
});
setState("sent");
setChecked("Bug");
setDescription("");
setOther("");
}
setState("sent");
setChecked("Bug");
setDescription("");
setOther("");
}
return (
<form className={styles.feedback} onSubmit={onSubmit}>
<h3>
<Text id="app.settings.pages.feedback.report" />
</h3>
<div className={styles.options}>
<Radio
checked={checked === "Bug"}
disabled={state === "sending"}
onSelect={() => setChecked("Bug")}>
<Text id="app.settings.pages.feedback.bug" />
</Radio>
<Radio
disabled={state === "sending"}
checked={checked === "Feature Request"}
onSelect={() => setChecked("Feature Request")}>
<Text id="app.settings.pages.feedback.feature" />
</Radio>
{(location.hostname === "vite.revolt.chat" ||
location.hostname === "local.revolt.chat") && (
<Radio
disabled={state === "sending"}
checked={other === "Revite"}
onSelect={() => {
setChecked("__other_option__");
setOther("Revite");
}}>
Issues with Revite
</Radio>
)}
<Radio
disabled={state === "sending"}
checked={
checked === "__other_option__" && other !== "Revite"
}
onSelect={() => setChecked("__other_option__")}>
<Localizer>
<InputBox
value={other}
disabled={state === "sending"}
name="entry.1151440373.other_option_response"
onChange={(e) => setOther(e.currentTarget.value)}
placeholder={
(
<Text id="app.settings.pages.feedback.other" />
) as any
}
/>
</Localizer>
</Radio>
</div>
<h3>
<Text id="app.settings.pages.feedback.describe" />
</h3>
<TextArea
// maxRows={10}
value={description}
id="entry.685672624"
disabled={state === "sending"}
onChange={(ev) => setDescription(ev.currentTarget.value)}
/>
<p>
<Button type="submit" contrast>
<Text id="app.settings.pages.feedback.send" />
</Button>
</p>
</form>
);
return (
<form className={styles.feedback} onSubmit={onSubmit}>
<h3>
<Text id="app.settings.pages.feedback.report" />
</h3>
<div className={styles.options}>
<Radio
checked={checked === "Bug"}
disabled={state === "sending"}
onSelect={() => setChecked("Bug")}>
<Text id="app.settings.pages.feedback.bug" />
</Radio>
<Radio
disabled={state === "sending"}
checked={checked === "Feature Request"}
onSelect={() => setChecked("Feature Request")}>
<Text id="app.settings.pages.feedback.feature" />
</Radio>
{(location.hostname === "vite.revolt.chat" ||
location.hostname === "local.revolt.chat") && (
<Radio
disabled={state === "sending"}
checked={other === "Revite"}
onSelect={() => {
setChecked("__other_option__");
setOther("Revite");
}}>
Issues with Revite
</Radio>
)}
<Radio
disabled={state === "sending"}
checked={
checked === "__other_option__" && other !== "Revite"
}
onSelect={() => setChecked("__other_option__")}>
<Localizer>
<InputBox
value={other}
disabled={state === "sending"}
name="entry.1151440373.other_option_response"
onChange={(e) => setOther(e.currentTarget.value)}
placeholder={
(
<Text id="app.settings.pages.feedback.other" />
) as any
}
/>
</Localizer>
</Radio>
</div>
<h3>
<Text id="app.settings.pages.feedback.describe" />
</h3>
<TextArea
// maxRows={10}
value={description}
id="entry.685672624"
disabled={state === "sending"}
onChange={(ev) => setDescription(ev.currentTarget.value)}
/>
<p>
<Button type="submit" contrast>
<Text id="app.settings.pages.feedback.send" />
</Button>
</p>
</form>
);
}

View File

@@ -5,9 +5,9 @@ import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector";
import {
Language,
LanguageEntry,
Languages as Langs,
Language,
LanguageEntry,
Languages as Langs,
} from "../../../context/Locale";
import Emoji from "../../../components/common/Emoji";
@@ -15,77 +15,77 @@ import Checkbox from "../../../components/ui/Checkbox";
import Tip from "../../../components/ui/Tip";
type Props = {
locale: Language;
locale: Language;
};
type Key = [string, LanguageEntry];
function Entry({ entry: [x, lang], locale }: { entry: Key } & Props) {
return (
<Checkbox
key={x}
className={styles.entry}
checked={locale === x}
onChange={(v) => {
if (v) {
dispatch({
type: "SET_LOCALE",
locale: x as Language,
});
}
}}>
<div className={styles.flag}>
<Emoji size={42} emoji={lang.emoji} />
</div>
<span className={styles.description}>{lang.display}</span>
</Checkbox>
);
return (
<Checkbox
key={x}
className={styles.entry}
checked={locale === x}
onChange={(v) => {
if (v) {
dispatch({
type: "SET_LOCALE",
locale: x as Language,
});
}
}}>
<div className={styles.flag}>
<Emoji size={42} emoji={lang.emoji} />
</div>
<span className={styles.description}>{lang.display}</span>
</Checkbox>
);
}
export function Component(props: Props) {
const languages = Object.keys(Langs).map((x) => [
x,
Langs[x as keyof typeof Langs],
]) as Key[];
const languages = Object.keys(Langs).map((x) => [
x,
Langs[x as keyof typeof Langs],
]) as Key[];
return (
<div className={styles.languages}>
<h3>
<Text id="app.settings.pages.language.select" />
</h3>
<div className={styles.list}>
{languages
.filter(([, lang]) => !lang.alt)
.map(([x, lang]) => (
<Entry key={x} entry={[x, lang]} {...props} />
))}
</div>
<h3>
<Text id="app.settings.pages.language.other" />
</h3>
<div className={styles.list}>
{languages
.filter(([, lang]) => lang.alt)
.map(([x, lang]) => (
<Entry key={x} entry={[x, lang]} {...props} />
))}
</div>
<Tip>
<span>
<Text id="app.settings.tips.languages.a" />
</span>{" "}
<a
href="https://weblate.insrt.uk/engage/revolt/?utm_source=widget"
target="_blank">
<Text id="app.settings.tips.languages.b" />
</a>
</Tip>
</div>
);
return (
<div className={styles.languages}>
<h3>
<Text id="app.settings.pages.language.select" />
</h3>
<div className={styles.list}>
{languages
.filter(([, lang]) => !lang.alt)
.map(([x, lang]) => (
<Entry key={x} entry={[x, lang]} {...props} />
))}
</div>
<h3>
<Text id="app.settings.pages.language.other" />
</h3>
<div className={styles.list}>
{languages
.filter(([, lang]) => lang.alt)
.map(([x, lang]) => (
<Entry key={x} entry={[x, lang]} {...props} />
))}
</div>
<Tip>
<span>
<Text id="app.settings.tips.languages.a" />
</span>{" "}
<a
href="https://weblate.insrt.uk/engage/revolt/?utm_source=widget"
target="_blank">
<Text id="app.settings.tips.languages.b" />
</a>
</Tip>
</div>
);
}
export const Languages = connectState(Component, (state) => {
return {
locale: state.locale,
};
return {
locale: state.locale,
};
});

View File

@@ -9,9 +9,9 @@ import { urlBase64ToUint8Array } from "../../../lib/conversion";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector";
import {
DEFAULT_SOUNDS,
NotificationOptions,
SoundOptions,
DEFAULT_SOUNDS,
NotificationOptions,
SoundOptions,
} from "../../../redux/reducers/settings";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
@@ -22,133 +22,133 @@ import Checkbox from "../../../components/ui/Checkbox";
import { SOUNDS_ARRAY } from "../../../assets/sounds/Audio";
interface Props {
options?: NotificationOptions;
options?: NotificationOptions;
}
export function Component({ options }: Props) {
const client = useContext(AppContext);
const { openScreen } = useIntermediate();
const [pushEnabled, setPushEnabled] = useState<undefined | boolean>(
undefined,
);
const client = useContext(AppContext);
const { openScreen } = useIntermediate();
const [pushEnabled, setPushEnabled] = useState<undefined | boolean>(
undefined,
);
// Load current state of pushManager.
useEffect(() => {
navigator.serviceWorker
?.getRegistration()
.then(async (registration) => {
const sub = await registration?.pushManager?.getSubscription();
setPushEnabled(sub !== null && sub !== undefined);
});
}, []);
// Load current state of pushManager.
useEffect(() => {
navigator.serviceWorker
?.getRegistration()
.then(async (registration) => {
const sub = await registration?.pushManager?.getSubscription();
setPushEnabled(sub !== null && sub !== undefined);
});
}, []);
const enabledSounds: SoundOptions = defaultsDeep(
options?.sounds ?? {},
DEFAULT_SOUNDS,
);
return (
<div className={styles.notifications}>
<h3>
<Text id="app.settings.pages.notifications.push_notifications" />
</h3>
<Checkbox
disabled={!("Notification" in window)}
checked={options?.desktopEnabled ?? false}
description={
<Text id="app.settings.pages.notifications.descriptions.enable_desktop" />
}
onChange={async (desktopEnabled) => {
if (desktopEnabled) {
let permission = await Notification.requestPermission();
if (permission !== "granted") {
return openScreen({
id: "error",
error: "DeniedNotification",
});
}
}
const enabledSounds: SoundOptions = defaultsDeep(
options?.sounds ?? {},
DEFAULT_SOUNDS,
);
return (
<div className={styles.notifications}>
<h3>
<Text id="app.settings.pages.notifications.push_notifications" />
</h3>
<Checkbox
disabled={!("Notification" in window)}
checked={options?.desktopEnabled ?? false}
description={
<Text id="app.settings.pages.notifications.descriptions.enable_desktop" />
}
onChange={async (desktopEnabled) => {
if (desktopEnabled) {
let permission = await Notification.requestPermission();
if (permission !== "granted") {
return openScreen({
id: "error",
error: "DeniedNotification",
});
}
}
dispatch({
type: "SETTINGS_SET_NOTIFICATION_OPTIONS",
options: { desktopEnabled },
});
}}>
<Text id="app.settings.pages.notifications.enable_desktop" />
</Checkbox>
<Checkbox
disabled={typeof pushEnabled === "undefined"}
checked={pushEnabled ?? false}
description={
<Text id="app.settings.pages.notifications.descriptions.enable_push" />
}
onChange={async (pushEnabled) => {
try {
const reg =
await navigator.serviceWorker?.getRegistration();
if (reg) {
if (pushEnabled) {
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
client.configuration!.vapid,
),
});
dispatch({
type: "SETTINGS_SET_NOTIFICATION_OPTIONS",
options: { desktopEnabled },
});
}}>
<Text id="app.settings.pages.notifications.enable_desktop" />
</Checkbox>
<Checkbox
disabled={typeof pushEnabled === "undefined"}
checked={pushEnabled ?? false}
description={
<Text id="app.settings.pages.notifications.descriptions.enable_push" />
}
onChange={async (pushEnabled) => {
try {
const reg =
await navigator.serviceWorker?.getRegistration();
if (reg) {
if (pushEnabled) {
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
client.configuration!.vapid,
),
});
// tell the server we just subscribed
const json = sub.toJSON();
if (json.keys) {
client.req("POST", "/push/subscribe", {
endpoint: sub.endpoint,
...(json.keys as {
p256dh: string;
auth: string;
}),
});
setPushEnabled(true);
}
} else {
const sub =
await reg.pushManager.getSubscription();
sub?.unsubscribe();
setPushEnabled(false);
// tell the server we just subscribed
const json = sub.toJSON();
if (json.keys) {
client.req("POST", "/push/subscribe", {
endpoint: sub.endpoint,
...(json.keys as {
p256dh: string;
auth: string;
}),
});
setPushEnabled(true);
}
} else {
const sub =
await reg.pushManager.getSubscription();
sub?.unsubscribe();
setPushEnabled(false);
client.req("POST", "/push/unsubscribe");
}
}
} catch (err) {
console.error("Failed to enable push!", err);
}
}}>
<Text id="app.settings.pages.notifications.enable_push" />
</Checkbox>
<h3>
<Text id="app.settings.pages.notifications.sounds" />
</h3>
{SOUNDS_ARRAY.map((key) => (
<Checkbox
checked={enabledSounds[key] ? true : false}
onChange={(enabled) =>
dispatch({
type: "SETTINGS_SET_NOTIFICATION_OPTIONS",
options: {
sounds: {
...options?.sounds,
[key]: enabled,
},
},
})
}>
<Text
id={`app.settings.pages.notifications.sound.${key}`}
/>
</Checkbox>
))}
</div>
);
client.req("POST", "/push/unsubscribe");
}
}
} catch (err) {
console.error("Failed to enable push!", err);
}
}}>
<Text id="app.settings.pages.notifications.enable_push" />
</Checkbox>
<h3>
<Text id="app.settings.pages.notifications.sounds" />
</h3>
{SOUNDS_ARRAY.map((key) => (
<Checkbox
checked={enabledSounds[key] ? true : false}
onChange={(enabled) =>
dispatch({
type: "SETTINGS_SET_NOTIFICATION_OPTIONS",
options: {
sounds: {
...options?.sounds,
[key]: enabled,
},
},
})
}>
<Text
id={`app.settings.pages.notifications.sound.${key}`}
/>
</Checkbox>
))}
</div>
);
}
export const Notifications = connectState(Component, (state) => {
return {
options: state.settings.notification,
};
return {
options: state.settings.notification,
};
});

View File

@@ -9,178 +9,178 @@ import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { UserProfile } from "../../../context/intermediate/popovers/UserProfile";
import { FileUploader } from "../../../context/revoltjs/FileUploads";
import {
ClientStatus,
StatusContext,
ClientStatus,
StatusContext,
} from "../../../context/revoltjs/RevoltClient";
import { useForceUpdate, useSelf } from "../../../context/revoltjs/hooks";
import AutoComplete, {
useAutoComplete,
useAutoComplete,
} from "../../../components/common/AutoComplete";
import Button from "../../../components/ui/Button";
export function Profile() {
const { intl } = useContext(IntlContext);
const status = useContext(StatusContext);
const { intl } = useContext(IntlContext);
const status = useContext(StatusContext);
const ctx = useForceUpdate();
const user = useSelf();
if (!user) return null;
const ctx = useForceUpdate();
const user = useSelf();
if (!user) return null;
const [profile, setProfile] = useState<undefined | Users.Profile>(
undefined,
);
const [profile, setProfile] = useState<undefined | Users.Profile>(
undefined,
);
// ! FIXME: temporary solution
// ! we should just announce profile changes through WS
function refreshProfile() {
ctx.client.users
.fetchProfile(user!._id)
.then((profile) => setProfile(profile ?? {}));
}
// ! FIXME: temporary solution
// ! we should just announce profile changes through WS
function refreshProfile() {
ctx.client.users
.fetchProfile(user!._id)
.then((profile) => setProfile(profile ?? {}));
}
useEffect(() => {
if (profile === undefined && status === ClientStatus.ONLINE) {
refreshProfile();
}
}, [status]);
useEffect(() => {
if (profile === undefined && status === ClientStatus.ONLINE) {
refreshProfile();
}
}, [status]);
const [changed, setChanged] = useState(false);
function setContent(content?: string) {
setProfile({ ...profile, content });
if (!changed) setChanged(true);
}
const [changed, setChanged] = useState(false);
function setContent(content?: string) {
setProfile({ ...profile, content });
if (!changed) setChanged(true);
}
const {
onChange,
onKeyUp,
onKeyDown,
onFocus,
onBlur,
...autoCompleteProps
} = useAutoComplete(setContent, {
users: { type: "all" },
});
const {
onChange,
onKeyUp,
onKeyDown,
onFocus,
onBlur,
...autoCompleteProps
} = useAutoComplete(setContent, {
users: { type: "all" },
});
return (
<div className={styles.user}>
<h3>
<Text id="app.special.modals.actions.preview" />
</h3>
<div className={styles.preview}>
<UserProfile
user_id={user._id}
dummy={true}
dummyProfile={profile}
onClose={() => {}}
/>
</div>
<div className={styles.row}>
<div className={styles.pfp}>
<h3>
<Text id="app.settings.pages.profile.profile_picture" />
</h3>
<FileUploader
width={80}
height={80}
style="icon"
fileType="avatars"
behaviour="upload"
maxFileSize={4_000_000}
onUpload={(avatar) =>
ctx.client.users.editUser({ avatar })
}
remove={() =>
ctx.client.users.editUser({ remove: "Avatar" })
}
defaultPreview={ctx.client.users.getAvatarURL(
user._id,
{ max_side: 256 },
true,
)}
previewURL={ctx.client.users.getAvatarURL(
user._id,
{ max_side: 256 },
true,
true,
)}
/>
</div>
<div className={styles.background}>
<h3>
<Text id="app.settings.pages.profile.custom_background" />
</h3>
<FileUploader
height={92}
style="banner"
behaviour="upload"
fileType="backgrounds"
maxFileSize={6_000_000}
onUpload={async (background) => {
await ctx.client.users.editUser({
profile: { background },
});
refreshProfile();
}}
remove={async () => {
await ctx.client.users.editUser({
remove: "ProfileBackground",
});
setProfile({ ...profile, background: undefined });
}}
previewURL={
profile?.background
? ctx.client.users.getBackgroundURL(
profile,
{ width: 1000 },
true,
)
: undefined
}
/>
</div>
</div>
<h3>
<Text id="app.settings.pages.profile.info" />
</h3>
<AutoComplete detached {...autoCompleteProps} />
<TextAreaAutoSize
maxRows={10}
minHeight={200}
maxLength={2000}
value={profile?.content ?? ""}
disabled={typeof profile === "undefined"}
onChange={(ev) => {
onChange(ev);
setContent(ev.currentTarget.value);
}}
placeholder={translate(
`app.settings.pages.profile.${
typeof profile === "undefined"
? "fetching"
: "placeholder"
}`,
"",
(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>
);
return (
<div className={styles.user}>
<h3>
<Text id="app.special.modals.actions.preview" />
</h3>
<div className={styles.preview}>
<UserProfile
user_id={user._id}
dummy={true}
dummyProfile={profile}
onClose={() => {}}
/>
</div>
<div className={styles.row}>
<div className={styles.pfp}>
<h3>
<Text id="app.settings.pages.profile.profile_picture" />
</h3>
<FileUploader
width={80}
height={80}
style="icon"
fileType="avatars"
behaviour="upload"
maxFileSize={4_000_000}
onUpload={(avatar) =>
ctx.client.users.editUser({ avatar })
}
remove={() =>
ctx.client.users.editUser({ remove: "Avatar" })
}
defaultPreview={ctx.client.users.getAvatarURL(
user._id,
{ max_side: 256 },
true,
)}
previewURL={ctx.client.users.getAvatarURL(
user._id,
{ max_side: 256 },
true,
true,
)}
/>
</div>
<div className={styles.background}>
<h3>
<Text id="app.settings.pages.profile.custom_background" />
</h3>
<FileUploader
height={92}
style="banner"
behaviour="upload"
fileType="backgrounds"
maxFileSize={6_000_000}
onUpload={async (background) => {
await ctx.client.users.editUser({
profile: { background },
});
refreshProfile();
}}
remove={async () => {
await ctx.client.users.editUser({
remove: "ProfileBackground",
});
setProfile({ ...profile, background: undefined });
}}
previewURL={
profile?.background
? ctx.client.users.getBackgroundURL(
profile,
{ width: 1000 },
true,
)
: undefined
}
/>
</div>
</div>
<h3>
<Text id="app.settings.pages.profile.info" />
</h3>
<AutoComplete detached {...autoCompleteProps} />
<TextAreaAutoSize
maxRows={10}
minHeight={200}
maxLength={2000}
value={profile?.content ?? ""}
disabled={typeof profile === "undefined"}
onChange={(ev) => {
onChange(ev);
setContent(ev.currentTarget.value);
}}
placeholder={translate(
`app.settings.pages.profile.${
typeof profile === "undefined"
? "fetching"
: "placeholder"
}`,
"",
(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>
);
}

View File

@@ -1,14 +1,14 @@
import { HelpCircle } from "@styled-icons/boxicons-regular";
import {
Android,
Firefoxbrowser,
Googlechrome,
Ios,
Linux,
Macos,
Microsoftedge,
Safari,
Windows,
Android,
Firefoxbrowser,
Googlechrome,
Ios,
Linux,
Macos,
Microsoftedge,
Safari,
Windows,
} from "@styled-icons/simple-icons";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
@@ -28,159 +28,159 @@ import Tip from "../../../components/ui/Tip";
dayjs.extend(relativeTime);
interface Session {
id: string;
friendly_name: string;
id: string;
friendly_name: string;
}
export function Sessions() {
const client = useContext(AppContext);
const deviceId = client.session?.id;
const client = useContext(AppContext);
const deviceId = client.session?.id;
const [sessions, setSessions] = useState<Session[] | undefined>(undefined);
const [attemptingDelete, setDelete] = useState<string[]>([]);
const history = useHistory();
const [sessions, setSessions] = useState<Session[] | undefined>(undefined);
const [attemptingDelete, setDelete] = useState<string[]>([]);
const history = useHistory();
function switchPage(to: string) {
history.replace(`/settings/${to}`);
}
function switchPage(to: string) {
history.replace(`/settings/${to}`);
}
useEffect(() => {
client.req("GET", "/auth/sessions").then((data) => {
data.sort(
(a, b) =>
(b.id === deviceId ? 1 : 0) - (a.id === deviceId ? 1 : 0),
);
setSessions(data);
});
}, []);
useEffect(() => {
client.req("GET", "/auth/sessions").then((data) => {
data.sort(
(a, b) =>
(b.id === deviceId ? 1 : 0) - (a.id === deviceId ? 1 : 0),
);
setSessions(data);
});
}, []);
if (typeof sessions === "undefined") {
return (
<div className={styles.loader}>
<Preloader type="ring" />
</div>
);
}
if (typeof sessions === "undefined") {
return (
<div className={styles.loader}>
<Preloader type="ring" />
</div>
);
}
function getIcon(session: Session) {
const name = session.friendly_name;
switch (true) {
case /firefox/i.test(name):
return <Firefoxbrowser />;
case /chrome/i.test(name):
return <Googlechrome />;
case /safari/i.test(name):
return <Safari />;
case /edge/i.test(name):
return <Microsoftedge />;
default:
return <HelpCircle />;
}
}
function getIcon(session: Session) {
const name = session.friendly_name;
switch (true) {
case /firefox/i.test(name):
return <Firefoxbrowser />;
case /chrome/i.test(name):
return <Googlechrome />;
case /safari/i.test(name):
return <Safari />;
case /edge/i.test(name):
return <Microsoftedge />;
default:
return <HelpCircle />;
}
}
function getSystemIcon(session: Session) {
const name = session.friendly_name;
switch (true) {
case /linux/i.test(name):
return <Linux />;
case /android/i.test(name):
return <Android />;
case /mac.*os/i.test(name):
return <Macos />;
case /ios/i.test(name):
return <Ios />;
case /windows/i.test(name):
return <Windows />;
default:
return null;
}
}
function getSystemIcon(session: Session) {
const name = session.friendly_name;
switch (true) {
case /linux/i.test(name):
return <Linux />;
case /android/i.test(name):
return <Android />;
case /mac.*os/i.test(name):
return <Macos />;
case /ios/i.test(name):
return <Ios />;
case /windows/i.test(name):
return <Windows />;
default:
return null;
}
}
const mapped = sessions.map((session) => {
return {
...session,
timestamp: decodeTime(session.id),
};
});
const mapped = sessions.map((session) => {
return {
...session,
timestamp: decodeTime(session.id),
};
});
mapped.sort((a, b) => b.timestamp - a.timestamp);
let id = mapped.findIndex((x) => x.id === deviceId);
mapped.sort((a, b) => b.timestamp - a.timestamp);
let id = mapped.findIndex((x) => x.id === deviceId);
const render = [
mapped[id],
...mapped.slice(0, id),
...mapped.slice(id + 1, mapped.length),
];
const render = [
mapped[id],
...mapped.slice(0, id),
...mapped.slice(id + 1, mapped.length),
];
return (
<div className={styles.sessions}>
<h3>
<Text id="app.settings.pages.sessions.active_sessions" />
</h3>
{render.map((session) => (
<div
className={styles.entry}
data-active={session.id === deviceId}
data-deleting={attemptingDelete.indexOf(session.id) > -1}>
{deviceId === session.id && (
<span className={styles.label}>
<Text id="app.settings.pages.sessions.this_device" />{" "}
</span>
)}
<div className={styles.session}>
<div className={styles.icon}>
{getIcon(session)}
<div>{getSystemIcon(session)}</div>
</div>
<div className={styles.info}>
<span className={styles.name}>
{session.friendly_name}
</span>
<span className={styles.time}>
<Text
id="app.settings.pages.sessions.created"
fields={{
time_ago: dayjs(
session.timestamp,
).fromNow(),
}}
/>
</span>
</div>
{deviceId !== session.id && (
<Button
onClick={async () => {
setDelete([
...attemptingDelete,
session.id,
]);
await client.req(
"DELETE",
`/auth/sessions/${session.id}` as "/auth/sessions",
);
setSessions(
sessions?.filter(
(x) => x.id !== session.id,
),
);
}}
disabled={
attemptingDelete.indexOf(session.id) > -1
}>
<Text id="app.settings.pages.logOut" />
</Button>
)}
</div>
</div>
))}
<Tip>
<span>
<Text id="app.settings.tips.sessions.a" />
</span>{" "}
<a onClick={() => switchPage("account")}>
<Text id="app.settings.tips.sessions.b" />
</a>
</Tip>
</div>
);
return (
<div className={styles.sessions}>
<h3>
<Text id="app.settings.pages.sessions.active_sessions" />
</h3>
{render.map((session) => (
<div
className={styles.entry}
data-active={session.id === deviceId}
data-deleting={attemptingDelete.indexOf(session.id) > -1}>
{deviceId === session.id && (
<span className={styles.label}>
<Text id="app.settings.pages.sessions.this_device" />{" "}
</span>
)}
<div className={styles.session}>
<div className={styles.icon}>
{getIcon(session)}
<div>{getSystemIcon(session)}</div>
</div>
<div className={styles.info}>
<span className={styles.name}>
{session.friendly_name}
</span>
<span className={styles.time}>
<Text
id="app.settings.pages.sessions.created"
fields={{
time_ago: dayjs(
session.timestamp,
).fromNow(),
}}
/>
</span>
</div>
{deviceId !== session.id && (
<Button
onClick={async () => {
setDelete([
...attemptingDelete,
session.id,
]);
await client.req(
"DELETE",
`/auth/sessions/${session.id}` as "/auth/sessions",
);
setSessions(
sessions?.filter(
(x) => x.id !== session.id,
),
);
}}
disabled={
attemptingDelete.indexOf(session.id) > -1
}>
<Text id="app.settings.pages.logOut" />
</Button>
)}
</div>
</div>
))}
<Tip>
<span>
<Text id="app.settings.tips.sessions.a" />
</span>{" "}
<a onClick={() => switchPage("account")}>
<Text id="app.settings.tips.sessions.b" />
</a>
</Tip>
</div>
);
}

View File

@@ -8,49 +8,49 @@ import { SyncKeys, SyncOptions } from "../../../redux/reducers/sync";
import Checkbox from "../../../components/ui/Checkbox";
interface Props {
options?: SyncOptions;
options?: SyncOptions;
}
export function Component(props: Props) {
return (
<div className={styles.notifications}>
<h3>
<Text id="app.settings.pages.sync.categories" />
</h3>
{(
[
["appearance", "appearance.title"],
["theme", "appearance.theme"],
["locale", "language.title"],
// notifications sync is always-on
] as [SyncKeys, string][]
).map(([key, title]) => (
<Checkbox
checked={
(props.options?.disabled ?? []).indexOf(key) === -1
}
description={
<Text
id={`app.settings.pages.sync.descriptions.${key}`}
/>
}
onChange={(enabled) =>
dispatch({
type: enabled
? "SYNC_ENABLE_KEY"
: "SYNC_DISABLE_KEY",
key,
})
}>
<Text id={`app.settings.pages.${title}`} />
</Checkbox>
))}
</div>
);
return (
<div className={styles.notifications}>
<h3>
<Text id="app.settings.pages.sync.categories" />
</h3>
{(
[
["appearance", "appearance.title"],
["theme", "appearance.theme"],
["locale", "language.title"],
// notifications sync is always-on
] as [SyncKeys, string][]
).map(([key, title]) => (
<Checkbox
checked={
(props.options?.disabled ?? []).indexOf(key) === -1
}
description={
<Text
id={`app.settings.pages.sync.descriptions.${key}`}
/>
}
onChange={(enabled) =>
dispatch({
type: enabled
? "SYNC_ENABLE_KEY"
: "SYNC_DISABLE_KEY",
key,
})
}>
<Text id={`app.settings.pages.${title}`} />
</Checkbox>
))}
</div>
);
}
export const Sync = connectState(Component, (state) => {
return {
options: state.sync,
};
return {
options: state.sync,
};
});

View File

@@ -7,31 +7,31 @@ import { AppContext } from "../../../context/revoltjs/RevoltClient";
import Tip from "../../../components/ui/Tip";
interface Props {
server: Servers.Server;
server: Servers.Server;
}
export function Bans({ server }: Props) {
const client = useContext(AppContext);
const [bans, setBans] = useState<Servers.Ban[] | undefined>(undefined);
const client = useContext(AppContext);
const [bans, setBans] = useState<Servers.Ban[] | undefined>(undefined);
useEffect(() => {
client.servers.fetchBans(server._id).then((bans) => setBans(bans));
}, []);
useEffect(() => {
client.servers.fetchBans(server._id).then((bans) => setBans(bans));
}, []);
return (
<div>
<Tip warning>This section is under construction.</Tip>
{bans?.map((x) => (
<div>
{x._id.user}: {x.reason ?? "no reason"}{" "}
<button
onClick={() =>
client.servers.unbanUser(server._id, x._id.user)
}>
unban
</button>
</div>
))}
</div>
);
return (
<div>
<Tip warning>This section is under construction.</Tip>
{bans?.map((x) => (
<div>
{x._id.user}: {x.reason ?? "no reason"}{" "}
<button
onClick={() =>
client.servers.unbanUser(server._id, x._id.user)
}>
unban
</button>
</div>
))}
</div>
);
}

View File

@@ -6,9 +6,9 @@ import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks";
import {
useChannels,
useForceUpdate,
useUsers,
useChannels,
useForceUpdate,
useUsers,
} from "../../../context/revoltjs/hooks";
import { getChannelName } from "../../../context/revoltjs/util";
@@ -17,70 +17,70 @@ import IconButton from "../../../components/ui/IconButton";
import Preloader from "../../../components/ui/Preloader";
interface Props {
server: Servers.Server;
server: Servers.Server;
}
export function Invites({ server }: Props) {
const [invites, setInvites] = useState<
InvitesNS.ServerInvite[] | undefined
>(undefined);
const [invites, setInvites] = useState<
InvitesNS.ServerInvite[] | undefined
>(undefined);
const ctx = useForceUpdate();
const [deleting, setDelete] = useState<string[]>([]);
const users = useUsers(invites?.map((x) => x.creator) ?? [], ctx);
const channels = useChannels(invites?.map((x) => x.channel) ?? [], ctx);
const ctx = useForceUpdate();
const [deleting, setDelete] = useState<string[]>([]);
const users = useUsers(invites?.map((x) => x.creator) ?? [], ctx);
const channels = useChannels(invites?.map((x) => x.channel) ?? [], ctx);
useEffect(() => {
ctx.client.servers
.fetchInvites(server._id)
.then((invites) => setInvites(invites));
}, []);
useEffect(() => {
ctx.client.servers
.fetchInvites(server._id)
.then((invites) => setInvites(invites));
}, []);
return (
<div className={styles.invites}>
<div className={styles.subtitle}>
<span>Invite Code</span>
<span>Invitor</span>
<span>Channel</span>
<span>Revoke</span>
</div>
{typeof invites === "undefined" && <Preloader type="ring" />}
{invites?.map((invite) => {
let creator = users.find((x) => x?._id === invite.creator);
let channel = channels.find((x) => x?._id === invite.channel);
return (
<div className={styles.invites}>
<div className={styles.subtitle}>
<span>Invite Code</span>
<span>Invitor</span>
<span>Channel</span>
<span>Revoke</span>
</div>
{typeof invites === "undefined" && <Preloader type="ring" />}
{invites?.map((invite) => {
let creator = users.find((x) => x?._id === invite.creator);
let channel = channels.find((x) => x?._id === invite.channel);
return (
<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]);
return (
<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);
await ctx.client.deleteInvite(invite._id);
setInvites(
invites?.filter(
(x) => x._id !== invite._id,
),
);
}}
disabled={deleting.indexOf(invite._id) > -1}>
<XCircle size={24} />
</IconButton>
</div>
);
})}
</div>
);
setInvites(
invites?.filter(
(x) => x._id !== invite._id,
),
);
}}
disabled={deleting.indexOf(invite._id) > -1}>
<XCircle size={24} />
</IconButton>
</div>
);
})}
</div>
);
}

View File

@@ -6,39 +6,39 @@ import { useEffect, useState } from "preact/hooks";
import { useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
interface Props {
server: Servers.Server;
server: Servers.Server;
}
// ! FIXME: bad code :)
export function Members({ server }: Props) {
const [members, setMembers] = useState<Servers.Member[] | undefined>(
undefined,
);
const [members, setMembers] = useState<Servers.Member[] | undefined>(
undefined,
);
const ctx = useForceUpdate();
const users = useUsers(members?.map((x) => x._id.user) ?? [], ctx);
const ctx = useForceUpdate();
const users = useUsers(members?.map((x) => x._id.user) ?? [], ctx);
useEffect(() => {
ctx.client.servers.members
.fetchMembers(server._id)
.then((members) => setMembers(members));
}, []);
useEffect(() => {
ctx.client.servers.members
.fetchMembers(server._id)
.then((members) => setMembers(members));
}, []);
return (
<div className={styles.members}>
<div className={styles.subtitle}>
{members?.length ?? 0} Members
</div>
{members &&
members.length > 0 &&
users?.map(
(x) =>
x && (
<div className={styles.member}>
<div>@{x.username}</div>
</div>
),
)}
</div>
);
return (
<div className={styles.members}>
<div className={styles.subtitle}>
{members?.length ?? 0} Members
</div>
{members &&
members.length > 0 &&
users?.map(
(x) =>
x && (
<div className={styles.member}>
<div>@{x.username}</div>
</div>
),
)}
</div>
);
}

View File

@@ -16,178 +16,178 @@ import ComboBox from "../../../components/ui/ComboBox";
import InputBox from "../../../components/ui/InputBox";
interface Props {
server: Servers.Server;
server: Servers.Server;
}
export function Overview({ server }: Props) {
const client = useContext(AppContext);
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);
const [description, setDescription] = useState(server.description ?? "");
const [systemMessages, setSystemMessages] = useState(
server.system_messages,
);
useEffect(() => setName(server.name), [server.name]);
useEffect(
() => setDescription(server.description ?? ""),
[server.description],
);
useEffect(
() => setSystemMessages(server.system_messages),
[server.system_messages],
);
useEffect(() => setName(server.name), [server.name]);
useEffect(
() => setDescription(server.description ?? ""),
[server.description],
);
useEffect(
() => setSystemMessages(server.system_messages),
[server.system_messages],
);
const [changed, setChanged] = useState(false);
function save() {
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;
const [changed, setChanged] = useState(false);
function save() {
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);
setChanged(false);
}
client.servers.edit(server._id, changes);
setChanged(false);
}
return (
<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) =>
client.servers.edit(server._id, { icon })
}
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>
return (
<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) =>
client.servers.edit(server._id, { icon })
}
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>
<Text id="app.main.servers.description" />
</h3>
<TextAreaAutoSize
maxRows={10}
minHeight={60}
maxLength={1024}
value={description}
placeholder={"Add a topic..."}
onChange={(ev) => {
setDescription(ev.currentTarget.value);
if (!changed) setChanged(true);
}}
/>
<h3>
<Text id="app.main.servers.description" />
</h3>
<TextAreaAutoSize
maxRows={10}
minHeight={60}
maxLength={1024}
value={description}
placeholder={"Add a topic..."}
onChange={(ev) => {
setDescription(ev.currentTarget.value);
if (!changed) setChanged(true);
}}
/>
<h3>
<Text id="app.main.servers.custom_banner" />
</h3>
<FileUploader
height={160}
style="banner"
fileType="banners"
behaviour="upload"
maxFileSize={6_000_000}
onUpload={(banner) =>
client.servers.edit(server._id, { banner })
}
previewURL={client.servers.getBannerURL(
server._id,
{ width: 1000 },
true,
)}
remove={() =>
client.servers.edit(server._id, { remove: "Banner" })
}
/>
<h3>
<Text id="app.main.servers.custom_banner" />
</h3>
<FileUploader
height={160}
style="banner"
fileType="banners"
behaviour="upload"
maxFileSize={6_000_000}
onUpload={(banner) =>
client.servers.edit(server._id, { banner })
}
previewURL={client.servers.getBannerURL(
server._id,
{ width: 1000 },
true,
)}
remove={() =>
client.servers.edit(server._id, { 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
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>
))}
<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
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>
);
<p>
<Button onClick={save} contrast disabled={!changed}>
<Text id="app.special.modals.actions.save" />
</Button>
</p>
</div>
);
}

View File

@@ -2,8 +2,8 @@ import { Plus } from "@styled-icons/boxicons-regular";
import isEqual from "lodash.isequal";
import { Servers } from "revolt.js/dist/api/objects";
import {
ChannelPermission,
ServerPermission,
ChannelPermission,
ServerPermission,
} from "revolt.js/dist/api/permissions";
import styles from "./Panes.module.scss";
@@ -23,157 +23,157 @@ import Tip from "../../../components/ui/Tip";
import ButtonItem from "../../../components/navigation/items/ButtonItem";
interface Props {
server: Servers.Server;
server: Servers.Server;
}
const I32ToU32 = (arr: number[]) => arr.map((x) => x >>> 0);
// ! FIXME: bad code :)
export function Roles({ server }: Props) {
const [role, setRole] = useState("default");
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const roles = server.roles ?? {};
const [role, setRole] = useState("default");
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const roles = server.roles ?? {};
if (role !== "default" && typeof roles[role] === "undefined") {
useEffect(() => setRole("default"));
return null;
}
if (role !== "default" && typeof roles[role] === "undefined") {
useEffect(() => setRole("default"));
return null;
}
const v = (id: string) =>
I32ToU32(
id === "default"
? server.default_permissions
: roles[id].permissions,
);
const [perm, setPerm] = useState(v(role));
useEffect(() => setPerm(v(role)), [role, roles[role]?.permissions]);
const v = (id: string) =>
I32ToU32(
id === "default"
? server.default_permissions
: roles[id].permissions,
);
const [perm, setPerm] = useState(v(role));
useEffect(() => setPerm(v(role)), [role, roles[role]?.permissions]);
const modified = !isEqual(perm, v(role));
const save = () =>
client.servers.setPermissions(server._id, role, {
server: perm[0],
channel: perm[1],
});
const deleteRole = () => {
setRole("default");
client.servers.deleteRole(server._id, role);
};
const modified = !isEqual(perm, v(role));
const save = () =>
client.servers.setPermissions(server._id, role, {
server: perm[0],
channel: perm[1],
});
const deleteRole = () => {
setRole("default");
client.servers.deleteRole(server._id, 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: server._id,
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>
);
} else {
return (
<ButtonItem
active={role === id}
onClick={() => setRole(id)}>
{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>
<section>
<Overline type="subtle">
<Text id="app.settings.permissions.server" />
</Overline>
{Object.keys(ServerPermission).map((key) => {
if (key === "View") return;
let value =
ServerPermission[
key as keyof typeof ServerPermission
];
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: server._id,
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>
);
} else {
return (
<ButtonItem
active={role === id}
onClick={() => setRole(id)}>
{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>
<section>
<Overline type="subtle">
<Text id="app.settings.permissions.server" />
</Overline>
{Object.keys(ServerPermission).map((key) => {
if (key === "View") return;
let value =
ServerPermission[
key as keyof typeof ServerPermission
];
return (
<Checkbox
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;
let value =
ChannelPermission[
key as keyof typeof ChannelPermission
];
return (
<Checkbox
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;
let value =
ChannelPermission[
key as keyof typeof ChannelPermission
];
return (
<Checkbox
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>
);
return (
<Checkbox
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>
);
}