Port modal / popover context.

This commit is contained in:
Paul
2021-06-19 18:46:05 +01:00
parent 5b77ed439f
commit 9706dd75f3
57 changed files with 2562 additions and 140 deletions

View File

@@ -0,0 +1,16 @@
.info {
.header {
display: flex;
align-items: center;
flex-direction: row;
h1 {
margin: 0;
flex-grow: 1;
}
div {
cursor: pointer;
}
}
}

View File

@@ -0,0 +1,38 @@
import { X } from "@styled-icons/feather";
import styles from "./ChannelInfo.module.scss";
import Modal from "../../../components/ui/Modal";
import { getChannelName } from "../../revoltjs/util";
import Markdown from "../../../components/markdown/Markdown";
import { useChannel, useForceUpdate } from "../../revoltjs/hooks";
interface Props {
channel_id: string;
onClose: () => void;
}
export function ChannelInfo({ channel_id, onClose }: Props) {
const ctx = useForceUpdate();
const channel = useChannel(channel_id, ctx);
if (!channel) return null;
if (channel.channel_type === "DirectMessage" || channel.channel_type === 'SavedMessages') {
onClose();
return null;
}
return (
<Modal visible={true} onClose={onClose}>
<div className={styles.info}>
<div className={styles.header}>
<h1>{ getChannelName(ctx.client, channel, [ ], true) }</h1>
<div onClick={onClose}>
<X size={36} />
</div>
</div>
<p>
<Markdown content={channel.description} />
</p>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,6 @@
.viewer {
img {
max-width: 90vw;
max-height: 90vh;
}
}

View File

@@ -0,0 +1,46 @@
import styles from "./ImageViewer.module.scss";
import Modal from "../../../components/ui/Modal";
import { useContext, useEffect } from "preact/hooks";
import { AppContext } from "../../revoltjs/RevoltClient";
import { Attachment, EmbedImage } from "revolt.js/dist/api/objects";
interface Props {
onClose: () => void;
embed?: EmbedImage;
attachment?: Attachment;
}
export function ImageViewer({ attachment, embed, onClose }: Props) {
if (attachment && attachment.metadata.type !== "Image") return null;
const client = useContext(AppContext);
useEffect(() => {
function keyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
}
}
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, []);
return (
<Modal visible={true} onClose={onClose} noBackground>
<div className={styles.viewer}>
{ attachment &&
<>
<img src={client.generateFileURL(attachment)} />
{/*<AttachmentActions attachment={attachment} />*/}
</>
}
{ embed &&
<>
{/*<img src={proxyImage(embed.url)} />*/}
{/*<EmbedMediaActions embed={embed} />*/}
</>
}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,21 @@
.list {
width: 400px;
max-width: 100%;
max-height: 360px;
overflow-y: scroll;
// ! FIXME: very temporary code
> label {
> span {
align-items: flex-start !important;
> span {
display: flex;
padding: 4px;
flex-direction: row;
gap: 10px;
justify-content: flex-start;
align-items: center;
}
}
}
}

View File

@@ -0,0 +1,64 @@
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/UserCheckbox";
interface Props {
omit?: string[];
onClose: () => void;
callback: (users: string[]) => Promise<void>;
}
export function UserPicker(props: Props) {
const [selected, setSelected] = useState<string[]>([]);
const omit = [...(props.omit || []), "00000000000000000000000000"];
const users = useUsers();
return (
<Modal
visible={true}
title={<Text id="app.special.popovers.user_picker.select" />}
onClose={props.onClose}
actions={[
{
text: <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 => (
<UserCheckbox
user={x}
checked={x.selected}
onChange={v => {
if (v) {
setSelected([...selected, x._id]);
} else {
setSelected(
selected.filter(y => y !== x._id)
);
}
}}
/>
))}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,165 @@
.modal {
height: 460px;
display: flex;
padding: 0 !important;
flex-direction: column;
}
.header {
background-size: cover;
border-radius: 8px 8px 0 0;
background-position: center;
&[data-force="light"] {
color: white;
}
&[data-force="dark"] {
color: black;
}
}
.profile {
gap: 16px;
width: 560px;
display: flex;
padding: 20px;
max-width: 100%;
align-items: center;
flex-direction: row;
.details {
flex-grow: 1;
min-width: 0;
display: flex;
flex-direction: column;
> * {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.username {
font-size: 22px;
font-weight: 600;
}
.status {
font-size: 13px;
}
}
}
.tabs {
gap: 8px;
display: flex;
padding: 0 1.5em;
font-size: .875rem;
> div {
padding: 8px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: border-bottom .3s;
&[data-active="true"] {
border-bottom: 2px solid var(--foreground);
cursor: default;
}
&:hover:not([data-active="true"]) {
border-bottom: 2px solid var(--tertiary-foreground);
}
}
}
.content {
gap: 8px;
height: 100%;
display: flex;
padding: 1em 1.5em;
max-width: 560px;
overflow-y: auto;
flex-direction: column;
background: var(--primary-background);
border-radius: 0 0 8px 8px;
.empty {
display: flex;
justify-content: center;
align-items: center;
}
.category {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: var(--tertiary-foreground);
margin-bottom: 8px;
&:not(:first-child) {
margin-top: 8px;
}
}
> div {
> span {
font-size: 15px;
}
}
}
.badges {
gap: 8px;
display: flex;
margin-top: 4px;
flex-direction: row;
img {
width: 32px;
height: 32px;
cursor: pointer;
}
}
.entries {
gap: 8px;
display: flex;
flex-direction: column;
a {
min-width: 0;
}
.entry {
gap: 8px;
min-width: 0;
padding: 12px;
display: flex;
cursor: pointer;
border-radius: 4px;
align-items: center;
color: var(--secondary-foreground);
background-color: var(--secondary-background);
transition: background-color .1s;
&:hover {
background-color: var(--primary-background);
}
img {
width: 32px;
height: 32px;
border-radius: 50%;
}
span {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}

View File

@@ -0,0 +1,341 @@
import Modal from "../../../components/ui/Modal";
import { Localizer, Text } from "preact-i18n";
import styles from "./UserProfile.module.scss";
import Preloader from "../../../components/ui/Preloader";
import { Route } from "revolt.js/dist/api/routes";
import { Users } from "revolt.js/dist/api/objects";
import { IntermediateContext, useIntermediate } from "../Intermediate";
import { Globe, Mail, Edit, UserPlus, Shield } from "@styled-icons/feather";
import { Link, useHistory } from "react-router-dom";
import { useContext, useEffect, useLayoutEffect, useState } from "preact/hooks";
import { decodeTime } from "ulid";
import { CashStack } from "@styled-icons/bootstrap";
import { AppContext, ClientStatus, StatusContext } from "../../revoltjs/RevoltClient";
import { useChannels, useForceUpdate, useUser, useUsers } from "../../revoltjs/hooks";
import UserIcon from '../../../components/common/UserIcon';
import UserStatus from '../../../components/common/UserStatus';
import Tooltip from '../../../components/common/Tooltip';
import ChannelIcon from '../../../components/common/ChannelIcon';
import Markdown from '../../../components/markdown/Markdown';
interface Props {
user_id: string;
dummy?: boolean;
onClose: () => void;
dummyProfile?: Users.Profile;
}
enum Badges {
Developer = 1,
Translator = 2,
Supporter = 4,
ResponsibleDisclosure = 8,
EarlyAdopter = 256
}
export function UserProfile({ user_id, onClose, dummy, dummyProfile }: Props) {
const { 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 client = useContext(AppContext);
const status = useContext(StatusContext);
const [tab, setTab] = useState("profile");
const history = useHistory();
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;
}
useLayoutEffect(() => {
if (!user_id) return;
if (typeof profile !== 'undefined') setProfile(undefined);
if (typeof mutual !== 'undefined') setMutual(undefined);
}, [user_id]);
if (dummy) {
useLayoutEffect(() => {
setProfile(dummyProfile);
}, [dummyProfile]);
}
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);
// ! FIXME: in the future, also check if mutual guilds
// ! maybe just allow mutual group to allow profile viewing
/*if (
user.relationship === Users.Relationship.Friend ||
user.relationship === Users.Relationship.User
) {*/
client.users
.fetchProfile(user_id)
.then(data => setProfile(data))
.catch(() => {});
//}
}
}, [profile, status]);
const mutualGroups = channels.filter(
channel =>
channel?.channel_type === "Group" &&
channel.recipients.includes(user_id)
);
const backgroundURL = profile && client.users.getBackgroundURL(profile, { width: 1000 }, true);
const badges = (user.badges ?? 0) | (decodeTime(user._id) < 1623751765790 ? Badges.EarlyAdopter : 0);
return (
<Modal
visible
border={dummy}
onClose={onClose}
dontModal={dummy}
>
<div
className={styles.header}
data-force={
profile?.background
? "light"
: undefined
}
style={{
backgroundImage: backgroundURL && `linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7) ), url('${backgroundURL}')`
}}
>
<div className={styles.profile}>
<UserIcon size={80} target={user} status />
<div className={styles.details}>
<Localizer>
<span
className={styles.username}
onClick={() => writeClipboard(user.username)}>
@{user.username}
</span>
</Localizer>
{user.status?.text && (
<span className={styles.status}>
<UserStatus user={user} />
</span>
)}
</div>
{user.relationship === Users.Relationship.Friend && (
<Localizer>
<Tooltip
content={
<Text id="app.context_menu.message_user" />
}
>
{/*<IconButton
onClick={() => {
onClose();
history.push(`/open/${user_id}`);
}}
>*/}
<Mail size={30} strokeWidth={1.5} />
{/*</IconButton>*/}
</Tooltip>
</Localizer>
)}
{user.relationship === Users.Relationship.User && (
/*<IconButton
onClick={() => {
onClose();
if (dummy) return;
history.push(`/settings/profile`);
}}
>*/
<Edit size={28} strokeWidth={1.5} />
/*</IconButton>*/
)}
{(user.relationship === Users.Relationship.Incoming ||
user.relationship === Users.Relationship.None) && (
/*<IconButton
onClick={() => client.users.addFriend(user.username)}
>*/
<UserPlus size={28} strokeWidth={1.5} />
/*</IconButton>*/
)}
</div>
<div className={styles.tabs}>
<div
data-active={tab === "profile"}
onClick={() => setTab("profile")}
>
<Text id="app.special.popovers.user_profile.profile" />
</div>
{ user.relationship !== Users.Relationship.User &&
<>
<div
data-active={tab === "friends"}
onClick={() => setTab("friends")}
>
<Text id="app.special.popovers.user_profile.mutual_friends" />
</div>
<div
data-active={tab === "groups"}
onClick={() => setTab("groups")}
>
<Text id="app.special.popovers.user_profile.mutual_groups" />
</div>
</>
}
</div>
</div>
<div className={styles.content}>
{tab === "profile" &&
<div>
{ !(profile?.content || (badges > 0)) &&
<div className={styles.empty}><Text id="app.special.popovers.user_profile.empty" /></div> }
{ (badges > 0) && <div className={styles.category}><Text id="app.special.popovers.user_profile.sub.badges" /></div> }
{ (badges > 0) && (
<div className={styles.badges}>
<Localizer>
{badges & Badges.Developer ? (
<Tooltip
content={
<Text id="app.navigation.tabs.dev" />
}
>
<img src="/assets/badges/developer.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.Translator ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.translator" />
}
>
<img src="/assets/badges/translator.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.EarlyAdopter ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.early_adopter" />
}
>
<img src="/assets/badges/early_adopter.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.Supporter ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.supporter" />
}
>
<CashStack size={32} color="#efab44" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.ResponsibleDisclosure ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.responsible_disclosure" />
}
>
<Shield size={32} color="gray" />
</Tooltip>
) : (
<></>
)}
</Localizer>
</div>
)}
{ 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 ? (
<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 && (
//<LinkProfile user_id={x._id}>
<div
className={styles.entry}
key={x._id}
>
<UserIcon size={32} target={x} />
<span>{x.username}</span>
</div>
//</LinkProfile>
)
)
)}
</div>
) : (
<Preloader />
))}
{tab === "groups" && (
<div className={styles.entries}>
{mutualGroups.length === 0 ? (
<div className={styles.empty}>
<Text id="app.special.popovers.user_profile.no_groups" />
</div>
) : (
mutualGroups.map(
x =>
x?.channel_type === "Group" && (
<Link to={`/channel/${x._id}`}>
<div
className={styles.entry}
key={x._id}
>
<ChannelIcon target={x} size={32} />
<span>{x.name}</span>
</div>
</Link>
)
)
)}
</div>
)}
</div>
</Modal>
);
}