From 9a7b5ea1f4499213b5384291964069ca5824f300 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 16 Dec 2021 17:36:39 -0800 Subject: [PATCH] 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 --- app/components/Avatar/Avatar.tsx | 2 +- app/components/Avatar/AvatarWithPresence.tsx | 83 +++++++++++++++---- app/components/Collaborators.tsx | 34 ++++++-- app/scenes/Document/components/Editor.tsx | 1 + app/scenes/Document/components/Header.tsx | 2 + .../Document/components/MultiplayerEditor.tsx | 39 +++++++-- .../Document/components/ObservingBanner.tsx | 60 ++++++++++++++ app/stores/UiStore.ts | 10 +++ server/models/User.ts | 3 +- .../api/__snapshots__/users.test.ts.snap | 12 +-- server/utils/avatars.ts | 3 +- shared/i18n/locales/en_US/translation.json | 1 + 12 files changed, 214 insertions(+), 36 deletions(-) create mode 100644 app/scenes/Document/components/ObservingBanner.tsx diff --git a/app/components/Avatar/Avatar.tsx b/app/components/Avatar/Avatar.tsx index 88601f43f..7121ec60a 100644 --- a/app/components/Avatar/Avatar.tsx +++ b/app/components/Avatar/Avatar.tsx @@ -11,7 +11,7 @@ type Props = { icon?: React.ReactNode; user?: User; alt?: string; - onClick?: () => void; + onClick?: React.MouseEventHandler; className?: string; }; diff --git a/app/components/Avatar/AvatarWithPresence.tsx b/app/components/Avatar/AvatarWithPresence.tsx index 6055f5665..0f4d9c5d1 100644 --- a/app/components/Avatar/AvatarWithPresence.tsx +++ b/app/components/Avatar/AvatarWithPresence.tsx @@ -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; }; @observer @@ -30,45 +32,60 @@ class AvatarWithPresence extends React.Component { }; 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 ( <> {user.name} {isCurrentUser && `(${t("You")})`} - {action && ( + {status && ( <>
- {action} + {status} )} } placement="bottom" > - +
- + {this.props.profileOnClick && ( + + )} ); } @@ -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); diff --git a/app/components/Collaborators.tsx b/app/components/Collaborators.tsx index 9d5f4ee8e..3870974c3 100644 --- a/app/components/Collaborators.tsx +++ b/app/components/Collaborators.tsx @@ -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([]); - 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) { { - 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 ( { + if (isPresent) { + ev.preventDefault(); + ev.stopPropagation(); + ui.setObservingUser( + isObserving ? undefined : collaborator.id + ); + } + } + : undefined + } /> ); }} diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 0d522fe68..7fc61b7b1 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -16,6 +16,7 @@ type Props = EditorProps & WithTranslation & { onChangeTitle: (text: string) => void; title: string; + id: string; document: Document; isDraft: boolean; multiplayer?: boolean; diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx index bcd462c38..6cff47b45 100644 --- a/app/scenes/Document/components/Header.tsx +++ b/app/scenes/Document/components/Header.tsx @@ -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={ <> + {isMobile && ( diff --git a/app/scenes/Document/components/MultiplayerEditor.tsx b/app/scenes/Document/components/MultiplayerEditor.tsx index 68836bc53..3834d668d 100644 --- a/app/scenes/Document/components/MultiplayerEditor.tsx +++ b/app/scenes/Document/components/MultiplayerEditor.tsx @@ -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( +export default React.forwardRef( MultiplayerEditor ); diff --git a/app/scenes/Document/components/ObservingBanner.tsx b/app/scenes/Document/components/ObservingBanner.tsx new file mode 100644 index 000000000..4bc3cab4c --- /dev/null +++ b/app/scenes/Document/components/ObservingBanner.tsx @@ -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 ( + + + {user && ( + + {t("Observing {{ userName }}", { userName: user.name })} + + )} + + + ); +} + +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); diff --git a/app/stores/UiStore.ts b/app/stores/UiStore.ts index 55ba38669..a833c677a 100644 --- a/app/stores/UiStore.ts +++ b/app/stores/UiStore.ts @@ -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 diff --git a/server/models/User.ts b/server/models/User.ts index 9ad8ac11a..1d13633da 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -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() { diff --git a/server/routes/api/__snapshots__/users.test.ts.snap b/server/routes/api/__snapshots__/users.test.ts.snap index 060fd066a..0686a9f91 100644 --- a/server/routes/api/__snapshots__/users.test.ts.snap +++ b/server/routes/api/__snapshots__/users.test.ts.snap @@ -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", diff --git a/server/utils/avatars.ts b/server/utils/avatars.ts index 84121f891..46f2e6535 100644 --- a/server/utils/avatars.ts +++ b/server/utils/avatars.ts @@ -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, diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 1907ee82b..13589deff 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -376,6 +376,7 @@ "Publish": "Publish", "Publishing": "Publishing", "Sorry, it looks like you don’t have permission to access the document": "Sorry, it looks like you don’t have permission to access the document", + "Observing {{ userName }}": "Observing {{ userName }}", "Nested documents": "Nested documents", "Referenced by": "Referenced by", "Anyone with the link <1>can view this document": "Anyone with the link <1>can view this document",