feat: Improved viewers popover (#2106)

* refactoring popover

* feat: DocumentViews popover

* i18n

* fix: tab focus warnings

* test: Add tests around users.info changes

* snapshots
This commit is contained in:
Tom Moor
2021-05-05 19:35:23 -07:00
committed by GitHub
parent e984a3dcdb
commit 896ee5c20d
18 changed files with 388 additions and 191 deletions

View File

@@ -17,6 +17,7 @@ type Props = {
isEditing: boolean,
isCurrentUser: boolean,
lastViewedAt: string,
profileOnClick: boolean,
t: TFunction,
};
@@ -65,7 +66,11 @@ class AvatarWithPresence extends React.Component<Props> {
<AvatarWrapper isPresent={isPresent}>
<Avatar
src={user.avatarUrl}
onClick={this.handleOpenProfile}
onClick={
this.props.profileOnClick === false
? undefined
: this.handleOpenProfile
}
size={32}
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
/>

View File

@@ -1,79 +1,93 @@
// @flow
import { sortBy, keyBy } from "lodash";
import { observer, inject } from "mobx-react";
import { filter } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { MAX_AVATAR_DISPLAY } from "shared/constants";
import DocumentPresenceStore from "stores/DocumentPresenceStore";
import ViewsStore from "stores/ViewsStore";
import Document from "models/Document";
import { AvatarWithPresence } from "components/Avatar";
import DocumentViews from "components/DocumentViews";
import Facepile from "components/Facepile";
import NudeButton from "components/NudeButton";
import Popover from "components/Popover";
import useStores from "hooks/useStores";
type Props = {
views: ViewsStore,
presence: DocumentPresenceStore,
type Props = {|
document: Document,
currentUserId: string,
};
|};
@observer
class Collaborators extends React.Component<Props> {
componentDidMount() {
if (!this.props.document.isDeleted) {
this.props.views.fetchPage({ documentId: this.props.document.id });
function Collaborators(props: Props) {
const { t } = useTranslation();
const { users, presence } = useStores();
const { document, currentUserId } = props;
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
? Array.from(documentPresence.values())
: [];
const presentIds = documentPresence.map((p) => p.userId);
const editingIds = documentPresence
.filter((p) => p.isEditing)
.map((p) => p.userId);
// ensure currently present via websocket are always ordered first
const presentUsers = filter(users.orderedData, (user) =>
presentIds.includes(user.id)
);
// load any users we don't know about
React.useEffect(() => {
if (users.isFetching) {
return;
}
}
render() {
const { document, presence, views, currentUserId } = this.props;
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
? Array.from(documentPresence.values())
: [];
const documentViews = views.inDocument(document.id);
const presentIds = documentPresence.map((p) => p.userId);
const editingIds = documentPresence
.filter((p) => p.isEditing)
.map((p) => p.userId);
// ensure currently present via websocket are always ordered first
const mostRecentViewers = sortBy(
documentViews.slice(0, MAX_AVATAR_DISPLAY),
(view) => {
return presentIds.includes(view.user.id);
presentIds.forEach((userId) => {
if (!users.get(userId)) {
return users.fetch(userId);
}
);
});
}, [document, users, presentIds]);
const viewersKeyedByUserId = keyBy(mostRecentViewers, (v) => v.user.id);
const overflow = documentViews.length - mostRecentViewers.length;
const popover = usePopoverState({
gutter: 0,
placement: "bottom-end",
});
return (
<FacepileHiddenOnMobile
users={mostRecentViewers.map((v) => v.user)}
overflow={overflow}
renderAvatar={(user) => {
const isPresent = presentIds.includes(user.id);
const isEditing = editingIds.includes(user.id);
const { lastViewedAt } = viewersKeyedByUserId[user.id];
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<NudeButton width={presentUsers.length * 32} height={32} {...props}>
<FacepileHiddenOnMobile
users={presentUsers}
renderAvatar={(user) => {
const isPresent = presentIds.includes(user.id);
const isEditing = editingIds.includes(user.id);
return (
<AvatarWithPresence
key={user.id}
user={user}
lastViewedAt={lastViewedAt}
isPresent={isPresent}
isEditing={isEditing}
isCurrentUser={currentUserId === user.id}
return (
<AvatarWithPresence
key={user.id}
user={user}
isPresent={isPresent}
isEditing={isEditing}
isCurrentUser={currentUserId === user.id}
profileOnClick={false}
/>
);
}}
/>
);
}}
/>
);
}
</NudeButton>
)}
</PopoverDisclosure>
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
<DocumentViews document={document} isOpen={popover.visible} />
</Popover>
</>
);
}
const FacepileHiddenOnMobile = styled(Facepile)`
@@ -82,4 +96,4 @@ const FacepileHiddenOnMobile = styled(Facepile)`
`};
`;
export default inject("views", "presence")(Collaborators);
export default observer(Collaborators);

View File

@@ -1,9 +1,13 @@
// @flow
import { useObserver } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import Document from "models/Document";
import DocumentMeta from "components/DocumentMeta";
import DocumentViews from "components/DocumentViews";
import Popover from "components/Popover";
import useStores from "../hooks/useStores";
type Props = {|
@@ -12,22 +16,41 @@ type Props = {|
to?: string,
|};
function DocumentMetaWithViews({ to, isDraft, document }: Props) {
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
const { views } = useStores();
const { t } = useTranslation();
const documentViews = useObserver(() => views.inDocument(document.id));
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
const popover = usePopoverState({
gutter: 8,
placement: "bottom",
modal: true,
});
return (
<Meta document={document} to={to}>
<Meta document={document} to={to} {...rest}>
{totalViewers && !isDraft ? (
<>
&nbsp;&middot; Viewed by{" "}
{onlyYou
? "only you"
: `${totalViewers} ${totalViewers === 1 ? "person" : "people"}`}
</>
<PopoverDisclosure {...popover}>
{(props) => (
<>
&nbsp;&middot;&nbsp;
<a {...props}>
{t("Viewed by")}{" "}
{onlyYou
? t("only you")
: `${totalViewers} ${
totalViewers === 1 ? t("person") : t("people")
}`}
</a>
</>
)}
</PopoverDisclosure>
) : null}
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
<DocumentViews document={document} isOpen={popover.visible} />
</Popover>
</Meta>
);
}

View File

@@ -0,0 +1,82 @@
// @flow
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import { sortBy } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "models/Document";
import Avatar from "components/Avatar";
import ListItem from "components/List/Item";
import PaginatedList from "components/PaginatedList";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
isOpen?: boolean,
|};
function DocumentViews({ document, isOpen }: Props) {
const { t } = useTranslation();
const { views, presence } = useStores();
React.useEffect(() => {
views.fetchPage({ documentId: document.id });
}, [views, document.id]);
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
? Array.from(documentPresence.values())
: [];
const presentIds = documentPresence.map((p) => p.userId);
const editingIds = documentPresence
.filter((p) => p.isEditing)
.map((p) => p.userId);
// ensure currently present via websocket are always ordered first
const documentViews = views.inDocument(document.id);
const sortedViews = sortBy(documentViews, (view) =>
presentIds.includes(view.user.id)
);
const users = React.useMemo(() => sortedViews.map((v) => v.user), [
sortedViews,
]);
return (
<>
{isOpen && (
<PaginatedList
items={users}
renderItem={(item) => {
const view = documentViews.find((v) => v.user.id === item.id);
const isPresent = presentIds.includes(item.id);
const isEditing = editingIds.includes(item.id);
const subtitle = isPresent
? isEditing
? t("Currently editing")
: t("Currently viewing")
: t("Viewed {{ timeAgo }} ago", {
timeAgo: distanceInWordsToNow(
view ? new Date(view.lastViewedAt) : new Date()
),
});
return (
<ListItem
key={item.id}
title={item.name}
subtitle={subtitle}
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
border={false}
/>
);
}}
/>
)}
</>
);
}
export default observer(DocumentViews);

View File

@@ -1,45 +1,41 @@
// @flow
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import styled, { withTheme } from "styled-components";
import styled from "styled-components";
import User from "models/User";
import Avatar from "components/Avatar";
import Flex from "components/Flex";
type Props = {
type Props = {|
users: User[],
size?: number,
overflow: number,
renderAvatar: (user: User) => React.Node,
};
onClick?: (event: SyntheticEvent<>) => mixed,
renderAvatar?: (user: User) => React.Node,
|};
@observer
class Facepile extends React.Component<Props> {
render() {
const {
users,
overflow,
size = 32,
renderAvatar = renderDefaultAvatar,
...rest
} = this.props;
return (
<Avatars {...rest}>
{overflow > 0 && (
<More size={size}>
<span>+{overflow}</span>
</More>
)}
{users.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
);
}
function Facepile({
users,
overflow,
size = 32,
renderAvatar = DefaultAvatar,
...rest
}: Props) {
return (
<Avatars {...rest}>
{overflow > 0 && (
<More size={size}>
<span>+{overflow}</span>
</More>
)}
{users.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
);
}
function renderDefaultAvatar(user: User) {
function DefaultAvatar(user: User) {
return <Avatar user={user} src={user.avatarUrl} size={32} />;
}
@@ -73,4 +69,4 @@ const Avatars = styled(Flex)`
cursor: pointer;
`;
export default inject("views", "presence")(withTheme(Facepile));
export default observer(Facepile);

View File

@@ -8,13 +8,14 @@ type Props = {
title: React.Node,
subtitle?: React.Node,
actions?: React.Node,
border?: boolean,
};
const ListItem = ({ image, title, subtitle, actions }: Props) => {
const ListItem = ({ image, title, subtitle, actions, border }: Props) => {
const compact = !subtitle;
return (
<Wrapper compact={compact}>
<Wrapper compact={compact} $border={border}>
{image && <Image>{image}</Image>}
<Content align={compact ? "center" : undefined} column={!compact}>
<Heading>{title}</Heading>
@@ -27,9 +28,11 @@ const ListItem = ({ image, title, subtitle, actions }: Props) => {
const Wrapper = styled.li`
display: flex;
padding: 10px 0;
padding: 8px 0;
margin: 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
border-bottom: 1px solid
${(props) =>
props.$border === false ? "transparent" : props.theme.divider};
&:last-child {
border-bottom: 0;
@@ -59,7 +62,8 @@ const Content = styled(Flex)`
const Subtitle = styled.p`
margin: 0;
font-size: 14px;
color: ${(props) => props.theme.slate};
color: ${(props) => props.theme.textTertiary};
margin-top: -2px;
`;
const Actions = styled.div`

View File

@@ -3,8 +3,8 @@ import * as React from "react";
import styled from "styled-components";
const Button = styled.button`
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
width: ${(props) => props.width || props.size}px;
height: ${(props) => props.height || props.size}px;
background: none;
border-radius: 4px;
line-height: 0;

34
app/components/Popover.js Normal file
View File

@@ -0,0 +1,34 @@
// @flow
import * as React from "react";
import { Popover as ReakitPopover } from "reakit/Popover";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
type Props = {
children: React.Node,
width?: number,
};
function Popover({ children, width = 380, ...rest }: Props) {
return (
<ReakitPopover {...rest}>
<Contents width={width}>{children}</Contents>
</ReakitPopover>
);
}
const Contents = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: 75% 0;
background: ${(props) => props.theme.menuBackground};
border-radius: 6px;
padding: 12px 24px;
max-height: 50vh;
overflow-y: scroll;
width: ${(props) => props.width}px;
box-shadow: ${(props) => props.theme.menuShadow};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
`;
export default Popover;

View File

@@ -16,7 +16,6 @@ import Badge from "components/Badge";
import Breadcrumb, { Slash } from "components/Breadcrumb";
import Button from "components/Button";
import Collaborators from "components/Collaborators";
import Fade from "components/Fade";
import Header from "components/Header";
import Tooltip from "components/Tooltip";
import ShareButton from "./ShareButton";
@@ -148,12 +147,10 @@ function DocumentHeader({
actions={
<>
{!isPublishing && isSaving && <Status>{t("Saving")}</Status>}
<Fade>
<Collaborators
document={document}
currentUserId={auth.user ? auth.user.id : undefined}
/>
</Fade>
<Collaborators
document={document}
currentUserId={auth.user ? auth.user.id : undefined}
/>
{isEditing && !isTemplate && isNew && (
<Action>
<TemplatesMenu document={document} />

View File

@@ -3,11 +3,10 @@ import { observer } from "mobx-react";
import { GlobeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { usePopoverState, Popover, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Document from "models/Document";
import Button from "components/Button";
import Popover from "components/Popover";
import Tooltip from "components/Tooltip";
import SharePopover from "./SharePopover";
import useStores from "hooks/useStores";
@@ -55,28 +54,14 @@ function ShareButton({ document }: Props) {
)}
</PopoverDisclosure>
<Popover {...popover} aria-label={t("Share")}>
<Contents>
<SharePopover
document={document}
share={share}
onSubmit={popover.hide}
/>
</Contents>
<SharePopover
document={document}
share={share}
onSubmit={popover.hide}
/>
</Popover>
</>
);
}
const Contents = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: 75% 0;
background: ${(props) => props.theme.menuBackground};
border-radius: 6px;
padding: 24px 24px 12px;
width: 380px;
box-shadow: ${(props) => props.theme.menuShadow};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
`;
export default observer(ShareButton);

View File

@@ -109,7 +109,7 @@ function DocumentShare({ document, share, onSubmit }: Props) {
type="text"
label={t("Link")}
placeholder={`${t("Loading")}`}
value={share ? share.url : undefined}
value={share ? share.url : ""}
labelHidden
readOnly
/>
@@ -126,7 +126,7 @@ function DocumentShare({ document, share, onSubmit }: Props) {
const Heading = styled.h2`
display: flex;
align-items: center;
margin-top: 0;
margin-top: 12px;
margin-left: -4px;
`;