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 1503 additions and 690 deletions
import { useHistory } from "react-router";
import { Server } from "revolt.js/dist/maps/Servers";
import { ulid } from "ulid";
import { Text } from "preact-i18n";
import { useHistory } from "react-router";
import { useContext, useState } from "preact/hooks";
import InputBox from "../../../components/ui/InputBox";
import Modal from "../../../components/ui/Modal";
import Overline from "../../../components/ui/Overline";
import { Children } from "../../../types/Preact";
import { takeError } from "../../revoltjs/util";
import { useContext, useState } from "preact/hooks";
import Overline from '../../../components/ui/Overline';
import InputBox from '../../../components/ui/InputBox';
import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
interface Props {
onClose: () => void;
......@@ -22,7 +26,7 @@ export function InputModal({
question,
field,
defaultValue,
callback
callback,
}: Props) {
const [processing, setProcessing] = useState(false);
const [value, setValue] = useState(defaultValue ?? "");
......@@ -35,39 +39,51 @@ export function InputModal({
disabled={processing}
actions={[
{
text: <Text id="app.special.modals.actions.ok" />,
confirmation: true,
children: <Text id="app.special.modals.actions.ok" />,
onClick: () => {
setProcessing(true);
callback(value)
.then(onClose)
.catch(err => {
.catch((err) => {
setError(takeError(err));
setProcessing(false)
})
}
setProcessing(false);
});
},
},
{
text: <Text id="app.special.modals.actions.cancel" />,
onClick: onClose
}
children: <Text id="app.special.modals.actions.cancel" />,
onClick: onClose,
},
]}
onClose={onClose}
>
{ field ? <Overline error={error} block>
{field}
</Overline> : (error && <Overline error={error} type="error" block />) }
<InputBox
value={value}
onChange={e => setValue(e.currentTarget.value)}
/>
onClose={onClose}>
<form>
{field ? (
<Overline error={error} block>
{field}
</Overline>
) : (
error && <Overline error={error} type="error" block />
)}
<InputBox
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>
</form>
</Modal>
);
}
type SpecialProps = { onClose: () => void } & (
{ type: "create_group" | "create_server" | "set_custom_status" | "add_friend" } |
{ type: "create_channel", server: string }
)
| {
type:
| "create_group"
| "create_server"
| "set_custom_status"
| "add_friend";
}
| { type: "create_role"; server: Server; callback: (id: string) => void }
);
export function SpecialInputModal(props: SpecialProps) {
const history = useHistory();
......@@ -76,83 +92,90 @@ export function SpecialInputModal(props: SpecialProps) {
const { onClose } = props;
switch (props.type) {
case "create_group": {
return <InputModal
onClose={onClose}
question={<Text id="app.main.groups.create" />}
field={<Text id="app.main.groups.name" />}
callback={async name => {
const group = await client.channels.createGroup(
{
return (
<InputModal
onClose={onClose}
question={<Text id="app.main.groups.create" />}
field={<Text id="app.main.groups.name" />}
callback={async (name) => {
const group = await client.channels.createGroup({
name,
nonce: ulid(),
users: []
}
);
users: [],
});
history.push(`/channel/${group._id}`);
}}
/>;
history.push(`/channel/${group._id}`);
}}
/>
);
}
case "create_server": {
return <InputModal
onClose={onClose}
question={<Text id="app.main.servers.create" />}
field={<Text id="app.main.servers.name" />}
callback={async name => {
const server = await client.servers.createServer(
{
return (
<InputModal
onClose={onClose}
question={<Text id="app.main.servers.create" />}
field={<Text id="app.main.servers.name" />}
callback={async (name) => {
const server = await client.servers.createServer({
name,
nonce: ulid()
}
);
nonce: ulid(),
});
history.push(`/server/${server._id}`);
}}
/>;
history.push(`/server/${server._id}`);
}}
/>
);
}
case "create_channel": {
return <InputModal
onClose={onClose}
question={<Text id="app.context_menu.create_channel" />}
field={<Text id="app.main.servers.channel_name" />}
callback={async name => {
const channel = await client.servers.createChannel(
props.server,
{
name,
nonce: ulid()
}
);
history.push(`/server/${props.server}/channel/${channel._id}`);
}}
/>;
case "create_role": {
return (
<InputModal
onClose={onClose}
question={
<Text id="app.settings.permissions.create_role" />
}
field={<Text id="app.settings.permissions.role_name" />}
callback={async (name) => {
const role = await props.server.createRole(name);
props.callback(role.id);
}}
/>
);
}
case "set_custom_status": {
return <InputModal
onClose={onClose}
question={<Text id="app.context_menu.set_custom_status" />}
field={<Text id="app.context_menu.custom_status" />}
defaultValue={client.user?.status?.text}
callback={text =>
client.users.editUser({
status: {
...client.user?.status,
text
}
})
}
/>;
return (
<InputModal
onClose={onClose}
question={<Text id="app.context_menu.set_custom_status" />}
field={<Text id="app.context_menu.custom_status" />}
defaultValue={client.user?.status?.text}
callback={(text) =>
client.users.edit({
status: {
...client.user?.status,
text: text.trim().length > 0 ? text : undefined,
},
})
}
/>
);
}
case "add_friend": {
return <InputModal
onClose={onClose}
question={"Add Friend"}
callback={username =>
client.users.addFriend(username)
}
/>;
return (
<InputModal
onClose={onClose}
question={"Add Friend"}
callback={(username) =>
client
.req(
"PUT",
`/users/${username}/friend` as "/users/id/friend",
)
.then(undefined)
}
/>
);
}
default: return null;
default:
return null;
}
}
.onboarding {
height: 100vh;
display: flex;
align-items: center;
flex-direction: column;
......@@ -7,7 +8,9 @@
flex: 1;
&.header {
gap: 8px;
padding: 3em;
display: flex;
text-align: center;
h1 {
......@@ -23,7 +26,7 @@
margin: auto;
display: block;
max-height: 420px;
border-radius: 8px;
border-radius: var(--border-radius);
}
input {
......
import { SubmitHandler, useForm } from "react-hook-form";
import styles from "./Onboarding.module.scss";
import { Text } from "preact-i18n";
import { useState } from "preact/hooks";
import { useForm } from "react-hook-form";
import styles from "./Onboarding.module.scss";
import { takeError } from "../../revoltjs/util";
import wideSVG from "../../../assets/wide.svg";
import Button from "../../../components/ui/Button";
import FormField from "../../../pages/login/FormField";
import Preloader from "../../../components/ui/Preloader";
import FormField from "../../../pages/login/FormField";
import { takeError } from "../../revoltjs/util";
interface Props {
onClose: () => void;
callback: (username: string, loginAfterSuccess?: true) => Promise<void>;
}
interface FormInputs {
username: string;
}
export function OnboardingModal({ onClose, callback }: Props) {
const { handleSubmit, register } = useForm();
const { handleSubmit, register } = useForm<FormInputs>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
async function onSubmit({ username }: { username: string }) {
const onSubmit: SubmitHandler<FormInputs> = ({ username }) => {
setLoading(true);
callback(username, true)
.then(onClose)
.catch((err: any) => {
.then(() => onClose())
.catch((err: unknown) => {
setError(takeError(err));
setLoading(false);
});
}
};
return (
<div className={styles.onboarding}>
<div className={styles.header}>
<h1>
<Text id="app.special.modals.onboarding.welcome" />
<img src="/assets/wide.svg" />
<img src={wideSVG} loading="eager" />
</h1>
</div>
<div className={styles.form}>
{loading ? (
<Preloader />
<Preloader type="spinner" />
) : (
<>
<p>
<Text id="app.special.modals.onboarding.pick" />
</p>
<form onSubmit={handleSubmit(onSubmit) as any}>
<form
onSubmit={
handleSubmit(
onSubmit,
) as JSX.GenericEventHandler<HTMLFormElement>
}>
<div>
<FormField
type="username"
......
......@@ -7,7 +7,7 @@
user-select: all;
font-size: 1.4em;
text-align: center;
font-family: "Fira Mono";
font-family: var(--monospace-font);
}
}
......
import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom";
import { Channel } from "revolt.js/dist/maps/Channels";
import { Message as MessageI } from "revolt.js/dist/maps/Messages";
import { Server } from "revolt.js/dist/maps/Servers";
import { User } from "revolt.js/dist/maps/Users";
import { ulid } from "ulid";
import styles from "./Prompt.module.scss";
import { Text } from "preact-i18n";
import styles from './Prompt.module.scss';
import { Children } from "../../../types/Preact";
import { useIntermediate } from "../Intermediate";
import { useContext, useEffect, useState } from "preact/hooks";
import { TextReact } from "../../../lib/i18n";
import Message from "../../../components/common/messaging/Message";
import UserIcon from "../../../components/common/user/UserIcon";
import InputBox from "../../../components/ui/InputBox";
import Overline from "../../../components/ui/Overline";
import UserIcon from "../../../components/common/UserIcon";
import Modal, { Action } from "../../../components/ui/Modal";
import { Channels, Servers } from "revolt.js/dist/api/objects";
import { useContext, useEffect, useState } from "preact/hooks";
import Overline from "../../../components/ui/Overline";
import Radio from "../../../components/ui/Radio";
import { Children } from "../../../types/Preact";
import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
import { useIntermediate } from "../Intermediate";
interface Props {
onClose: () => void;
......@@ -20,7 +33,14 @@ interface Props {
error?: string;
}
export function PromptModal({ onClose, question, content, actions, disabled, error }: Props) {
export function PromptModal({
onClose,
question,
content,
actions,
disabled,
error,
}: Props) {
return (
<Modal
visible={true}
......@@ -28,73 +48,107 @@ export function PromptModal({ onClose, question, content, actions, disabled, err
actions={actions}
onClose={onClose}
disabled={disabled}>
{ error && <Overline error={error} type="error" /> }
{ content }
{error && <Overline error={error} type="error" />}
{content}
</Modal>
);
}
type SpecialProps = { onClose: () => void } & (
{ type: "leave_group", target: Channels.GroupChannel } |
{ type: "close_dm", target: Channels.DirectMessageChannel } |
{ type: "leave_server", target: Servers.Server } |
{ type: "delete_server", target: Servers.Server } |
{ type: "delete_channel", target: Channels.TextChannel } |
{ type: "delete_message", target: Channels.Message } |
{ type: "create_invite", target: Channels.TextChannel | Channels.GroupChannel } |
{ type: "kick_member", target: Servers.Server, user: string } |
{ type: "ban_member", target: Servers.Server, user: string }
)
export function SpecialPromptModal(props: SpecialProps) {
| { type: "leave_group"; target: Channel }
| { type: "close_dm"; target: Channel }
| { type: "leave_server"; target: Server }
| { type: "delete_server"; target: Server }
| { type: "delete_channel"; target: Channel }
| { type: "delete_message"; target: MessageI }
| {
type: "create_invite";
target: Channel;
}
| { type: "kick_member"; target: Server; user: User }
| { type: "ban_member"; target: Server; user: User }
| { type: "unfriend_user"; target: User }
| { type: "block_user"; target: User }
| { type: "create_channel"; target: Server }
);
export const SpecialPromptModal = observer((props: SpecialProps) => {
const client = useContext(AppContext);
const [ processing, setProcessing ] = useState(false);
const [ error, setError ] = useState<undefined | string>(undefined);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<undefined | string>(undefined);
const { onClose } = props;
switch (props.type) {
case 'leave_group':
case 'close_dm':
case 'leave_server':
case 'delete_server':
case 'delete_message':
case 'delete_channel': {
case "leave_group":
case "close_dm":
case "leave_server":
case "delete_server":
case "delete_channel":
case "unfriend_user":
case "block_user": {
const EVENTS = {
'close_dm': 'confirm_close_dm',
'delete_server': 'confirm_delete',
'delete_channel': 'confirm_delete',
'delete_message': 'confirm_delete_message',
'leave_group': 'confirm_leave',
'leave_server': 'confirm_leave'
close_dm: ["confirm_close_dm", "close"],
delete_server: ["confirm_delete", "delete"],
delete_channel: ["confirm_delete", "delete"],
leave_group: ["confirm_leave", "leave"],
leave_server: ["confirm_leave", "leave"],
unfriend_user: ["unfriend_user", "remove"],
block_user: ["block_user", "block"],
};
let event = EVENTS[props.type];
let name = props.type === 'close_dm' ? client.users.get(client.channels.getRecipient(props.target._id))?.username :
props.type === 'delete_message' ? undefined : props.target.name;
const event = EVENTS[props.type];
let name;
switch (props.type) {
case "unfriend_user":
case "block_user":
name = props.target.username;
break;
case "close_dm":
name = props.target.recipient?.username;
break;
default:
name = props.target.name;
}
return (
<PromptModal
onClose={onClose}
question={<Text
id={props.type === 'delete_message' ? 'app.context_menu.delete_message' : `app.special.modals.prompt.${event}`}
fields={{ name }}
/>}
question={
<Text
id={`app.special.modals.prompt.${event[0]}`}
fields={{ name }}
/>
}
actions={[
{
confirmation: true,
contrast: true,
error: true,
text: <Text id="app.special.modals.actions.delete" />,
children: (
<Text
id={`app.special.modals.actions.${event[1]}`}
/>
),
onClick: async () => {
setProcessing(true);
try {
if (props.type === 'leave_group' || props.type === 'close_dm' || props.type === 'delete_channel') {
await client.channels.delete(props.target._id);
} else if (props.type === 'delete_message') {
await client.channels.deleteMessage(props.target.channel, props.target._id);
} else {
await client.servers.delete(props.target._id);
switch (props.type) {
case "unfriend_user":
await props.target.removeFriend();
break;
case "block_user":
await props.target.blockUser();
break;
case "leave_group":
case "close_dm":
case "delete_channel":
props.target.delete();
break;
case "leave_server":
case "delete_server":
props.target.delete();
break;
}
onClose();
......@@ -102,28 +156,89 @@ export function SpecialPromptModal(props: SpecialProps) {
setError(takeError(err));
setProcessing(false);
}
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<TextReact
id={`app.special.modals.prompt.${event[0]}_long`}
fields={{ name: <b>{name}</b> }}
/>
}
disabled={processing}
error={error}
/>
);
}
case "delete_message": {
return (
<PromptModal
onClose={onClose}
question={<Text id={"app.context_menu.delete_message"} />}
actions={[
{
confirmation: true,
contrast: true,
error: true,
children: (
<Text id="app.special.modals.actions.delete" />
),
onClick: async () => {
setProcessing(true);
try {
props.target.delete();
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
plain: true,
},
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
]}
content={<Text id={`app.special.modals.prompt.${event}_long`} />}
content={
<>
<Text
id={`app.special.modals.prompt.confirm_delete_message_long`}
/>
<Message
message={props.target}
head={true}
contrast
/>
</>
}
disabled={processing}
error={error}
/>
)
);
}
case "create_invite": {
const [ code, setCode ] = useState('abcdef');
const [code, setCode] = useState("abcdef");
const { writeClipboard } = useIntermediate();
useEffect(() => {
setProcessing(true);
client.channels.createInvite(props.target._id)
.then(code => setCode(code))
.catch(err => setError(takeError(err)))
props.target
.createInvite()
.then((code) => setCode(code))
.catch((err) => setError(takeError(err)))
.finally(() => setProcessing(false));
}, []);
}, [props.target]);
return (
<PromptModal
......@@ -131,69 +246,89 @@ export function SpecialPromptModal(props: SpecialProps) {
question={<Text id={`app.context_menu.create_invite`} />}
actions={[
{
text: <Text id="app.special.modals.actions.ok" />,
children: (
<Text id="app.special.modals.actions.ok" />
),
confirmation: true,
onClick: onClose
onClick: onClose,
},
{
text: <Text id="app.context_menu.copy_link" />,
onClick: () => writeClipboard(`${window.location.protocol}//${window.location.host}/invite/${code}`)
}
children: <Text id="app.context_menu.copy_link" />,
onClick: () =>
writeClipboard(
`${window.location.protocol}//${window.location.host}/invite/${code}`,
),
},
]}
content={
processing ?
processing ? (
<Text id="app.special.modals.prompt.create_invite_generate" />
: <div className={styles.invite}>
<Text id="app.special.modals.prompt.create_invite_created" />
<code>{code}</code>
) : (
<div className={styles.invite}>
<Text id="app.special.modals.prompt.create_invite_created" />
<code>{code}</code>
</div>
)
}
disabled={processing}
error={error}
/>
)
);
}
case "kick_member": {
const user = client.users.get(props.user);
return (
<PromptModal
onClose={onClose}
question={<Text id={`app.context_menu.kick_member`} />}
actions={[
{
text: <Text id="app.special.modals.actions.kick" />,
children: (
<Text id="app.special.modals.actions.kick" />
),
contrast: true,
error: true,
confirmation: true,
onClick: async () => {
setProcessing(true);
try {
await client.servers.members.kickMember(props.target._id, props.user);
client.members
.getKey({
server: props.target._id,
user: props.user._id,
})
?.kick();
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
]}
content={<div className={styles.column}>
<UserIcon target={user} size={64} />
<Text
id="app.special.modals.prompt.confirm_kick"
fields={{ name: user?.username }} />
</div>}
content={
<div className={styles.column}>
<UserIcon target={props.user} size={64} />
<Text
id="app.special.modals.prompt.confirm_kick"
fields={{ name: props.user?.username }}
/>
</div>
}
disabled={processing}
error={error}
/>
)
);
}
case "ban_member": {
const [ reason, setReason ] = useState<string | undefined>(undefined);
const user = client.users.get(props.user);
const [reason, setReason] = useState<string | undefined>(undefined);
return (
<PromptModal
......@@ -201,37 +336,130 @@ export function SpecialPromptModal(props: SpecialProps) {
question={<Text id={`app.context_menu.ban_member`} />}
actions={[
{
text: <Text id="app.special.modals.actions.ban" />,
children: (
<Text id="app.special.modals.actions.ban" />
),
contrast: true,
error: true,
confirmation: true,
onClick: async () => {
setProcessing(true);
try {
await client.servers.banUser(props.target._id, props.user, { reason });
await props.target.banUser(props.user._id, {
reason,
});
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
]}
content={<div className={styles.column}>
<UserIcon target={user} size={64} />
<Text
id="app.special.modals.prompt.confirm_ban"
fields={{ name: user?.username }} />
<Overline><Text id="app.special.modals.prompt.confirm_ban_reason" /></Overline>
<InputBox value={reason ?? ''} onChange={e => setReason(e.currentTarget.value)} />
</div>}
content={
<div className={styles.column}>
<UserIcon target={props.user} size={64} />
<Text
id="app.special.modals.prompt.confirm_ban"
fields={{ name: props.user?.username }}
/>
<Overline>
<Text id="app.special.modals.prompt.confirm_ban_reason" />
</Overline>
<InputBox
value={reason ?? ""}
onChange={(e) =>
setReason(e.currentTarget.value)
}
/>
</div>
}
disabled={processing}
error={error}
/>
)
);
}
default: return null;
case "create_channel": {
const [name, setName] = useState("");
const [type, setType] = useState<"Text" | "Voice">("Text");
const history = useHistory();
return (
<PromptModal
onClose={onClose}
question={<Text id="app.context_menu.create_channel" />}
actions={[
{
confirmation: true,
contrast: true,
children: (
<Text id="app.special.modals.actions.create" />
),
onClick: async () => {
setProcessing(true);
try {
const channel =
await props.target.createChannel({
type,
name,
nonce: ulid(),
});
history.push(
`/server/${props.target._id}/channel/${channel._id}`,
);
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<>
<Overline block type="subtle">
<Text id="app.main.servers.channel_type" />
</Overline>
<Radio
checked={type === "Text"}
onSelect={() => setType("Text")}>
<Text id="app.main.servers.text_channel" />
</Radio>
<Radio
checked={type === "Voice"}
onSelect={() => setType("Voice")}>
<Text id="app.main.servers.voice_channel" />
</Radio>
<Overline block type="subtle">
<Text id="app.main.servers.channel_name" />
</Overline>
<InputBox
value={name}
onChange={(e) => setName(e.currentTarget.value)}
/>
</>
}
disabled={processing}
error={error}
/>
);
}
default:
return null;
}
}
});
import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal";
interface Props {
......@@ -15,8 +16,8 @@ export function SignedOutModal({ onClose }: Props) {
{
onClick: onClose,
confirmation: true,
text: <Text id="app.special.modals.actions.ok" />
}
children: <Text id="app.special.modals.actions.ok" />,
},
]}
/>
);
......
import { X } from "@styled-icons/feather";
import { X } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js/dist/maps/Channels";
import styles from "./ChannelInfo.module.scss";
import Modal from "../../../components/ui/Modal";
import { getChannelName } from "../../revoltjs/util";
import Markdown from "../../../components/markdown/Markdown";
import { useChannel, useForceUpdate } from "../../revoltjs/hooks";
import { getChannelName } from "../../revoltjs/util";
interface Props {
channel_id: string;
channel: Channel;
onClose: () => void;
}
export function ChannelInfo({ channel_id, onClose }: Props) {
const ctx = useForceUpdate();
const channel = useChannel(channel_id, ctx);
if (!channel) return null;
if (channel.channel_type === "DirectMessage" || channel.channel_type === 'SavedMessages') {
export const ChannelInfo = observer(({ channel, onClose }: Props) => {
if (
channel.channel_type === "DirectMessage" ||
channel.channel_type === "SavedMessages"
) {
onClose();
return null;
}
......@@ -24,15 +27,15 @@ export function ChannelInfo({ channel_id, onClose }: Props) {
<Modal visible={true} onClose={onClose}>
<div className={styles.info}>
<div className={styles.header}>
<h1>{ getChannelName(ctx.client, channel, [ ], true) }</h1>
<h1>{getChannelName(channel, true)}</h1>
<div onClick={onClose}>
<X size={36} />
</div>
</div>
<p>
<Markdown content={channel.description} />
<Markdown content={channel.description!} />
</p>
</div>
</Modal>
);
}
});
.viewer {
display: flex;
flex-direction: column;
border-end-end-radius: 4px;
border-end-start-radius: 4px;
overflow: hidden;
img {
width: auto;
height: auto;
max-width: 90vw;
max-height: 90vh;
max-height: 75vh;
border-bottom: thin solid var(--tertiary-foreground);
object-fit: contain;
}
}
/* eslint-disable react-hooks/rules-of-hooks */
import { Attachment, AttachmentMetadata } from "revolt-api/types/Autumn";
import { EmbedImage } from "revolt-api/types/January";
import styles from "./ImageViewer.module.scss";
import AttachmentActions from "../../../components/common/messaging/attachments/AttachmentActions";
import EmbedMediaActions from "../../../components/common/messaging/embed/EmbedMediaActions";
import Modal from "../../../components/ui/Modal";
import { useContext, useEffect } from "preact/hooks";
import { AppContext } from "../../revoltjs/RevoltClient";
import { Attachment, EmbedImage } from "revolt.js/dist/api/objects";
import { useClient } from "../../revoltjs/RevoltClient";
interface Props {
onClose: () => void;
......@@ -10,36 +16,45 @@ interface Props {
attachment?: Attachment;
}
export function ImageViewer({ attachment, embed, onClose }: Props) {
if (attachment && attachment.metadata.type !== "Image") return null;
const client = useContext(AppContext);
type ImageMetadata = AttachmentMetadata & { type: "Image" };
useEffect(() => {
function keyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
}
}
export function ImageViewer({ attachment, embed, onClose }: Props) {
if (attachment && attachment.metadata.type !== "Image") {
console.warn(
`Attempted to use a non valid attatchment type in the image viewer: ${attachment.metadata.type}`,
);
return null;
}
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, []);
const client = useClient();
return (
<Modal visible={true} onClose={onClose} noBackground>
<div className={styles.viewer}>
{ attachment &&
{attachment && (
<>
<img src={client.generateFileURL(attachment)} />
{/*<AttachmentActions attachment={attachment} />*/}
<img
loading="eager"
src={client.generateFileURL(attachment)}
width={(attachment.metadata as ImageMetadata).width}
height={
(attachment.metadata as ImageMetadata).height
}
/>
<AttachmentActions attachment={attachment} />
</>
}
{ embed &&
)}
{embed && (
<>
{/*<img src={proxyImage(embed.url)} />*/}
{/*<EmbedMediaActions embed={embed} />*/}
<img
loading="eager"
src={client.proxyFile(embed.url)}
width={embed.width}
height={embed.height}
/>
<EmbedMediaActions embed={embed} />
</>
}
)}
</div>
</Modal>
);
......
import { SubmitHandler, useForm } from "react-hook-form";
import { Text } from "preact-i18n";
import { useForm } from "react-hook-form";
import Modal from "../../../components/ui/Modal";
import { takeError } from "../../revoltjs/util";
import { useContext, useState } from "preact/hooks";
import FormField from '../../../pages/login/FormField';
import Modal from "../../../components/ui/Modal";
import Overline from "../../../components/ui/Overline";
import FormField from "../../../pages/login/FormField";
import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
interface Props {
onClose: () => void;
field: "username" | "email" | "password";
}
interface FormInputs {
password: string;
new_email: string;
new_username: string;
new_password: string;
// TODO: figure out if this is correct or not
// it wasn't in the types before this was typed but the element itself was there
current_password?: string;
}
export function ModifyAccountModal({ onClose, field }: Props) {
const client = useContext(AppContext);
const { handleSubmit, register, errors } = useForm();
const { handleSubmit, register, errors } = useForm<FormInputs>();
const [error, setError] = useState<string | undefined>(undefined);
async function onSubmit({
const onSubmit: SubmitHandler<FormInputs> = async ({
password,
new_username,
new_email,
new_password
}: {
password: string;
new_username: string;
new_email: string;
new_password: string;
}) {
new_password,
}) => {
try {
if (field === "email") {
await client.req("POST", "/auth/change/email", {
password,
new_email
new_email,
});
onClose();
} else if (field === "password") {
await client.req("POST", "/auth/change/password", {
password,
new_password
new_password,
});
onClose();
} else if (field === "username") {
await client.req("PATCH", "/users/id/username", {
username: new_username,
password
password,
});
onClose();
}
} catch (err) {
setError(takeError(err));
}
}
};
return (
<Modal
......@@ -62,20 +71,27 @@ export function ModifyAccountModal({ onClose, field }: Props) {
{
confirmation: true,
onClick: handleSubmit(onSubmit),
text:
children:
field === "email" ? (
<Text id="app.special.modals.actions.send_email" />
) : (
<Text id="app.special.modals.actions.update" />
)
),
},
{
onClick: onClose,
text: <Text id="app.special.modals.actions.close" />
}
]}
>
<form onSubmit={handleSubmit(onSubmit) as any}>
children: <Text id="app.special.modals.actions.close" />,
},
]}>
{/* Preact / React typing incompatabilities */}
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit(
onSubmit,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
)(e as any);
}}>
{field === "email" && (
<FormField
type="email"
......
import { observer } from "mobx-react-lite";
import { User } from "revolt.js/dist/maps/Users";
import styles from "./UserPicker.module.scss";
import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal";
import { Friend } from "../../../pages/friends/Friend";
interface Props {
users: User[];
onClose: () => void;
}
export const PendingRequests = observer(({ users, onClose }: Props) => {
return (
<Modal
visible={true}
title={<Text id="app.special.friends.pending" />}
onClose={onClose}>
<div className={styles.list}>
{users.map((x) => (
<Friend user={x!} key={x!._id} />
))}
</div>
</Modal>
);
});
......@@ -4,7 +4,6 @@
max-height: 360px;
overflow-y: scroll;
// ! FIXME: very temporary code
> label {
> span {
align-items: flex-start !important;
......@@ -18,4 +17,4 @@
}
}
}
}
\ No newline at end of file
}
import { RelationshipStatus } from "revolt-api/types/Users";
import styles from "./UserPicker.module.scss";
import { Text } from "preact-i18n";
import { useState } from "preact/hooks";
import styles from "./UserPicker.module.scss";
import { useUsers } from "../../revoltjs/hooks";
import UserCheckbox from "../../../components/common/user/UserCheckbox";
import Modal from "../../../components/ui/Modal";
import { User, Users } from "revolt.js/dist/api/objects";
import UserCheckbox from "../../../components/common/UserCheckbox";
import { useClient } from "../../revoltjs/RevoltClient";
interface Props {
omit?: string[];
......@@ -16,7 +19,7 @@ export function UserPicker(props: Props) {
const [selected, setSelected] = useState<string[]>([]);
const omit = [...(props.omit || []), "00000000000000000000000000"];
const users = useUsers();
const client = useClient();
return (
<Modal
......@@ -25,34 +28,29 @@ export function UserPicker(props: Props) {
onClose={props.onClose}
actions={[
{
text: <Text id="app.special.modals.actions.ok" />,
onClick: () => props.callback(selected).then(props.onClose)
}
]}
>
children: <Text id="app.special.modals.actions.ok" />,
onClick: () => props.callback(selected).then(props.onClose),
},
]}>
<div className={styles.list}>
{(users.filter(
x =>
x &&
x.relationship === Users.Relationship.Friend &&
!omit.includes(x._id)
) as User[])
.map(x => {
return {
...x,
selected: selected.includes(x._id)
};
})
.map(x => (
{[...client.users.values()]
.filter(
(x) =>
x &&
x.relationship === RelationshipStatus.Friend &&
!omit.includes(x._id),
)
.map((x) => (
<UserCheckbox
key={x._id}
user={x}
checked={x.selected}
onChange={v => {
checked={selected.includes(x._id)}
onChange={(v) => {
if (v) {
setSelected([...selected, x._id]);
} else {
setSelected(
selected.filter(y => y !== x._id)
selected.filter((y) => y !== x._id),
);
}
}}
......
......@@ -7,7 +7,7 @@
.header {
background-size: cover;
border-radius: 8px 8px 0 0;
border-radius: var(--border-radius) var(--border-radius) 0 0;
background-position: center;
background-color: var(--secondary-background);
......@@ -35,7 +35,7 @@
display: flex;
flex-direction: column;
> * {
* {
min-width: 0;
overflow: hidden;
white-space: nowrap;
......@@ -57,13 +57,13 @@
gap: 8px;
display: flex;
padding: 0 1.5em;
font-size: .875rem;
font-size: 0.875rem;
> div {
padding: 8px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: border-bottom .3s;
transition: border-bottom 0.3s;
&[data-active="true"] {
border-bottom: 2px solid var(--foreground);
......@@ -81,7 +81,10 @@
height: 100%;
display: flex;
padding: 1em 1.5em;
max-width: 560px;
max-height: 240px;
overflow-y: auto;
flex-direction: column;
background: var(--primary-background);
......@@ -140,11 +143,11 @@
padding: 12px;
display: flex;
cursor: pointer;
border-radius: 4px;
align-items: center;
transition: background-color 0.1s;
color: var(--secondary-foreground);
border-radius: var(--border-radius);
background-color: var(--secondary-background);
transition: background-color .1s;
&:hover {
background-color: var(--primary-background);
......
import Modal from "../../../components/ui/Modal";
import { Localizer, Text } from "preact-i18n";
import styles from "./UserProfile.module.scss";
import Preloader from "../../../components/ui/Preloader";
import { Route } from "revolt.js/dist/api/routes";
import { Users } from "revolt.js/dist/api/objects";
import { IntermediateContext, useIntermediate } from "../Intermediate";
import { Globe, Mail, Edit, UserPlus, Shield } from "@styled-icons/feather";
import { Money } from "@styled-icons/boxicons-regular";
import { Envelope, Edit, UserPlus, Shield } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { Link, useHistory } from "react-router-dom";
import { Profile, RelationshipStatus } from "revolt-api/types/Users";
import { UserPermission } from "revolt.js/dist/api/permissions";
import { Route } from "revolt.js/dist/api/routes";
import styles from "./UserProfile.module.scss";
import { Localizer, Text } from "preact-i18n";
import { useContext, useEffect, useLayoutEffect, useState } from "preact/hooks";
import { decodeTime } from "ulid";
import { CashStack } from "@styled-icons/bootstrap";
import { AppContext, ClientStatus, StatusContext } from "../../revoltjs/RevoltClient";
import { useChannels, useForceUpdate, useUser, useUsers } from "../../revoltjs/hooks";
import UserIcon from '../../../components/common/UserIcon';
import UserStatus from '../../../components/common/UserStatus';
import Tooltip from '../../../components/common/Tooltip';
import ChannelIcon from '../../../components/common/ChannelIcon';
import Markdown from '../../../components/markdown/Markdown';
import ChannelIcon from "../../../components/common/ChannelIcon";
import Tooltip from "../../../components/common/Tooltip";
import UserIcon from "../../../components/common/user/UserIcon";
import UserStatus from "../../../components/common/user/UserStatus";
import IconButton from "../../../components/ui/IconButton";
import Modal from "../../../components/ui/Modal";
import Preloader from "../../../components/ui/Preloader";
import Markdown from "../../../components/markdown/Markdown";
import {
ClientStatus,
StatusContext,
useClient,
} from "../../revoltjs/RevoltClient";
import { useIntermediate } from "../Intermediate";
interface Props {
user_id: string;
dummy?: boolean;
onClose: () => void;
dummyProfile?: Users.Profile;
onClose?: () => void;
dummyProfile?: Profile;
}
enum Badges {
......@@ -30,312 +38,316 @@ enum Badges {
Translator = 2,
Supporter = 4,
ResponsibleDisclosure = 8,
EarlyAdopter = 256
EarlyAdopter = 256,
}
export function UserProfile({ user_id, onClose, dummy, dummyProfile }: Props) {
const { writeClipboard } = useIntermediate();
export const UserProfile = observer(
({ user_id, onClose, dummy, dummyProfile }: Props) => {
const { openScreen, writeClipboard } = useIntermediate();
const [profile, setProfile] = useState<undefined | null | Users.Profile>(
undefined
);
const [mutual, setMutual] = useState<
undefined | null | Route<"GET", "/users/id/mutual">["response"]
>(undefined);
const [profile, setProfile] = useState<undefined | null | Profile>(
undefined,
);
const [mutual, setMutual] = useState<
undefined | null | Route<"GET", "/users/id/mutual">["response"]
>(undefined);
const client = useContext(AppContext);
const status = useContext(StatusContext);
const [tab, setTab] = useState("profile");
const history = useHistory();
const history = useHistory();
const client = useClient();
const status = useContext(StatusContext);
const [tab, setTab] = useState("profile");
const ctx = useForceUpdate();
const all_users = useUsers(undefined, ctx);
const channels = useChannels(undefined, ctx);
const user = all_users.find(x => x!._id === user_id);
const users = mutual?.users ? all_users.filter(x => mutual.users.includes(x!._id)) : undefined;
const user = client.users.get(user_id);
if (!user) {
if (onClose) useEffect(onClose, []);
return null;
}
if (!user) {
useEffect(onClose, []);
return null;
}
const users = mutual?.users.map((id) => client.users.get(id));
useLayoutEffect(() => {
if (!user_id) return;
if (typeof profile !== 'undefined') setProfile(undefined);
if (typeof mutual !== 'undefined') setMutual(undefined);
}, [user_id]);
const mutualGroups = [...client.channels.values()].filter(
(channel) =>
channel?.channel_type === "Group" &&
channel.recipient_ids!.includes(user_id),
);
if (dummy) {
useLayoutEffect(() => {
setProfile(dummyProfile);
}, [dummyProfile]);
}
if (!user_id) return;
if (typeof profile !== "undefined") setProfile(undefined);
if (typeof mutual !== "undefined") setMutual(undefined);
// eslint-disable-next-line
}, [user_id]);
useEffect(() => {
if (dummy) return;
if (
status === ClientStatus.ONLINE &&
typeof mutual === "undefined"
) {
setMutual(null);
client.users
.fetchMutual(user_id)
.then(data => setMutual(data));
}
}, [mutual, status]);
useEffect(() => {
if (dummy) {
setProfile(dummyProfile);
}
}, [dummy, dummyProfile]);
useEffect(() => {
if (dummy) return;
if (
status === ClientStatus.ONLINE &&
typeof profile === "undefined"
) {
setProfile(null);
useEffect(() => {
if (dummy) return;
if (
status === ClientStatus.ONLINE &&
typeof mutual === "undefined"
) {
setMutual(null);
user.fetchMutual().then(setMutual);
}
}, [mutual, status, dummy, user]);
// ! FIXME: in the future, also check if mutual guilds
// ! maybe just allow mutual group to allow profile viewing
/*if (
user.relationship === Users.Relationship.Friend ||
user.relationship === Users.Relationship.User
) {*/
client.users
.fetchProfile(user_id)
.then(data => setProfile(data))
.catch(() => {});
//}
}
}, [profile, status]);
useEffect(() => {
if (dummy) return;
if (
status === ClientStatus.ONLINE &&
typeof profile === "undefined"
) {
setProfile(null);
const mutualGroups = channels.filter(
channel =>
channel?.channel_type === "Group" &&
channel.recipients.includes(user_id)
);
if (user.permission & UserPermission.ViewProfile) {
user.fetchProfile().then(setProfile);
}
}
}, [profile, status, dummy, user]);
const backgroundURL = profile && client.users.getBackgroundURL(profile, { width: 1000 }, true);
const badges = (user.badges ?? 0) | (decodeTime(user._id) < 1623751765790 ? Badges.EarlyAdopter : 0);
const backgroundURL =
profile &&
client.generateFileURL(profile.background, { width: 1000 }, true);
const badges = user.badges ?? 0;
return (
<Modal
visible
border={dummy}
onClose={onClose}
dontModal={dummy}
>
<div
className={styles.header}
data-force={
profile?.background
? "light"
: undefined
}
style={{
backgroundImage: backgroundURL && `linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7) ), url('${backgroundURL}')`
}}
>
<div className={styles.profile}>
<UserIcon size={80} target={user} status />
<div className={styles.details}>
<Localizer>
<span
className={styles.username}
onClick={() => writeClipboard(user.username)}>
@{user.username}
</span>
</Localizer>
{user.status?.text && (
<span className={styles.status}>
<UserStatus user={user} />
</span>
return (
<Modal
visible
border={dummy}
padding={false}
onClose={onClose}
dontModal={dummy}>
<div
className={styles.header}
data-force={profile?.background ? "light" : undefined}
style={{
backgroundImage:
backgroundURL &&
`linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7) ), url('${backgroundURL}')`,
}}>
<div className={styles.profile}>
<UserIcon size={80} target={user} status animate />
<div className={styles.details}>
<Localizer>
<span
className={styles.username}
onClick={() =>
writeClipboard(user.username)
}>
@{user.username}
</span>
</Localizer>
{user.status?.text && (
<span className={styles.status}>
<UserStatus user={user} tooltip />
</span>
)}
</div>
{user.relationship === RelationshipStatus.Friend && (
<Localizer>
<Tooltip
content={
<Text id="app.context_menu.message_user" />
}>
<IconButton
onClick={() => {
onClose?.();
history.push(`/open/${user_id}`);
}}>
<Envelope size={30} />
</IconButton>
</Tooltip>
</Localizer>
)}
{user.relationship === RelationshipStatus.User && (
<IconButton
onClick={() => {
onClose?.();
if (dummy) return;
history.push(`/settings/profile`);
}}>
<Edit size={28} />
</IconButton>
)}
{(user.relationship === RelationshipStatus.Incoming ||
user.relationship === RelationshipStatus.None) && (
<IconButton onClick={() => user.addFriend()}>
<UserPlus size={28} />
</IconButton>
)}
</div>
{user.relationship === Users.Relationship.Friend && (
<Localizer>
<Tooltip
content={
<Text id="app.context_menu.message_user" />
}
>
{/*<IconButton
onClick={() => {
onClose();
history.push(`/open/${user_id}`);
}}
>*/}
<Mail size={30} strokeWidth={1.5} />
{/*</IconButton>*/}
</Tooltip>
</Localizer>
)}
{user.relationship === Users.Relationship.User && (
/*<IconButton
onClick={() => {
onClose();
if (dummy) return;
history.push(`/settings/profile`);
}}
>*/
<Edit size={28} strokeWidth={1.5} />
/*</IconButton>*/
)}
{(user.relationship === Users.Relationship.Incoming ||
user.relationship === Users.Relationship.None) && (
/*<IconButton
onClick={() => client.users.addFriend(user.username)}
>*/
<UserPlus size={28} strokeWidth={1.5} />
/*</IconButton>*/
)}
</div>
<div className={styles.tabs}>
<div
data-active={tab === "profile"}
onClick={() => setTab("profile")}
>
<Text id="app.special.popovers.user_profile.profile" />
<div className={styles.tabs}>
<div
data-active={tab === "profile"}
onClick={() => setTab("profile")}>
<Text id="app.special.popovers.user_profile.profile" />
</div>
{user.relationship !== RelationshipStatus.User && (
<>
<div
data-active={tab === "friends"}
onClick={() => setTab("friends")}>
<Text id="app.special.popovers.user_profile.mutual_friends" />
</div>
<div
data-active={tab === "groups"}
onClick={() => setTab("groups")}>
<Text id="app.special.popovers.user_profile.mutual_groups" />
</div>
</>
)}
</div>
{ user.relationship !== Users.Relationship.User &&
<>
<div
data-active={tab === "friends"}
onClick={() => setTab("friends")}
>
<Text id="app.special.popovers.user_profile.mutual_friends" />
</div>
<div
data-active={tab === "groups"}
onClick={() => setTab("groups")}
>
<Text id="app.special.popovers.user_profile.mutual_groups" />
</div>
</>
}
</div>
</div>
<div className={styles.content}>
{tab === "profile" &&
<div>
{ !(profile?.content || (badges > 0)) &&
<div className={styles.empty}><Text id="app.special.popovers.user_profile.empty" /></div> }
{ (badges > 0) && <div className={styles.category}><Text id="app.special.popovers.user_profile.sub.badges" /></div> }
{ (badges > 0) && (
<div className={styles.badges}>
<Localizer>
{badges & Badges.Developer ? (
<Tooltip
content={
<Text id="app.navigation.tabs.dev" />
}
>
<img src="/assets/badges/developer.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.Translator ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.translator" />
}
>
<img src="/assets/badges/translator.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.EarlyAdopter ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.early_adopter" />
}
>
<img src="/assets/badges/early_adopter.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.Supporter ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.supporter" />
}
>
<CashStack size={32} color="#efab44" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.ResponsibleDisclosure ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.responsible_disclosure" />
}
>
<Shield size={32} color="gray" />
</Tooltip>
) : (
<></>
)}
</Localizer>
<div className={styles.content}>
{tab === "profile" && (
<div>
{!(profile?.content || badges > 0) && (
<div className={styles.empty}>
<Text id="app.special.popovers.user_profile.empty" />
</div>
)}
{badges > 0 && (
<div className={styles.category}>
<Text id="app.special.popovers.user_profile.sub.badges" />
</div>
)}
{badges > 0 && (
<div className={styles.badges}>
<Localizer>
{badges & Badges.Developer ? (
<Tooltip
content={
<Text id="app.navigation.tabs.dev" />
}>
<img src="/assets/badges/developer.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.Translator ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.translator" />
}>
<img src="/assets/badges/translator.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.EarlyAdopter ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.early_adopter" />
}>
<img src="/assets/badges/early_adopter.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.Supporter ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.supporter" />
}>
<Money
size={32}
color="#efab44"
/>
</Tooltip>
) : (
<></>
)}
{badges &
Badges.ResponsibleDisclosure ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.responsible_disclosure" />
}>
<Shield
size={32}
color="gray"
/>
</Tooltip>
) : (
<></>
)}
</Localizer>
</div>
)}
{profile?.content && (
<div className={styles.category}>
<Text id="app.special.popovers.user_profile.sub.information" />
</div>
)}
<Markdown content={profile?.content} />
{/*<div className={styles.category}><Text id="app.special.popovers.user_profile.sub.connections" /></div>*/}
</div>
)}
{ profile?.content && <div className={styles.category}><Text id="app.special.popovers.user_profile.sub.information" /></div> }
<Markdown content={profile?.content} />
{/*<div className={styles.category}><Text id="app.special.popovers.user_profile.sub.connections" /></div>*/}
</div>}
{tab === "friends" &&
(users ? (
{tab === "friends" &&
(users ? (
<div className={styles.entries}>
{users.length === 0 ? (
<div className={styles.empty}>
<Text id="app.special.popovers.user_profile.no_users" />
</div>
) : (
users.map(
(x) =>
x && (
<div
onClick={() =>
openScreen({
id: "profile",
user_id: x._id,
})
}
className={styles.entry}
key={x._id}>
<UserIcon
size={32}
target={x}
/>
<span>{x.username}</span>
</div>
),
)
)}
</div>
) : (
<Preloader type="ring" />
))}
{tab === "groups" && (
<div className={styles.entries}>
{users.length === 0 ? (
{mutualGroups.length === 0 ? (
<div className={styles.empty}>
<Text id="app.special.popovers.user_profile.no_users" />
<Text id="app.special.popovers.user_profile.no_groups" />
</div>
) : (
users.map(
x =>
x && (
//<LinkProfile user_id={x._id}>
mutualGroups.map(
(x) =>
x?.channel_type === "Group" && (
<Link to={`/channel/${x._id}`}>
<div
className={styles.entry}
key={x._id}
>
<UserIcon size={32} target={x} />
<span>{x.username}</span>
key={x._id}>
<ChannelIcon
target={x}
size={32}
/>
<span>{x.name}</span>
</div>
//</LinkProfile>
)
</Link>
),
)
)}
</div>
) : (
<Preloader />
))}
{tab === "groups" && (
<div className={styles.entries}>
{mutualGroups.length === 0 ? (
<div className={styles.empty}>
<Text id="app.special.popovers.user_profile.no_groups" />
</div>
) : (
mutualGroups.map(
x =>
x?.channel_type === "Group" && (
<Link to={`/channel/${x._id}`}>
<div
className={styles.entry}
key={x._id}
>
<ChannelIcon target={x} size={32} />
<span>{x.name}</span>
</div>
</Link>
)
)
)}
</div>
)}
</div>
</Modal>
);
}
)}
</div>
</Modal>
);
},
);
import { ReactNode } from "react";
import { useContext } from "preact/hooks";
import { Redirect } from "react-router-dom";
import { useContext } from "preact/hooks";
import { Children } from "../../types/Preact";
import { OperationsContext } from "./RevoltClient";
interface Props {
auth?: boolean;
children: ReactNode | ReactNode[];
children: Children;
}
export const CheckAuth = (props: Props) => {
......
......@@ -10,7 +10,7 @@
&.banner {
.image {
border-radius: 4px;
border-radius: var(--border-radius);
}
.modify {
......
// ! FIXME: also TEMP CODE
// ! RE-WRITE WITH STYLED-COMPONENTS
import { Plus } from "@styled-icons/boxicons-regular";
import { Pencil } from "@styled-icons/boxicons-solid";
import Axios, { AxiosRequestConfig } from "axios";
import { Text } from "preact-i18n";
import { takeError } from "./util";
import styles from "./FileUploads.module.scss";
import classNames from "classnames";
import styles from './FileUploads.module.scss';
import Axios, { AxiosRequestConfig } from "axios";
import { useContext, useState } from "preact/hooks";
import { Edit, Plus, X } from "@styled-icons/feather";
import Preloader from "../../components/ui/Preloader";
import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks";
import { determineFileSize } from "../../lib/fileSize";
import IconButton from '../../components/ui/IconButton';
import IconButton from "../../components/ui/IconButton";
import Preloader from "../../components/ui/Preloader";
import { useIntermediate } from "../intermediate/Intermediate";
import { AppContext } from "./RevoltClient";
import { takeError } from "./util";
type Props = {
maxFileSize: number
remove: () => Promise<void>
fileType: 'backgrounds' | 'icons' | 'avatars' | 'attachments' | 'banners'
maxFileSize: number;
remove: () => Promise<void>;
fileType: "backgrounds" | "icons" | "avatars" | "attachments" | "banners";
} & (
{ behaviour: 'ask', onChange: (file: File) => void } |
{ behaviour: 'upload', onUpload: (id: string) => Promise<void> }
) & (
{ style: 'icon' | 'banner', defaultPreview?: string, previewURL?: string, width?: number, height?: number } |
{ style: 'attachment', attached: boolean, uploading: boolean, cancel: () => void, size?: number }
)
export async function uploadFile(autumnURL: string, tag: string, file: File, config?: AxiosRequestConfig) {
| { behaviour: "ask"; onChange: (file: File) => void }
| { behaviour: "upload"; onUpload: (id: string) => Promise<void> }
| {
behaviour: "multi";
onChange: (files: File[]) => void;
append?: (files: File[]) => void;
}
) &
(
| {
style: "icon" | "banner";
defaultPreview?: string;
previewURL?: string;
width?: number;
height?: number;
}
| {
style: "attachment";
attached: boolean;
uploading: boolean;
cancel: () => void;
size?: number;
}
);
export async function uploadFile(
autumnURL: string,
tag: string,
file: File,
config?: AxiosRequestConfig,
) {
const formData = new FormData();
formData.append("file", file);
const res = await Axios.post(autumnURL + "/" + tag, formData, {
const res = await Axios.post(`${autumnURL}/${tag}`, formData, {
headers: {
"Content-Type": "multipart/form-data"
"Content-Type": "multipart/form-data",
},
...config
...config,
});
return res.data.id;
}
export function grabFiles(
maxFileSize: number,
cb: (files: File[]) => void,
tooLarge: () => void,
multiple?: boolean,
) {
const input = document.createElement("input");
input.type = "file";
input.multiple = multiple ?? false;
input.onchange = async (e) => {
const files = (e.currentTarget as HTMLInputElement)?.files;
if (!files) return;
for (const file of files) {
if (file.size > maxFileSize) {
return tooLarge();
}
}
cb(Array.from(files));
};
input.click();
}
export function FileUploader(props: Props) {
const { fileType, maxFileSize, remove } = props;
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const [ uploading, setUploading ] = useState(false);
const [uploading, setUploading] = useState(false);
function onClick() {
if (uploading) return;
const input = document.createElement("input");
input.type = "file";
input.onchange = async e => {
setUploading(true);
try {
const files = (e.target as any)?.files;
if (files && files[0]) {
let file = files[0];
grabFiles(
maxFileSize,
async (files) => {
setUploading(true);
if (file.size > maxFileSize) {
return openScreen({ id: "error", error: "FileTooLarge" });
}
if (props.behaviour === 'ask') {
await props.onChange(file);
try {
if (props.behaviour === "multi") {
props.onChange(files);
} else if (props.behaviour === "ask") {
props.onChange(files[0]);
} else {
await props.onUpload(await uploadFile(client.configuration!.features.autumn.url, fileType, file));
await props.onUpload(
await uploadFile(
client.configuration!.features.autumn.url,
fileType,
files[0],
),
);
}
} catch (err) {
return openScreen({ id: "error", error: takeError(err) });
} finally {
setUploading(false);
}
} catch (err) {
return openScreen({ id: "error", error: takeError(err) });
} finally {
setUploading(false);
}
};
input.click();
},
() => openScreen({ id: "error", error: "FileTooLarge" }),
props.behaviour === "multi",
);
}
function removeOrUpload() {
if (uploading) return;
if (props.style === 'attachment') {
if (props.style === "attachment") {
if (props.attached) {
props.remove();
} else {
onClick();
}
} else if (props.previewURL) {
props.remove();
} else {
if (props.previewURL) {
props.remove();
} else {
onClick();
}
onClick();
}
}
if (props.style === 'icon' || props.style === 'banner') {
if (props.behaviour === "multi" && props.append) {
// eslint-disable-next-line
useEffect(() => {
// File pasting.
function paste(e: ClipboardEvent) {
const items = e.clipboardData?.items;
if (typeof items === "undefined") return;
if (props.behaviour !== "multi" || !props.append) return;
const files = [];
for (const item of items) {
if (!item.type.startsWith("text/")) {
const blob = item.getAsFile();
if (blob) {
if (blob.size > props.maxFileSize) {
openScreen({
id: "error",
error: "FileTooLarge",
});
}
files.push(blob);
}
}
}
props.append(files);
}
// Let the browser know we can drop files.
function dragover(e: DragEvent) {
e.stopPropagation();
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
}
// File dropping.
function drop(e: DragEvent) {
e.preventDefault();
if (props.behaviour !== "multi" || !props.append) return;
const dropped = e.dataTransfer?.files;
if (dropped) {
const files = [];
for (const item of dropped) {
if (item.size > props.maxFileSize) {
openScreen({ id: "error", error: "FileTooLarge" });
}
files.push(item);
}
props.append(files);
}
}
document.addEventListener("paste", paste);
document.addEventListener("dragover", dragover);
document.addEventListener("drop", drop);
return () => {
document.removeEventListener("paste", paste);
document.removeEventListener("dragover", dragover);
document.removeEventListener("drop", drop);
};
}, [openScreen, props, props.append]);
}
if (props.style === "icon" || props.style === "banner") {
const { style, previewURL, defaultPreview, width, height } = props;
return (
<div className={classNames(styles.uploader,
{ [styles.icon]: style === 'icon',
[styles.banner]: style === 'banner' })}
<div
className={classNames(styles.uploader, {
[styles.icon]: style === "icon",
[styles.banner]: style === "banner",
})}
data-uploading={uploading}>
<div className={styles.image}
style={{ backgroundImage:
style === 'icon' ? `url('${previewURL ?? defaultPreview}')` :
(previewURL ? `linear-gradient( rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url('${previewURL}')` : 'black'),
width, height
<div
className={styles.image}
style={{
backgroundImage:
style === "icon"
? `url('${previewURL ?? defaultPreview}')`
: previewURL
? `linear-gradient( rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url('${previewURL}')`
: "black",
width,
height,
}}
onClick={onClick}>
{ uploading ?
{uploading ? (
<div className={styles.uploading}>
<Preloader />
</div> :
<Preloader type="ring" />
</div>
) : (
<div className={styles.edit}>
<Edit size={30} />
</div> }
<Pencil size={30} />
</div>
)}
</div>
<div className={styles.modify}>
<span onClick={removeOrUpload}>{
uploading ? <Text id="app.main.channel.uploading_file" /> :
props.previewURL ? <Text id="app.settings.actions.remove" /> :
<Text id="app.settings.actions.upload" /> }</span>
<span className={styles.small}><Text id="app.settings.actions.max_filesize" fields={{ filesize: determineFileSize(maxFileSize) }} /></span>
<span onClick={removeOrUpload}>
{uploading ? (
<Text id="app.main.channel.uploading_file" />
) : props.previewURL ? (
<Text id="app.settings.actions.remove" />
) : (
<Text id="app.settings.actions.upload" />
)}
</span>
<span className={styles.small}>
<Text
id="app.settings.actions.max_filesize"
fields={{
filesize: determineFileSize(maxFileSize),
}}
/>
</span>
</div>
</div>
)
} else if (props.style === 'attachment') {
);
} else if (props.style === "attachment") {
const { attached, uploading, cancel, size } = props;
return (
<IconButton
......@@ -138,10 +275,11 @@ export function FileUploader(props: Props) {
if (uploading) return cancel();
if (attached) return remove();
onClick();
}}>
{ attached ? <X size={size} /> : <Plus size={size} />}
}}
rotate={uploading || attached ? "45deg" : undefined}>
<Plus size={size} />
</IconButton>
)
);
}
return null;
......
import { Route, Switch, useHistory, useParams } from "react-router-dom";
import { Presence, RelationshipStatus } from "revolt-api/types/Users";
import { SYSTEM_USER_ID } from "revolt.js";
import { Message } from "revolt.js/dist/maps/Messages";
import { User } from "revolt.js/dist/maps/Users";
import { decodeTime } from "ulid";
import { useCallback, useContext, useEffect } from "preact/hooks";
import { useTranslation } from "../../lib/i18n";
import { connectState } from "../../redux/connector";
import {
getNotificationState,
Notifications,
shouldNotify,
} from "../../redux/reducers/notifications";
import { NotificationOptions } from "../../redux/reducers/settings";
import { SoundContext } from "../Settings";
import { AppContext } from "./RevoltClient";
interface Props {
options?: NotificationOptions;
notifs: Notifications;
}
const notifications: { [key: string]: Notification } = {};
async function createNotification(
title: string,
options: globalThis.NotificationOptions,
) {
try {
return new Notification(title, options);
} catch (err) {
const sw = await navigator.serviceWorker.getRegistration();
sw?.showNotification(title, options);
}
}
function Notifier({ options, notifs }: Props) {
const translate = useTranslation();
const showNotification = options?.desktopEnabled ?? false;
const client = useContext(AppContext);
const { guild: guild_id, channel: channel_id } = useParams<{
guild: string;
channel: string;
}>();
const history = useHistory();
const playSound = useContext(SoundContext);
const message = useCallback(
async (msg: Message) => {
if (msg.author_id === client.user!._id) return;
if (msg.channel_id === channel_id && document.hasFocus()) return;
if (client.user!.status?.presence === Presence.Busy) return;
if (msg.author?.relationship === RelationshipStatus.Blocked) return;
const notifState = getNotificationState(notifs, msg.channel!);
if (!shouldNotify(notifState, msg, client.user!._id)) return;
playSound("message");
if (!showNotification) return;
let title;
switch (msg.channel?.channel_type) {
case "SavedMessages":
return;
case "DirectMessage":
title = `@${msg.author?.username}`;
break;
case "Group":
if (msg.author?._id === SYSTEM_USER_ID) {
title = msg.channel.name;
} else {
title = `@${msg.author?.username} - ${msg.channel.name}`;
}
break;
case "TextChannel":
title = `@${msg.author?.username} (#${msg.channel.name}, ${msg.channel.server?.name})`;
break;
default:
title = msg.channel?._id;
break;
}
let image;
if (msg.attachments) {
const imageAttachment = msg.attachments.find(
(x) => x.metadata.type === "Image",
);
if (imageAttachment) {
image = client.generateFileURL(imageAttachment, {
max_side: 720,
});
}
}
let body, icon;
if (typeof msg.content === "string") {
body = client.markdownToText(msg.content);
icon = msg.author?.generateAvatarURL({ max_side: 256 });
} else {
const users = client.users;
switch (msg.content.type) {
case "user_added":
case "user_remove":
{
const user = users.get(msg.content.id);
body = translate(
`app.main.channel.system.${
msg.content.type === "user_added"
? "added_by"
: "removed_by"
}`,
{
user: user?.username,
other_user: users.get(msg.content.by)
?.username,
},
);
icon = user?.generateAvatarURL({
max_side: 256,
});
}
break;
case "user_joined":
case "user_left":
case "user_kicked":
case "user_banned":
{
const user = users.get(msg.content.id);
body = translate(
`app.main.channel.system.${msg.content.type}`,
{ user: user?.username },
);
icon = user?.generateAvatarURL({
max_side: 256,
});
}
break;
case "channel_renamed":
{
const user = users.get(msg.content.by);
body = translate(
`app.main.channel.system.channel_renamed`,
{
user: users.get(msg.content.by)?.username,
name: msg.content.name,
},
);
icon = user?.generateAvatarURL({
max_side: 256,
});
}
break;
case "channel_description_changed":
case "channel_icon_changed":
{
const user = users.get(msg.content.by);
body = translate(
`app.main.channel.system.${msg.content.type}`,
{ user: users.get(msg.content.by)?.username },
);
icon = user?.generateAvatarURL({
max_side: 256,
});
}
break;
}
}
const notif = await createNotification(title!, {
icon,
image,
body,
timestamp: decodeTime(msg._id),
tag: msg.channel?._id,
badge: "/assets/icons/android-chrome-512x512.png",
silent: true,
});
if (notif) {
notif.addEventListener("click", () => {
window.focus();
const id = msg.channel_id;
if (id !== channel_id) {
const channel = client.channels.get(id);
if (channel) {
if (channel.channel_type === "TextChannel") {
history.push(
`/server/${channel.server_id}/channel/${id}`,
);
} else {
history.push(`/channel/${id}`);
}
}
}
});
notifications[msg.channel_id] = notif;
notif.addEventListener(
"close",
() => delete notifications[msg.channel_id],
);
}
},
[
history,
showNotification,
translate,
channel_id,
client,
notifs,
playSound,
],
);
const relationship = useCallback(
async (user: User) => {
if (client.user?.status?.presence === Presence.Busy) return;
if (!showNotification) return;
let event;
switch (user.relationship) {
case RelationshipStatus.Incoming:
event = translate("notifications.sent_request", {
person: user.username,
});
break;
case RelationshipStatus.Friend:
event = translate("notifications.now_friends", {
person: user.username,
});
break;
default:
return;
}
const notif = await createNotification(event, {
icon: user.generateAvatarURL({ max_side: 256 }),
badge: "/assets/icons/android-chrome-512x512.png",
timestamp: +new Date(),
});
notif?.addEventListener("click", () => {
history.push(`/friends`);
});
},
[client.user?.status?.presence, history, showNotification, translate],
);
useEffect(() => {
client.addListener("message", message);
client.addListener("user/relationship", relationship);
return () => {
client.removeListener("message", message);
client.removeListener("user/relationship", relationship);
};
}, [
client,
playSound,
guild_id,
channel_id,
showNotification,
notifs,
message,
relationship,
]);
useEffect(() => {
function visChange() {
if (document.visibilityState === "visible") {
if (notifications[channel_id]) {
notifications[channel_id].close();
}
}
}
visChange();
document.addEventListener("visibilitychange", visChange);
return () =>
document.removeEventListener("visibilitychange", visChange);
}, [guild_id, channel_id]);
return null;
}
const NotifierComponent = connectState(
Notifier,
(state) => {
return {
options: state.settings.notification,
notifs: state.notifications,
};
},
true,
);
export default function NotificationsComponent() {
return (
<Switch>
<Route path="/server/:server/channel/:channel">
<NotifierComponent />
</Route>
<Route path="/channel/:channel">
<NotifierComponent />
</Route>
<Route path="/">
<NotifierComponent />
</Route>
</Switch>
);
}
import { Text } from "preact-i18n";
import { WifiOff } from "@styled-icons/boxicons-regular";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { Children } from "../../types/Preact";
import { WifiOff } from "@styled-icons/feather";
import Preloader from "../../components/ui/Preloader";
import { Children } from "../../types/Preact";
import { ClientStatus, StatusContext } from "./RevoltClient";
interface Props {
......@@ -29,7 +32,7 @@ const Base = styled.div`
export default function RequiresOnline(props: Props) {
const status = useContext(StatusContext);
if (status === ClientStatus.CONNECTING) return <Preloader />;
if (status === ClientStatus.CONNECTING) return <Preloader type="ring" />;
if (status !== ClientStatus.ONLINE && status !== ClientStatus.READY)
return (
<Base>
......@@ -40,5 +43,5 @@ export default function RequiresOnline(props: Props) {
</Base>
);
return <>{ props.children }</>;
return <>{props.children}</>;
}