From c6eafbb8d513e7e47c8289929414198287d95b88 Mon Sep 17 00:00:00 2001
From: Josh Perez <60019601+josh-signal@users.noreply.github.com>
Date: Thu, 19 Nov 2020 13:11:35 -0500
Subject: [PATCH] Fix tooltip bugs

---
 ACKNOWLEDGMENTS.md                      |  24 ----
 package.json                            |   1 -
 patches/react-tooltip-lite+1.12.0.patch |  60 ----------
 stylesheets/_modules.scss               | 139 ++++++++++++++++++++----
 ts/components/CallScreen.tsx            |   3 -
 ts/components/CallingButton.stories.tsx |  20 +---
 ts/components/CallingButton.tsx         |  37 ++-----
 ts/components/CallingHeader.tsx         |  25 +----
 ts/components/CallingLobby.tsx          |  13 +--
 ts/components/CallingPip.tsx            |  24 ++--
 ts/components/InContactsIcon.tsx        |  28 ++---
 ts/components/IncomingCallBar.tsx       |  24 ++--
 ts/components/Tooltip.stories.tsx       |  80 ++++++++++++++
 ts/components/Tooltip.tsx               |  81 ++++++++++++++
 ts/util/lint/exceptions.json            |   6 +-
 yarn.lock                               |   7 --
 16 files changed, 338 insertions(+), 234 deletions(-)
 delete mode 100644 patches/react-tooltip-lite+1.12.0.patch
 create mode 100644 ts/components/Tooltip.stories.tsx
 create mode 100644 ts/components/Tooltip.tsx

diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md
index f4ff13b7b5..39657cac84 100644
--- a/ACKNOWLEDGMENTS.md
+++ b/ACKNOWLEDGMENTS.md
@@ -2223,30 +2223,6 @@ Signal Desktop makes use of the following open source projects.
     OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
     SOFTWARE.
 
-## react-tooltip-lite
-
-    MIT License
-
-    Copyright (c) 2019 Benny Sidelinger
-
-    Permission is hereby granted, free of charge, to any person obtaining a copy
-    of this software and associated documentation files (the "Software"), to deal
-    in the Software without restriction, including without limitation the rights
-    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-    copies of the Software, and to permit persons to whom the Software is
-    furnished to do so, subject to the following conditions:
-
-    The above copyright notice and this permission notice shall be included in all
-    copies or substantial portions of the Software.
-
-    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-    SOFTWARE.
-
 ## react-virtualized
 
     The MIT License (MIT)
diff --git a/package.json b/package.json
index 59c34a1dc1..9728245827 100644
--- a/package.json
+++ b/package.json
@@ -126,7 +126,6 @@
     "react-redux": "7.1.0",
     "react-router-dom": "5.0.1",
     "react-sortable-hoc": "1.9.1",
-    "react-tooltip-lite": "1.12.0",
     "react-virtualized": "9.21.0",
     "read-last-lines": "1.3.0",
     "redux": "4.0.1",
