Просмотр исходного кода

feat(replays): Create a Share button to allow sharing replays at a specific timestamp (#41811)

Here's the modal with a custom timestamp that someone wants to share:

<img width="766" alt="SCR-20221129-dxz"
src="https://user-images.githubusercontent.com/187460/204609026-3edb4d6a-5ee1-42ad-91b0-e73081af7e3b.png">


By default the timestamp is the current time of the player, in this
example 2 minutes into the replay:
<img width="770" alt="SCR-20221129-dxt"
src="https://user-images.githubusercontent.com/187460/204609488-9242e50c-a208-4bd3-9f55-0d74614cb386.png">



1. When the modal opens it starts with the default time, `2:00` in this
case.
2. Check the checkbox and edit the time: it'll reflect in the url right
away
3. If you un-check the checkmark, so it's not going to use a custom time
anymore, then the text box will go back to saying the default `2:00`
again.
4. Click the checkmark to bring back the custom time you entered in step
2

Fixes #41731
Ryan Albrecht 2 лет назад
Родитель
Сommit
32986cd2ba

+ 4 - 2
static/app/components/inputGroup.tsx

@@ -44,7 +44,7 @@ export const InputGroupContext = createContext<InputContext>({inputProps: {}});
  *     <InputTrailingItems> … </InputTrailingItems>
  *   </InputGroup>
  */
-export function InputGroup({children}: React.HTMLAttributes<HTMLDivElement>) {
+export function InputGroup({children, className}: React.HTMLAttributes<HTMLDivElement>) {
   const [leadingWidth, setLeadingWidth] = useState<number>();
   const [trailingWidth, setTrailingWidth] = useState<number>();
   const [inputProps, setInputProps] = useState<Partial<InputProps>>({});
@@ -63,7 +63,9 @@ export function InputGroup({children}: React.HTMLAttributes<HTMLDivElement>) {
 
   return (
     <InputGroupContext.Provider value={contextValue}>
-      <InputGroupWrap disabled={inputProps.disabled}>{children}</InputGroupWrap>
+      <InputGroupWrap className={className} disabled={inputProps.disabled}>
+        {children}
+      </InputGroupWrap>
     </InputGroupContext.Provider>
   );
 }

+ 112 - 0
static/app/components/replays/shareButton.tsx

@@ -0,0 +1,112 @@
+import {useState} from 'react';
+import styled from '@emotion/styled';
+
+import {openModal} from 'sentry/actionCreators/modal';
+import Button from 'sentry/components/button';
+import Checkbox from 'sentry/components/checkbox';
+import {Input} from 'sentry/components/inputGroup';
+import {useReplayContext} from 'sentry/components/replays/replayContext';
+import TextCopyInput from 'sentry/components/textCopyInput';
+import {IconUpload} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {formatSecondsToClock, parseClockToSeconds} from 'sentry/utils/formatters';
+import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
+import {useRoutes} from 'sentry/utils/useRoutes';
+
+function ShareModal({currentTimeSec, Header, Body}) {
+  const routes = useRoutes();
+  const [isCustom, setIsCustom] = useState(false);
+  const [customSeconds, setSeconds] = useState(currentTimeSec);
+
+  const url = new URL(window.location.href);
+  const {searchParams} = url;
+  searchParams.set('referrer', getRouteStringFromRoutes(routes));
+  searchParams.set('t', isCustom ? String(customSeconds) : currentTimeSec);
+
+  // Use `value` instead of `defaultValue` so the number resets to
+  // `currentTimeSec` if the user toggles isCustom
+  const value = isCustom
+    ? formatSecondsToClock(customSeconds, {padAll: false})
+    : formatSecondsToClock(currentTimeSec, {padAll: false});
+
+  return (
+    <div>
+      <Header>
+        <h3>Share Replay</h3>
+      </Header>
+      <Body>
+        <StyledTextCopyInput aria-label={t('Deeplink to current timestamp')} size="sm">
+          {url.toString()}
+        </StyledTextCopyInput>
+
+        <InputRow>
+          <StyledCheckbox
+            id="replay_share_custom_time"
+            name="replay_share_custom_time"
+            checked={isCustom}
+            onChange={() => setIsCustom(prev => !prev)}
+          />
+          <StyledLabel htmlFor="replay_share_custom_time">{t('Start at')}</StyledLabel>
+          <StyledInput
+            name="time"
+            placeholder=""
+            disabled={!isCustom}
+            value={value}
+            onChange={e => setSeconds(parseClockToSeconds(e.target.value))}
+          />
+        </InputRow>
+      </Body>
+    </div>
+  );
+}
+
+function ShareButton() {
+  // Cannot use this hook inside the modal because context will not be wired up
+  const {currentTime} = useReplayContext();
+
+  // floor() to remove ms level precision. It's a cleaner url by default this way.
+  const currentTimeSec = Math.floor(currentTime / 1000);
+
+  return (
+    <Button
+      size="xs"
+      icon={<IconUpload size="xs" />}
+      onClick={() =>
+        openModal(deps => <ShareModal currentTimeSec={currentTimeSec} {...deps} />)
+      }
+    >
+      {t('Share')}
+    </Button>
+  );
+}
+
+const StyledTextCopyInput = styled(TextCopyInput)`
+  /* Keep height consistent with the other input in the modal */
+  input {
+    height: 38px;
+  }
+`;
+
+const InputRow = styled('div')`
+  margin-top: ${space(2)};
+  display: flex;
+  flex-direction: row;
+  gap: ${space(1)};
+  align-items: center;
+`;
+
+const StyledCheckbox = styled(Checkbox)`
+  margin: 0 !important;
+`;
+
+const StyledLabel = styled('label')`
+  margin: 0;
+`;
+
+const StyledInput = styled(Input)`
+  width: auto;
+  max-width: 90px;
+`;
+
+export default ShareButton;

+ 3 - 28
static/app/components/replays/utils.tsx

@@ -1,13 +1,7 @@
-import padStart from 'lodash/padStart';
-
 import {Crumb} from 'sentry/types/breadcrumbs';
+import {formatSecondsToClock} from 'sentry/utils/formatters';
 import type {ReplaySpan} from 'sentry/views/replays/types';
 
-function padZero(num: number, len = 2): string {
-  const str = String(num);
-  return padStart(str, len, '0');
-}
-
 const SECOND = 1000;
 const MINUTE = 60 * SECOND;
 const HOUR = 60 * MINUTE;
@@ -32,7 +26,6 @@ export function showPlayerTime(
   return formatTime(relativeTimeInMs(timestamp, relativeTimeMs), showMs);
 }
 
-// TODO: move into 'sentry/utils/formatters'
 export function formatTime(ms: number, showMs?: boolean): string {
   if (ms <= 0 || isNaN(ms)) {
     if (showMs) {
@@ -42,26 +35,8 @@ export function formatTime(ms: number, showMs?: boolean): string {
     return '00:00';
   }
 
-  const hour = Math.floor(ms / HOUR);
-  ms = ms % HOUR;
-  const minute = Math.floor(ms / MINUTE);
-  ms = ms % MINUTE;
-  const second = Math.floor(ms / SECOND);
-
-  let formattedTime = '00:00';
-
-  if (hour) {
-    formattedTime = `${padZero(hour)}:${padZero(minute)}:${padZero(second)}`;
-  } else {
-    formattedTime = `${padZero(minute)}:${padZero(second)}`;
-  }
-
-  if (showMs) {
-    const milliseconds = Math.floor(ms % SECOND);
-    formattedTime = `${formattedTime}.${padZero(milliseconds, 3)}`;
-  }
-
-  return formattedTime;
+  const seconds = ms / 1000;
+  return formatSecondsToClock(showMs ? seconds : Math.floor(seconds));
 }
 
 /**

+ 77 - 0
static/app/utils/formatters.spec.tsx

@@ -1,10 +1,15 @@
 import {
+  DAY,
   formatAbbreviatedNumber,
   formatFloat,
   formatPercentage,
+  formatSecondsToClock,
   getDuration,
   getExactDuration,
+  MONTH,
+  parseClockToSeconds,
   userDisplayName,
+  WEEK,
 } from 'sentry/utils/formatters';
 
 describe('getDuration()', function () {
@@ -77,6 +82,78 @@ describe('getDuration()', function () {
   });
 });
 
+describe('formatSecondsToClock', function () {
+  it('should format durations', function () {
+    expect(formatSecondsToClock(0)).toBe('00:00');
+    expect(formatSecondsToClock(0.1)).toBe('00:00.100');
+    expect(formatSecondsToClock(1)).toBe('00:01');
+    expect(formatSecondsToClock(2)).toBe('00:02');
+    expect(formatSecondsToClock(65)).toBe('01:05');
+    expect(formatSecondsToClock(65.123)).toBe('01:05.123');
+    expect(formatSecondsToClock(122)).toBe('02:02');
+    expect(formatSecondsToClock(3720)).toBe('01:02:00');
+    expect(formatSecondsToClock(36000)).toBe('10:00:00');
+    expect(formatSecondsToClock(86400)).toBe('24:00:00');
+    expect(formatSecondsToClock(86400 * 2)).toBe('48:00:00');
+  });
+
+  it('should format negative durations', function () {
+    expect(formatSecondsToClock(-0)).toBe('00:00');
+    expect(formatSecondsToClock(-0.1)).toBe('00:00.100');
+    expect(formatSecondsToClock(-1)).toBe('00:01');
+    expect(formatSecondsToClock(-2)).toBe('00:02');
+    expect(formatSecondsToClock(-65)).toBe('01:05');
+    expect(formatSecondsToClock(-65.123)).toBe('01:05.123');
+    expect(formatSecondsToClock(-122)).toBe('02:02');
+    expect(formatSecondsToClock(-3720)).toBe('01:02:00');
+    expect(formatSecondsToClock(-36000)).toBe('10:00:00');
+    expect(formatSecondsToClock(-86400)).toBe('24:00:00');
+    expect(formatSecondsToClock(-86400 * 2)).toBe('48:00:00');
+  });
+
+  it('should not pad when padAll:false is set', function () {
+    const padAll = false;
+    expect(formatSecondsToClock(0, {padAll})).toBe('0:00');
+    expect(formatSecondsToClock(0.1, {padAll})).toBe('0:00.100');
+    expect(formatSecondsToClock(1, {padAll})).toBe('0:01');
+    expect(formatSecondsToClock(65, {padAll})).toBe('1:05');
+    expect(formatSecondsToClock(3720, {padAll})).toBe('1:02:00');
+  });
+});
+
+describe('parseClockToSeconds', function () {
+  it('should format durations', function () {
+    expect(parseClockToSeconds('0:00')).toBe(0);
+    expect(parseClockToSeconds('0:00.100')).toBe(0.1);
+    expect(parseClockToSeconds('0:01')).toBe(1);
+    expect(parseClockToSeconds('0:02')).toBe(2);
+    expect(parseClockToSeconds('1:05')).toBe(65);
+    expect(parseClockToSeconds('1:05.123')).toBe(65.123);
+    expect(parseClockToSeconds('2:02')).toBe(122);
+    expect(parseClockToSeconds('1:02:00')).toBe(3720);
+    expect(parseClockToSeconds('10:00:00')).toBe(36000);
+    expect(parseClockToSeconds('24:00:00')).toBe(DAY / 1000);
+    expect(parseClockToSeconds('48:00:00')).toBe((DAY * 2) / 1000);
+    expect(parseClockToSeconds('2:00:00:00')).toBe((DAY * 2) / 1000);
+    expect(parseClockToSeconds('1:00:00:00:00')).toBe(WEEK / 1000);
+    expect(parseClockToSeconds('1:00:00:00:00:00')).toBe(MONTH / 1000);
+  });
+
+  it('should ignore non-numeric input', function () {
+    expect(parseClockToSeconds('hello world')).toBe(0);
+    expect(parseClockToSeconds('a:b:c')).toBe(0);
+    expect(parseClockToSeconds('a:b:c.d')).toBe(0);
+    expect(parseClockToSeconds('a:b:10.d')).toBe(10);
+    expect(parseClockToSeconds('a:10:c.d')).toBe(600);
+  });
+
+  it('should handle as much invalid input as possible', function () {
+    expect(parseClockToSeconds('a:b:c.123')).toBe(0.123);
+    expect(parseClockToSeconds('a:b:10.d')).toBe(10);
+    expect(parseClockToSeconds('a:10:c.d')).toBe(600);
+  });
+});
+
 describe('formatAbbreviatedNumber()', function () {
   it('should abbreviate numbers', function () {
     expect(formatAbbreviatedNumber(0)).toBe('0');

+ 46 - 0
static/app/utils/formatters.tsx

@@ -187,6 +187,52 @@ export function getExactDuration(seconds: number, abbreviation: boolean = false)
   return `0${abbreviation ? t('ms') : ` ${t('milliseconds')}`}`;
 }
 
+export function formatSecondsToClock(
+  seconds: number,
+  {padAll}: {padAll: boolean} = {padAll: true}
+) {
+  if (seconds === 0 || isNaN(seconds)) {
+    return padAll ? '00:00' : '0:00';
+  }
+
+  const divideBy = (msValue: number, time: number) => {
+    return {
+      quotient: msValue < 0 ? Math.ceil(msValue / time) : Math.floor(msValue / time),
+      remainder: msValue % time,
+    };
+  };
+
+  // value in milliseconds
+  const absMSValue = round(Math.abs(seconds * 1000));
+
+  const {quotient: hours, remainder: rMins} = divideBy(absMSValue, HOUR);
+  const {quotient: minutes, remainder: rSeconds} = divideBy(rMins, MINUTE);
+  const {quotient: secs, remainder: milliseconds} = divideBy(rSeconds, SECOND);
+
+  const fill = (num: number) => (num < 10 ? `0${num}` : String(num));
+
+  const parts = hours
+    ? [padAll ? fill(hours) : hours, fill(minutes), fill(secs)]
+    : [padAll ? fill(minutes) : minutes, fill(secs)];
+
+  return milliseconds ? `${parts.join(':')}.${milliseconds}` : parts.join(':');
+}
+
+export function parseClockToSeconds(clock: string) {
+  const [rest, milliseconds] = clock.split('.');
+  const parts = rest.split(':');
+
+  let seconds = 0;
+  const progression = [MONTH, WEEK, DAY, HOUR, MINUTE, SECOND].slice(parts.length * -1);
+  for (let i = 0; i < parts.length; i++) {
+    const num = Number(parts[i]) || 0;
+    const time = progression[i] / 1000;
+    seconds += num * time;
+  }
+  const ms = Number(milliseconds) || 0;
+  return seconds + ms / 1000;
+}
+
 export function formatFloat(number: number, places: number) {
   const multi = Math.pow(10, places);
   return parseInt((number * multi).toString(), 10) / multi;

+ 2 - 0
static/app/views/replays/detail/page.tsx

@@ -5,6 +5,7 @@ import {FeatureFeedback} from 'sentry/components/featureFeedback';
 import * as Layout from 'sentry/components/layouts/thirds';
 import DeleteButton from 'sentry/components/replays/deleteButton';
 import DetailsPageBreadcrumbs from 'sentry/components/replays/header/detailsPageBreadcrumbs';
+import ShareButton from 'sentry/components/replays/shareButton';
 import {CrumbWalker} from 'sentry/components/replays/walker/urlWalker';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import space from 'sentry/styles/space';
@@ -31,6 +32,7 @@ function Page({children, crumbs, orgSlug, replayRecord}: Props) {
       <DetailsPageBreadcrumbs orgSlug={orgSlug} replayRecord={replayRecord} />
 
       <ButtonActionsWrapper>
+        <ShareButton />
         <DeleteButton />
         <FeatureFeedback featureName="replay" buttonProps={{size: 'xs'}} />
       </ButtonActionsWrapper>