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 1594 additions and 1308 deletions
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 Modal from "../../../components/ui/Modal";
import { User, Users } from "revolt.js/dist/api/objects";
import UserCheckbox from "../../../components/common/user/UserCheckbox";
import Modal from "../../../components/ui/Modal";
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 { decodeTime } from "ulid";
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 { Localizer, Text } from "preact-i18n";
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 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 { Route } from "revolt.js/dist/api/routes";
import { Users } from "revolt.js/dist/api/objects";
import { useIntermediate } from "../Intermediate";
import Preloader from "../../../components/ui/Preloader";
import Tooltip from '../../../components/common/Tooltip';
import IconButton from "../../../components/ui/IconButton";
import Markdown from '../../../components/markdown/Markdown';
import { UserPermission } from "revolt.js/dist/api/permissions";
import UserIcon from '../../../components/common/user/UserIcon';
import ChannelIcon from '../../../components/common/ChannelIcon';
import UserStatus from '../../../components/common/user/UserStatus';
import { Envelope, Edit, UserPlus, Shield, Money } from "@styled-icons/boxicons-regular";
import { useContext, useEffect, useLayoutEffect, useState } from "preact/hooks";
import { AppContext, ClientStatus, StatusContext } from "../../revoltjs/RevoltClient";
import { useChannels, useForceUpdate, useUserPermission, useUsers } from "../../revoltjs/hooks";
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 {
......@@ -31,304 +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 history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext);
const [tab, setTab] = useState("profile");
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;
if (!user) {
useEffect(onClose, []);
return null;
}
const user = client.users.get(user_id);
if (!user) {
if (onClose) useEffect(onClose, []);
return null;
}
const permissions = useUserPermission(user!._id, ctx);
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) return;
if (
status === ClientStatus.ONLINE &&
typeof profile === "undefined"
) {
setProfile(null);
if (permissions & UserPermission.ViewProfile) {
client.users
.fetchProfile(user_id)
.then(data => setProfile(data))
.catch(() => {});
useEffect(() => {
if (dummy) {
setProfile(dummyProfile);
}
}
}, [profile, status]);
}, [dummy, dummyProfile]);
const mutualGroups = channels.filter(
channel =>
channel?.channel_type === "Group" &&
channel.recipients.includes(user_id)
);
useEffect(() => {
if (dummy) return;
if (
status === ClientStatus.ONLINE &&
typeof mutual === "undefined"
) {
setMutual(null);
user.fetchMutual().then(setMutual);
}
}, [mutual, 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);
useEffect(() => {
if (dummy) return;
if (
status === ClientStatus.ONLINE &&
typeof profile === "undefined"
) {
setProfile(null);
return (
<Modal visible
border={dummy}
padding={false}
onClose={onClose}
dontModal={dummy}>
<div
className={styles.header}
data-force={
profile?.background
? "light"
: undefined
if (user.permission & UserPermission.ViewProfile) {
user.fetchProfile().then(setProfile);
}
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>
}
}, [profile, status, dummy, user]);
const backgroundURL =
profile &&
client.generateFileURL(profile.background, { width: 1000 }, true);
const badges = user.badges ?? 0;
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}`);
}}>
<Envelope size={30} />
</IconButton>
</Tooltip>
</Localizer>
)}
{user.relationship === Users.Relationship.User && (
<IconButton
onClick={() => {
onClose();
if (dummy) return;
history.push(`/settings/profile`);
}}>
<Edit size={28} />
</IconButton>
)}
{(user.relationship === Users.Relationship.Incoming ||
user.relationship === Users.Relationship.None) && (
<IconButton onClick={() => client.users.addFriend(user.username)}>
<UserPlus size={28} />
</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" />
}
>
<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 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 type="ring" />
))}
{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 { useContext } from "preact/hooks";
import { Redirect } from "react-router-dom";
import { Children } from "../../types/Preact";
import { useContext } from "preact/hooks";
import { Children } from "../../types/Preact";
import { OperationsContext } from "./RevoltClient";
interface Props {
......
......@@ -10,7 +10,7 @@
&.banner {
.image {
border-radius: 4px;
border-radius: var(--border-radius);
}
.modify {
......
import { Text } from "preact-i18n";
import { takeError } from "./util";
import classNames from "classnames";
import { AppContext } from "./RevoltClient";
import styles from './FileUploads.module.scss';
import { Plus } from "@styled-icons/boxicons-regular";
import { Pencil } from "@styled-icons/boxicons-solid";
import Axios, { AxiosRequestConfig } from "axios";
import styles from "./FileUploads.module.scss";
import classNames from "classnames";
import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks";
import Preloader from "../../components/ui/Preloader";
import { determineFileSize } from "../../lib/fileSize";
import IconButton from '../../components/ui/IconButton';
import { Edit, Plus, X, XCircle } from "@styled-icons/boxicons-regular";
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> } |
{ 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) {
| { 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) {
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.target as any)?.files;
input.onchange = async (e) => {
const files = (e.currentTarget as HTMLInputElement)?.files;
if (!files) return;
for (let file of files) {
for (const file of files) {
if (file.size > maxFileSize) {
return tooLarge();
}
......@@ -63,65 +95,76 @@ export function FileUploader(props: Props) {
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const [ uploading, setUploading ] = useState(false);
const [uploading, setUploading] = useState(false);
function onClick() {
if (uploading) return;
grabFiles(maxFileSize, async files => {
setUploading(true);
grabFiles(
maxFileSize,
async (files) => {
setUploading(true);
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, files[0]));
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,
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);
}
}, () =>
openScreen({ id: "error", error: "FileTooLarge" }),
props.behaviour === 'multi');
},
() => 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.behaviour === 'multi' && props.append) {
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;
if (props.behaviour !== "multi" || !props.append) return;
let files = [];
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' });
openScreen({
id: "error",
error: "FileTooLarge",
});
}
files.push(blob);
......@@ -142,14 +185,14 @@ export function FileUploader(props: Props) {
// File dropping.
function drop(e: DragEvent) {
e.preventDefault();
if (props.behaviour !== 'multi' || !props.append) return;
if (props.behaviour !== "multi" || !props.append) return;
const dropped = e.dataTransfer?.files;
if (dropped) {
let files = [];
const files = [];
for (const item of dropped) {
if (item.size > props.maxFileSize) {
openScreen({ id: 'error', error: 'FileTooLarge' });
openScreen({ id: "error", error: "FileTooLarge" });
}
files.push(item);
......@@ -168,41 +211,63 @@ export function FileUploader(props: Props) {
document.removeEventListener("dragover", dragover);
document.removeEventListener("drop", drop);
};
}, [ props.append ]);
}, [openScreen, props, props.append]);
}
if (props.style === 'icon' || props.style === 'banner') {
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 type="ring" />
</div> :
</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
......@@ -210,10 +275,11 @@ export function FileUploader(props: Props) {
if (uploading) return cancel();
if (attached) return remove();
onClick();
}}>
{ uploading ? <XCircle size={size} /> : 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 { SoundContext } from "../Settings";
import { AppContext } from "./RevoltClient";
import { useCallback, useContext, useEffect } from "preact/hooks";
import { useTranslation } from "../../lib/i18n";
import { Users } from "revolt.js/dist/api/objects";
import { useContext, useEffect } from "preact/hooks";
import { connectState } from "../../redux/connector";
import { Message, SYSTEM_USER_ID, User } from "revolt.js";
import {
getNotificationState,
Notifications,
shouldNotify,
} from "../../redux/reducers/notifications";
import { NotificationOptions } from "../../redux/reducers/settings";
import { Route, Switch, useHistory, useParams } from "react-router-dom";
import { getNotificationState, Notifications, shouldNotify } from "../../redux/reducers/notifications";
import { SoundContext } from "../Settings";
import { AppContext } from "./RevoltClient";
interface Props {
options?: NotificationOptions;
......@@ -17,11 +27,14 @@ interface Props {
const notifications: { [key: string]: Notification } = {};
async function createNotification(title: string, options: globalThis.NotificationOptions) {
async function createNotification(
title: string,
options: globalThis.NotificationOptions,
) {
try {
return new Notification(title, options);
} catch (err) {
let sw = await navigator.serviceWorker.getRegistration();
const sw = await navigator.serviceWorker.getRegistration();
sw?.showNotification(title, options);
}
}
......@@ -38,167 +51,225 @@ function Notifier({ options, notifs }: Props) {
const history = useHistory();
const playSound = useContext(SoundContext);
async function message(msg: Message) {
if (msg.author === client.user!._id) return;
if (msg.channel === channel_id && document.hasFocus()) return;
if (client.user!.status?.presence === Users.Presence.Busy) return;
const channel = client.channels.get(msg.channel);
const author = client.users.get(msg.author);
if (!channel) return;
if (author?.relationship === Users.Relationship.Blocked) return;
const notifState = getNotificationState(notifs, channel);
if (!shouldNotify(notifState, msg, client.user!._id)) return;
playSound('message');
if (!showNotification) return;
let title;
switch (channel.channel_type) {
case "SavedMessages":
return;
case "DirectMessage":
title = `@${author?.username}`;
break;
case "Group":
if (author?._id === SYSTEM_USER_ID) {
title = channel.name;
} else {
title = `@${author?.username} - ${channel.name}`;
}
break;
case "TextChannel":
const server = client.servers.get(channel.server);
title = `@${author?.username} (#${channel.name}, ${server?.name})`;
break;
default:
title = msg.channel;
break;
}
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;
let image;
if (msg.attachments) {
let imageAttachment = msg.attachments.find(x => x.metadata.type === 'Image');
if (imageAttachment) {
image = client.generateFileURL(imageAttachment, { max_side: 720 });
}
}
const notifState = getNotificationState(notifs, msg.channel!);
if (!shouldNotify(notifState, msg, client.user!._id)) return;
let body, icon;
if (typeof msg.content === "string") {
body = client.markdownToText(msg.content);
icon = client.users.getAvatarURL(msg.author, { max_side: 256 });
} else {
let users = client.users;
switch (msg.content.type) {
case "user_added":
case "user_remove":
body = translate(
`app.main.channel.system.${msg.content.type === 'user_added' ? 'added_by' : 'removed_by'}`,
{ user: users.get(msg.content.id)?.username, other_user: users.get(msg.content.by)?.username }
);
icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
playSound("message");
if (!showNotification) return;
let title;
switch (msg.channel?.channel_type) {
case "SavedMessages":
return;
case "DirectMessage":
title = `@${msg.author?.username}`;
break;
case "user_joined":
case "user_left":
case "user_kicked":
case "user_banned":
body = translate(
`app.main.channel.system.${msg.content.type}`,
{ user: users.get(msg.content.id)?.username }
);
icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
case "Group":
if (msg.author?._id === SYSTEM_USER_ID) {
title = msg.channel.name;
} else {
title = `@${msg.author?.username} - ${msg.channel.name}`;
}
break;
case "channel_renamed":
body = translate(
`app.main.channel.system.channel_renamed`,
{ user: users.get(msg.content.by)?.username, name: msg.content.name }
);
icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 });
case "TextChannel":
title = `@${msg.author?.username} (#${msg.channel.name}, ${msg.channel.server?.name})`;
break;
case "channel_description_changed":
case "channel_icon_changed":
body = translate(
`app.main.channel.system.${msg.content.type}`,
{ user: users.get(msg.content.by)?.username }
);
icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 });
default:
title = msg.channel?._id;
break;
}
}
let notif = await createNotification(title, {
icon,
image,
body,
timestamp: decodeTime(msg._id),
tag: msg.channel,
badge: '/assets/icons/android-chrome-512x512.png',
silent: true
});
if (notif) {
notif.addEventListener("click", () => {
window.focus();
const id = msg.channel;
if (id !== channel_id) {
let channel = client.channels.get(id);
if (channel) {
if (channel.channel_type === 'TextChannel') {
history.push(`/server/${channel.server}/channel/${id}`);
} else {
history.push(`/channel/${id}`);
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,
});
notifications[msg.channel] = notif;
notif.addEventListener(
"close",
() => delete notifications[msg.channel]
);
}
}
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}`);
}
}
}
});
async function relationship(user: User, property: string) {
if (client.user?.status?.presence === Users.Presence.Busy) return;
if (property !== "relationship") return;
if (!showNotification) return;
let event;
switch (user.relationship) {
case Users.Relationship.Incoming:
event = translate("notifications.sent_request", { person: user.username });
break;
case Users.Relationship.Friend:
event = translate("notifications.now_friends", { person: user.username });
break;
default:
return;
}
notifications[msg.channel_id] = notif;
notif.addEventListener(
"close",
() => delete notifications[msg.channel_id],
);
}
},
[
history,
showNotification,
translate,
channel_id,
client,
notifs,
playSound,
],
);
let notif = await createNotification(event, {
icon: client.users.getAvatarURL(user._id, { max_side: 256 }),
badge: '/assets/icons/android-chrome-512x512.png',
timestamp: +new Date()
});
const relationship = useCallback(
async (user: User) => {
if (client.user?.status?.presence === Presence.Busy) return;
if (!showNotification) return;
notif?.addEventListener("click", () => {
history.push(`/friends`);
});
}
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.users.addListener("mutation", relationship);
client.addListener("user/relationship", relationship);
return () => {
client.removeListener("message", message);
client.users.removeListener("mutation", relationship);
client.removeListener("user/relationship", relationship);
};
}, [client, playSound, guild_id, channel_id, showNotification, notifs]);
}, [
client,
playSound,
guild_id,
channel_id,
showNotification,
notifs,
message,
relationship,
]);
useEffect(() => {
function visChange() {
......@@ -216,21 +287,21 @@ function Notifier({ options, notifs }: Props) {
document.removeEventListener("visibilitychange", visChange);
}, [guild_id, channel_id]);
return <></>;
return null;
}
const NotifierComponent = connectState(
Notifier,
state => {
(state) => {
return {
options: state.settings.notification,
notifs: state.notifications
notifs: state.notifications,
};
},
true
true,
);
export default function Notifications() {
export default function NotificationsComponent() {
return (
<Switch>
<Route path="/server/:server/channel/:channel">
......
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/boxicons-regular";
import Preloader from "../../components/ui/Preloader";
import { Children } from "../../types/Preact";
import { ClientStatus, StatusContext } from "./RevoltClient";
interface Props {
......@@ -40,5 +43,5 @@ export default function RequiresOnline(props: Props) {
</Base>
);
return <>{ props.children }</>;
return <>{props.children}</>;
}
import { openDB } from 'idb';
/* eslint-disable react-hooks/rules-of-hooks */
import { Client } from "revolt.js";
import { takeError } from "./util";
import { createContext } from "preact";
import { Children } from "../../types/Preact";
import { Route } from "revolt.js/dist/api/routes";
import { createContext } from "preact";
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import { SingletonMessageRenderer } from "../../lib/renderer/Singleton";
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector";
import Preloader from "../../components/ui/Preloader";
import { WithDispatcher } from "../../redux/reducers";
import { AuthState } from "../../redux/reducers/auth";
import { SyncOptions } from "../../redux/reducers/sync";
import { useEffect, useMemo, useState } from "preact/hooks";
import { useIntermediate } from '../intermediate/Intermediate';
import Preloader from "../../components/ui/Preloader";
import { Children } from "../../types/Preact";
import { useIntermediate } from "../intermediate/Intermediate";
import { registerEvents, setReconnectDisallowed } from "./events";
import { SingletonMessageRenderer } from '../../lib/renderer/Singleton';
import { takeError } from "./util";
export enum ClientStatus {
INIT,
......@@ -32,74 +36,60 @@ export interface ClientOperations {
ready: () => boolean;
}
export const AppContext = createContext<Client>(undefined as any);
export const StatusContext = createContext<ClientStatus>(undefined as any);
export const OperationsContext = createContext<ClientOperations>(undefined as any);
// By the time they are used, they should all be initialized.
// Currently the app does not render until a client is built and the other two are always initialized on first render.
// - insert's words
export const AppContext = createContext<Client>(null!);
export const StatusContext = createContext<ClientStatus>(null!);
export const OperationsContext = createContext<ClientOperations>(null!);
type Props = WithDispatcher & {
type Props = {
auth: AuthState;
children: Children;
};
function Context({ auth, children, dispatcher }: Props) {
function Context({ auth, children }: Props) {
const { openScreen } = useIntermediate();
const [status, setStatus] = useState(ClientStatus.INIT);
const [client, setClient] = useState<Client>(undefined as unknown as Client);
const [client, setClient] = useState<Client>(
undefined as unknown as Client,
);
useEffect(() => {
(async () => {
let db;
try {
// Match sw.ts#L23
db = await openDB('state', 3, {
upgrade(db) {
for (let store of [ "channels", "servers", "users", "members" ]) {
db.createObjectStore(store, {
keyPath: '_id'
});
}
},
});
} catch (err) {
console.error('Failed to open IndexedDB store, continuing without.');
}
const client = new Client({
autoReconnect: false,
apiURL: import.meta.env.VITE_API_URL,
debug: import.meta.env.DEV,
db
});
setClient(client);
SingletonMessageRenderer.subscribe(client);
setStatus(ClientStatus.LOADING);
})();
}, [ ]);
}, []);
if (status === ClientStatus.INIT) return null;
const operations: ClientOperations = useMemo(() => {
return {
login: async data => {
login: async (data) => {
setReconnectDisallowed(true);
try {
const onboarding = await client.login(data);
setReconnectDisallowed(false);
const login = () =>
dispatcher({
dispatch({
type: "LOGIN",
session: client.session as any
session: client.session!, // This [null assertion] is ok, we should have a session by now. - insert's words
});
if (onboarding) {
openScreen({
id: "onboarding",
callback: async (username: string) => {
await (onboarding as any)(username, true);
login();
}
callback: async (username: string) =>
onboarding(username, true).then(login),
});
} else {
login();
......@@ -109,11 +99,11 @@ function Context({ auth, children, dispatcher }: Props) {
throw err;
}
},
logout: async shouldRequest => {
dispatcher({ type: "LOGOUT" });
logout: async (shouldRequest) => {
dispatch({ type: "LOGOUT" });
client.reset();
dispatcher({ type: "RESET" });
dispatch({ type: "RESET" });
openScreen({ id: "none" });
setStatus(ClientStatus.READY);
......@@ -129,23 +119,20 @@ function Context({ auth, children, dispatcher }: Props) {
}
},
loggedIn: () => typeof auth.active !== "undefined",
ready: () => (
operations.loggedIn() &&
typeof client.user !== "undefined"
)
}
}, [ client, auth.active ]);
ready: () =>
operations.loggedIn() && typeof client.user !== "undefined",
};
}, [client, auth.active, openScreen]);
useEffect(() => registerEvents({ operations, dispatcher }, setStatus, client), [ client ]);
useEffect(
() => registerEvents({ operations }, setStatus, client),
[client, operations],
);
useEffect(() => {
(async () => {
if (client.db) {
await client.restore();
}
if (auth.active) {
dispatcher({ type: "QUEUE_FAIL_ALL" });
dispatch({ type: "QUEUE_FAIL_ALL" });
const active = auth.accounts[auth.active];
client.user = client.users.get(active.session.user_id);
......@@ -153,21 +140,20 @@ function Context({ auth, children, dispatcher }: Props) {
return setStatus(ClientStatus.OFFLINE);
}
if (operations.ready())
setStatus(ClientStatus.CONNECTING);
if (operations.ready()) setStatus(ClientStatus.CONNECTING);
if (navigator.onLine) {
await client
.fetchConfiguration()
.catch(() =>
console.error("Failed to connect to API server.")
console.error("Failed to connect to API server."),
);
}
try {
await client.fetchConfiguration();
const callback = await client.useExistingSession(
active.session
active.session,
);
if (callback) {
......@@ -185,7 +171,7 @@ function Context({ auth, children, dispatcher }: Props) {
}
} else {
try {
await client.fetchConfiguration()
await client.fetchConfiguration();
} catch (err) {
console.error("Failed to connect to API server.");
}
......@@ -193,6 +179,7 @@ function Context({ auth, children, dispatcher }: Props) {
setStatus(ClientStatus.READY);
}
})();
// eslint-disable-next-line
}, []);
if (status === ClientStatus.LOADING) {
......@@ -203,20 +190,18 @@ function Context({ auth, children, dispatcher }: Props) {
<AppContext.Provider value={client}>
<StatusContext.Provider value={status}>
<OperationsContext.Provider value={operations}>
{ children }
{children}
</OperationsContext.Provider>
</StatusContext.Provider>
</AppContext.Provider>
);
}
export default connectState<{ children: Children }>(
Context,
state => {
return {
auth: state.auth,
sync: state.sync
};
},
true
);
export default connectState<{ children: Children }>(Context, (state) => {
return {
auth: state.auth,
sync: state.sync,
};
});
export const useClient = () => useContext(AppContext);
/**
* This file monitors the message cache to delete any queued messages that have already sent.
*/
import { Message } from "revolt.js/dist/maps/Messages";
import { Message } from "revolt.js";
import { AppContext } from "./RevoltClient";
import { Typing } from "../../redux/reducers/typing";
import { useContext, useEffect } from "preact/hooks";
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector";
import { WithDispatcher } from "../../redux/reducers";
import { QueuedMessage } from "../../redux/reducers/queue";
type Props = WithDispatcher & {
import { AppContext } from "./RevoltClient";
type Props = {
messages: QueuedMessage[];
typing: Typing
};
function StateMonitor(props: Props) {
const client = useContext(AppContext);
useEffect(() => {
props.dispatcher({
type: 'QUEUE_DROP_ALL'
dispatch({
type: "QUEUE_DROP_ALL",
});
}, [ ]);
}, []);
useEffect(() => {
function add(msg: Message) {
if (!msg.nonce) return;
if (!props.messages.find(x => x.id === msg.nonce)) return;
if (!props.messages.find((x) => x.id === msg.nonce)) return;
props.dispatcher({
type: 'QUEUE_REMOVE',
nonce: msg.nonce
dispatch({
type: "QUEUE_REMOVE",
nonce: msg.nonce,
});
}
client.addListener('message', add);
return () => client.removeListener('message', add);
}, [ props.messages ]);
useEffect(() => {
function removeOld() {
if (!props.typing) return;
for (let channel of Object.keys(props.typing)) {
let users = props.typing[channel];
for (let user of users) {
if (+ new Date() > user.started + 5000) {
props.dispatcher({
type: 'TYPING_STOP',
channel,
user: user.id
});
}
}
}
}
removeOld();
let interval = setInterval(removeOld, 1000);
return () => clearInterval(interval);
}, [ props.typing ]);
client.addListener("message", add);
return () => client.removeListener("message", add);
}, [client, props.messages]);
return <></>;
return null;
}
export default connectState(
StateMonitor,
state => {
return {
messages: [...state.queue],
typing: state.typing
};
},
true
);
export default connectState(StateMonitor, (state) => {
return {
messages: [...state.queue],
};
});
/**
* This file monitors changes to settings and syncs them to the server.
*/
import isEqual from "lodash.isequal";
import { Language } from "../Locale";
import { Sync } from "revolt.js/dist/api/objects";
import { useContext, useEffect } from "preact/hooks";
import { UserSettings } from "revolt-api/types/Sync";
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { useCallback, useContext, useEffect, useMemo } from "preact/hooks";
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector";
import { WithDispatcher } from "../../redux/reducers";
import { Settings } from "../../redux/reducers/settings";
import { Notifications } from "../../redux/reducers/notifications";
import { Settings } from "../../redux/reducers/settings";
import {
DEFAULT_ENABLED_SYNC,
SyncData,
SyncKeys,
SyncOptions,
} from "../../redux/reducers/sync";
import { Language } from "../Locale";
import { AppContext, ClientStatus, StatusContext } from "./RevoltClient";
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { DEFAULT_ENABLED_SYNC, SyncData, SyncKeys, SyncOptions } from "../../redux/reducers/sync";
type Props = WithDispatcher & {
settings: Settings,
locale: Language,
sync: SyncOptions,
notifications: Notifications
type Props = {
settings: Settings;
locale: Language;
sync: SyncOptions;
notifications: Notifications;
};
var lastValues: { [key in SyncKeys]?: any } = { };
const lastValues: { [key in SyncKeys]?: unknown } = {};
export function mapSync(packet: Sync.UserSettings, revision?: { [key: string]: number }) {
let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = {};
for (let key of Object.keys(packet)) {
let [ timestamp, obj ] = packet[key];
if (timestamp < (revision ?? {} as any)[key] ?? 0) {
export function mapSync(
packet: UserSettings,
revision?: Record<string, number>,
) {
const update: { [key in SyncKeys]?: [number, SyncData[key]] } = {};
for (const key of Object.keys(packet)) {
const [timestamp, obj] = packet[key];
if (timestamp < (revision ?? {})[key] ?? 0) {
continue;
}
let object;
if (obj[0] === '{') {
object = JSON.parse(obj)
if (obj[0] === "{") {
object = JSON.parse(obj);
} else {
object = obj;
}
lastValues[key as SyncKeys] = object;
update[key as SyncKeys] = [ timestamp, object ];
update[key as SyncKeys] = [timestamp, object];
}
return update;
......@@ -52,38 +62,57 @@ function SyncManager(props: Props) {
useEffect(() => {
if (status === ClientStatus.ONLINE) {
client
.syncFetchSettings(DEFAULT_ENABLED_SYNC.filter(x => !props.sync?.disabled?.includes(x)))
.then(data => {
props.dispatcher({
type: 'SYNC_UPDATE',
update: mapSync(data)
.syncFetchSettings(
DEFAULT_ENABLED_SYNC.filter(
(x) => !props.sync?.disabled?.includes(x),
),
)
.then((data) => {
dispatch({
type: "SYNC_UPDATE",
update: mapSync(data),
});
});
client
.syncFetchUnreads()
.then(unreads => props.dispatcher({ type: 'UNREADS_SET', unreads }));
.then((unreads) => dispatch({ type: "UNREADS_SET", unreads }));
}
}, [ status ]);
function syncChange(key: SyncKeys, data: any) {
let timestamp = + new Date();
props.dispatcher({
type: 'SYNC_SET_REVISION',
key,
timestamp
});
client.syncSetSettings({
[key]: data
}, timestamp);
}
let disabled = props.sync.disabled ?? [];
for (let [key, object] of [ ['appearance', props.settings.appearance], ['theme', props.settings.theme], ['locale', props.locale], ['notifications', props.notifications] ] as [SyncKeys, any][]) {
}, [client, props.sync?.disabled, status]);
const syncChange = useCallback(
(key: SyncKeys, data: unknown) => {
const timestamp = +new Date();
dispatch({
type: "SYNC_SET_REVISION",
key,
timestamp,
});
client.syncSetSettings(
{
[key]: data as string,
},
timestamp,
);
},
[client],
);
const disabled = useMemo(
() => props.sync.disabled ?? [],
[props.sync.disabled],
);
for (const [key, object] of [
["appearance", props.settings.appearance],
["theme", props.settings.theme],
["locale", props.locale],
["notifications", props.notifications],
] as [SyncKeys, unknown][]) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (disabled.indexOf(key) === -1) {
if (typeof lastValues[key] !== 'undefined') {
if (typeof lastValues[key] !== "undefined") {
if (!isEqual(lastValues[key], object)) {
syncChange(key, object);
}
......@@ -91,37 +120,34 @@ function SyncManager(props: Props) {
}
lastValues[key] = object;
}, [ disabled, object ]);
}, [key, syncChange, disabled, object]);
}
useEffect(() => {
function onPacket(packet: ClientboundNotification) {
if (packet.type === 'UserSettingsUpdate') {
let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = mapSync(packet.update, props.sync.revision);
if (packet.type === "UserSettingsUpdate") {
const update: { [key in SyncKeys]?: [number, SyncData[key]] } =
mapSync(packet.update, props.sync.revision);
props.dispatcher({
type: 'SYNC_UPDATE',
update
dispatch({
type: "SYNC_UPDATE",
update,
});
}
}
client.addListener('packet', onPacket);
return () => client.removeListener('packet', onPacket);
}, [ disabled, props.sync ]);
client.addListener("packet", onPacket);
return () => client.removeListener("packet", onPacket);
}, [client, disabled, props.sync]);
return <></>;
return null;
}
export default connectState(
SyncManager,
state => {
return {
settings: state.settings,
locale: state.locale,
sync: state.sync,
notifications: state.notifications
};
},
true
);
export default connectState(SyncManager, (state) => {
return {
settings: state.settings,
locale: state.locale,
sync: state.sync,
notifications: state.notifications,
};
});
import { Client } from "revolt.js/dist";
import { Message } from "revolt.js/dist/maps/Messages";
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { WithDispatcher } from "../../redux/reducers";
import { Client, Message } from "revolt.js/dist";
import {
ClientOperations,
ClientStatus
} from "./RevoltClient";
import { StateUpdater } from "preact/hooks";
export var preventReconnect = false;
import { dispatch } from "../../redux";
import { ClientOperations, ClientStatus } from "./RevoltClient";
export let preventReconnect = false;
let preventUntil = 0;
export function setReconnectDisallowed(allowed: boolean) {
preventReconnect = allowed;
}
export function registerEvents({
operations,
dispatcher
}: { operations: ClientOperations } & WithDispatcher, setStatus: StateUpdater<ClientStatus>, client: Client) {
export function registerEvents(
{ operations }: { operations: ClientOperations },
setStatus: StateUpdater<ClientStatus>,
client: Client,
) {
function attemptReconnect() {
if (preventReconnect) return;
function reconnect() {
preventUntil = +new Date() + 2000;
client.websocket.connect().catch(err => console.error(err));
client.websocket.connect().catch((err) => console.error(err));
}
if (+new Date() > preventUntil) {
......@@ -32,7 +34,8 @@ export function registerEvents({
}
}
const listeners = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let listeners: Record<string, (...args: any[]) => void> = {
connecting: () =>
operations.ready() && setStatus(ClientStatus.CONNECTING),
......@@ -45,29 +48,11 @@ export function registerEvents({
packet: (packet: ClientboundNotification) => {
switch (packet.type) {
case "ChannelStartTyping": {
if (packet.user === client.user?._id) return;
dispatcher({
type: "TYPING_START",
channel: packet.id,
user: packet.user
});
break;
}
case "ChannelStopTyping": {
if (packet.user === client.user?._id) return;
dispatcher({
type: "TYPING_STOP",
channel: packet.id,
user: packet.user
});
break;
}
case "ChannelAck": {
dispatcher({
dispatch({
type: "UNREADS_MARK_READ",
channel: packet.id,
message: packet.message_id
message: packet.message_id,
});
break;
}
......@@ -75,44 +60,32 @@ export function registerEvents({
},
message: (message: Message) => {
if (message.mentions?.includes(client.user!._id)) {
dispatcher({
if (message.mention_ids?.includes(client.user!._id)) {
dispatch({
type: "UNREADS_MENTION",
channel: message.channel,
message: message._id
channel: message.channel_id,
message: message._id,
});
}
},
ready: () => setStatus(ClientStatus.ONLINE)
ready: () => setStatus(ClientStatus.ONLINE),
};
let listenerFunc: { [key: string]: Function };
if (import.meta.env.DEV) {
listenerFunc = {};
for (const listener of Object.keys(listeners)) {
listenerFunc[listener] = (...args: any[]) => {
console.debug(`Calling ${listener} with`, args);
(listeners as any)[listener](...args);
};
}
} else {
listenerFunc = listeners;
}
for (const listener of Object.keys(listenerFunc)) {
client.addListener(listener, (listenerFunc as any)[listener]);
}
function logMutation(target: string, key: string) {
console.log('(o) Object mutated', target, '\nChanged:', key);
listeners = new Proxy(listeners, {
get:
(target, listener) =>
(...args: unknown[]) => {
console.debug(`Calling ${listener.toString()} with`, args);
Reflect.get(target, listener)(...args);
},
});
}
if (import.meta.env.DEV) {
client.users.addListener('mutation', logMutation);
client.servers.addListener('mutation', logMutation);
client.channels.addListener('mutation', logMutation);
client.servers.members.addListener('mutation', logMutation);
// TODO: clean this a bit and properly handle types
for (const listener in listeners) {
client.addListener(listener, listeners[listener]);
}
const online = () => {
......@@ -122,7 +95,7 @@ export function registerEvents({
attemptReconnect();
}
};
const offline = () => {
if (operations.ready()) {
setReconnectDisallowed(true);
......@@ -135,15 +108,11 @@ export function registerEvents({
window.addEventListener("offline", offline);
return () => {
for (const listener of Object.keys(listenerFunc)) {
client.removeListener(listener, (listenerFunc as any)[listener]);
}
if (import.meta.env.DEV) {
client.users.removeListener('mutation', logMutation);
client.servers.removeListener('mutation', logMutation);
client.channels.removeListener('mutation', logMutation);
client.servers.members.removeListener('mutation', logMutation);
for (const listener in listeners) {
client.removeListener(
listener,
listeners[listener as keyof typeof listeners],
);
}
window.removeEventListener("online", online);
......
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
import { Client, PermissionCalculator } from 'revolt.js';
import { AppContext } from "./RevoltClient";
export interface HookContext {
client: Client,
forceUpdate: () => void
}
export function useForceUpdate(context?: HookContext): HookContext {
const client = useContext(AppContext);
if (context) return context;
const H = useState(0);
var updateState: (_: number) => void;
if (Array.isArray(H)) {
let [, u] = H;
updateState = u;
} else {
console.warn('Failed to construct using useState.');
updateState = ()=>{};
}
return { client, forceUpdate: () => updateState(Math.random()) };
}
function useObject(type: string, id?: string | string[], context?: HookContext) {
const ctx = useForceUpdate(context);
function update(target: any) {
if (typeof id === 'string' ? target === id :
Array.isArray(id) ? id.includes(target) : true) {
ctx.forceUpdate();
}
}
const map = (ctx.client as any)[type];
useEffect(() => {
map.addListener("update", update);
return () => map.removeListener("update", update);
}, [id]);
return typeof id === 'string' ? map.get(id)
: Array.isArray(id) ? id.map(x => map.get(x))
: map.toArray();
}
export function useUser(id?: string, context?: HookContext) {
if (typeof id === "undefined") return;
return useObject('users', id, context) as Readonly<Users.User> | undefined;
}
export function useSelf(context?: HookContext) {
const ctx = useForceUpdate(context);
return useUser(ctx.client.user!._id, ctx);
}
export function useUsers(ids?: string[], context?: HookContext) {
return useObject('users', ids, context) as (Readonly<Users.User> | undefined)[];
}
export function useChannel(id?: string, context?: HookContext) {
if (typeof id === "undefined") return;
return useObject('channels', id, context) as Readonly<Channels.Channel> | undefined;
}
export function useChannels(ids?: string[], context?: HookContext) {
return useObject('channels', ids, context) as (Readonly<Channels.Channel> | undefined)[];
}
export function useServer(id?: string, context?: HookContext) {
if (typeof id === "undefined") return;
return useObject('servers', id, context) as Readonly<Servers.Server> | undefined;
}
export function useServers(ids?: string[], context?: HookContext) {
return useObject('servers', ids, context) as (Readonly<Servers.Server> | undefined)[];
}
export function useDMs(context?: HookContext) {
const ctx = useForceUpdate(context);
function mutation(target: string) {
let channel = ctx.client.channels.get(target);
if (channel) {
if (channel.channel_type === 'DirectMessage' || channel.channel_type === 'Group') {
ctx.forceUpdate();
}
}
}
const map = ctx.client.channels;
useEffect(() => {
map.addListener("update", mutation);
return () => map.removeListener("update", mutation);
}, []);
return map
.toArray()
.filter(x => x.channel_type === 'DirectMessage' || x.channel_type === 'Group' || x.channel_type === 'SavedMessages') as (Channels.GroupChannel | Channels.DirectMessageChannel | Channels.SavedMessagesChannel)[];
}
export function useUserPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context);
const mutation = (target: string) => (target === id) && ctx.forceUpdate();
useEffect(() => {
ctx.client.users.addListener("update", mutation);
return () => ctx.client.users.removeListener("update", mutation);
}, [id]);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forUser(id);
}
export function useChannelPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context);
const channel = ctx.client.channels.get(id);
const server = (channel && (channel.channel_type === 'TextChannel' || channel.channel_type === 'VoiceChannel')) ? channel.server : undefined;
const mutation = (target: string) => (target === id) && ctx.forceUpdate();
const mutationServer = (target: string) => (target === server) && ctx.forceUpdate();
const mutationMember = (target: string) => (target.substr(26) === ctx.client.user!._id) && ctx.forceUpdate();
useEffect(() => {
ctx.client.channels.addListener("update", mutation);
if (server) {
ctx.client.servers.addListener("update", mutationServer);
ctx.client.servers.members.addListener("update", mutationMember);
}
return () => {
ctx.client.channels.removeListener("update", mutation);
if (server) {
ctx.client.servers.removeListener("update", mutationServer);
ctx.client.servers.members.removeListener("update", mutationMember);
}
}
}, [id]);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forChannel(id);
}
export function useServerPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context);
const mutation = (target: string) => (target === id) && ctx.forceUpdate();
const mutationMember = (target: string) => (target.substr(26) === ctx.client.user!._id) && ctx.forceUpdate();
useEffect(() => {
ctx.client.servers.addListener("update", mutation);
ctx.client.servers.members.addListener("update", mutationMember);
return () => {
ctx.client.servers.removeListener("update", mutation);
ctx.client.servers.members.removeListener("update", mutationMember);
}
}, [id]);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forServer(id);
}
import { Channel, Message, User } from "revolt.js/dist/api/objects";
import { Children } from "../../types/Preact";
import { Channel } from "revolt.js/dist/maps/Channels";
import { Text } from "preact-i18n";
import { Client } from "revolt.js";
export function takeError(
error: any
): string {
import { Children } from "../../types/Preact";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function takeError(error: any): string {
const type = error?.response?.data?.type;
let id = type;
const id = type;
if (!type) {
if (error?.response?.status === 403) {
return "Unauthorized";
} else if (error && (!!error.isAxiosError && !error.response)) {
} else if (error && !!error.isAxiosError && !error.response) {
return "NetworkError";
}
......@@ -22,13 +22,20 @@ export function takeError(
return id;
}
export function getChannelName(client: Client, channel: Channel, prefixType?: boolean): Children {
export function getChannelName(
channel: Channel,
prefixType?: boolean,
): Children {
if (channel.channel_type === "SavedMessages")
return <Text id="app.navigation.tabs.saved" />;
if (channel.channel_type === "DirectMessage") {
let uid = client.channels.getRecipient(channel._id);
return <>{prefixType && "@"}{client.users.get(uid)?.username}</>;
return (
<>
{prefixType && "@"}
{channel.recipient!.username}
</>
);
}
if (channel.channel_type === "TextChannel" && prefixType) {
......@@ -37,12 +44,3 @@ export function getChannelName(client: Client, channel: Channel, prefixType?: bo
return <>{channel.name}</>;
}
export type MessageObject = Omit<Message, "edited"> & { edited?: string };
export function mapMessage(message: Partial<Message>) {
const { edited, ...msg } = message;
return {
...msg,
edited: edited?.$date,
} as MessageObject;
}
......@@ -2,4 +2,3 @@ interface ImportMetaEnv {
VITE_API_URL: string;
VITE_THEMES_URL: string;
}
\ No newline at end of file
type Build = "stable" | "nightly" | "dev";
type NativeConfig = {
frame: boolean;
build: Build;
discordRPC: boolean;
hardwareAcceleration: boolean;
};
declare interface Window {
isNative?: boolean;
nativeVersion: string;
native: {
min();
max();
close();
reload();
relaunch();
getConfig(): NativeConfig;
set(key: keyof NativeConfig, value: unknown);
getAutoStart(): Promise<boolean>;
enableAutoStart(): Promise<void>;
disableAutoStart(): Promise<void>;
};
}
declare const Fragment = preact.Fragment;
import { Link, LinkProps } from "react-router-dom";
type Props = LinkProps & JSX.HTMLAttributes<HTMLAnchorElement> & {
active: boolean
};
type Props = LinkProps &
JSX.HTMLAttributes<HTMLAnchorElement> & {
active: boolean;
};
export default function ConditionalLink(props: Props) {
const { active, ...linkProps } = props;
if (active) {
return <a>{ props.children }</a>;
} else {
return <Link {...linkProps} />;
return <a>{props.children}</a>;
}
return <Link {...linkProps} />;
}
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import {
At,
Bell,
BellOff,
Check,
CheckSquare,
ChevronRight,
Block,
Square,
LeftArrowAlt,
Trash,
} from "@styled-icons/boxicons-regular";
import { Cog, UserVoice } from "@styled-icons/boxicons-solid";
import { useHistory } from "react-router-dom";
import { Attachment, Channels, Message, Servers, Users } from "revolt.js/dist/api/objects";
import { Attachment } from "revolt-api/types/Autumn";
import { Presence, RelationshipStatus } from "revolt-api/types/Users";
import {
ChannelPermission,
ServerPermission,
UserPermission,
} from "revolt.js/dist/api/permissions";
import { Channel } from "revolt.js/dist/maps/Channels";
import { Message } from "revolt.js/dist/maps/Messages";
import { Server } from "revolt.js/dist/maps/Servers";
import { User } from "revolt.js/dist/maps/Users";
import {
ContextMenu,
ContextMenuWithData,
MenuItem,
openContextMenu
openContextMenu,
} from "preact-context-menu";
import { ChannelPermission, ServerPermission, UserPermission } from "revolt.js/dist/api/permissions";
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { dispatch } from "../redux";
import { connectState } from "../redux/connector";
import {
getNotificationState,
Notifications,
NotificationState,
} from "../redux/reducers/notifications";
import { QueuedMessage } from "../redux/reducers/queue";
import { WithDispatcher } from "../redux/reducers";
import { useIntermediate } from "../context/intermediate/Intermediate";
import { AppContext, ClientStatus, StatusContext } from "../context/revoltjs/RevoltClient";
import { Screen, useIntermediate } from "../context/intermediate/Intermediate";
import {
AppContext,
ClientStatus,
StatusContext,
} from "../context/revoltjs/RevoltClient";
import { takeError } from "../context/revoltjs/util";
import { useChannel, useChannelPermission, useForceUpdate, useServer, useServerPermission, useUser, useUserPermission } from "../context/revoltjs/hooks";
import { Children } from "../types/Preact";
import Tooltip from "../components/common/Tooltip";
import UserStatus from "../components/common/user/UserStatus";
import IconButton from "../components/ui/IconButton";
import LineDivider from "../components/ui/LineDivider";
import { connectState } from "../redux/connector";
import { Children } from "../types/Preact";
import { internalEmit } from "./eventEmitter";
import { At, Bell, BellOff, Check, CheckSquare, ChevronRight, Block, Square, LeftArrowAlt } from "@styled-icons/boxicons-regular";
import { getNotificationState, Notifications, NotificationState } from "../redux/reducers/notifications";
interface ContextMenuData {
user?: string;
......@@ -38,49 +73,62 @@ type Action =
| { action: "copy_id"; id: string }
| { action: "copy_selection" }
| { action: "copy_text"; content: string }
| { action: "mark_as_read"; channel: Channels.Channel }
| { action: "mark_as_read"; channel: Channel }
| { action: "retry_message"; message: QueuedMessage }
| { action: "cancel_message"; message: QueuedMessage }
| { action: "mention"; user: string }
| { action: "reply_message"; id: string }
| { action: "quote_message"; content: string }
| { action: "edit_message"; id: string }
| { action: "delete_message"; target: Channels.Message }
| { action: "delete_message"; target: Message }
| { action: "open_file"; attachment: Attachment }
| { action: "save_file"; attachment: Attachment }
| { action: "copy_file_link"; attachment: Attachment }
| { action: "open_link"; link: string }
| { action: "copy_link"; link: string }
| { action: "remove_member"; channel: string; user: string }
| { action: "kick_member"; target: Servers.Server; user: string }
| { action: "ban_member"; target: Servers.Server; user: string }
| { action: "view_profile"; user: string }
| { action: "message_user"; user: string }
| { action: "block_user"; user: string }
| { action: "unblock_user"; user: string }
| { action: "add_friend"; user: string }
| { action: "remove_friend"; user: string }
| { action: "cancel_friend"; user: string }
| { action: "set_presence"; presence: Users.Presence }
| { action: "remove_member"; channel: Channel; user: User }
| { action: "kick_member"; target: Server; user: User }
| { action: "ban_member"; target: Server; user: User }
| { action: "view_profile"; user: User }
| { action: "message_user"; user: User }
| { action: "block_user"; user: User }
| { action: "unblock_user"; user: User }
| { action: "add_friend"; user: User }
| { action: "remove_friend"; user: User }
| { action: "cancel_friend"; user: User }
| { action: "set_presence"; presence: Presence }
| { action: "set_status" }
| { action: "clear_status" }
| { action: "create_channel"; target: Servers.Server }
| { action: "create_invite"; target: Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel }
| { action: "leave_group"; target: Channels.GroupChannel }
| { action: "delete_channel"; target: Channels.TextChannel | Channels.VoiceChannel }
| { action: "close_dm"; target: Channels.DirectMessageChannel }
| { action: "leave_server"; target: Servers.Server }
| { action: "delete_server"; target: Servers.Server }
| { action: "open_notification_options", channel: Channels.Channel }
| { action: "open_channel_settings", id: string }
| { action: "open_server_settings", id: string }
| { action: "open_server_channel_settings", server: string, id: string }
| { action: "set_notification_state", key: string, state?: NotificationState };
type Props = WithDispatcher & {
notifications: Notifications
| { action: "create_channel"; target: Server }
| {
action: "create_invite";
target: Channel;
}
| { action: "leave_group"; target: Channel }
| {
action: "delete_channel";
target: Channel;
}
| { action: "close_dm"; target: Channel }
| { action: "leave_server"; target: Server }
| { action: "delete_server"; target: Server }
| { action: "open_notification_options"; channel: Channel }
| { action: "open_settings" }
| { action: "open_channel_settings"; id: string }
| { action: "open_server_settings"; id: string }
| { action: "open_server_channel_settings"; server: string; id: string }
| {
action: "set_notification_state";
key: string;
state?: NotificationState;
};
type Props = {
notifications: Notifications;
};
// ! FIXME: I dare someone to re-write this
// Tip: This should just be split into separate context menus per logical area.
function ContextMenus(props: Props) {
const { openScreen, writeClipboard } = useIntermediate();
const client = useContext(AppContext);
......@@ -98,57 +146,65 @@ function ContextMenus(props: Props) {
writeClipboard(data.id);
break;
case "copy_selection":
writeClipboard(document.getSelection()?.toString() ?? '');
writeClipboard(document.getSelection()?.toString() ?? "");
break;
case "mark_as_read":
{
if (data.channel.channel_type === 'SavedMessages' ||
data.channel.channel_type === 'VoiceChannel') return;
if (
data.channel.channel_type === "SavedMessages" ||
data.channel.channel_type === "VoiceChannel"
)
return;
const message =
typeof data.channel.last_message === "string"
? data.channel.last_message
: data.channel.last_message!._id;
let message = data.channel.channel_type === 'TextChannel' ? data.channel.last_message : data.channel.last_message._id;
props.dispatcher({
dispatch({
type: "UNREADS_MARK_READ",
channel: data.channel._id,
message
message,
});
client.req('PUT', `/channels/${data.channel._id}/ack/${message}` as '/channels/id/ack/id');
client.req(
"PUT",
`/channels/${data.channel._id}/ack/${message}` as "/channels/id/ack/id",
);
}
break;
case "retry_message":
{
const nonce = data.message.id;
const fail = (error: any) =>
props.dispatcher({
const fail = (error: string) =>
dispatch({
type: "QUEUE_FAIL",
nonce,
error
error,
});
client.channels
.sendMessage(
data.message.channel,
{
nonce: data.message.id,
content: data.message.data.content as string,
replies: data.message.data.replies
}
)
.get(data.message.channel)!
.sendMessage({
nonce: data.message.id,
content: data.message.data.content as string,
replies: data.message.data.replies,
})
.catch(fail);
props.dispatcher({
dispatch({
type: "QUEUE_START",
nonce
nonce,
});
}
break;
case "cancel_message":
{
props.dispatcher({
dispatch({
type: "QUEUE_REMOVE",
nonce: data.message.id
nonce: data.message.id,
});
}
break;
......@@ -159,7 +215,7 @@ function ContextMenus(props: Props) {
"MessageBox",
"append",
`<@${data.user}>`,
"mention"
"mention",
);
}
break;
......@@ -170,11 +226,7 @@ function ContextMenus(props: Props) {
case "reply_message":
{
internalEmit(
"ReplyBar",
"add",
data.id
);
internalEmit("ReplyBar", "add", data.id);
}
break;
......@@ -184,14 +236,18 @@ function ContextMenus(props: Props) {
"MessageBox",
"append",
data.content,
"quote"
"quote",
);
}
break;
case "edit_message":
{
internalEmit("MessageRenderer", "edit_message", data.id);
internalEmit(
"MessageRenderer",
"edit_message",
data.id,
);
}
break;
......@@ -200,7 +256,7 @@ function ContextMenus(props: Props) {
window
.open(
client.generateFileURL(data.attachment),
"_blank"
"_blank",
)
?.focus();
}
......@@ -210,8 +266,13 @@ function ContextMenus(props: Props) {
{
window.open(
// ! FIXME: do this from revolt.js
client.generateFileURL(data.attachment)?.replace('attachments', 'attachments/download'),
"_blank"
client
.generateFileURL(data.attachment)
?.replace(
"attachments",
"attachments/download",
),
"_blank",
);
}
break;
......@@ -221,7 +282,9 @@ function ContextMenus(props: Props) {
const { filename } = data.attachment;
writeClipboard(
// ! FIXME: do from r.js
client.generateFileURL(data.attachment) + `/${encodeURI(filename)}`,
`${client.generateFileURL(
data.attachment,
)}/${encodeURI(filename)}`,
);
}
break;
......@@ -240,17 +303,17 @@ function ContextMenus(props: Props) {
case "remove_member":
{
client.channels.removeMember(data.channel, data.user);
data.channel.removeMember(data.user._id);
}
break;
case "view_profile":
openScreen({ id: 'profile', user_id: data.user });
openScreen({ id: "profile", user_id: data.user._id });
break;
case "message_user":
{
const channel = await client.users.openDM(data.user);
const channel = await data.user.openDM();
if (channel) {
history.push(`/channel/${channel._id}`);
}
......@@ -259,44 +322,57 @@ function ContextMenus(props: Props) {
case "add_friend":
{
let user = client.users.get(data.user);
if (user) {
await client.users.addFriend(user.username);
}
await data.user.addFriend();
}
break;
case "block_user":
await client.users.blockUser(data.user);
openScreen({
id: "special_prompt",
type: "block_user",
target: data.user,
});
break;
case "unblock_user":
await client.users.unblockUser(data.user);
await data.user.unblockUser();
break;
case "remove_friend":
openScreen({
id: "special_prompt",
type: "unfriend_user",
target: data.user,
});
break;
case "cancel_friend":
await client.users.removeFriend(data.user);
await data.user.removeFriend();
break;
case "set_presence":
{
await client.users.editUser({
await client.users.edit({
status: {
...client.user?.status,
presence: data.presence
}
presence: data.presence,
},
});
}
break;
case "set_status": openScreen({ id: "special_input", type: "set_custom_status" }); break;
case "set_status":
openScreen({
id: "special_input",
type: "set_custom_status",
});
break;
case "clear_status":
{
let { text, ...status } = client.user?.status ?? {};
await client.users.editUser({ status });
const { text: _text, ...status } =
client.user?.status ?? {};
await client.users.edit({ status });
}
break;
case "leave_group":
case "close_dm":
case "leave_server":
......@@ -304,32 +380,58 @@ function ContextMenus(props: Props) {
case "delete_server":
case "delete_message":
case "create_channel":
// @ts-expect-error
case "create_invite": openScreen({ id: "special_prompt", type: data.action, target: data.target }); break;
case "create_invite":
// Typescript flattens the case types into a single type and type structure and specifity is lost
openScreen({
id: "special_prompt",
type: data.action,
target: data.target,
} as unknown as Screen);
break;
case "ban_member":
case "kick_member": openScreen({ id: "special_prompt", type: data.action, target: data.target, user: data.user }); break;
case "kick_member":
openScreen({
id: "special_prompt",
type: data.action,
target: data.target,
user: data.user,
});
break;
case "open_notification_options": {
openContextMenu("NotificationOptions", { channel: data.channel });
openContextMenu("NotificationOptions", {
channel: data.channel,
});
break;
}
case "open_channel_settings": history.push(`/channel/${data.id}/settings`); break;
case "open_server_channel_settings": history.push(`/server/${data.server}/channel/${data.id}/settings`); break;
case "open_server_settings": history.push(`/server/${data.id}/settings`); break;
case "open_settings":
history.push("/settings");
break;
case "open_channel_settings":
history.push(`/channel/${data.id}/settings`);
break;
case "open_server_channel_settings":
history.push(
`/server/${data.server}/channel/${data.id}/settings`,
);
break;
case "open_server_settings":
history.push(`/server/${data.id}/settings`);
break;
case "set_notification_state": {
const { key, state } = data;
if (state) {
props.dispatcher({ type: "NOTIFICATIONS_SET", key, state });
dispatch({ type: "NOTIFICATIONS_SET", key, state });
} else {
props.dispatcher({ type: "NOTIFICATIONS_REMOVE", key });
dispatch({ type: "NOTIFICATIONS_REMOVE", key });
}
break;
}
}
})().catch(err => {
})().catch((err) => {
openScreen({ id: "error", error: takeError(err) });
});
}
......@@ -345,29 +447,27 @@ function ContextMenus(props: Props) {
server_list,
queued,
unread,
contextualChannel: cxid
contextualChannel: cxid,
}: ContextMenuData) => {
const forceUpdate = useForceUpdate();
const elements: Children[] = [];
var lastDivider = false;
let lastDivider = false;
function generateAction(
action: Action,
locale?: string,
disabled?: boolean,
tip?: Children
tip?: Children,
) {
lastDivider = false;
elements.push(
<MenuItem data={action} disabled={disabled}>
<Text
id={`app.context_menu.${locale ??
action.action}`}
id={`app.context_menu.${
locale ?? action.action
}`}
/>
{ tip && <div className="tip">
{ tip }
</div> }
</MenuItem>
{tip && <div className="tip">{tip}</div>}
</MenuItem>,
);
}
......@@ -378,34 +478,59 @@ function ContextMenus(props: Props) {
}
if (server_list) {
let server = useServer(server_list, forceUpdate);
let permissions = useServerPermission(server_list, forceUpdate);
const server = client.servers.get(server_list)!;
const permissions = server.permission;
if (server) {
if (permissions & ServerPermission.ManageChannels) generateAction({ action: 'create_channel', target: server });
if (permissions & ServerPermission.ManageServer) generateAction({ action: 'open_server_settings', id: server_list });
if (permissions & ServerPermission.ManageChannels)
generateAction({
action: "create_channel",
target: server,
});
if (permissions & ServerPermission.ManageServer)
generateAction({
action: "open_server_settings",
id: server_list,
});
}
return elements;
}
if (document.getSelection()?.toString().length ?? 0 > 0) {
generateAction({ action: "copy_selection" }, undefined, undefined, <Text id="shortcuts.ctrlc" />);
generateAction(
{ action: "copy_selection" },
undefined,
undefined,
<Text id="shortcuts.ctrlc" />,
);
pushDivider();
}
const channel = useChannel(cid, forceUpdate);
const contextualChannel = useChannel(cxid, forceUpdate);
const channel = cid ? client.channels.get(cid) : undefined;
const contextualChannel = cxid
? client.channels.get(cxid)
: undefined;
const targetChannel = channel ?? contextualChannel;
const user = useUser(uid, forceUpdate);
const serverChannel = targetChannel && (targetChannel.channel_type === 'TextChannel' || targetChannel.channel_type === 'VoiceChannel') ? targetChannel : undefined;
const server = useServer(serverChannel ? serverChannel.server : sid, forceUpdate);
const channelPermissions = targetChannel ? useChannelPermission(targetChannel._id, forceUpdate) : 0;
const serverPermissions = server ? useServerPermission(server._id, forceUpdate) : (
serverChannel ? useServerPermission(serverChannel.server, forceUpdate) : 0
);
const userPermissions = user ? useUserPermission(user._id, forceUpdate) : 0;
const user = uid ? client.users.get(uid) : undefined;
const serverChannel =
targetChannel &&
(targetChannel.channel_type === "TextChannel" ||
targetChannel.channel_type === "VoiceChannel")
? targetChannel
: undefined;
const s = serverChannel ? serverChannel.server_id! : sid;
const server = s ? client.servers.get(s) : undefined;
const channelPermissions = targetChannel?.permission || 0;
const serverPermissions =
(server
? server.permission
: serverChannel
? serverChannel.server?.permission
: 0) || 0;
const userPermissions = (user ? user.permission : 0) || 0;
if (channel && unread) {
generateAction({ action: "mark_as_read", channel });
......@@ -415,7 +540,7 @@ function ContextMenus(props: Props) {
if (user && user._id !== userId) {
generateAction({
action: "mention",
user: user._id
user: user._id,
});
pushDivider();
......@@ -423,116 +548,147 @@ function ContextMenus(props: Props) {
}
if (user) {
let actions: string[];
let actions: Action["action"][];
switch (user.relationship) {
case Users.Relationship.User: actions = []; break;
case Users.Relationship.Friend:
case RelationshipStatus.User:
actions = [];
break;
case RelationshipStatus.Friend:
actions = ["remove_friend", "block_user"];
break;
case RelationshipStatus.Incoming:
actions = [
"remove_friend",
"block_user"
"add_friend",
"cancel_friend",
"block_user",
];
break;
case Users.Relationship.Incoming:
actions = ["add_friend", "block_user"];
break;
case Users.Relationship.Outgoing:
case RelationshipStatus.Outgoing:
actions = ["cancel_friend", "block_user"];
break;
case Users.Relationship.Blocked:
case RelationshipStatus.Blocked:
actions = ["unblock_user"];
break;
case Users.Relationship.BlockedOther:
case RelationshipStatus.BlockedOther:
actions = ["block_user"];
break;
case Users.Relationship.None:
case RelationshipStatus.None:
default:
actions = ["add_friend", "block_user"];
}
if (userPermissions & UserPermission.ViewProfile) {
generateAction({ action: 'view_profile', user: user._id });
generateAction({
action: "view_profile",
user,
});
}
if (user._id !== userId && userPermissions & UserPermission.SendMessage) {
generateAction({ action: 'message_user', user: user._id });
if (
user._id !== userId &&
userPermissions & UserPermission.SendMessage
) {
generateAction({
action: "message_user",
user,
});
}
for (const action of actions) {
for (let i = 0; i < actions.length; i++) {
// Typescript can't determine that user the actions are linked together correctly
generateAction({
action: action as any,
user: user._id
});
action: actions[i],
user,
} as unknown as Action);
}
}
if (contextualChannel) {
if (contextualChannel.channel_type === "Group" && uid) {
if (
contextualChannel.owner === userId &&
contextualChannel.owner_id === userId &&
userId !== uid
) {
generateAction({
action: "remove_member",
channel: contextualChannel._id,
user: uid
channel: contextualChannel,
user: user!,
});
}
}
if (server && uid && userId !== uid && uid !== server.owner) {
if (serverPermissions & ServerPermission.KickMembers)
generateAction({ action: "kick_member", target: server, user: uid });
if (
server &&
uid &&
userId !== uid &&
uid !== server.owner
) {
if (
serverPermissions & ServerPermission.KickMembers
)
generateAction({
action: "kick_member",
target: server,
user: user!,
});
if (serverPermissions & ServerPermission.BanMembers)
generateAction({ action: "ban_member", target: server, user: uid });
generateAction({
action: "ban_member",
target: server,
user: user!,
});
}
}
if (queued) {
generateAction({
action: "retry_message",
message: queued
message: queued,
});
generateAction({
action: "cancel_message",
message: queued
message: queued,
});
}
if (message && !queued) {
generateAction({
action: "reply_message",
id: message._id
id: message._id,
});
if (
typeof message.content === "string" &&
message.content.length > 0
) {
generateAction({
action: "quote_message",
content: message.content
content: message.content,
});
generateAction({
action: "copy_text",
content: message.content
content: message.content,
});
}
if (message.author === userId) {
if (message.author_id === userId) {
generateAction({
action: "edit_message",
id: message._id
id: message._id,
});
}
if (message.author === userId ||
channelPermissions & ChannelPermission.ManageMessages) {
if (
message.author_id === userId ||
channelPermissions &
ChannelPermission.ManageMessages
) {
generateAction({
action: "delete_message",
target: message
target: message,
});
}
......@@ -544,40 +700,39 @@ function ContextMenus(props: Props) {
generateAction(
{
action: "open_file",
attachment: message.attachments[0]
attachment: message.attachments[0],
},
type === "Image"
? "open_image"
: type === "Video"
? "open_video"
: "open_file"
: "open_file",
);
generateAction(
{
action: "save_file",
attachment: message.attachments[0]
attachment: message.attachments[0],
},
type === "Image"
? "save_image"
: type === "Video"
? "save_video"
: "save_file"
: "save_file",
);
generateAction(
{
action: "copy_file_link",
attachment: message.attachments[0]
attachment: message.attachments[0],
},
"copy_link"
"copy_link",
);
}
if (document.activeElement?.tagName === "A") {
let link = document.activeElement.getAttribute(
"href"
);
const link =
document.activeElement.getAttribute("href");
if (link) {
pushDivider();
generateAction({ action: "open_link", link });
......@@ -586,147 +741,283 @@ function ContextMenus(props: Props) {
}
}
let id = sid ?? cid ?? uid ?? message?._id;
const id = sid ?? cid ?? uid ?? message?._id;
if (id) {
pushDivider();
if (channel) {
if (channel.channel_type !== 'VoiceChannel') {
generateAction({ action: "open_notification_options", channel }, undefined, undefined, <ChevronRight size={24} />);
if (channel.channel_type !== "VoiceChannel") {
generateAction(
{
action: "open_notification_options",
channel,
},
undefined,
undefined,
<ChevronRight size={24} />,
);
}
switch (channel.channel_type) {
case 'Group':
case "Group":
// ! generateAction({ action: "create_invite", target: channel }); FIXME: add support for group invites
generateAction({ action: "open_channel_settings", id: channel._id }, "open_group_settings");
generateAction({ action: "leave_group", target: channel }, "leave_group");
generateAction(
{
action: "open_channel_settings",
id: channel._id,
},
"open_group_settings",
);
generateAction(
{
action: "leave_group",
target: channel,
},
"leave_group",
);
break;
case 'DirectMessage':
generateAction({ action: "close_dm", target: channel });
case "DirectMessage":
generateAction({
action: "close_dm",
target: channel,
});
break;
case 'TextChannel':
case 'VoiceChannel':
// ! FIXME: add permission for invites
generateAction({ action: "create_invite", target: channel });
if (serverPermissions & ServerPermission.ManageServer)
generateAction({ action: "open_server_channel_settings", server: channel.server, id: channel._id }, "open_channel_settings");
if (serverPermissions & ServerPermission.ManageChannels)
generateAction({ action: "delete_channel", target: channel });
case "TextChannel":
case "VoiceChannel":
if (
channelPermissions &
ChannelPermission.InviteOthers
) {
generateAction({
action: "create_invite",
target: channel,
});
}
if (
serverPermissions &
ServerPermission.ManageServer
)
generateAction(
{
action: "open_server_channel_settings",
server: channel.server_id!,
id: channel._id,
},
"open_channel_settings",
);
if (
serverPermissions &
ServerPermission.ManageChannels
)
generateAction({
action: "delete_channel",
target: channel,
});
break;
}
}
if (sid && server) {
if (serverPermissions & ServerPermission.ManageServer)
generateAction({ action: "open_server_settings", id: server._id }, "open_server_settings");
if (
serverPermissions &
ServerPermission.ManageServer
)
generateAction(
{
action: "open_server_settings",
id: server._id,
},
"open_server_settings",
);
if (userId === server.owner) {
generateAction({ action: "delete_server", target: server }, "delete_server");
generateAction(
{ action: "delete_server", target: server },
"delete_server",
);
} else {
generateAction({ action: "leave_server", target: server }, "leave_server");
generateAction(
{ action: "leave_server", target: server },
"leave_server",
);
}
}
generateAction(
{ action: "copy_id", id },
sid ? "copy_sid" :
cid ? "copy_cid" :
message ? "copy_mid" : "copy_uid"
sid
? "copy_sid"
: cid
? "copy_cid"
: message
? "copy_mid"
: "copy_uid",
);
}
return elements;
}}
</ContextMenuWithData>
<ContextMenu id="Status" onClose={contextClick}>
<span data-disabled={true}>@{client.user?.username}</span>
<LineDivider />
<MenuItem
data={{
action: "set_presence",
presence: Users.Presence.Online
}}
disabled={!isOnline}
>
<div className="indicator online" />
<Text id={`app.status.online`} />
</MenuItem>
<MenuItem
data={{
action: "set_presence",
presence: Users.Presence.Idle
}}
disabled={!isOnline}
>
<div className="indicator idle" />
<Text id={`app.status.idle`} />
</MenuItem>
<MenuItem
data={{
action: "set_presence",
presence: Users.Presence.Busy
}}
disabled={!isOnline}
>
<div className="indicator busy" />
<Text id={`app.status.busy`} />
</MenuItem>
<MenuItem
data={{
action: "set_presence",
presence: Users.Presence.Invisible
}}
disabled={!isOnline}
>
<div className="indicator invisible" />
<Text id={`app.status.invisible`} />
</MenuItem>
<LineDivider />
<MenuItem data={{ action: "set_status" }} disabled={!isOnline}>
<Text id={`app.context_menu.custom_status`} />
</MenuItem>
{client.user?.status?.text && (
<MenuItem
data={{ action: "clear_status" }}
disabled={!isOnline}
>
<Text id={`app.context_menu.clear_status`} />
</MenuItem>
)}
</ContextMenu>
<ContextMenuWithData id="NotificationOptions" onClose={contextClick}>
{({ channel }: { channel: Channels.Channel }) => {
<ContextMenuWithData
id="Status"
onClose={contextClick}
className="Status">
{() => {
const user = client.user!;
return (
<>
<div className="header">
<div className="main">
<div
className="username"
onClick={() =>
writeClipboard(
client.user!.username,
)
}>
<Tooltip
content={
<Text id="app.special.copy_username" />
}>
@{user.username}
</Tooltip>
</div>
<div
className="status"
onClick={() =>
contextClick({
action: "set_status",
})
}>
<UserStatus user={user} />
</div>
</div>
<IconButton>
<MenuItem
data={{ action: "open_settings" }}>
<Cog size={22} />
</MenuItem>
</IconButton>
</div>
<LineDivider />
<MenuItem
data={{
action: "set_presence",
presence: Presence.Online,
}}
disabled={!isOnline}>
<div className="indicator online" />
<Text id={`app.status.online`} />
</MenuItem>
<MenuItem
data={{
action: "set_presence",
presence: Presence.Idle,
}}
disabled={!isOnline}>
<div className="indicator idle" />
<Text id={`app.status.idle`} />
</MenuItem>
<MenuItem
data={{
action: "set_presence",
presence: Presence.Busy,
}}
disabled={!isOnline}>
<div className="indicator busy" />
<Text id={`app.status.busy`} />
</MenuItem>
<MenuItem
data={{
action: "set_presence",
presence: Presence.Invisible,
}}
disabled={!isOnline}>
<div className="indicator invisible" />
<Text id={`app.status.invisible`} />
</MenuItem>
<LineDivider />
<MenuItem
data={{ action: "set_status" }}
disabled={!isOnline}>
<UserVoice size={18} />
<Text id={`app.context_menu.custom_status`} />
{client.user!.status?.text && (
<IconButton>
<MenuItem
data={{ action: "clear_status" }}>
<Trash size={18} />
</MenuItem>
</IconButton>
)}
</MenuItem>
</>
);
}}
</ContextMenuWithData>
<ContextMenuWithData
id="NotificationOptions"
onClose={contextClick}>
{({ channel }: { channel: Channel }) => {
const state = props.notifications[channel._id];
const actual = getNotificationState(props.notifications, channel);
const actual = getNotificationState(
props.notifications,
channel,
);
let elements: Children[] = [
<MenuItem data={{ action: "set_notification_state", key: channel._id }}>
<Text id={`app.main.channel.notifications.default`} />
const elements: Children[] = [
<MenuItem
key="notif"
data={{
action: "set_notification_state",
key: channel._id,
}}>
<Text
id={`app.main.channel.notifications.default`}
/>
<div className="tip">
{ (state !== undefined) && <Square size={20} /> }
{ (state === undefined) && <CheckSquare size={20} /> }
{state !== undefined && <Square size={20} />}
{state === undefined && (
<CheckSquare size={20} />
)}
</div>
</MenuItem>
</MenuItem>,
];
function generate(key: string, icon: Children) {
elements.push(
<MenuItem data={{ action: "set_notification_state", key: channel._id, state: key }}>
{ icon }
<Text id={`app.main.channel.notifications.${key}`} />
{ (state === undefined && actual === key) && <div className="tip"><LeftArrowAlt size={20} /></div> }
{ (state === key) && <div className="tip"><Check size={20} /></div> }
</MenuItem>
<MenuItem
key={key}
data={{
action: "set_notification_state",
key: channel._id,
state: key,
}}>
{icon}
<Text
id={`app.main.channel.notifications.${key}`}
/>
{state === undefined && actual === key && (
<div className="tip">
<LeftArrowAlt size={20} />
</div>
)}
{state === key && (
<div className="tip">
<Check size={20} />
</div>
)}
</MenuItem>,
);
}
generate('all', <Bell size={24} />);
generate('mention', <At size={24} />);
generate('muted', <BellOff size={24} />);
generate('none', <Block size={24} />);
generate("all", <Bell size={24} />);
generate("mention", <At size={24} />);
generate("muted", <BellOff size={24} />);
generate("none", <Block size={24} />);
return elements;
}}
......@@ -735,12 +1026,8 @@ function ContextMenus(props: Props) {
);
}
export default connectState(
ContextMenus,
state => {
return {
notifications: state.notifications
};
},
true
);
export default connectState(ContextMenus, (state) => {
return {
notifications: state.notifications,
};
});