From ca87ca6fe902530bd317718bac7f9113a9c5a98e Mon Sep 17 00:00:00 2001
From: Oliver Benns <oliverbenns@users.noreply.github.com>
Date: Fri, 19 Jun 2020 11:36:49 +0100
Subject: [PATCH] Add user list component + snap to user functionality (#1749)

---
 src/actions/actionNavigate.tsx | 52 ++++++++++++++++++++++++++++++++++
 src/actions/index.ts           |  2 ++
 src/actions/manager.tsx        |  7 ++++-
 src/actions/types.ts           |  4 ++-
 src/clients.ts                 | 30 ++++++++++++++++++++
 src/components/Avatar.scss     | 14 +++++++++
 src/components/Avatar.tsx      | 25 ++++++++++++++++
 src/components/LayerUI.tsx     | 21 +++++++++++++-
 src/components/MobileMenu.tsx  | 17 +++++++++++
 src/components/Tooltip.scss    | 48 +++++++++++++++++++++++++++++++
 src/components/Tooltip.tsx     | 15 ++++++++++
 src/components/UserList.css    | 22 ++++++++++++++
 src/components/UserList.tsx    | 23 +++++++++++++++
 src/css/styles.scss            |  1 +
 src/locales/en.json            |  3 +-
 src/renderer/renderScene.ts    | 19 ++-----------
 src/tests/clients.test.ts      | 39 +++++++++++++++++++++++++
 src/types.ts                   | 23 +++++++--------
 18 files changed, 333 insertions(+), 32 deletions(-)
 create mode 100644 src/actions/actionNavigate.tsx
 create mode 100644 src/clients.ts
 create mode 100644 src/components/Avatar.scss
 create mode 100644 src/components/Avatar.tsx
 create mode 100644 src/components/Tooltip.scss
 create mode 100644 src/components/Tooltip.tsx
 create mode 100644 src/components/UserList.css
 create mode 100644 src/components/UserList.tsx
 create mode 100644 src/tests/clients.test.ts

diff --git a/src/actions/actionNavigate.tsx b/src/actions/actionNavigate.tsx
new file mode 100644
index 00000000..1e079795
--- /dev/null
+++ b/src/actions/actionNavigate.tsx
@@ -0,0 +1,52 @@
+import React from "react";
+import { Avatar } from "../components/Avatar";
+import { register } from "./register";
+import { getClientColors, getClientInitials } from "../clients";
+import { Collaborator } from "../types";
+import { normalizeScroll } from "../scene";
+
+export const actionGoToCollaborator = register({
+  name: "goToCollaborator",
+  perform: (_elements, appState, value) => {
+    const point = value as Collaborator["pointer"];
+    if (!point) {
+      return { appState, commitToHistory: false };
+    }
+
+    return {
+      appState: {
+        ...appState,
+        scrollX: normalizeScroll(window.innerWidth / 2 - point.x),
+        scrollY: normalizeScroll(window.innerHeight / 2 - point.y),
+        // Close mobile menu
+        openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
+      },
+      commitToHistory: false,
+    };
+  },
+  PanelComponent: ({ appState, updateData, id }) => {
+    const clientId = id;
+
+    if (!clientId) {
+      return null;
+    }
+
+    const collaborator = appState.collaborators.get(clientId);
+
+    if (!collaborator) {
+      return null;
+    }
+
+    const { background } = getClientColors(clientId);
+    const shortName = getClientInitials(collaborator.username);
+
+    return (
+      <Avatar
+        color={background}
+        onClick={() => updateData(collaborator.pointer)}
+      >
+        {shortName}
+      </Avatar>
+    );
+  },
+});
diff --git a/src/actions/index.ts b/src/actions/index.ts
index 7bd662b3..959f0b5d 100644
--- a/src/actions/index.ts
+++ b/src/actions/index.ts
@@ -47,3 +47,5 @@ export {
 } from "./actionMenu";
 
 export { actionGroup, actionUngroup } from "./actionGroup";
+
+export { actionGoToCollaborator } from "./actionNavigate";
diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx
index 86aed55a..ece81d90 100644
--- a/src/actions/manager.tsx
+++ b/src/actions/manager.tsx
@@ -101,7 +101,11 @@ export class ActionManager implements ActionsManagerInterface {
       }));
   }
 
