Skip to content
42 changes: 39 additions & 3 deletions static/app/components/replays/replayBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
import {useEffect, useState} from 'react';
import styled from '@emotion/styled';
import invariant from 'invariant';

import {Tooltip} from '@sentry/scraps/tooltip';

import {ProjectAvatar} from 'sentry/components/core/avatar/projectAvatar';
import {UserAvatar} from 'sentry/components/core/avatar/userAvatar';
import {Grid} from 'sentry/components/core/layout';
import {Flex} from 'sentry/components/core/layout/flex';
import {Text} from 'sentry/components/core/text';
import {DateTime} from 'sentry/components/dateTime';
import {
getLiveDurationMs,
getReplayExpiresAtMs,
LIVE_TOOLTIP_MESSAGE,
LiveIndicator,
} from 'sentry/components/replays/replayLiveIndicator';
import TimeSince from 'sentry/components/timeSince';
import {IconCalendar} from 'sentry/icons/iconCalendar';
import {IconDelete} from 'sentry/icons/iconDelete';
import {t} from 'sentry/locale';
import * as events from 'sentry/utils/events';
import {useReplayPrefs} from 'sentry/utils/replays/playback/providers/replayPreferencesContext';
import useProjectFromId from 'sentry/utils/useProjectFromId';
import useTimeout from 'sentry/utils/useTimeout';
import type {ReplayListRecordWithTx} from 'sentry/views/performance/transactionSummary/transactionReplays/useReplaysWithTxData';
import type {ReplayListRecord} from 'sentry/views/replays/types';

