Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions static/app/components/replays/header/replayMetaData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ const KeyMetrics = styled('dl')`
grid-template-rows: max-content 1fr;
grid-template-columns: repeat(4, max-content);
grid-auto-flow: column;
height: 42px;
gap: 0 ${space(3)};
align-items: center;
color: ${p => p.theme.subText};
Expand All @@ -125,12 +126,12 @@ const KeyMetrics = styled('dl')`
`;

const KeyMetricLabel = styled('dt')`
font-size: ${p => p.theme.fontSize.md};
font-size: ${p => p.theme.fontSize.sm};
`;

const KeyMetricData = styled('dd')`
font-size: ${p => p.theme.fontSize.xl};
font-weight: ${p => p.theme.fontWeight.normal};
font-size: ${p => p.theme.fontSize.md};
font-weight: ${p => p.theme.fontWeight.bold};
display: flex;
align-items: center;
gap: ${space(1)};
Expand Down
2 changes: 1 addition & 1 deletion static/app/components/replays/header/replayViewers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default function ReplayViewers({projectId, replayId}: Props) {
});

return isPending || isError ? (
<Placeholder width="55px" height="27px" />
<Placeholder width="25px" height="25px" />
) : (
<AvatarList avatarSize={25} users={data?.data.viewed_by} />
);
Expand Down
17 changes: 4 additions & 13 deletions static/app/components/replays/replayBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
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 {
LIVE_TOOLTIP_MESSAGE,
LiveIndicator,
useLiveBadge,
} from 'sentry/components/replays/replayLiveIndicator';
import {LiveBadge, useLiveBadge} from 'sentry/components/replays/replayLiveIndicator';
import TimeSince from 'sentry/components/timeSince';
import {IconCalendar} from 'sentry/icons/iconCalendar';
import {IconDelete} from 'sentry/icons/iconDelete';
Expand Down Expand Up @@ -79,17 +73,14 @@ export default function ReplayBadge({replay}: Props) {
/>

<Flex direction="column" gap="xs" justify="center">
<Flex direction="row" align="center">
<Flex direction="row" align="center" gap="xs">
{/* We use div here because the Text component has 100% width and will push live indicator to the far right */}
<div>
<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}
{isLive ? <LiveBadge /> : null}
</Flex>

<Flex gap="xs">
Expand Down
225 changes: 225 additions & 0 deletions static/app/components/replays/replayLiveIndicator.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import {act} from 'react';
import {OrganizationFixture} from 'sentry-fixture/organization';
import {ReplayRecordFixture} from 'sentry-fixture/replayRecord';

import {makeTestQueryClient} from 'sentry-test/queryClient';
import {renderHook, waitFor} from 'sentry-test/reactTestingLibrary';

import {QueryClientProvider} from 'sentry/utils/queryClient';
import {OrganizationContext} from 'sentry/views/organizationContext';

import {useLiveBadge, useLiveRefresh} from './replayLiveIndicator';

jest.mock('sentry/views/replays/detail/ai/replaySummaryContext', () => ({
useReplaySummaryContext: () => ({
startSummaryRequest: jest.fn(),
}),
}));

jest.mock('sentry/utils/replays/hooks/useReplayProjectSlug', () => ({
useReplayProjectSlug: () => 'test-project',
}));

jest.useFakeTimers();

describe('useLiveBadge', () => {
it('should return isLive=true when replay finished within 5 minutes', () => {
const now = Date.now();
const startedAt = new Date(now - 60_000); // 1 minute ago
const finishedAt = new Date(now); // just now

const {result} = renderHook(() =>
useLiveBadge({
startedAt,
finishedAt,
})
);

expect(result.current.isLive).toBe(true);
});

it('should return isLive=false when replay finished more than 5 minutes ago', () => {
const now = Date.now();
const startedAt = new Date(now - 10 * 60_000); // 10 minutes ago
const finishedAt = new Date(now - 6 * 60_000); // 6 minutes ago (more than 5 min threshold)

const {result} = renderHook(() =>
useLiveBadge({
startedAt,
finishedAt,
})
);

expect(result.current.isLive).toBe(false);
});

it('should return isLive=false when replay has expired (started more than 1 hour ago)', () => {
const now = Date.now();
const startedAt = new Date(now - 2 * 60 * 60_000); // 2 hours ago
const finishedAt = new Date(now); // just now

const {result} = renderHook(() =>
useLiveBadge({
startedAt,
finishedAt,
})
);

expect(result.current.isLive).toBe(false);
});

it('should transition from isLive=true to isLive=false after 5 minutes', async () => {
const now = Date.now();
const startedAt = new Date(now - 60_000); // 1 minute ago
const finishedAt = new Date(now); // just now

const {result} = renderHook(() =>
useLiveBadge({
startedAt,
finishedAt,
})
);

expect(result.current.isLive).toBe(true);

// Advance time by 5 minutes + 1ms
await act(async () => {
await jest.advanceTimersByTimeAsync(5 * 60 * 1000 + 1);
});

expect(result.current.isLive).toBe(false);
});

it('should return isLive=false when finishedAt is null', () => {
const now = Date.now();
const startedAt = new Date(now - 60_000);

const {result} = renderHook(() =>
useLiveBadge({
startedAt,
finishedAt: null,
})
);

expect(result.current.isLive).toBe(false);
});
});