-  renderAction = (name: ActionName) => {
+  // Id is an attribute that we can use to pass in data like keys.
+  // This is needed for dynamically generated action components
+  // like the user list. We can use this key to extract more
+  // data from app state. This is an alternative to generic prop hell!
+  renderAction = (name: ActionName, id?: string) => {
     if (this.actions[name] && "PanelComponent" in this.actions[name]) {
       const action = this.actions[name];
       const PanelComponent = action.PanelComponent!;
@@ -120,6 +124,7 @@ export class ActionManager implements ActionsManagerInterface {
           elements={this.getElementsIncludingDeleted()}
           appState={this.getAppState()}
           updateData={updateData}
+          id={id}
         />
       );
     }
diff --git a/src/actions/types.ts b/src/actions/types.ts
index e0189398..49b6426e 100644
--- a/src/actions/types.ts
+++ b/src/actions/types.ts
@@ -58,7 +58,8 @@ export type ActionName =
   | "toggleFullScreen"
   | "toggleShortcuts"
   | "group"
-  | "ungroup";
+  | "ungroup"
+  | "goToCollaborator";
 
 export interface Action {
   name: ActionName;
@@ -66,6 +67,7 @@ export interface Action {
     elements: readonly ExcalidrawElement[];
     appState: AppState;
     updateData: (formData?: any) => void;
+    id?: string;
   }>;
   perform: ActionFn;
   keyPriority?: number;
diff --git a/src/clients.ts b/src/clients.ts
new file mode 100644
index 00000000..01de96ef
--- /dev/null
+++ b/src/clients.ts
@@ -0,0 +1,30 @@
+import colors from "./colors";
+
+export const getClientColors = (clientId: string) => {
+  // Naive way of getting an integer out of the clientId
+  const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
+
+  // Skip transparent background.
+  const backgrounds = colors.elementBackground.slice(1);
+  const strokes = colors.elementStroke.slice(1);
+  return {
+    background: backgrounds[sum % backgrounds.length],
+    stroke: strokes[sum % strokes.length],
+  };
+};
+
+export const getClientInitials = (username?: string | null) => {
+  if (!username) {
+    return "?";
+  }
+  const names = username.trim().split(" ");
+
+  if (names.length < 2) {
+    return names[0].substring(0, 2).toUpperCase();
+  }
+
+  const firstName = names[0];
+  const lastName = names[names.length - 1];
+
+  return (firstName[0] + lastName[0]).toUpperCase();
+};
diff --git a/src/components/Avatar.scss b/src/components/Avatar.scss
new file mode 100644
index 00000000..e1baaa63
--- /dev/null
+++ b/src/components/Avatar.scss
@@ -0,0 +1,14 @@
+@import "../css/_variables";
+
+.Avatar {
+  width: 2.5rem;
+  height: 2.5rem;
+  border-radius: 1.25rem;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: $oc-white;
+  cursor: pointer;
+  font-size: 0.8rem;
+  font-weight: 500;
+}
diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx
new file mode 100644
index 00000000..418f25fc
--- /dev/null
+++ b/src/components/Avatar.tsx
@@ -0,0 +1,25 @@
+import "./Avatar.scss";
+
+import React from "react";
+
+type AvatarProps = {
+  children: string;
+  className?: string;
+  onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
+  color: string;
+};
+
+export const Avatar = ({
+  children,
+  className,
+  color,
+  onClick,
+}: AvatarProps) => (
+  <div
+    className={`Avatar ${className}`}
+    style={{ background: color }}
+    onClick={onClick}
+  >
+    {children}
+  </div>
+);
diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx
index ddbefe34..57e0fc34 100644
--- a/src/components/LayerUI.tsx
+++ b/src/components/LayerUI.tsx
@@ -10,6 +10,7 @@ import { ActionManager } from "../actions/manager";
 import { Island } from "./Island";
 import Stack from "./Stack";
 import { FixedSideContainer } from "./FixedSideContainer";
+import { UserList } from "./UserList";
 import { LockIcon } from "./LockIcon";
 import { ExportDialog, ExportCB } from "./ExportDialog";
 import { LanguageList } from "./LanguageList";
@@ -28,6 +29,7 @@ import { LoadingMessage } from "./LoadingMessage";
 import { CLASSES } from "../constants";
 import { shield } from "./icons";
 import { GitHubCorner } from "./GitHubCorner";
+import { Tooltip } from "./Tooltip";
 
 import "./LayerUI.scss";
 
@@ -61,6 +63,7 @@ const LayerUI = ({
 }: LayerUIProps) => {
   const isMobile = useIsMobile();
 
+  // TODO: Extend tooltip component and use here.
   const renderEncryptedIcon = () => (
     <a
       className={`encrypted-icon tooltip zen-mode-visibility ${
@@ -203,7 +206,23 @@ const LayerUI = ({
               </Stack.Col>
             )}
           </Section>
-          <div />
+          <UserList
+            className={`zen-mode-transition ${
+              zenModeEnabled && "transition-right"
+            }`}
+          >
+            {Array.from(appState.collaborators)
+              // Collaborator is either not initialized or is actually the current user.
+              .filter(([_, client]) => Object.keys(client).length !== 0)
+              .map(([clientId, client]) => (
+                <Tooltip
+                  label={client.username || "Unknown user"}
+                  key={clientId}
+                >
+                  {actionManager.renderAction("goToCollaborator", clientId)}
+                </Tooltip>
+              ))}
+          </UserList>
         </div>
         {
           <div
diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx
index cda1a177..dbd7f672 100644
--- a/src/components/MobileMenu.tsx
+++ b/src/components/MobileMenu.tsx
@@ -16,6 +16,7 @@ import { RoomDialog } from "./RoomDialog";
 import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
 import { LockIcon } from "./LockIcon";
 import { LoadingMessage } from "./LoadingMessage";
+import { UserList } from "./UserList";
 
 type MobileMenuProps = {
   appState: AppState;
@@ -105,6 +106,22 @@ export const MobileMenu = ({
                     }}
                   />
                 </fieldset>
+                <fieldset>
+                  <legend>{t("labels.collaborators")}</legend>
+                  <UserList mobile>
+                    {Array.from(appState.collaborators)
+                      // Collaborator is either not initialized or is actually the current user.
+                      .filter(([_, client]) => Object.keys(client).length !== 0)
+                      .map(([clientId, client]) => (
+                        <React.Fragment key={clientId}>
+                          {actionManager.renderAction(
+                            "goToCollaborator",
+                            clientId,
+                          )}
+                        </React.Fragment>
+                      ))}
+                  </UserList>
+                </fieldset>
               </Stack.Col>
             </div>
           </Section>
diff --git a/src/components/Tooltip.scss b/src/components/Tooltip.scss
new file mode 100644
index 00000000..a42f3430
--- /dev/null
+++ b/src/components/Tooltip.scss
@@ -0,0 +1,48 @@
+@import "../css/_variables";
+
+.Tooltip {
+  position: relative;
+}
+
+.Tooltip__label {
+  --arrow-size: 4px;
+  visibility: hidden;
+  width: 10ch;
+  background: $oc-black;
+  color: $oc-white;
+  text-align: center;
+  border-radius: 4px;
+  padding: 4px;
+  position: absolute;
+  z-index: 10;
+  font-size: 0.7rem;
+  line-height: 1.5;
+  top: calc(100% + var(--arrow-size) + 3px);
+  // extra pixel offset for unknown reasons
+  left: calc(-50% + var(--arrow-size) / 2 - 1px);
+  word-wrap: break-word;
+
+  &::after {
+    content: "";
+    border: var(--arrow-size) solid transparent;
+    border-bottom-color: $oc-black;
+    position: absolute;
+    bottom: 100%;
+    left: calc(50% - var(--arrow-size));
+  }
+}
+
+// the following 3 rules ensure that the tooltip doesn't show (nor affect
+// the cursor) when you drag over when you draw on canvas, but at the same
+// time it still works when clicking on the link/shield
+body:active .Tooltip:not(:hover) {
+  pointer-events: none;
+}
+
+body:not(:active) .Tooltip:hover .Tooltip__label {
+  visibility: visible;
+}
+
+.Tooltip__label:hover {
+  visibility: visible;
+}
diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx
new file mode 100644
index 00000000..084d3ca8
--- /dev/null
+++ b/src/components/Tooltip.tsx
@@ -0,0 +1,15 @@
+import "./Tooltip.scss";
+
+import React from "react";
+
+type TooltipProps = {
+  children: React.ReactNode;
+  label: string;
+};
+
+export const Tooltip = ({ children, label }: TooltipProps) => (
+  <div className="Tooltip">
+    <span className="Tooltip__label">{label}</span>
+    {children}
+  </div>
+);
diff --git a/src/components/UserList.css b/src/components/UserList.css
new file mode 100644
index 00000000..32d5ada8
--- /dev/null
+++ b/src/components/UserList.css
@@ -0,0 +1,22 @@
+.UserList {
+  pointer-events: none;
+  /*github corner*/
+  padding: var(--space-factor) 40px var(--space-factor) var(--space-factor);
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+}
+
+.UserList > * {
+  pointer-events: all;
+  margin: 0 0 var(--space-factor) var(--space-factor);
+}
+
+.UserList_mobile {
+  padding: 0;
+  justify-content: normal;
+}
+
+.UserList_mobile > * {
+  margin: 0 var(--space-factor) var(--space-factor) 0;
+}
diff --git a/src/components/UserList.tsx b/src/components/UserList.tsx
new file mode 100644
index 00000000..e2487d0e
--- /dev/null
+++ b/src/components/UserList.tsx
@@ -0,0 +1,23 @@
+import "./UserList.css";
+
+import React from "react";
+
+type UserListProps = {
+  children: React.ReactNode;
+  className?: string;
+  mobile?: boolean;
+};
+
+export const UserList = ({ children, className, mobile }: UserListProps) => {
+  let compClassName = "UserList";
+
+  if (className) {
+    compClassName += ` ${className}`;
+  }
+
+  if (mobile) {
+    compClassName += " UserList_mobile";
+  }
+
+  return <div className={compClassName}>{children}</div>;
+};
diff --git a/src/css/styles.scss b/src/css/styles.scss
index a68db059..5c871d2d 100644
--- a/src/css/styles.scss
+++ b/src/css/styles.scss
@@ -457,6 +457,7 @@ button,
 .github-corner {
   position: fixed;
   top: 0;
+  z-index: 2;
   :root[dir="ltr"] & {
     right: 0;
   }
diff --git a/src/locales/en.json b/src/locales/en.json
index ad9731af..803fd363 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -61,7 +61,8 @@
     "yourName": "Your name",
     "madeWithExcalidraw": "Made with Excalidraw",
     "group": "Group selection",
-    "ungroup": "Ungroup selection"
+    "ungroup": "Ungroup selection",
+    "collaborators": "Collaborators"
   },
   "buttons": {
     "clearReset": "Reset the canvas",
diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts
index 8d6245b5..309ee971 100644
--- a/src/renderer/renderScene.ts
+++ b/src/renderer/renderScene.ts
@@ -28,7 +28,7 @@ import {
 import { getSelectedElements } from "../scene/selection";
 
 import { renderElement, renderElementToSvg } from "./renderElement";
-import colors from "../colors";
+import { getClientColors } from "../clients";
 import { isLinearElement } from "../element/typeChecks";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import {
@@ -39,19 +39,6 @@ import {
 
 type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
 
-const colorsForClientId = (clientId: string) => {
-  // Naive way of getting an integer out of the clientId
-  const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
-
-  // Skip transparent background.
-  const backgrounds = colors.elementBackground.slice(1);
-  const strokes = colors.elementStroke.slice(1);
-  return {
-    background: backgrounds[sum % backgrounds.length],
-    stroke: strokes[sum % strokes.length],
-  };
-};
-
 const strokeRectWithRotation = (
   context: CanvasRenderingContext2D,
   x: number,
@@ -232,7 +219,7 @@ export const renderScene = (
       if (sceneState.remoteSelectedElementIds[element.id]) {
         selectionColors.push(
           ...sceneState.remoteSelectedElementIds[element.id].map((socketId) => {
-            const { background } = colorsForClientId(socketId);
+            const { background } = getClientColors(socketId);
             return background;
           }),
         );
@@ -444,7 +431,7 @@ export const renderScene = (
     y = Math.max(y, 0);
     y = Math.min(y, normalizedCanvasHeight - height);
 
-    const { background, stroke } = colorsForClientId(clientId);
+    const { background, stroke } = getClientColors(clientId);
 
     const strokeStyle = context.strokeStyle;
     const fillStyle = context.fillStyle;
diff --git a/src/tests/clients.test.ts b/src/tests/clients.test.ts
new file mode 100644
index 00000000..61594fc7
--- /dev/null
+++ b/src/tests/clients.test.ts
@@ -0,0 +1,39 @@
+import { getClientInitials } from "../clients";
+
+describe("getClientInitials", () => {
+  it("returns substring if one name provided", () => {
+    const result = getClientInitials("Alan");
+    expect(result).toBe("AL");
+  });
+
+  it("returns initials", () => {
+    const result = getClientInitials("John Doe");
+    expect(result).toBe("JD");
+  });
+
+  it("returns correct initials if many names provided", () => {
+    const result = getClientInitials("John Alan Doe");
+    expect(result).toBe("JD");
+  });
+
+  it("returns single initial if 1 letter provided", () => {
+    const result = getClientInitials("z");
+    expect(result).toBe("Z");
+  });
+
+  it("trims trailing whitespace", () => {
+    const result = getClientInitials("  q    ");
+    expect(result).toBe("Q");
+  });
+
+  it('returns "?" if falsey value provided', () => {
+    let result = getClientInitials("");
+    expect(result).toBe("?");
+
+    result = getClientInitials(undefined);
+    expect(result).toBe("?");
+
+    result = getClientInitials(null);
+    expect(result).toBe("?");
+  });
+});
diff --git a/src/types.ts b/src/types.ts
index ecdd4a63..9e205d2c 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -16,6 +16,16 @@ import { LinearElementEditor } from "./element/linearElementEditor";
 export type FlooredNumber = number & { _brand: "FlooredNumber" };
 export type Point = Readonly<RoughPoint>;
 
+export type Collaborator = {
+  pointer?: {
+    x: number;
+    y: number;
+  };
+  button?: "up" | "down";
+  selectedElementIds?: AppState["selectedElementIds"];
+  username?: string | null;
+};
+
 export type AppState = {
   isLoading: boolean;
   errorMessage: string | null;
@@ -58,18 +68,7 @@ export type AppState = {
   lastPointerDownWith: PointerType;
   selectedElementIds: { [id: string]: boolean };
   previousSelectedElementIds: { [id: string]: boolean };
-  collaborators: Map<
-    string,
-    {
-      pointer?: {
-        x: number;
-        y: number;
-      };
-      button?: "up" | "down";
-      selectedElementIds?: AppState["selectedElementIds"];
-      username?: string | null;
-    }
-  >;
+  collaborators: Map<string, Collaborator>;
   shouldCacheIgnoreZoom: boolean;
   showShortcutsDialog: boolean;
   zenModeEnabled: boolean;