Expand All @@ -26,6 +36,21 @@ export default function ReplayBadge({replay}: Props) {
const [prefs] = useReplayPrefs();
const timestampType = prefs.timestampType;

const [isLive, setIsLive] = useState(
Date.now() < getReplayExpiresAtMs(replay.started_at)
);

const {start: startTimeout} = useTimeout({
timeMs: getLiveDurationMs(replay.finished_at),
onTimeout: () => {
setIsLive(false);
},
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Live Indicator Timeout Not Updated

The timeout duration is calculated only once during initial render using liveDuration(replay.finished_at). If replay.finished_at changes (e.g., when a live replay finishes and the data is updated), the timeout will not be recalculated. This means the live indicator may not turn off at the correct time when a replay transitions from live to finished. The timeMs parameter should be recalculated when replay.finished_at changes, or the timeout should be restarted when the replay prop changes.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's fine


useEffect(() => {
startTimeout();
}, [startTimeout]);

if (replay.is_archived) {
return (
<Grid columns="24px 1fr" gap="md" align="center" justify="center">
Expand Down Expand Up @@ -65,10 +90,21 @@ export default function ReplayBadge({replay}: Props) {
}}
size={24}
/>

<Flex direction="column" gap="xs" justify="center">
<Text size="md" bold ellipsis data-underline-on-hover>
{replay.user.display_name || t('Anonymous User')}
</Text>
<Flex direction="row" align="center">
<div>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this div necessary?

<Text size="md" bold ellipsis data-underline-on-hover>
{replay.user.display_name || t('Anonymous User')}
</Text>
</div>
{isLive ? (
<Tooltip title={LIVE_TOOLTIP_MESSAGE}>
<LiveIndicator />
</Tooltip>
) : null}
</Flex>

<Flex gap="xs">
{/* Avatar is used instead of ProjectBadge because using ProjectBadge increases spacing, which doesn't look as good */}
{project ? <ProjectAvatar size={12} project={project} /> : null}
Expand Down
60 changes: 60 additions & 0 deletions static/app/components/replays/replayLiveIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {keyframes} from '@emotion/react';
import styled from '@emotion/styled';

import {t} from 'sentry/locale';
import type {ReplayRecord} from 'sentry/views/replays/types';

export const LIVE_TOOLTIP_MESSAGE = t('This replay is still in progress.');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need "still"?

Suggested change
export const LIVE_TOOLTIP_MESSAGE = t('This replay is still in progress.');
export const LIVE_TOOLTIP_MESSAGE = t('This replay is in progress.');

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember if we use periods in tooltips

Suggested change
export const LIVE_TOOLTIP_MESSAGE = t('This replay is still in progress.');
export const LIVE_TOOLTIP_MESSAGE = t('This replay is in progress');


export function getReplayExpiresAtMs(startedAt: ReplayRecord['started_at']): number {
const ONE_HOUR_MS = 3_600_000;
return startedAt ? startedAt.getTime() + ONE_HOUR_MS : 0;
}

export function getLiveDurationMs(finishedAt: ReplayRecord['finished_at']): number {
if (!finishedAt) {
return 0;
}
const FIVE_MINUTE_MS = 300_000;
return Math.max(finishedAt.getTime() + FIVE_MINUTE_MS - Date.now(), 0);
}

const pulse = keyframes`
0% {
transform: scale(0.1);
opacity: 1
}
40%, 100% {
transform: scale(1);
opacity: 0;
}
`;

export const LiveIndicator = styled('div')`
background: ${p => p.theme.successText};
height: 8px;
width: 8px;
position: relative;
border-radius: 50%;
margin-left: ${p => p.theme.space.sm};
margin-right: ${p => p.theme.space.sm};
@media (prefers-reduced-motion: reduce) {
&:before {
display: none;
}
}
&:before {
content: '';
animation: ${pulse} 3s ease-out infinite;
border: 6px solid ${p => p.theme.successText};
position: absolute;
border-radius: 50%;
height: 20px;
width: 20px;
top: -6px;
left: -6px;
}
`;
97 changes: 24 additions & 73 deletions static/app/views/replays/detail/header/replayDetailsUserBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import {keyframes} from '@emotion/react';
import styled from '@emotion/styled';

import {Flex} from '@sentry/scraps/layout';
import {Text} from '@sentry/scraps/text';
import {Tooltip} from '@sentry/scraps/tooltip';

import {Button} from 'sentry/components/core/button';
import {Flex} from 'sentry/components/core/layout';
import {Link} from 'sentry/components/core/link';
import {Text} from 'sentry/components/core/text';
import {Tooltip} from 'sentry/components/core/tooltip';
import UserBadge from 'sentry/components/idBadge/userBadge';
import * as Layout from 'sentry/components/layouts/thirds';
import Placeholder from 'sentry/components/placeholder';
import ReplayLoadingState from 'sentry/components/replays/player/replayLoadingState';
import {
getLiveDurationMs,
getReplayExpiresAtMs,
LIVE_TOOLTIP_MESSAGE,
LiveIndicator,
} from 'sentry/components/replays/replayLiveIndicator';
import TimeSince from 'sentry/components/timeSince';
import {IconCalendar, IconRefresh} from 'sentry/icons';
import {t} from 'sentry/locale';
Expand All @@ -30,7 +36,6 @@ interface Props {
export default function ReplayDetailsUserBadge({readerResult}: Props) {
const organization = useOrganization();
const replayRecord = readerResult.replayRecord;
const replayReader = readerResult.replay;

const {slug: orgSlug} = organization;
const replayId = readerResult.replayId;
Expand Down Expand Up @@ -80,30 +85,22 @@ export default function ReplayDetailsUserBadge({readerResult}: Props) {
}
};

const ONE_HOUR_MS = 3_600_000;

// We poll for the replay record for 60 minutes after the replay started.
const REPLAY_EXPIRY_TIMESTAMP = replayReader
? replayReader.getStartTimestampMs() + ONE_HOUR_MS
: 0;
const isReplayExpired =
Date.now() > getReplayExpiresAtMs(replayRecord?.started_at ?? null);

const polledReplayRecord = usePollReplayRecord({
enabled: Date.now() < REPLAY_EXPIRY_TIMESTAMP,
enabled: !isReplayExpired,
replayId,
orgSlug,
});

const polledCountSegments = polledReplayRecord?.count_segments ?? 0;
const polledFinishedAt = polledReplayRecord?.finished_at?.getTime() ?? 0;

const prevSegments = replayRecord?.count_segments ?? 0;

const showRefreshButton = polledCountSegments > prevSegments;

const FIVE_MINUTE_MS = 300_000;
const showIsLive =
Date.now() < polledFinishedAt + FIVE_MINUTE_MS &&
Date.now() < REPLAY_EXPIRY_TIMESTAMP;
const showLiveIndicator =
!isReplayExpired && replayRecord && getLiveDurationMs(replayRecord.finished_at) > 0;

const badge = replayRecord ? (
<UserBadge
Expand Down Expand Up @@ -137,13 +134,18 @@ export default function ReplayDetailsUserBadge({readerResult}: Props) {
isTooltipHoverable
unitStyle="regular"
/>
{showIsLive ? (
{showLiveIndicator ? (
<Tooltip
showUnderline
title={LIVE_TOOLTIP_MESSAGE}
underlineColor="success"
title={t('This replay is still in progress.')}
showUnderline
>
<Live />
<Flex align="center">
<Text bold variant="success" data-test-id="live-badge">
{t('LIVE')}
</Text>
<LiveIndicator />
</Flex>
</Tooltip>
) : null}
<Button
Expand Down Expand Up @@ -200,54 +202,3 @@ const DisplayHeader = styled('div')`
display: flex;
flex-direction: column;
`;

function Live() {
return (
<Flex align="center">
<Text bold variant="success" data-test-id="live-badge">
{t('LIVE')}
</Text>
<LiveIndicator />
</Flex>
);
}

const pulse = keyframes`
0% {
transform: scale(0.1);
opacity: 1
}

40%, 100% {
transform: scale(1);
opacity: 0;
}
`;

const LiveIndicator = styled('div')`
background: ${p => p.theme.successText};
height: 8px;
width: 8px;
position: relative;
border-radius: 50%;
margin-left: ${p => p.theme.space.sm};
margin-right: ${p => p.theme.space.sm};

@media (prefers-reduced-motion: reduce) {
&:before {
display: none;
}
}

&:before {
content: '';
animation: ${pulse} 3s ease-out infinite;
border: 6px solid ${p => p.theme.successText};
position: absolute;
border-radius: 50%;
height: 20px;
width: 20px;
top: -6px;
left: -6px;
}
`;
Loading