describe('useLiveRefresh', () => {
const organization = OrganizationFixture();

function createWrapper() {
const queryClient = makeTestQueryClient();
return function Wrapper({children}: {children: React.ReactNode}) {
return (
<QueryClientProvider client={queryClient}>
<OrganizationContext value={organization}>{children}</OrganizationContext>
</QueryClientProvider>
);
};
}

beforeEach(() => {
MockApiClient.clearMockResponses();
});

it('should not show refresh button when replay is undefined', () => {
const {result} = renderHook(() => useLiveRefresh({replay: undefined}), {
wrapper: createWrapper(),
});

expect(result.current.shouldShowRefreshButton).toBe(false);
});

it('should not show refresh button initially when polled segments equals current segments', () => {
const replay = ReplayRecordFixture({
count_segments: 5,
});

// Mock the polling endpoint to return same segment count
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/replays/${replay.id}/`,
body: {data: replay},
});

const {result} = renderHook(() => useLiveRefresh({replay}), {
wrapper: createWrapper(),
});

// Initial state - no refresh button since polled and current are equal
expect(result.current.shouldShowRefreshButton).toBe(false);
});

it('should show refresh button when polled segments is greater than current segments', async () => {
const now = Date.now();
const replay = ReplayRecordFixture({
started_at: new Date(now - 60_000), // 1 minute ago (not expired)
count_segments: 5,
});

const updatedReplay = ReplayRecordFixture({
...replay,
count_segments: 10,
});

// Mock the polling endpoint to return updated segment count
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/replays/${replay.id}/`,
body: {data: updatedReplay},
});

const {result} = renderHook(() => useLiveRefresh({replay}), {
wrapper: createWrapper(),
});

// Wait for the API call to complete and state to update
await waitFor(() => {
expect(result.current.shouldShowRefreshButton).toBe(true);
});
});

it('should not poll when replay has expired (started more than 1 hour ago)', async () => {
const now = Date.now();
const replay = ReplayRecordFixture({
started_at: new Date(now - 2 * 60 * 60_000), // 2 hours ago (expired)
count_segments: 5,
});

const replayEndpoint = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/replays/${replay.id}/`,
body: {data: {...replay, count_segments: 10}},
});

renderHook(() => useLiveRefresh({replay}), {
wrapper: createWrapper(),
});

// Advance time past polling interval
await act(async () => {
await jest.advanceTimersByTimeAsync(30_000 + 1);
});

// Polling should not happen for expired replays
expect(replayEndpoint).not.toHaveBeenCalled();
});

it('should provide a doRefresh function that can be called', () => {
const now = Date.now();
const replay = ReplayRecordFixture({
started_at: new Date(now - 60_000),
count_segments: 5,
});

const updateMock = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/replays/${replay.id}/`,
body: {data: replay},
});

const {result} = renderHook(() => useLiveRefresh({replay}), {
wrapper: createWrapper(),
});

result.current.doRefresh();
expect(updateMock).toHaveBeenCalledTimes(1);
});
});
27 changes: 21 additions & 6 deletions static/app/components/replays/replayLiveIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import {useCallback, useEffect, useRef, useState} from 'react';
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 {t} from 'sentry/locale';
import {trackAnalytics} from 'sentry/utils/analytics';
import {useQueryClient} from 'sentry/utils/queryClient';
Expand All @@ -12,14 +16,14 @@ import useTimeout from 'sentry/utils/useTimeout';
import {useReplaySummaryContext} from 'sentry/views/replays/detail/ai/replaySummaryContext';
import type {ReplayRecord} from 'sentry/views/replays/types';

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

export function getReplayExpiresAtMs(startedAt: ReplayRecord['started_at']): number {
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 {
function getLiveDurationMs(finishedAt: ReplayRecord['finished_at']): number {
if (!finishedAt) {
return 0;
}
Expand All @@ -39,14 +43,12 @@ const pulse = keyframes`
}
`;

export const LiveIndicator = styled('div')`
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 {
Expand All @@ -67,6 +69,19 @@ export const LiveIndicator = styled('div')`
}
`;

export function LiveBadge() {
return (
<Flex align="center" gap="xs">
<Tooltip title={LIVE_TOOLTIP_MESSAGE} underlineColor="success" showUnderline>
<LiveIndicator />
</Tooltip>
<Text size="xs" bold variant="success" data-test-id="live-badge">
{t('Live')}
</Text>
</Flex>
);
}

interface UseLiveBadgeParams {
finishedAt: ReplayRecord['finished_at'];
startedAt: ReplayRecord['started_at'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default function ReplayDetailsHeaderActions({readerResult}: Props) {
renderArchived={() => null}
renderError={() => null}
renderThrottled={() => null}
renderLoading={() => <Placeholder height="33px" width="203px" />}
renderLoading={() => <Placeholder height="32px" width="352px" />}
renderMissing={() => null}
renderProcessingError={({replayRecord, projectSlug}) => (
<ButtonActionsWrapper>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {Flex} from 'sentry/components/core/layout';
import Placeholder from 'sentry/components/placeholder';
import ReplayMetaData from 'sentry/components/replays/header/replayMetaData';
import ReplayLoadingState from 'sentry/components/replays/player/replayLoadingState';
Expand All @@ -14,7 +15,11 @@ export default function ReplayDetailsMetadata({readerResult}: Props) {
renderArchived={() => null}
renderError={() => null}
renderThrottled={() => null}
renderLoading={() => <Placeholder height="47px" width="203px" />}
renderLoading={() => (
<Flex justify="end">
<Placeholder height="42px" width="276px" />
</Flex>
)}
renderMissing={() => null}
renderProcessingError={() => null}
>
Expand Down
Loading
Loading