Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
No results found
Show changes
Showing
with 1466 additions and 1260 deletions
import { X, Plus } from "@styled-icons/boxicons-regular";
import { PhoneCall, Envelope } from "@styled-icons/boxicons-solid";
import { User, Users } from "revolt.js/dist/api/objects";
import { PhoneCall, Envelope, UserX } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom";
import { RelationshipStatus } from "revolt-api/types/Users";
import { User } from "revolt.js/dist/maps/Users";
import styles from "./Friend.module.scss";
import classNames from "classnames";
......@@ -12,10 +15,6 @@ import { stopPropagation } from "../../lib/stopPropagation";
import { VoiceOperationsContext } from "../../context/Voice";
import { useIntermediate } from "../../context/intermediate/Intermediate";
import {
AppContext,
OperationsContext,
} from "../../context/revoltjs/RevoltClient";
import UserIcon from "../../components/common/user/UserIcon";
import UserStatus from "../../components/common/user/UserStatus";
......@@ -24,113 +23,123 @@ 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);
export const Friend = observer(({ user }: Props) => {
const history = useHistory();
const { openScreen } = useIntermediate();
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 === RelationshipStatus.Friend) {
subtext = <UserStatus user={user} />;
actions.push(
<>
<IconButton
type="circle"
className={classNames(styles.button, styles.success)}
onClick={(ev) =>
stopPropagation(
ev,
user
.openDM()
.then(connect)
.then((x) => history.push(`/channel/${x._id}`)),
)
}>
<PhoneCall size={20} />
</IconButton>
<IconButton
type="circle"
className={styles.button}
onClick={(ev) =>
stopPropagation(
ev,
user
.openDM()
.then((channel) =>
history.push(`/channel/${channel._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 === RelationshipStatus.Incoming) {
actions.push(
<IconButton
type="circle"
className={styles.button}
onClick={(ev) => stopPropagation(ev, user.addFriend())}>
<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 === RelationshipStatus.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 === RelationshipStatus.Friend ||
user.relationship === RelationshipStatus.Outgoing ||
user.relationship === RelationshipStatus.Incoming
) {
actions.push(
<IconButton
type="circle"
className={classNames(
styles.button,
styles.remove,
styles.error,
)}
onClick={(ev) =>
stopPropagation(
ev,
user.relationship === RelationshipStatus.Friend
? openScreen({
id: "special_prompt",
type: "unfriend_user",
target: user,
})
: user.removeFriend(),
)
}>
<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 === RelationshipStatus.Blocked) {
actions.push(
<IconButton
type="circle"
className={classNames(styles.button, styles.error)}
onClick={(ev) => stopPropagation(ev, user.unblockUser())}>
<UserX 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>
);
});
import {
ChevronDown,
ChevronRight,
ListPlus,
} from "@styled-icons/boxicons-regular";
import { ChevronRight } from "@styled-icons/boxicons-regular";
import { UserDetail, MessageAdd, UserPlus } from "@styled-icons/boxicons-solid";
import { User, Users } from "revolt.js/dist/api/objects";
import { observer } from "mobx-react-lite";
import { RelationshipStatus, Presence } from "revolt-api/types/Users";
import { User } from "revolt.js/dist/maps/Users";
import styles from "./Friend.module.scss";
import { Text } from "preact-i18n";
......@@ -13,105 +11,102 @@ import { TextReact } from "../../lib/i18n";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useIntermediate } from "../../context/intermediate/Intermediate";
import { useUsers } from "../../context/revoltjs/hooks";
import { useClient } from "../../context/revoltjs/RevoltClient";
import CollapsibleSection from "../../components/common/CollapsibleSection";
import Tooltip from "../../components/common/Tooltip";
import UserIcon from "../../components/common/user/UserIcon";
import Details from "../../components/ui/Details";
import Header from "../../components/ui/Header";
import IconButton from "../../components/ui/IconButton";
import Overline from "../../components/ui/Overline";
import { Children } from "../../types/Preact";
import { Friend } from "./Friend";
export default function Friends() {
const { openScreen } = useIntermediate();
export default observer(() => {
const { openScreen } = useIntermediate();
const users = useUsers() as User[];
users.sort((a, b) => a.username.localeCompare(b.username));
const client = useClient();
const users = [...client.users.values()];
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 === RelationshipStatus.Friend,
);
const lists = [
[
"",
users.filter((x) => x.relationship === RelationshipStatus.Incoming),
],
[
"app.special.friends.sent",
users.filter((x) => x.relationship === RelationshipStatus.Outgoing),
"outgoing",
],
[
"app.status.online",
friends.filter(
(x) => x.online && x.status?.presence !== Presence.Invisible,
),
"online",
],
[
"app.status.offline",
friends.filter(
(x) => !x.online || x.status?.presence === Presence.Invisible,
),
"offline",
],
[
"app.special.friends.blocked",
users.filter((x) => x.relationship === RelationshipStatus.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 key={x._id}>{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 +114,102 @@ 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}
data-mobile={isTouchscreenDevice}>
{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,
})
}>
<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
key={section_id}
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>
</>
);
});
......@@ -11,15 +11,13 @@
}
}
ul {
.actions {
gap: 8px;
margin: auto;
display: block;
font-size: 18px;
text-align: center;
li {
list-style: lower-greek;
}
display: flex;
width: fit-content;
align-items: center;
flex-direction: column;
}
}
......
......@@ -5,37 +5,45 @@ import styles from "./Home.module.scss";
import { Text } from "preact-i18n";
import wideSVG from "../../assets/wide.svg";
import Tooltip from "../../components/common/Tooltip";
import Button from "../../components/ui/Button";
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" />
<br />
<img src={wideSVG} />
</h3>
<div className={styles.actions}>
<Link to="/invite/Testers">
<Button contrast error>
Join testers server
</Button>
</Link>
<a
href="https://insrt.uk/donate"
target="_blank"
rel="noreferrer">
<Button contrast gold>
Donate to Revolt
</Button>
</a>
<Link to="/settings/feedback">
<Button contrast>Give feedback</Button>
</Link>
<Link to="/settings">
<Tooltip content="You can also right-click the user icon in the top left, or left click it if you're already home.">
<Button contrast>Open settings</Button>
</Tooltip>
</Link>
</div>
</div>
);
}
......@@ -34,10 +34,10 @@
.details {
text-align: center;
border-radius: 3px;
align-self: center;
padding: 32px 16px 16px 16px;
background: rgba(0, 0, 0, 0.6);
border-radius: var(--border-radius);
h1 {
margin: 0;
......
import { ArrowBack } from "@styled-icons/boxicons-regular";
import { autorun } from "mobx";
import { useHistory, useParams } from "react-router-dom";
import { Invites } from "revolt.js/dist/api/objects";
import { RetrievedInvite } from "revolt-api/types/Invites";
import styles from "./Invite.module.scss";
import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks";
import { defer } from "../../lib/defer";
import { TextReact } from "../../lib/i18n";
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 +25,140 @@ 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,
);
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>
);
}
// ! 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>
)}
<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);
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>
);
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<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)));
}
}, [client, code, invite, status]);
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>
{!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>
<TextReact
id="app.special.invite.invited_by"
fields={{
user: (
<>
<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);
if (invite.type === "Server") {
if (
client.servers.get(invite.server_id)
) {
history.push(
`/server/${invite.server_id}/channel/${invite.channel_id}`,
);
}
const dispose = autorun(() => {
const server = client.servers.get(
invite.server_id,
);
defer(() => {
if (server) {
history.push(
`/server/${server._id}/channel/${invite.channel_id}`,
);
}
});
dispose();
});
}
await client.joinInvite(code);
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
}}>
{status === ClientStatus.READY ? (
<Text id="app.special.invite.login" />
) : (
<Text id="app.special.invite.accept" />
)}
</Button>
</>
)}
</div>
</div>
);
}
import { UseFormMethods } from "react-hook-form";
import { Text, Localizer } from "preact-i18n";
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: UseFormMethods["register"];
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
placeholder={
(
<Text id={`login.enter.${type}`} />
) as unknown as string
}
name={
type === "current_password" ? "password" : name ?? type
}
type={
type === "invite" || type === "username"
? "text"
: type === "current_password"
? "password"
: type
}
// See https://github.com/mozilla/contain-facebook/issues/783
className="fbc-has-badge"
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>
</>
);
}
......@@ -11,6 +11,7 @@ import { AppContext } from "../../context/revoltjs/RevoltClient";
import LocaleSelector from "../../components/common/LocaleSelector";
import { Titlebar } from "../../components/native/Titlebar";
import { APP_VERSION } from "../../version";
import background from "./background.jpg";
import { FormCreate } from "./forms/FormCreate";
......@@ -19,56 +20,61 @@ 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 (
<>
{window.isNative && !window.native.getConfig().frame && (
<Titlebar />
)}
<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>
</>
);
}
......@@ -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);
useEffect(() => {
if (!client.configuration?.features.captcha.enabled) {
props.onSuccess();
}
}, []);
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>
);
const client = useContext(AppContext);
useEffect(() => {
if (!client.configuration?.features.captcha.enabled) {
props.onSuccess();
}
}, [client.configuration?.features.captcha.enabled, props]);
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>
);
}
......@@ -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 [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(),
},
});
async function onSubmit(data: FormInputs) {
setGlobalError(undefined);
setLoading(true);
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 });
}
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);
}
}
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" />;
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>
);
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 { handleSubmit, register, errors, setError } = useForm<FormInputs>({
defaultValues: {
email: "",
password: "",
invite: getInviteCode(),
},
});
async function onSubmit(data: FormInputs) {
setGlobalError(undefined);
setLoading(true);
function onError(err: unknown) {
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 });
}
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);
}
}
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" />;
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>
);
}
......@@ -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);
}}
/>
);
}
......@@ -8,25 +8,29 @@ 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;
if (window.isNative) {
device_name = `Revolt Desktop on ${os}`;
} else {
device_name = `${name} on ${os}`;
}
} else {
device_name = "Unknown Device";
}
await login({ ...data, device_name });
history.push("/");
}}
/>
);
await login({ ...data, device_name });
history.push("/");
}}
/>
);
}
......@@ -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);
}}
/>
);
}
......@@ -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");
}}
/>
);
}
......@@ -2,19 +2,28 @@ 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"
rel="noreferrer">
<Text id="general.about" />
</a>
&middot;
<a
href="https://revolt.chat/terms"
target="_blank"
rel="noreferrer">
<Text id="general.tos" />
</a>
&middot;
<a
href="https://revolt.chat/privacy"
target="_blank"
rel="noreferrer">
<Text id="general.privacy" />
</a>
</span>
);
}
......@@ -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" rel="noreferrer">
<Button>
<Text
id="login.open_mail_provider"
fields={{ provider: provider[0] }}
/>
</Button>
</a>
</div>
);
}
import { ListCheck, ListUl } from "@styled-icons/boxicons-regular";
import { Route, useHistory, useParams } from "react-router-dom";
import { Route, Switch, useHistory, useParams } from "react-router-dom";
import { Text } from "preact-i18n";
import { useChannel, useForceUpdate } from "../../context/revoltjs/hooks";
import { useClient } from "../../context/revoltjs/RevoltClient";
import { getChannelName } from "../../context/revoltjs/util";
import Category from "../../components/ui/Category";
......@@ -13,75 +13,79 @@ 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 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 client = useClient();
const history = useHistory();
const channel = client.channels.get(cid);
if (to) {
history.replace(`${base_url}/${to}`);
} else {
history.replace(base_url);
}
}
if (!channel) return null;
if (
channel.channel_type === "SavedMessages" ||
channel.channel_type === "DirectMessage"
)
return null;
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>,
function switchPage(to?: string) {
let base_url;
switch (channel?.channel_type) {
case "TextChannel":
case "VoiceChannel":
base_url = `/server/${channel.server_id}/channel/${cid}/settings`;
break;
default:
base_url = `/channel/${cid}/settings`;
}
<Route path="/">
<Overview channel={channel} />
</Route>,
]}
category="channel_pages"
switchPage={switchPage}
defaultPage="overview"
showExitButton
/>
);
if (to) {
history.replace(`${base_url}/${to}`);
} else {
history.replace(base_url);
}
}
return (
<GenericSettings
pages={[
{
category: (
<Category
variant="uniform"
text={getChannelName(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={
<Switch>
<Route path="/server/:server/channel/:channel/settings/permissions">
<Permissions channel={channel} />
</Route>
<Route path="/channel/:channel/settings/permissions">
<Permissions channel={channel} />
</Route>
<Route>
<Overview channel={channel} />
</Route>
</Switch>
}
category="channel_pages"
switchPage={switchPage}
defaultPage="overview"
showExitButton
/>
);
}
import { ArrowBack, X, XCircle } from "@styled-icons/boxicons-regular";
import { ArrowBack, X } from "@styled-icons/boxicons-regular";
import { Helmet } from "react-helmet";
import { Switch, useHistory, useParams } from "react-router-dom";
import { useHistory, useParams } from "react-router-dom";
import styles from "./Settings.module.scss";
import classNames from "classnames";
import { Text } from "preact-i18n";
import { useContext, useEffect } from "preact/hooks";
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
......@@ -19,140 +20,169 @@ 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;
hidden?: boolean;
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("/");
}
}
const [closing, setClosing] = useState(false);
const exitSettings = useCallback(() => {
if (history.length > 1) {
setClosing(true);
useEffect(() => {
function keyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
exitSettings();
}
}
setTimeout(() => {
history.goBack();
}, 100);
} else {
history.push("/");
}
}, [history]);
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, []);
useEffect(() => {
function keyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
exitSettings();
}
}
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>
);
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, [exitSettings]);
return (
<div
className={classNames(styles.settings, {
[styles.closing]: closing,
[styles.native]: window.isNative,
})}
data-mobile={isTouchscreenDevice}>
<Helmet>
<meta
name="theme-color"
content={
isTouchscreenDevice
? theme["background"]
: theme["secondary-background"]
}
/>
</Helmet>
{isTouchscreenDevice && (
<Header placement="primary">
{typeof page === "undefined" ? (
<>
{showExitButton && (
<IconButton onClick={exitSettings}>
<X
size={27}
style={{ marginInlineEnd: "8px" }}
/>
</IconButton>
)}
<Text id="app.settings.title" />
</>
) : (
<>
<IconButton onClick={() => switchPage()}>
<ArrowBack
size={24}
style={{ marginInlineEnd: "10px" }}
/>
</IconButton>
<Text
id={`app.settings.${category}.${page}.title`}
/>
</>
)}
</Header>
)}
{(!isTouchscreenDevice || typeof page === "undefined") && (
<div className={styles.sidebar}>
<div className={styles.scrollbox}>
<div className={styles.container}>
{pages.map((entry, i) =>
entry.hidden ? undefined : (
<>
{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>
</div>
)}
{(!isTouchscreenDevice || typeof page === "string") && (
<div className={styles.content}>
<div className={styles.scrollbox}>
<div className={styles.contentcontainer}>
{!isTouchscreenDevice &&
!pages.find(
(x) => x.id === page && x.hideTitle,
) && (
<h1>
<Text
id={`app.settings.${category}.${
page ?? defaultPage
}.title`}
/>
</h1>
)}
{children}
</div>
{!isTouchscreenDevice && (
<div className={styles.action}>
<div
onClick={exitSettings}
className={styles.closeButton}>
<X size={28} />
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}
import {
ListUl,
Share,
Group,
ListCheck,
} from "@styled-icons/boxicons-regular";
import { XSquare } from "@styled-icons/boxicons-solid";
import { Route, useHistory, useParams } from "react-router-dom";
import { ListUl, ListCheck, ListMinus } from "@styled-icons/boxicons-regular";
import { XSquare, Share, Group } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { Route, Switch, useHistory, useParams } from "react-router-dom";
import { Text } from "preact-i18n";
import RequiresOnline from "../../context/revoltjs/RequiresOnline";
import { useServer } from "../../context/revoltjs/hooks";
import { useClient } from "../../context/revoltjs/RevoltClient";
import Category from "../../components/ui/Category";
import { GenericSettings } from "./GenericSettings";
import { Bans } from "./server/Bans";
import { Categories } from "./server/Categories";
import { Invites } from "./server/Invites";
import { Members } from "./server/Members";
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;
export default observer(() => {
const { server: sid } = useParams<{ server: string }>();
const client = useClient();
const server = client.servers.get(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} />,
id: "overview",
icon: <ListUl size={20} />,
title: (
<Text id="app.settings.server_pages.overview.title" />
),
},
{
id: "categories",
icon: <ListMinus size={20} />,
title: (
<Text id="app.settings.server_pages.categories.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={
<Switch>
<Route path="/server/:server/settings/categories">
<Categories server={server} />
</Route>
<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>
<Overview server={server} />
</Route>
</Switch>
}
category="server_pages"
switchPage={switchPage}
defaultPage="overview"
showExitButton
/>
);
});
/* Settings animations */
@keyframes open {
0% {transform: scale(1.2);};
100% {transform: scale(1);};
0% {
transform: scale(1.2);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes opacity {
0% {opacity: 0;};
20% {opacity: .5;}
50% {opacity: 1;}
@keyframes close {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(1.2);
opacity: 0;
}
}
@keyframes close {
0% {transform: scale(1); opacity: 1;};
100% {transform: scale(1.2); opacity: 0;};
@keyframes opacity {
0% {
opacity: 0;
}
20% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
/* Settings CSS */
.settings[data-mobile="true"] {
flex-direction: column;
background: var(--primary-header);
.sidebar, .content {
.sidebar,
.content {
background: var(--primary-background);
}
.scrollbox {
&::-webkit-scrollbar-thumb {
border-top: none;
}
}
/* Sidebar */
.sidebar {
justify-content: flex-start;
overflow-y: auto;
.container {
padding: 20px 8px;
padding: 20px 8px calc(var(--bottom-navigation-height) + 30px);
min-width: 218px;
}
> div {
.scrollbox {
width: 100%;
}
.version {
place-items: center;
}
}
/* Content */
.content {
padding: 10px 12px 50px;
padding: 0;
.scrollbox {
overflow: auto;
}
.contentcontainer {
max-width: unset !important;
padding: 16px 12px var(--bottom-navigation-height) !important;
}
}
}
......@@ -52,8 +88,11 @@
width: 100%;
height: 100%;
position: fixed;
animation: open .18s ease-out,
opacity .18s;
animation: open 0.18s ease-out, opacity 0.18s;
&.closing {
animation: close 0.18s ease-in;
}
}
.settings {
......@@ -61,20 +100,40 @@
display: flex;
user-select: none;
flex-direction: row;
justify-content: center;
background: var(--primary-background);
.scrollbox {
overflow-y: scroll;
visibility: hidden;
transition: visibility 0.1s;
}
.container,
.contentcontainer,
.scrollbox:hover,
.scrollbox:focus {
visibility: visible;
}
// All children receive custom scrollbar.
> * > ::-webkit-scrollbar-thumb {
width: 4px;
background-clip: content-box;
border-top: 80px solid transparent;
}
.sidebar {
flex: 2;
flex: 1 0 218px;
display: flex;
flex-shrink: 0;
overflow-y: scroll;
justify-content: flex-end;
background: var(--secondary-background);
.container {
width: 218px;
padding: 60px 8px;
min-width: 218px;
padding: 80px 8px;
display: flex;
gap: 2px;
flex-direction: column;
}
.divider {
......@@ -84,20 +143,17 @@
.donate {
color: goldenrod !important;
}
.logOut {
color: var(--error) !important;
}
.version {
margin: 1rem 12px 0;
font-size: 10px;
font-size: 0.625rem;
color: var(--secondary-foreground);
font-family: var(--monoscape-font), monospace;
font-family: var(--monospace-font), monospace;
user-select: text;
display: grid;
//place-items: center;
> div {
gap: 2px;
......@@ -105,87 +161,118 @@
flex-direction: column;
}
.revision a:hover {
a:hover {
text-decoration: underline;
}
}
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
.content {
flex: 3;
max-width: 740px;
padding: 60px 2em;
overflow-y: scroll;
overflow-x: hidden;
flex: 1 1 800px;
display: flex;
overflow-y: auto;
.scrollbox {
display: flex;
flex-grow: 1;
}
.contentcontainer {
display: flex;
gap: 13px;
height: fit-content;
max-width: 740px;
padding: 80px 32px;
width: 100%;
flex-direction: column;
}
details {
margin: 14px 0;
}
h1 {
margin-top: 0;
line-height: 1em;
font-size: 1.2em;
margin: 0;
line-height: 1rem;
font-size: 1.2rem;
font-weight: 600;
}
h3 {
font-size: 13px;
font-size: 0.8125rem;
text-transform: uppercase;
color: var(--secondary-foreground);
&:first-child {
margin-top: 0;
}
}
h4 {
margin: 4px 2px;
font-size: 13px;
font-size: 0.8125rem;
color: var(--tertiary-foreground);
text-transform: uppercase;
}
h5 {
margin-top: 0;
font-size: 0.75rem;
font-weight: 400;
}
.footer {
border-top: 1px solid;
margin: 0;
padding-top: 5px;
font-size: 14px;
font-size: 0.875rem;
color: var(--secondary-foreground);
}
}
.action {
flex: 1;
flex-shrink: 0;
padding: 60px 8px;
color: var(--tertiary-background);
flex-grow: 1;
padding: 80px 8px;
visibility: visible;
position: sticky;
top: 0;
&:after {
content: "ESC";
margin-top: 4px;
display: flex;
text-align: center;
align-content: center;
justify-content: center;
position: relative;
color: var(--foreground);
width: 48px;
opacity: .5;
font-size: .75em;
}
> div {
display: inline;
> svg {
&:active {
transform: translateY(2px);
}
width: 40px;
opacity: 0.5;
font-size: 0.75rem;
}
.closeButton {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
height: 40px;
width: 40px;
border: 3px solid var(--tertiary-background);
cursor: pointer;
svg {
color: var(--secondary-foreground);
}
&:hover {
background: var(--secondary-header);
}
&:active {
transform: translateY(2px);
}
}
}
}
.loader {
> div {
margin: auto;
@media (pointer: coarse) {
.scrollbox {
visibility: visible !important;
overflow-y: auto;
}
}