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 1153 additions and 377 deletions
.title {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.actions {
display: flex;
align-items: center;
gap: 20px;
}
.list {
padding: 16px;
padding: 0 10px 10px 10px;
user-select: none;
overflow-y: scroll;
&[data-empty="true"] {
img {
height: 120px;
border-radius: 8px;
border-radius: var(--border-radius);
}
gap: 16px;
......@@ -16,15 +28,19 @@
flex-direction: column;
justify-content: center;
}
&[data-mobile="true"] {
padding-bottom: var(--bottom-navigation-height);
}
}
.friend {
padding: 10px;
height: 60px;
display: flex;
border-radius: 5px;
align-items: center;
flex-direction: row;
padding: 0 10px;
cursor: pointer;
align-items: center;
border-radius: var(--border-radius);
&:hover {
background: var(--secondary-background);
......@@ -38,17 +54,25 @@
flex-grow: 1;
margin: 0 12px;
font-size: 16px;
font-weight: 600;
display: flex;
flex-direction: column;
justify-content: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
overflow: hidden;
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subtext {
font-size: 12px;
font-weight: 400;
color: var(--tertiary-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
......@@ -56,16 +80,118 @@
display: flex;
gap: 12px;
> div {
height: 32px;
width: 32px;
.button {
width: 36px;
height: 36px;
&:hover.error {
background: var(--error);
}
&:hover.success {
background: var(--success);
}
}
}
}
//! FIXME: Move this to the Header component, do this:
// 1. Check if header has topic, if yes, flex-grow: 0 on the title.
// 2. If header has no topic (example: friends page), flex-grow 1 on the header title.
.divider {
width: 1px;
height: 24px;
background: var(--primary-background);
}
.title {
flex-grow: 1;
}
.pending {
padding: 1em;
display: flex;
cursor: pointer;
margin-top: 1em;
align-items: center;
flex-direction: row;
border-radius: var(--border-radius);
background: var(--secondary-background);
svg {
flex-shrink: 0;
}
.avatars {
display: flex;
flex-shrink: 0;
margin-inline-end: 15px;
svg:not(:first-child) {
position: relative;
margin-left: -32px;
}
}
.details {
flex-grow: 1;
display: flex;
flex-direction: column;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
> div {
font-size: 16px;
font-weight: 800;
display: flex;
gap: 6px;
align-items: center;
min-width: 0;
.title {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
span {
width: 1.5em;
height: 1.5em;
font-size: 12px;
color: white;
border-radius: 50%;
align-items: center;
display: inline-flex;
justify-content: center;
background: var(--error);
flex-shrink: 0;
}
}
.from {
.user {
font-weight: 600;
}
}
> span {
font-weight: 400;
font-size: 12px;
color: var(--tertiary-foreground);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
@media only screen and (max-width: 768px) {
.list {
padding: 0 8px 8px 8px;
}
.remove {
display: none;
}
}
import { Text } from "preact-i18n";
import { Link } from "react-router-dom";
import { X, Plus } from "@styled-icons/boxicons-regular";
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 { useContext } from "preact/hooks";
import { Children } from "../../types/Preact";
import { X, Plus, Mail } from "@styled-icons/feather";
import UserIcon from "../../components/common/UserIcon";
import IconButton from "../../components/ui/IconButton";
import classNames from "classnames";
import { attachContextMenu } from "preact-context-menu";
import { User, Users } from "revolt.js/dist/api/objects";
import UserStatus from '../../components/common/UserStatus';
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { stopPropagation } from "../../lib/stopPropagation";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { VoiceOperationsContext } from "../../context/Voice";
import { useIntermediate } from "../../context/intermediate/Intermediate";
import UserIcon from "../../components/common/user/UserIcon";
import UserStatus from "../../components/common/user/UserStatus";
import IconButton from "../../components/ui/IconButton";
import { Children } from "../../types/Preact";
interface Props {
user: User;
}
export function Friend({ user }: Props) {
const client = useContext(AppContext);
export const Friend = observer(({ user }: Props) => {
const history = useHistory();
const { openScreen } = useIntermediate();
const { connect } = useContext(VoiceOperationsContext);
const actions: Children[] = [];
let subtext: Children = null;
if (user.relationship === Users.Relationship.Friend) {
subtext = <UserStatus user={user} />
if (user.relationship === RelationshipStatus.Friend) {
subtext = <UserStatus user={user} />;
actions.push(
<IconButton type="circle"
onClick={stopPropagation}>
<Link to={'/open/' + user._id}>
<Mail size={20} />
</Link>
</IconButton>
<>
<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) {
if (user.relationship === RelationshipStatus.Incoming) {
actions.push(
<IconButton type="circle"
onClick={ev => stopPropagation(ev, client.users.addFriend(user.username))}>
<IconButton
type="circle"
className={styles.button}
onClick={(ev) => stopPropagation(ev, user.addFriend())}>
<Plus size={24} />
</IconButton>
</IconButton>,
);
subtext = <Text id="app.special.friends.incoming" />;
}
if (user.relationship === Users.Relationship.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
user.relationship === RelationshipStatus.Friend ||
user.relationship === RelationshipStatus.Outgoing ||
user.relationship === RelationshipStatus.Incoming
) {
actions.push(
<IconButton type="circle"
onClick={ev => stopPropagation(ev, client.users.removeFriend(user._id))}>
<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>
</IconButton>,
);
}
if (user.relationship === Users.Relationship.Blocked) {
if (user.relationship === RelationshipStatus.Blocked) {
actions.push(
<IconButton type="circle"
onClick={ev => stopPropagation(ev, client.users.unblockUser(user._id))}>
<X size={24} />
</IconButton>
<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={32} status />
<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>
)}
<span>{user.username}</span>
{subtext && <span className={styles.subtext}>{subtext}</span>}
</div>
<div className={styles.actions}>{actions}</div>
</div>
);
}
});
import styles from "./Friend.module.scss";
import { UserPlus } from "@styled-icons/feather";
import { ChevronRight } from "@styled-icons/boxicons-regular";
import { UserDetail, MessageAdd, UserPlus } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { RelationshipStatus, Presence } from "revolt-api/types/Users";
import { User } from "revolt.js/dist/maps/Users";
import { Friend } from "./Friend";
import styles from "./Friend.module.scss";
import { Text } from "preact-i18n";
import { TextReact } from "../../lib/i18n";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useIntermediate } from "../../context/intermediate/Intermediate";
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 Header from "../../components/ui/Header";
import Overline from "../../components/ui/Overline";
import IconButton from "../../components/ui/IconButton";
import { useUsers } from "../../context/revoltjs/hooks";
import { User, Users } from "revolt.js/dist/api/objects";
import { useIntermediate } from "../../context/intermediate/Intermediate";
export default function Friends() {
import { Children } from "../../types/Preact";
import { Friend } from "./Friend";
export default observer(() => {
const { openScreen } = useIntermediate();
const users = useUsers() as User[];
const client = useClient();
const users = [...client.users.values()];
users.sort((a, b) => a.username.localeCompare(b.username));
const pending = users.filter(
x =>
x.relationship === Users.Relationship.Incoming ||
x.relationship === Users.Relationship.Outgoing
);
const friends = users.filter(
x => x.relationship === Users.Relationship.Friend
);
const blocked = users.filter(
x => x.relationship === Users.Relationship.Blocked
(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 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 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="actions">
<IconButton onClick={() => openScreen({ id: 'special_input', type: 'add_friend' })}>
<UserPlus size={24} />
</IconButton>
<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>
{/*
<div className={styles.divider} />
<Tooltip content={"Friend Activity"} placement="bottom">
<IconButton>
<TennisBall size={24} />
</IconButton>
</Tooltip>
*/}
</div>
</Header>
<div
className={styles.list}
data-empty={
pending.length + friends.length + blocked.length === 0
}
>
{pending.length + friends.length + blocked.length === 0 && (
data-empty={isEmpty}
data-mobile={isTouchscreenDevice}>
{isEmpty && (
<>
<img src="https://img.insrt.uk/xexu7/XOPoBUTI47.png/raw" />
<Text id="app.special.friends.nobody" />
</>
)}
{pending.length > 0 && (
<Overline type="subtle">
<Text id="app.special.friends.pending" />{" "}
{pending.length}
</Overline>
)}
{pending.map(y => (
<Friend key={y._id} user={y} />
))}
{friends.length > 0 && (
<Overline type="subtle">
<Text id="app.navigation.tabs.friends" />{" "}
{friends.length}
</Overline>
)}
{friends.map(y => (
<Friend key={y._id} user={y} />
))}
{blocked.length > 0 && (
<Overline type="subtle">
<Text id="app.special.friends.blocked" />{" "}
{blocked.length}
</Overline>
{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>
)}
{blocked.map(y => (
<Friend key={y._id} user={y} />
))}
{lists.map(([i18n, list, section_id], index) => {
if (index === 0) return;
if (list.length === 0) return;
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;
}
}
......
import styles from "./Home.module.scss";
import { Home as HomeIcon } from "@styled-icons/boxicons-solid";
import { Link } from "react-router-dom";
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"><Text id="app.navigation.tabs.home" /></Header>
<Header placement="primary">
<HomeIcon size={24} />
<Text id="app.navigation.tabs.home" />
</Header>
<h3>
<Text id="app.special.modals.onboarding.welcome" /> <img src="/assets/wide.svg" />
<Text id="app.special.modals.onboarding.welcome" />
<br />
<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 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>
);
}
.invite {
height: 100%;
display: flex;
color: white;
user-select: none;
align-items: center;
flex-direction: column;
background-size: cover;
justify-content: center;
background-position: center;
* {
overflow: visible;
}
.icon {
width: 64px;
z-index: 100;
text-align: left;
position: relative;
> * {
top: -32px;
position: absolute;
}
}
.leave {
top: 8px;
left: 8px;
cursor: pointer;
position: fixed;
}
.details {
text-align: center;
align-self: center;
padding: 32px 16px 16px 16px;
background: rgba(0, 0, 0, 0.6);
border-radius: var(--border-radius);
h1 {
margin: 0;
font-weight: 500;
}
h2 {
margin: 4px;
opacity: 0.7;
font-size: 0.8em;
font-weight: 400;
}
h3 {
gap: 8px;
display: flex;
font-size: 1em;
font-weight: 400;
flex-direction: row;
justify-content: center;
}
button {
margin: auto;
display: block;
background: rgba(0, 0, 0, 0.8);
}
}
}
.preloader {
height: 100%;
display: grid;
place-items: center;
}
import { ArrowBack } from "@styled-icons/boxicons-regular";
import { autorun } from "mobx";
import { useHistory, useParams } from "react-router-dom";
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,
} from "../../context/revoltjs/RevoltClient";
import { takeError } from "../../context/revoltjs/util";
import ServerIcon from "../../components/common/ServerIcon";
import UserIcon from "../../components/common/user/UserIcon";
import Button from "../../components/ui/Button";
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<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 Overline from '../../components/ui/Overline';
import InputBox from '../../components/ui/InputBox';
import { Text, Localizer } from 'preact-i18n';
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;
register: UseFormMethods["register"];
error?: string;
name?: string;
}
......@@ -15,7 +18,7 @@ export default function FormField({
register,
showOverline,
error,
name
name,
}: Props) {
return (
<>
......@@ -26,7 +29,11 @@ export default function FormField({
)}
<Localizer>
<InputBox
placeholder={(<Text id={`login.enter.${type}`} />) as any}
placeholder={
(
<Text id={`login.enter.${type}`} />
) as unknown as string
}
name={
type === "current_password" ? "password" : name ?? type
}
......@@ -37,6 +44,8 @@ export default function FormField({
? "password"
: type
}
// See https://github.com/mozilla/contain-facebook/issues/783
className="fbc-has-badge"
ref={register(
type === "password" || type === "current_password"
? {
......@@ -47,19 +56,19 @@ export default function FormField({
? "TooShort"
: value.length > 1024
? "TooLong"
: undefined
: undefined,
}
: type === "email"
? {
required: "RequiredField",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "InvalidEmail"
}
message: "InvalidEmail",
},
}
: type === "username"
? { required: "RequiredField" }
: { required: "RequiredField" }
: { required: "RequiredField" },
)}
/>
</Localizer>
......
import { Text } from "preact-i18n";
import { Helmet } from "react-helmet";
import { Route, Switch } from "react-router-dom";
import { LIBRARY_VERSION } from "revolt.js";
import styles from "./Login.module.scss";
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { APP_VERSION } from "../../version";
import { LIBRARY_VERSION } from "revolt.js";
import { Route, Switch } from "react-router-dom";
import { ThemeContext } from "../../context/Theme";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import background from "./background.jpg";
import LocaleSelector from "../../components/common/LocaleSelector";
import { FormLogin } from "./forms/FormLogin";
import { Titlebar } from "../../components/native/Titlebar";
import { APP_VERSION } from "../../version";
import background from "./background.jpg";
import { FormCreate } from "./forms/FormCreate";
import { FormLogin } from "./forms/FormLogin";
import { FormResend } from "./forms/FormResend";
import { FormReset, FormSendReset } from "./forms/FormReset";
......@@ -20,52 +24,57 @@ export default function Login() {
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>
<>
{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>
<div
className={styles.bg}
style={{ background: `url('${background}')` }}
/>
</div>
</>
);
};
}
import { Text } from "preact-i18n";
import styles from "../Login.module.scss";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import styles from "../Login.module.scss";
import { Text } from "preact-i18n";
import { useContext, useEffect } from "preact/hooks";
import Preloader from "../../../components/ui/Preloader";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import Preloader from "../../../components/ui/Preloader";
export interface CaptchaProps {
onSuccess: (token?: string) => void;
onCancel: () => void;
......@@ -17,16 +20,16 @@ export function CaptchaBlock(props: CaptchaProps) {
if (!client.configuration?.features.captcha.enabled) {
props.onSuccess();
}
}, []);
}, [client.configuration?.features.captcha.enabled, props]);
if (!client.configuration?.features.captcha.enabled)
return <Preloader />;
return <Preloader type="spinner" />;
return (
<div>
<HCaptcha
sitekey={client.configuration.features.captcha.key}
onVerify={token => props.onSuccess(token)}
onVerify={(token) => props.onSuccess(token)}
/>
<div className={styles.footer}>
<a onClick={props.onCancel}>
......
import { Legal } from "./Legal";
import { Text } from "preact-i18n";
import { CheckCircle, Envelope } from "@styled-icons/boxicons-regular";
import { useForm } from "react-hook-form";
import { Link } from "react-router-dom";
import styles from "../Login.module.scss";
import { useForm } from "react-hook-form";
import { MailProvider } from "./MailProvider";
import { Text } from "preact-i18n";
import { useContext, useState } from "preact/hooks";
import { CheckCircle, Mail } from "@styled-icons/feather";
import { CaptchaBlock, CaptchaProps } from "./CaptchaBlock";
import { takeError } from "../../../context/revoltjs/util";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { takeError } from "../../../context/revoltjs/util";
import FormField from "../FormField";
import wideSVG from "../../../assets/wide.svg";
import Button from "../../../components/ui/Button";
import Overline from "../../../components/ui/Overline";
import Preloader from "../../../components/ui/Preloader";
import FormField from "../FormField";
import { CaptchaBlock, CaptchaProps } from "./CaptchaBlock";
import { Legal } from "./Legal";
import { MailProvider } from "./MailProvider";
interface Props {
page: "create" | "login" | "send_reset" | "reset" | "resend";
callback: (fields: {
......@@ -26,11 +30,17 @@ interface Props {
}
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 code = urlParams.get("code");
return code ?? "";
}
interface FormInputs {
email: string;
password: string;
invite: string;
}
export function Form({ page, callback }: Props) {
......@@ -41,23 +51,19 @@ export function Form({ page, callback }: Props) {
const [error, setGlobalError] = useState<string | undefined>(undefined);
const [captcha, setCaptcha] = useState<CaptchaProps | undefined>(undefined);
const { handleSubmit, register, errors, setError } = useForm({
const { handleSubmit, register, errors, setError } = useForm<FormInputs>({
defaultValues: {
email: '',
password: '',
invite: getInviteCode()
}
email: "",
password: "",
invite: getInviteCode(),
},
});
async function onSubmit(data: {
email: string;
password: string;
invite: string;
}) {
async function onSubmit(data: FormInputs) {
setGlobalError(undefined);
setLoading(true);
function onError(err: any) {
function onError(err: unknown) {
setLoading(false);
const error = takeError(err);
......@@ -79,7 +85,7 @@ export function Form({ page, callback }: Props) {
page !== "reset"
) {
setCaptcha({
onSuccess: async captcha => {
onSuccess: async (captcha) => {
setCaptcha(undefined);
try {
await callback({ ...data, captcha });
......@@ -91,7 +97,7 @@ export function Form({ page, callback }: Props) {
onCancel: () => {
setCaptcha(undefined);
setLoading(false);
}
},
});
} else {
await callback(data);
......@@ -107,7 +113,7 @@ export function Form({ page, callback }: Props) {
<div className={styles.success}>
{client.configuration?.features.email ? (
<>
<Mail size={72} />
<Envelope size={72} />
<h2>
<Text id="login.check_mail" />
</h2>
......@@ -136,11 +142,18 @@ export function Form({ page, callback }: Props) {
}
if (captcha) return <CaptchaBlock {...captcha} />;
if (loading) return <Preloader />;
if (loading) return <Preloader type="spinner" />;
return (
<div className={styles.form}>
<form onSubmit={handleSubmit(onSubmit) as any}>
<img src={wideSVG} />
{/* Preact / React typing incompatabilities */}
<form
onSubmit={
handleSubmit(
onSubmit,
) as JSX.GenericEventHandler<HTMLFormElement>
}>
{page !== "reset" && (
<FormField
type="email"
......
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { useContext } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { Form } from "./Form";
export function FormCreate() {
......@@ -8,7 +10,7 @@ export function FormCreate() {
return (
<Form
page="create"
callback={async data => {
callback={async (data) => {
await client.register(import.meta.env.VITE_API_URL, data);
}}
/>
......
import { Form } from "./Form";
import { detect } from "detect-browser";
import { useContext } from "preact/hooks";
import { useHistory } from "react-router-dom";
import { useContext } from "preact/hooks";
import { OperationsContext } from "../../../context/revoltjs/RevoltClient";
import { Form } from "./Form";
export function FormLogin() {
const { login } = useContext(OperationsContext);
const history = useHistory();
......@@ -11,12 +14,16 @@ export function FormLogin() {
return (
<Form
page="login"
callback={async data => {
callback={async (data) => {
const browser = detect();
let device_name;
if (browser) {
const { name, os } = browser;
device_name = `${name} on ${os}`;
if (window.isNative) {
device_name = `Revolt Desktop on ${os}`;
} else {
device_name = `${name} on ${os}`;
}
} else {
device_name = "Unknown Device";
}
......
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { useContext } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { Form } from "./Form";
export function FormResend() {
......@@ -8,7 +10,7 @@ export function FormResend() {
return (
<Form
page="resend"
callback={async data => {
callback={async (data) => {
await client.req("POST", "/auth/resend", data);
}}
/>
......
import { Form } from "./Form";
import { useContext } from "preact/hooks";
import { useHistory, useParams } from "react-router-dom";
import { useContext } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { Form } from "./Form";
export function FormSendReset() {
const client = useContext(AppContext);
return (
<Form
page="send_reset"
callback={async data => {
callback={async (data) => {
await client.req("POST", "/auth/send_reset", data);
}}
/>
......@@ -24,10 +27,10 @@ export function FormReset() {
return (
<Form
page="reset"
callback={async data => {
await client.req("POST", "/auth/reset" as any, {
callback={async (data) => {
await client.req("POST", "/auth/reset", {
token,
...(data as any)
...data,
});
history.push("/login");
}}
......
......@@ -7,21 +7,21 @@ export function Legal() {
<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>
......
import { Text } from "preact-i18n";
import styles from "../Login.module.scss";
import { Text } from "preact-i18n";
import Button from "../../../components/ui/Button";
interface Props {
......@@ -42,7 +43,7 @@ export function MailProvider({ email }: Props) {
return (
<div className={styles.mailProvider}>
<a href={provider[1]} target="_blank">
<a href={provider[1]} target="_blank" rel="noreferrer">
<Button>
<Text
id="login.open_mail_provider"
......
import { ListCheck, ListUl } from "@styled-icons/boxicons-regular";
import { Route, Switch, useHistory, useParams } from "react-router-dom";
import { Text } from "preact-i18n";
import { List } from "@styled-icons/feather";
import Category from "../../components/ui/Category";
import { GenericSettings } from "./GenericSettings";
import { useClient } from "../../context/revoltjs/RevoltClient";
import { getChannelName } from "../../context/revoltjs/util";
import { Route, useHistory, useParams } from "react-router-dom";
import { useChannel, useForceUpdate } from "../../context/revoltjs/hooks";
import { Overview } from "./channel/Overview";
import Category from "../../components/ui/Category";
import { GenericSettings } from "./GenericSettings";
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 client = useClient();
const history = useHistory();
const channel = client.channels.get(cid);
if (!channel) return null;
if (
channel.channel_type === "SavedMessages" ||
channel.channel_type === "DirectMessage"
)
return null;
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`;
}
if (to) {
history.replace(`/channel/${cid}/settings/${to}`);
history.replace(`${base_url}/${to}`);
} else {
history.replace(`/channel/${cid}/settings`);
history.replace(base_url);
}
}
......@@ -28,19 +48,44 @@ export default function ChannelSettings() {
<GenericSettings
pages={[
{
category: <Category variant="uniform" text={getChannelName(ctx.client, channel, [], true)} />,
id: 'overview',
icon: <List size={20} strokeWidth={2} />,
title: <Text id="app.settings.channel_pages.overview.title" />
}
]}
children={[
<Route path="/"><Overview channel={channel} /></Route>
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 { Text } from "preact-i18n";
import { useEffect } from "preact/hooks";
import { ArrowBack, X } from "@styled-icons/boxicons-regular";
import { Helmet } from "react-helmet";
import { useHistory, useParams } from "react-router-dom";
import styles from "./Settings.module.scss";
import { Children } from "../../types/Preact";
import Header from '../../components/ui/Header';
import Category from '../../components/ui/Category';
import classNames from "classnames";
import { Text } from "preact-i18n";
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { ThemeContext } from "../../context/Theme";
import Category from "../../components/ui/Category";
import Header from "../../components/ui/Header";
import IconButton from "../../components/ui/IconButton";
import LineDivider from "../../components/ui/LineDivider";
import { ArrowLeft, X, XCircle } from "@styled-icons/feather";
import { Switch, useHistory, useParams } from "react-router-dom";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
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
}[]
custom?: Children
children: Children
defaultPage: string
showExitButton?: boolean
switchPage: (to?: string) => void
category: 'pages' | 'channel_pages' | 'server_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 }: Props) {
export function GenericSettings({
pages,
switchPage,
category,
custom,
children,
defaultPage,
showExitButton,
}: Props) {
const history = useHistory();
const { page } = useParams<{ page: string; }>();
const theme = useContext(ThemeContext);
const { page } = useParams<{ page: string }>();
const [closing, setClosing] = useState(false);
const exitSettings = useCallback(() => {
if (history.length > 1) {
setClosing(true);
function exitSettings() {
if (history.length > 0) {
history.goBack();
setTimeout(() => {
history.goBack();
}, 100);
} else {
history.push('/');
history.push("/");
}
}
}, [history]);
useEffect(() => {
function keyDown(e: KeyboardEvent) {
......@@ -48,24 +72,46 @@ export function GenericSettings({ pages, switchPage, category, custom, children,
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, []);
}, [exitSettings]);
return (
<div className={styles.settings} data-mobile={isTouchscreenDevice}>
<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 &&
{showExitButton && (
<IconButton onClick={exitSettings}>
<X size={24} />
</IconButton> }
<X
size={27}
style={{ marginInlineEnd: "8px" }}
/>
</IconButton>
)}
<Text id="app.settings.title" />
</>
) : (
<>
<IconButton onClick={() => switchPage()}>
<ArrowLeft size={24} />
<ArrowBack
size={24}
style={{ marginInlineEnd: "10px" }}
/>
</IconButton>
<Text
id={`app.settings.${category}.${page}.title`}
......@@ -76,43 +122,65 @@ export function GenericSettings({ pages, switchPage, category, custom, children,
)}
{(!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 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}>
{!isTouchscreenDevice && (
<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 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, 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 Category from "../../components/ui/Category";
import { GenericSettings } from "./GenericSettings";
import { useServer } from "../../context/revoltjs/hooks";
import { Route, useHistory, useParams } from "react-router-dom";
import { List, Share, Users, XSquare } from "@styled-icons/feather";
import RequiresOnline from "../../context/revoltjs/RequiresOnline";
import { useClient } from "../../context/revoltjs/RevoltClient";
import { Overview } from "./server/Overview";
import { Members } from "./server/Members";
import { Invites } from "./server/Invites";
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);
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();
......@@ -30,36 +38,79 @@ export default function ServerSettings() {
pages={[
{
category: <Category variant="uniform" text={server.name} />,
id: 'overview',
icon: <List size={20} strokeWidth={2} />,
title: <Text id="app.settings.channel_pages.overview.title" />
id: "overview",
icon: <ListUl size={20} />,
title: (
<Text id="app.settings.server_pages.overview.title" />
),
},
{
id: 'members',
icon: <Users size={20} strokeWidth={2} />,
title: "Members"
id: "categories",
icon: <ListMinus size={20} />,
title: (
<Text id="app.settings.server_pages.categories.title" />
),
},
{
id: 'invites',
icon: <Share size={20} strokeWidth={2} />,
title: "Invites"
id: "members",
icon: <Group size={20} />,
title: (
<Text id="app.settings.server_pages.members.title" />
),
},
{
id: 'bans',
icon: <XSquare size={20} strokeWidth={2} />,
title: "Bans"
}
]}
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="/"><Overview server={server} /></Route>
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
/>
)
}
);
});