feat: Added ability to click another user to observe them (sync scroll position) (#2858)

* feat: Added ability to click another user to observe them, mainly for fun

* language, lower debounce, prevent tooltip from hiding when toggling observation

* fix: Don't allow observing self, added banner at top of screen

* Dont edit tooltip as it's confusing between our actions and theirs

* snapshots
This commit is contained in:
Tom Moor
2021-12-16 17:36:39 -08:00
committed by GitHub
parent 4266b2eb3c
commit 9a7b5ea1f4
12 changed files with 214 additions and 36 deletions

View File

@@ -11,7 +11,7 @@ type Props = {
icon?: React.ReactNode;
user?: User;
alt?: string;
onClick?: () => void;
onClick?: React.MouseEventHandler<HTMLImageElement>;
className?: string;
};

View File

@@ -2,7 +2,7 @@ import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import styled from "styled-components";
import styled, { css } from "styled-components";
import User from "~/models/User";
import UserProfile from "~/scenes/UserProfile";
import Avatar from "~/components/Avatar";
@@ -12,8 +12,10 @@ type Props = WithTranslation & {
user: User;
isPresent: boolean;
isEditing: boolean;
isObserving: boolean;
isCurrentUser: boolean;
profileOnClick: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
};
@observer
@@ -30,45 +32,60 @@ class AvatarWithPresence extends React.Component<Props> {
};
render() {
const { user, isPresent, isEditing, isCurrentUser, t } = this.props;
const action = isPresent
const {
onClick,
user,
isPresent,
isEditing,
isObserving,
isCurrentUser,
t,
} = this.props;
const status = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("previously edited");
return (
<>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{action && (
{status && (
<>
<br />
{action}
{status}
</>
)}
</Centered>
}
placement="bottom"
>
<AvatarWrapper isPresent={isPresent}>
<AvatarWrapper
$isPresent={isPresent}
$isObserving={isObserving}
$color={user.color}
>
<Avatar
src={user.avatarUrl}
onClick={
this.props.profileOnClick === false
? undefined
? onClick
: this.handleOpenProfile
}
size={32}
/>
</AvatarWrapper>
</Tooltip>
<UserProfile
user={user}
isOpen={this.isOpen}
onRequestClose={this.handleCloseProfile}
/>
{this.props.profileOnClick && (
<UserProfile
user={user}
isOpen={this.isOpen}
onRequestClose={this.handleCloseProfile}
/>
)}
</>
);
}
@@ -78,9 +95,47 @@ const Centered = styled.div`
text-align: center;
`;
const AvatarWrapper = styled.div<{ isPresent: boolean }>`
opacity: ${(props) => (props.isPresent ? 1 : 0.5)};
const AvatarWrapper = styled.div<{
$isPresent: boolean;
$isObserving: boolean;
$color: string;
}>`
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
border-radius: 50%;
position: relative;
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
transition: border-color 100ms ease-in-out;
border: 2px solid transparent;
pointer-events: none;
${(props) =>
props.$isObserving &&
css`
border: 2px solid ${props.$color};
box-shadow: inset 0 0 0 2px ${props.theme.background};
&:hover {
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
}
`}
}
&:hover:after {
border: 2px solid ${(props) => props.$color};
box-shadow: inset 0 0 0 2px ${(props) => props.theme.background};
}
`;
export default withTranslation()(AvatarWithPresence);

View File

@@ -11,6 +11,7 @@ import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile";
import NudeButton from "~/components/NudeButton";
import Popover from "~/components/Popover";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
@@ -21,9 +22,10 @@ type Props = {
function Collaborators(props: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const team = useCurrentTeam();
const currentUserId = user?.id;
const [requestedUserIds, setRequestedUserIds] = React.useState<string[]>([]);
const { users, presence } = useStores();
const { users, presence, ui } = useStores();
const { document } = props;
const documentPresence = presence.get(document.id);
const documentPresenceArray = documentPresence
@@ -78,17 +80,35 @@ function Collaborators(props: Props) {
<NudeButton width={collaborators.length * 32} height={32} {...props}>
<FacepileHiddenOnMobile
users={collaborators}
renderAvatar={(user) => {
const isPresent = presentIds.includes(user.id);
const isEditing = editingIds.includes(user.id);
renderAvatar={(collaborator) => {
const isPresent = presentIds.includes(collaborator.id);
const isEditing = editingIds.includes(collaborator.id);
const isObserving = ui.observingUserId === collaborator.id;
const isObservable =
team.collaborativeEditing && collaborator.id !== user.id;
return (
<AvatarWithPresence
key={user.id}
user={user}
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
isCurrentUser={currentUserId === user.id}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
profileOnClick={false}
onClick={
isObservable
? (ev) => {
if (isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(
isObserving ? undefined : collaborator.id
);
}
}
: undefined
}
/>
);
}}

View File

@@ -16,6 +16,7 @@ type Props = EditorProps &
WithTranslation & {
onChangeTitle: (text: string) => void;
title: string;
id: string;
document: Document;
isDraft: boolean;
multiplayer?: boolean;

View File

@@ -29,6 +29,7 @@ import TemplatesMenu from "~/menus/TemplatesMenu";
import { NavigationNode } from "~/types";
import { metaDisplay } from "~/utils/keyboard";
import { newDocumentPath, editDocumentUrl } from "~/utils/routeHelpers";
import ObservingBanner from "./ObservingBanner";
import PublicBreadcrumb from "./PublicBreadcrumb";
import ShareButton from "./ShareButton";
@@ -194,6 +195,7 @@ function DocumentHeader({
}
actions={
<>
<ObservingBanner />
{isMobile && (
<TocWrapper>
<TableOfContentsMenu headings={headings} />

View File

@@ -1,4 +1,5 @@
import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider";
import { throttle } from "lodash";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
@@ -27,7 +28,9 @@ export type ConnectionStatus =
| "disconnected"
| void;
type AwarenessChangeEvent = { states: { user: { id: string }; cursor: any }[] };
type AwarenessChangeEvent = {
states: { user: { id: string }; cursor: any; scrollY: number | undefined }[];
};
type ConnectionStatusEvent = { status: ConnectionStatus };
@@ -68,6 +71,23 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
token,
});
const syncScrollPosition = throttle(() => {
provider.setAwarenessField(
"scrollY",
window.scrollY / window.innerHeight
);
}, 200);
const finishObserving = () => {
if (ui.observingUserId) {
ui.setObservingUser(undefined);
}
};
window.addEventListener("click", finishObserving);
window.addEventListener("wheel", finishObserving);
window.addEventListener("scroll", syncScrollPosition);
provider.on("authenticationFailed", () => {
showToast(
t(
@@ -78,12 +98,16 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
});
provider.on("awarenessChange", ({ states }: AwarenessChangeEvent) => {
states.forEach(({ user, cursor }) => {
states.forEach(({ user, cursor, scrollY }) => {
if (user) {
// could know if the user is editing here using `state.cursor` but it
// feels distracting in the UI, once multiplayer is on for everyone we
// can stop diffentiating
presence.touch(documentId, user.id, !!cursor);
if (scrollY !== undefined && user.id === ui.observingUserId) {
window.scrollTo({
top: scrollY * window.innerHeight,
behavior: "smooth",
});
}
}
});
});
@@ -128,6 +152,9 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
setRemoteProvider(provider);
return () => {
window.removeEventListener("click", finishObserving);
window.removeEventListener("wheel", finishObserving);
window.removeEventListener("scroll", syncScrollPosition);
provider?.destroy();
localProvider?.destroy();
setRemoteProvider(null);
@@ -228,6 +255,6 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
);
}
export default React.forwardRef<typeof MultiplayerEditor, any>(
export default React.forwardRef<typeof MultiplayerEditor, Props>(
MultiplayerEditor
);

View File

@@ -0,0 +1,60 @@
import { m, AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import useStores from "~/hooks/useStores";
const transition = {
type: "spring",
stiffness: 500,
damping: 30,
};
/**
* A small banner that is displayed at the top of the screen when the user is
* observing another user while editing a document
*/
function ObservingBanner() {
const { ui, users } = useStores();
const { t } = useTranslation();
const user = ui.observingUserId ? users.get(ui.observingUserId) : undefined;
return (
<Positioner>
<AnimatePresence>
{user && (
<Banner
$color={user.color}
transition={transition}
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: -5 }}
exit={{ opacity: 0, y: -30 }}
>
{t("Observing {{ userName }}", { userName: user.name })}
</Banner>
)}
</AnimatePresence>
</Positioner>
);
}
const Positioner = styled.div`
position: fixed;
top: 0;
left: 50%;
transform: translateX(-50%);
`;
const Banner = styled(m.div)<{ $color: string }>`
padding: 6px 6px 1px;
font-size: 13px;
font-weight: 500;
z-index: ${(props) => props.theme.depths.header + 1};
color: ${(props) => props.theme.white};
background: ${(props) => props.$color};
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
`;
export default observer(ObservingBanner);

View File

@@ -36,6 +36,9 @@ class UiStore {
@observable
activeCollectionId: string | undefined;
@observable
observingUserId: string | undefined;
@observable
progressBarVisible = false;
@@ -121,6 +124,7 @@ class UiStore {
@action
setActiveDocument = (document: Document): void => {
this.activeDocumentId = document.id;
this.observingUserId = undefined;
if (document.isActive) {
this.activeCollectionId = document.collectionId;
@@ -142,9 +146,15 @@ class UiStore {
this.activeCollectionId = collection.id;
};
@action
setObservingUser = (userId: string | undefined): void => {
this.observingUserId = userId;
};
@action
clearActiveDocument = (): void => {
this.activeDocumentId = undefined;
this.observingUserId = undefined;
};
@action

View File

@@ -91,12 +91,13 @@ const User = sequelize.define(
return original;
}
const color = this.color.replace(/^#/, "");
const initial = this.name ? this.name[0] : "?";
const hash = crypto
.createHash("md5")
.update(this.email || "")
.digest("hex");
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png`;
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png?c=${color}`;
},
color() {

View File

@@ -3,7 +3,7 @@
exports[`#users.activate should activate a suspended user 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
@@ -56,7 +56,7 @@ Object {
exports[`#users.demote should demote an admin 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
@@ -91,7 +91,7 @@ Object {
exports[`#users.demote should demote an admin to member 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
@@ -126,7 +126,7 @@ Object {
exports[`#users.demote should demote an admin to viewer 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
@@ -179,7 +179,7 @@ Object {
exports[`#users.promote should promote a new admin 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
@@ -241,7 +241,7 @@ Object {
exports[`#users.suspend should suspend an user 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
"color": "#e600e0",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",

View File

@@ -1,7 +1,8 @@
import crypto from "crypto";
import fetch from "fetch-with-proxy";
export const DEFAULT_AVATAR_HOST = "https://tiley.herokuapp.com";
export const DEFAULT_AVATAR_HOST =
process.env.DEFAULT_AVATAR_HOST || "https://tiley.herokuapp.com";
export async function generateAvatarUrl({
id,

View File

@@ -376,6 +376,7 @@
"Publish": "Publish",
"Publishing": "Publishing",
"Sorry, it looks like you dont have permission to access the document": "Sorry, it looks like you dont have permission to access the document",
"Observing {{ userName }}": "Observing {{ userName }}",
"Nested documents": "Nested documents",
"Referenced by": "Referenced by",
"Anyone with the link <1></1>can view this document": "Anyone with the link <1></1>can view this document",