diff --git a/patches/react-tooltip-lite+1.12.0.patch b/patches/react-tooltip-lite+1.12.0.patch
deleted file mode 100644
index 502a73534b..0000000000
--- a/patches/react-tooltip-lite+1.12.0.patch
+++ /dev/null
@@ -1,60 +0,0 @@
-diff --git a/node_modules/react-tooltip-lite/dist/index.js b/node_modules/react-tooltip-lite/dist/index.js
-index 32ce07d..6461913 100644
---- a/node_modules/react-tooltip-lite/dist/index.js
-+++ b/node_modules/react-tooltip-lite/dist/index.js
-@@ -80,7 +80,7 @@ function (_React$Component) {
-
-     _this.state = {
-       showTip: false,
--      hasHover: false,
-+      hasHover: 0,
-       ignoreShow: false,
-       hasBeenShown: false
-     };
-@@ -232,7 +232,7 @@ function (_React$Component) {
-       var _this3 = this;
-
-       this.setState({
--        hasHover: false
-+        hasHover: 0
-       });
-
-       if (this.state.showTip) {
-@@ -250,7 +250,7 @@ function (_React$Component) {
-     value: function startHover() {
-       if (!this.state.ignoreShow) {
-         this.setState({
--          hasHover: true
-+          hasHover: (this.state.hasHover || 0) + 1,
-         });
-         clearTimeout(this.hoverTimeout);
-         this.hoverTimeout = setTimeout(this.checkHover, this.props.hoverDelay);
-@@ -260,7 +260,7 @@ function (_React$Component) {
-     key: "endHover",
-     value: function endHover() {
-       this.setState({
--        hasHover: false
-+        hasHover: Math.max((this.state.hasHover || 0) - 1, 0),
-       });
-       clearTimeout(this.hoverTimeout);
-       this.hoverTimeout = setTimeout(this.checkHover, this.props.mouseOutDelay || this.props.hoverDelay);
-@@ -268,7 +268,7 @@ function (_React$Component) {
-   }, {
-     key: "checkHover",
-     value: function checkHover() {
--      this.state.hasHover ? this.showTip() : this.hideTip();
-+      this.state.hasHover > 0 ? this.showTip() : this.hideTip();
-     }
-   }, {
-     key: "render",
-@@ -330,7 +330,9 @@ function (_React$Component) {
-         props[eventToggle] = this.toggleTip; // only use hover if they don't have a toggle event
-       } else if (useHover && !isControlledByProps) {
-         props.onMouseEnter = this.startHover;
--        props.onMouseLeave = tipContentHover || mouseOutDelay ? this.endHover : this.hideTip;
-+        props.onMouseLeave = this.endHover;
-+        props.onFocus = this.startHover;
-+        props.onBlur = this.endHover;
-         props.onTouchStart = this.targetTouchStart;
-         props.onTouchEnd = this.targetTouchEnd;
-
diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss
index e15efc87a8..aa74808d1b 100644
--- a/stylesheets/_modules.scss
+++ b/stylesheets/_modules.scss
@@ -2782,24 +2782,34 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
   }
 }
 
-.module-in-contacts-icon__tooltip {
-  .react-tooltip-lite {
+span.module-in-contacts-icon__tooltip {
+  /* Written in this way to add more specificity and avoid !important */
+  .module-tooltip {
     color: $color-white;
     background-color: $ultramarine-ui-light;
-  }
 
-  .react-tooltip-lite-arrow {
-    border-color: $ultramarine-ui-light;
-  }
+    &[data-placement='top'] {
+      .module-tooltip-arrow::after {
+        border-top-color: $ultramarine-ui-light;
+      }
+    }
 
-  @include dark-theme {
-    .react-tooltip-lite {
-      color: $color-white;
-      background-color: $ultramarine-ui-light;
+    &[data-placement='right'] {
+      .module-tooltip-arrow::after {
+        border-right-color: $ultramarine-ui-light;
+      }
+    }
+
+    &[data-placement='bottom'] {
+      .module-tooltip-arrow::after {
+        border-bottom-color: $ultramarine-ui-light;
+      }
     }
 
-    .react-tooltip-lite-arrow {
-      border-color: $ultramarine-ui-light;
+    &[data-placement='left'] {
+      .module-tooltip-arrow::after {
+        border-left-color: $ultramarine-ui-light;
+      }
     }
   }
 }
@@ -6522,8 +6532,8 @@ button.module-image__border-overlay:focus {
   margin-top: 54px;
   overflow: scroll;
   padding: 14px;
-  padding-bottom: 0;
   width: 280px;
+  padding-bottom: 0;
 
   &__overlay {
     display: flex;
@@ -10183,10 +10193,13 @@ $contact-modal-padding: 18px;
   }
 }
 
-/* Third-party module: react-tooltip-lite */
-
-.react-tooltip-lite {
+.module-tooltip {
   border-radius: 8px;
+  display: inline-block;
+  padding: 8px 21px;
+  position: fixed;
+  text-align: center;
+  z-index: 999;
 
   @include light-theme {
     background-color: $color-gray-02;
@@ -10196,14 +10209,96 @@ $contact-modal-padding: 18px;
     background-color: $color-gray-65;
     color: $color-gray-05;
   }
-}
 
-.react-tooltip-lite-arrow {
-  @include light-theme {
-    border-color: $color-gray-02;
+  .module-tooltip-arrow {
+    position: absolute;
   }
-  @include dark-theme {
-    border-color: $color-gray-65;
+
+  .module-tooltip-arrow::after {
+    border: solid 6px transparent;
+    content: '';
+    display: block;
+    height: 0;
+    margin-left: -6px;
+    margin-top: -6px;
+    position: absolute;
+    width: 0;
+  }
+
+  &[data-placement='top'] {
+    margin-bottom: 12px;
+
+    .module-tooltip-arrow {
+      bottom: 0;
+    }
+
+    .module-tooltip-arrow::after {
+      bottom: -12px;
+
+      @include light-theme {
+        border-top-color: $color-gray-02;
+      }
+      @include dark-theme {
+        border-top-color: $color-gray-65;
+      }
+    }
+  }
+
+  &[data-placement='right'] {
+    margin-left: 12px;
+
+    .module-tooltip-arrow {
+      left: 0;
+    }
+
+    .module-tooltip-arrow::after {
+      left: -6px;
+
+      @include light-theme {
+        border-right-color: $color-gray-02;
+      }
+      @include dark-theme {
+        border-right-color: $color-gray-65;
+      }
+    }
+  }
+
+  &[data-placement='bottom'] {
+    margin-top: 12px;
+
+    .module-tooltip-arrow {
+      top: 0;
+    }
+
+    .module-tooltip-arrow::after {
+      top: -6px;
+
+      @include light-theme {
+        border-bottom-color: $color-gray-02;
+      }
+      @include dark-theme {
+        border-bottom-color: $color-gray-65;
+      }
+    }
+  }
+
+  &[data-placement='left'] {
+    margin-right: 12px;
+
+    .module-tooltip-arrow {
+      right: 0;
+    }
+
+    .module-tooltip-arrow::after {
+      right: -12px;
+
+      @include light-theme {
+        border-left-color: $color-gray-02;
+      }
+      @include dark-theme {
+        border-left-color: $color-gray-65;
+      }
+    }
   }
 }
 
diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx
index b76fa4aa18..9a9d36dfd8 100644
--- a/ts/components/CallScreen.tsx
+++ b/ts/components/CallScreen.tsx
@@ -253,13 +253,11 @@ export const CallScreen: React.FC<PropsType> = ({
             buttonType={videoButtonType}
             i18n={i18n}
             onClick={toggleVideo}
-            tooltipDistance={24}
           />
           <CallingButton
             buttonType={audioButtonType}
             i18n={i18n}
             onClick={toggleAudio}
-            tooltipDistance={24}
           />
           <CallingButton
             buttonType={CallingButtonType.HANG_UP}
@@ -267,7 +265,6 @@ export const CallScreen: React.FC<PropsType> = ({
             onClick={() => {
               hangUp({ conversationId: conversation.id });
             }}
-            tooltipDistance={24}
           />
         </div>
         <div
diff --git a/ts/components/CallingButton.stories.tsx b/ts/components/CallingButton.stories.tsx
index 089f972556..76c1c09a7f 100644
--- a/ts/components/CallingButton.stories.tsx
+++ b/ts/components/CallingButton.stories.tsx
@@ -3,15 +3,11 @@
 
 import * as React from 'react';
 import { storiesOf } from '@storybook/react';
-import { number, select } from '@storybook/addon-knobs';
+import { select } from '@storybook/addon-knobs';
 import { action } from '@storybook/addon-actions';
 
-import {
-  CallingButton,
-  CallingButtonType,
-  PropsType,
-  TooltipDirection,
-} from './CallingButton';
+import { CallingButton, CallingButtonType, PropsType } from './CallingButton';
+import { TooltipPlacement } from './Tooltip';
 import { setup as setupI18n } from '../../js/modules/i18n';
 import enMessages from '../../_locales/en/messages.json';
 
@@ -27,12 +23,8 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
   onClick: action('on-click'),
   tooltipDirection: select(
     'tooltipDirection',
-    TooltipDirection,
-    overrideProps.tooltipDirection || TooltipDirection.DOWN
-  ),
-  tooltipDistance: number(
-    'tooltipDistance',
-    overrideProps.tooltipDistance || 16
+    TooltipPlacement,
+    overrideProps.tooltipDirection || TooltipPlacement.Bottom
   ),
 });
 
@@ -87,7 +79,7 @@ story.add('Video Disabled', () => {
 
 story.add('Tooltip right', () => {
   const props = createProps({
-    tooltipDirection: TooltipDirection.RIGHT,
+    tooltipDirection: TooltipPlacement.Right,
   });
   return <CallingButton {...props} />;
 });
diff --git a/ts/components/CallingButton.tsx b/ts/components/CallingButton.tsx
index 5eac291d83..56ef0a7ca9 100644
--- a/ts/components/CallingButton.tsx
+++ b/ts/components/CallingButton.tsx
@@ -3,16 +3,9 @@
 
 import React from 'react';
 import classNames from 'classnames';
-import Tooltip from 'react-tooltip-lite';
+import { Tooltip, TooltipPlacement } from './Tooltip';
 import { LocalizerType } from '../types/Util';
 
-export enum TooltipDirection {
-  UP = 'up',
-  RIGHT = 'right',
-  DOWN = 'down',
-  LEFT = 'left',
-}
-
 export enum CallingButtonType {
   AUDIO_DISABLED = 'AUDIO_DISABLED',
   AUDIO_OFF = 'AUDIO_OFF',
@@ -27,16 +20,14 @@ export type PropsType = {
   buttonType: CallingButtonType;
   i18n: LocalizerType;
   onClick: () => void;
-  tooltipDirection?: TooltipDirection;
-  tooltipDistance?: number;
+  tooltipDirection?: TooltipPlacement;
 };
 
 export const CallingButton = ({
   buttonType,
   i18n,
   onClick,
-  tooltipDirection = TooltipDirection.DOWN,
-  tooltipDistance = 16,
+  tooltipDirection,
 }: PropsType): JSX.Element => {
   let classNameSuffix = '';
   let tooltipContent = '';
@@ -69,21 +60,15 @@ export const CallingButton = ({
   );
 
   return (
-    <button
-      aria-label={tooltipContent}
-      type="button"
-      className={className}
-      onClick={onClick}
-    >
-      <Tooltip
-        arrowSize={6}
-        content={tooltipContent}
-        direction={tooltipDirection}
-        distance={tooltipDistance}
-        hoverDelay={0}
+    <Tooltip content={tooltipContent} direction={tooltipDirection}>
+      <button
+        aria-label={tooltipContent}
+        type="button"
+        className={className}
+        onClick={onClick}
       >
         <div />
-      </Tooltip>
-    </button>
+      </button>
+    </Tooltip>
   );
 };
diff --git a/ts/components/CallingHeader.tsx b/ts/components/CallingHeader.tsx
index be39101947..411d5cd302 100644
--- a/ts/components/CallingHeader.tsx
+++ b/ts/components/CallingHeader.tsx
@@ -2,8 +2,8 @@
 // SPDX-License-Identifier: AGPL-3.0-only
 
 import React from 'react';
-import Tooltip from 'react-tooltip-lite';
 import { LocalizerType } from '../types/Util';
+import { Tooltip } from './Tooltip';
 
 export type PropsType = {
   canPip?: boolean;
@@ -34,52 +34,39 @@ export const CallingHeader = ({
       {isGroupCall ? (
         <div className="module-calling-tools__button">
           <Tooltip
-            arrowSize={6}
             content={i18n('calling__participants', [
               String(remoteParticipants),
             ])}
-            direction="down"
-            hoverDelay={0}
           >
             <button
-              type="button"
               aria-label={i18n('calling__participants', [
                 String(remoteParticipants),
               ])}
               className="module-calling-button__participants"
               onClick={toggleParticipants}
+              type="button"
             />
           </Tooltip>
         </div>
       ) : null}
       <div className="module-calling-tools__button">
-        <Tooltip
-          arrowSize={6}
-          content={i18n('callingDeviceSelection__settings')}
-          direction="down"
-          hoverDelay={0}
-        >
+        <Tooltip content={i18n('callingDeviceSelection__settings')}>
           <button
-            type="button"
             aria-label={i18n('callingDeviceSelection__settings')}
             className="module-calling-button__settings"
             onClick={toggleSettings}
+            type="button"
           />
         </Tooltip>
       </div>
       {canPip && (
         <div className="module-calling-tools__button">
-          <Tooltip
-            arrowSize={6}
-            content={i18n('calling__pip--on')}
-            direction="down"
-            hoverDelay={0}
-          >
+          <Tooltip content={i18n('calling__pip--on')}>
             <button
-              type="button"
               aria-label={i18n('calling__pip--on')}
               className="module-calling-button__pip"
               onClick={togglePip}
+              type="button"
             />
           </Tooltip>
         </div>
diff --git a/ts/components/CallingLobby.tsx b/ts/components/CallingLobby.tsx
index 897ae51175..d779ef888b 100644
--- a/ts/components/CallingLobby.tsx
+++ b/ts/components/CallingLobby.tsx
@@ -7,11 +7,8 @@ import {
   SetLocalPreviewType,
   SetLocalVideoType,
 } from '../state/ducks/calling';
-import {
-  CallingButton,
-  CallingButtonType,
-  TooltipDirection,
-} from './CallingButton';
+import { CallingButton, CallingButtonType } from './CallingButton';
+import { TooltipPlacement } from './Tooltip';
 import { CallBackgroundBlur } from './CallBackgroundBlur';
 import { CallingHeader } from './CallingHeader';
 import { Spinner } from './Spinner';
@@ -145,15 +142,13 @@ export const CallingLobby = ({
             buttonType={videoButtonType}
             i18n={i18n}
             onClick={toggleVideo}
-            tooltipDirection={TooltipDirection.UP}
-            tooltipDistance={24}
+            tooltipDirection={TooltipPlacement.Top}
           />
           <CallingButton
             buttonType={audioButtonType}
             i18n={i18n}
             onClick={toggleAudio}
-            tooltipDirection={TooltipDirection.UP}
-            tooltipDistance={24}
+            tooltipDirection={TooltipPlacement.Top}
           />
         </div>
       </div>
diff --git a/ts/components/CallingPip.tsx b/ts/components/CallingPip.tsx
index d0316d0b52..d3d6e4714b 100644
--- a/ts/components/CallingPip.tsx
+++ b/ts/components/CallingPip.tsx
@@ -2,7 +2,7 @@
 // SPDX-License-Identifier: AGPL-3.0-only
 
 import React from 'react';
-import Tooltip from 'react-tooltip-lite';
+import { Tooltip } from './Tooltip';
 import { CallingPipRemoteVideo } from './CallingPipRemoteVideo';
 import { LocalizerType } from '../types/Util';
 import { ConversationType } from '../state/ducks/conversations';
@@ -182,27 +182,23 @@ export const CallingPip = ({
       ) : null}
       <div className="module-calling-pip__actions">
         <button
-          type="button"
           aria-label={i18n('calling__hangup')}
           className="module-calling-pip__button--hangup"
           onClick={() => {
             hangUp({ conversationId: conversation.id });
           }}
-        />
-        <button
           type="button"
-          aria-label={i18n('calling__pip--off')}
-          className="module-calling-pip__button--pip"
-          onClick={togglePip}
-        >
-          <Tooltip
-            arrowSize={6}
-            content={i18n('calling__pip--off')}
-            hoverDelay={0}
+        />
+        <Tooltip content={i18n('calling__pip--off')}>
+          <button
+            aria-label={i18n('calling__pip--off')}
+            className="module-calling-pip__button--pip"
+            onClick={togglePip}
+            type="button"
           >
             <div />
-          </Tooltip>
-        </button>
+          </button>
+        </Tooltip>
       </div>
     </div>
   );
diff --git a/ts/components/InContactsIcon.tsx b/ts/components/InContactsIcon.tsx
index 282737d3f1..07a4de85b5 100644
--- a/ts/components/InContactsIcon.tsx
+++ b/ts/components/InContactsIcon.tsx
@@ -2,8 +2,8 @@
 // SPDX-License-Identifier: AGPL-3.0-only
 
 import React from 'react';
-import Tooltip from 'react-tooltip-lite';
 
+import { Tooltip } from './Tooltip';
 import { LocalizerType } from '../types/Util';
 
 type PropsType = {
@@ -15,22 +15,16 @@ export const InContactsIcon = (props: PropsType): JSX.Element => {
 
   /* eslint-disable jsx-a11y/no-noninteractive-tabindex */
   return (
-    <Tooltip
-      tagName="span"
-      direction="bottom"
-      className="module-in-contacts-icon__tooltip"
-      arrowSize={8}
-      content={i18n('contactInAddressBook')}
-      distance={13}
-      hoverDelay={0}
-    >
-      <span
-        tabIndex={0}
-        role="img"
-        aria-label={i18n('contactInAddressBook')}
-        className="module-in-contacts-icon__icon"
-      />
-    </Tooltip>
+    <span className="module-in-contacts-icon__tooltip">
+      <Tooltip content={i18n('contactInAddressBook')}>
+        <span
+          tabIndex={0}
+          role="img"
+          aria-label={i18n('contactInAddressBook')}
+          className="module-in-contacts-icon__icon"
+        />
+      </Tooltip>
+    </span>
   );
   /* eslint-enable jsx-a11y/no-noninteractive-tabindex */
 };
diff --git a/ts/components/IncomingCallBar.tsx b/ts/components/IncomingCallBar.tsx
index f4b1400f06..83236f087c 100644
--- a/ts/components/IncomingCallBar.tsx
+++ b/ts/components/IncomingCallBar.tsx
@@ -2,8 +2,8 @@
 // SPDX-License-Identifier: AGPL-3.0-only
 
 import React from 'react';
-import Tooltip from 'react-tooltip-lite';
 import { Avatar } from './Avatar';
+import { Tooltip } from './Tooltip';
 import { ContactName } from './conversation/ContactName';
 import { LocalizerType } from '../types/Util';
 import { ColorType } from '../types/Colors';
@@ -41,22 +41,16 @@ const CallButton = ({
   tooltipContent,
 }: CallButtonProps): JSX.Element => {
   return (
-    <button
-      className={`module-incoming-call__button module-incoming-call__button--${classSuffix}`}
-      onClick={onClick}
-      tabIndex={tabIndex}
-      type="button"
-    >
-      <Tooltip
-        arrowSize={6}
-        content={tooltipContent}
-        direction="bottom"
-        distance={16}
-        hoverDelay={0}
+    <Tooltip content={tooltipContent}>
+      <button
+        className={`module-incoming-call__button module-incoming-call__button--${classSuffix}`}
+        onClick={onClick}
+        tabIndex={tabIndex}
+        type="button"
       >
         <div />
-      </Tooltip>
-    </button>
+      </button>
+    </Tooltip>
   );
 };
 
diff --git a/ts/components/Tooltip.stories.tsx b/ts/components/Tooltip.stories.tsx
new file mode 100644
index 0000000000..13ef7d6efb
--- /dev/null
+++ b/ts/components/Tooltip.stories.tsx
@@ -0,0 +1,80 @@
+// Copyright 2020 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import * as React from 'react';
+import { storiesOf } from '@storybook/react';
+import { select } from '@storybook/addon-knobs';
+
+import { Tooltip, TooltipPlacement, PropsType } from './Tooltip';
+
+const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
+  content: overrideProps.content || 'Hello World',
+  direction: select('direction', TooltipPlacement, overrideProps.direction),
+  sticky: overrideProps.sticky,
+});
+
+const story = storiesOf('Components/Tooltip', module);
+
+const Trigger = (
+  <span
+    style={{
+      display: 'inline-block',
+      marginTop: 200,
+      marginBottom: 200,
+      marginLeft: 200,
+      marginRight: 200,
+    }}
+  >
+    Trigger
+  </span>
+);
+
+story.add('Top', () => (
+  <Tooltip
+    {...createProps({
+      direction: TooltipPlacement.Top,
+    })}
+  >
+    {Trigger}
+  </Tooltip>
+));
+
+story.add('Right', () => (
+  <Tooltip
+    {...createProps({
+      direction: TooltipPlacement.Right,
+    })}
+  >
+    {Trigger}
+  </Tooltip>
+));
+
+story.add('Bottom', () => (
+  <Tooltip
+    {...createProps({
+      direction: TooltipPlacement.Bottom,
+    })}
+  >
+    {Trigger}
+  </Tooltip>
+));
+
+story.add('Left', () => (
+  <Tooltip
+    {...createProps({
+      direction: TooltipPlacement.Left,
+    })}
+  >
+    {Trigger}
+  </Tooltip>
+));
+
+story.add('Sticky', () => (
+  <Tooltip
+    {...createProps({
+      sticky: true,
+    })}
+  >
+    {Trigger}
+  </Tooltip>
+));
diff --git a/ts/components/Tooltip.tsx b/ts/components/Tooltip.tsx
new file mode 100644
index 0000000000..72912a76b9
--- /dev/null
+++ b/ts/components/Tooltip.tsx
@@ -0,0 +1,81 @@
+// Copyright 2020 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React from 'react';
+import { Manager, Reference, Popper } from 'react-popper';
+
+export enum TooltipPlacement {
+  Top = 'top',
+  Right = 'right',
+  Bottom = 'bottom',
+  Left = 'left',
+}
+
+export type PropsType = {
+  content: string | JSX.Element;
+  direction?: TooltipPlacement;
+  sticky?: boolean;
+};
+
+export const Tooltip: React.FC<PropsType> = ({
+  children,
+  content,
+  direction,
+  sticky,
+}) => {
+  const isSticky = Boolean(sticky);
+  const [showTooltip, setShowTooltip] = React.useState(isSticky);
+
+  return (
+    <Manager>
+      <Reference>
+        {({ ref }) => (
+          <span
+            onBlur={() => {
+              if (!isSticky) {
+                setShowTooltip(false);
+              }
+            }}
+            onFocus={() => {
+              if (!isSticky) {
+                setShowTooltip(true);
+              }
+            }}
+            onMouseEnter={() => {
+              if (!isSticky) {
+                setShowTooltip(true);
+              }
+            }}
+            onMouseLeave={() => {
+              if (!isSticky) {
+                setShowTooltip(false);
+              }
+            }}
+            ref={ref}
+          >
+            {children}
+          </span>
+        )}
+      </Reference>
+      <Popper placement={direction}>
+        {({ arrowProps, placement, ref, style }) =>
+          showTooltip && (
+            <div
+              className="module-tooltip"
+              ref={ref}
+              style={style}
+              data-placement={placement}
+            >
+              {content}
+              <div
+                className="module-tooltip-arrow"
+                ref={arrowProps.ref}
+                style={arrowProps.style}
+              />
+            </div>
+          )
+        }
+      </Popper>
+    </Manager>
+  );
+};
diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json
index 8e0a16b733..ade88df303 100644
--- a/ts/util/lint/exceptions.json
+++ b/ts/util/lint/exceptions.json
@@ -14391,7 +14391,7 @@
     "rule": "React-useRef",
     "path": "ts/components/CallingLobby.js",
     "line": "    const localVideoRef = react_1.default.useRef(null);",
-    "lineNumber": 14,
+    "lineNumber": 15,
     "reasonCategory": "usageTrusted",
     "updated": "2020-10-26T19:12:24.410Z",
     "reasonDetail": "Used to get the local video element for rendering."
@@ -14400,7 +14400,7 @@
     "rule": "React-useRef",
     "path": "ts/components/CallingLobby.tsx",
     "line": "  const localVideoRef = React.useRef(null);",
-    "lineNumber": 61,
+    "lineNumber": 58,
     "reasonCategory": "usageTrusted",
     "updated": "2020-10-26T19:12:24.410Z",
     "reasonDetail": "Used to get the local video element for rendering."
@@ -15176,4 +15176,4 @@
     "reasonCategory": "falseMatch",
     "updated": "2020-09-08T23:07:22.682Z"
   }
-]
\ No newline at end of file
+]
diff --git a/yarn.lock b/yarn.lock
index 4c31ceb188..ab01c2599f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -13508,13 +13508,6 @@ react-textarea-autosize@^7.1.0:
     "@babel/runtime" "^7.1.2"
     prop-types "^15.6.0"
 
-react-tooltip-lite@1.12.0:
-  version "1.12.0"
-  resolved "https://registry.yarnpkg.com/react-tooltip-lite/-/react-tooltip-lite-1.12.0.tgz#f6cd1323cdd9f5f80dd0e71a30ef59f401dee9ba"
-  integrity sha512-QjDnmDmjtLNKvLY6bzUOG8W6ZDBTiE4UXugGzClOQEGvMvbkJn2GvZvLwRaxsN/GCx7589RgbGaESMiJAm+zWg==
-  dependencies:
-    prop-types "^15.5.8"
-
 react-transition-group@^2.2.1:
   version "2.9.0"
   resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d"