diff --git a/static/app/components/replays/header/replayMetaData.tsx b/static/app/components/replays/header/replayMetaData.tsx index b42cfd0669cb82..06fe281df9a253 100644 --- a/static/app/components/replays/header/replayMetaData.tsx +++ b/static/app/components/replays/header/replayMetaData.tsx @@ -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}; @@ -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)}; diff --git a/static/app/components/replays/header/replayViewers.tsx b/static/app/components/replays/header/replayViewers.tsx index 7957949cbd8d3e..906ef3c9ca0652 100644 --- a/static/app/components/replays/header/replayViewers.tsx +++ b/static/app/components/replays/header/replayViewers.tsx @@ -29,7 +29,7 @@ export default function ReplayViewers({projectId, replayId}: Props) { }); return isPending || isError ? ( - + ) : ( ); diff --git a/static/app/components/replays/replayBadge.tsx b/static/app/components/replays/replayBadge.tsx index f8ef0fd2dcb74b..f562681c69b23b 100644 --- a/static/app/components/replays/replayBadge.tsx +++ b/static/app/components/replays/replayBadge.tsx @@ -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'; @@ -79,17 +73,14 @@ export default function ReplayBadge({replay}: Props) { /> - + + {/* We use div here because the Text component has 100% width and will push live indicator to the far right */} {replay.user.display_name || t('Anonymous User')} - {isLive ? ( - - - - ) : null} + {isLive ? : null} diff --git a/static/app/components/replays/replayLiveIndicator.spec.tsx b/static/app/components/replays/replayLiveIndicator.spec.tsx new file mode 100644 index 00000000000000..25e21426dd50d6 --- /dev/null +++ b/static/app/components/replays/replayLiveIndicator.spec.tsx @@ -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 ( + + {children} + + ); + }; + } + + 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); + }); +}); diff --git a/static/app/components/replays/replayLiveIndicator.tsx b/static/app/components/replays/replayLiveIndicator.tsx index 210f854a69f0fc..5cf683b4b58313 100644 --- a/static/app/components/replays/replayLiveIndicator.tsx +++ b/static/app/components/replays/replayLiveIndicator.tsx @@ -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'; @@ -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; } @@ -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 { @@ -67,6 +69,19 @@ export const LiveIndicator = styled('div')` } `; +export function LiveBadge() { + return ( + + + + + + {t('Live')} + + + ); +} + interface UseLiveBadgeParams { finishedAt: ReplayRecord['finished_at']; startedAt: ReplayRecord['started_at']; diff --git a/static/app/views/replays/detail/header/replayDetailsHeaderActions.tsx b/static/app/views/replays/detail/header/replayDetailsHeaderActions.tsx index 5753a753a77782..ffd2e5cc15346a 100644 --- a/static/app/views/replays/detail/header/replayDetailsHeaderActions.tsx +++ b/static/app/views/replays/detail/header/replayDetailsHeaderActions.tsx @@ -20,7 +20,7 @@ export default function ReplayDetailsHeaderActions({readerResult}: Props) { renderArchived={() => null} renderError={() => null} renderThrottled={() => null} - renderLoading={() => } + renderLoading={() => } renderMissing={() => null} renderProcessingError={({replayRecord, projectSlug}) => ( diff --git a/static/app/views/replays/detail/header/replayDetailsMetadata.tsx b/static/app/views/replays/detail/header/replayDetailsMetadata.tsx index 747fb52e1f68f9..17b5db40f3aada 100644 --- a/static/app/views/replays/detail/header/replayDetailsMetadata.tsx +++ b/static/app/views/replays/detail/header/replayDetailsMetadata.tsx @@ -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'; @@ -14,7 +15,11 @@ export default function ReplayDetailsMetadata({readerResult}: Props) { renderArchived={() => null} renderError={() => null} renderThrottled={() => null} - renderLoading={() => } + renderLoading={() => ( + + + + )} renderMissing={() => null} renderProcessingError={() => null} > diff --git a/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx b/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx index e5a41c96b025d2..61ce009ef38053 100644 --- a/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx +++ b/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx @@ -1,6 +1,8 @@ import {useMemo, useRef, useState} from 'react'; import styled from '@emotion/styled'; +import {Text} from '@sentry/scraps/text'; + import {Breadcrumbs} from 'sentry/components/breadcrumbs'; import {Button} from 'sentry/components/core/button'; import {LinkButton} from 'sentry/components/core/button/linkButton'; @@ -8,7 +10,8 @@ import {Flex} from 'sentry/components/core/layout'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import Placeholder from 'sentry/components/placeholder'; import {useReplayContext} from 'sentry/components/replays/replayContext'; -import {IconChevron, IconCopy} from 'sentry/icons'; +import {useLiveRefresh} from 'sentry/components/replays/replayLiveIndicator'; +import {IconChevron, IconCopy, IconRefresh} from 'sentry/icons'; import {t} from 'sentry/locale'; import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; @@ -36,6 +39,9 @@ export default function ReplayDetailsPageBreadcrumbs({readerResult}: Props) { const {currentTime} = useReplayContext(); const {replays, currentReplayIndex} = useReplayPlaylist(); + const {shouldShowRefreshButton, doRefresh} = useLiveRefresh({ + replay: replayRecord ?? undefined, + }); // We use a ref to store the initial location so that we can use it to navigate to the previous and next replays // without dirtying the URL with the URL params from the tabs navigation. @@ -145,20 +151,35 @@ export default function ReplayDetailsPageBreadcrumbs({readerResult}: Props) { > {getShortEventId(replayRecord?.id)} + {isHovered && ( + + copy(replayUrlWithTimestamp, { + successMessage: t('Copied replay link to clipboard'), + }) + } + size="zero" + borderless + icon={} + /> + )} + + {shouldShowRefreshButton ? ( - copy(replayUrlWithTimestamp, { - successMessage: t('Copied replay link to clipboard'), - }) - } + title={t('Replay is outdated. Refresh for latest activity.')} + data-test-id="refresh-button" size="zero" - borderless - style={isHovered ? {} : {visibility: 'hidden'}} - icon={} - /> - + priority="link" + onClick={doRefresh} + icon={} + > + + {t('Update')} + + + ) : null} ) : ( @@ -173,4 +194,5 @@ export default function ReplayDetailsPageBreadcrumbs({readerResult}: Props) { const StyledBreadcrumbs = styled(Breadcrumbs)` padding: 0; + height: 34px; `; diff --git a/static/app/views/replays/detail/header/replayDetailsUserBadge.spec.tsx b/static/app/views/replays/detail/header/replayDetailsUserBadge.spec.tsx index 4a570cdfac9950..c200bc03acb906 100644 --- a/static/app/views/replays/detail/header/replayDetailsUserBadge.spec.tsx +++ b/static/app/views/replays/detail/header/replayDetailsUserBadge.spec.tsx @@ -1,10 +1,7 @@ import {act, type ReactNode} from 'react'; import {duration} from 'moment-timezone'; import {OrganizationFixture} from 'sentry-fixture/organization'; -import { - ReplayConsoleEventFixture, - ReplayNavigateEventFixture, -} from 'sentry-fixture/replay/helpers'; +import {ReplayNavigateEventFixture} from 'sentry-fixture/replay/helpers'; import {RRWebInitFrameEventsFixture} from 'sentry-fixture/replay/rrweb'; import {ReplayRecordFixture} from 'sentry-fixture/replayRecord'; @@ -22,11 +19,8 @@ const {organization, project} = initializeOrg({ organization: OrganizationFixture({}), }); -const mockInvalidateQueries = jest.fn(); - function wrapper({children}: {children?: ReactNode}) { const queryClient = makeTestQueryClient(); - queryClient.invalidateQueries = mockInvalidateQueries; return ( {children} @@ -45,138 +39,6 @@ jest.useFakeTimers(); describe('replayDetailsUserBadge', () => { beforeEach(() => { MockApiClient.clearMockResponses(); - mockInvalidateQueries.mockClear(); - }); - it('should not show refresh button on initial render', async () => { - const replayRecord = replayRecordFixture({count_segments: 100}); - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/replays/${replayRecord.id}/`, - body: {data: replayRecord}, - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/replays-events-meta/`, - body: { - data: [], - }, - headers: { - Link: [ - '; rel="previous"; results="false"; cursor="0:1:0"', - '; rel="next"; results="false"; cursor="0:1:0"', - ].join(','), - }, - }); - - const {result} = renderHook(useLoadReplayReader, { - wrapper, - initialProps: { - orgSlug: organization.slug, - replaySlug: `${project.slug}:${replayRecord.id}`, - }, - }); - - await waitFor(() => - expect(result.current.replayRecord?.count_segments).toBeDefined() - ); - - render(); - expect(screen.queryByTestId('refresh-button')).not.toBeVisible(); - }); - - it('should show refresh button when replay record is outdated', async () => { - const now = Date.now(); - const STARTED_AT = new Date(now - 10 * 1000); - const FINISHED_AT_FIVE_SECONDS = new Date(now - 5 * 1000); - const FINISHED_AT_TEN_SECONDS = new Date(now); - - const replayRecord = replayRecordFixture({ - started_at: STARTED_AT, - finished_at: FINISHED_AT_FIVE_SECONDS, - duration: duration(5, 'seconds'), - count_errors: 0, - count_segments: 1, - error_ids: [], - }); - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/replays/${replayRecord.id}/`, - body: {data: replayRecord}, - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/replays-events-meta/`, - body: { - data: [], - }, - headers: { - Link: [ - '; rel="previous"; results="false"; cursor="0:1:0"', - '; rel="next"; results="false"; cursor="0:1:0"', - ].join(','), - }, - }); - - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/replays/${replayRecord.id}/recording-segments/`, - body: [ - RRWebInitFrameEventsFixture({ - timestamp: STARTED_AT, - }), - ReplayConsoleEventFixture({timestamp: STARTED_AT}), - ], - match: [(_url, options) => options.query?.cursor === '0:0:0'], - }); - - const {result} = renderHook(useLoadReplayReader, { - wrapper, - initialProps: { - orgSlug: organization.slug, - replaySlug: `${project.slug}:${replayRecord.id}`, - }, - }); - - await waitFor(() => - expect(result.current.replayRecord?.count_segments).toBeDefined() - ); - - render(, {organization}); - - expect(screen.queryByTestId('refresh-button')).not.toBeVisible(); - - const updatedReplayRecord = replayRecordFixture({ - started_at: STARTED_AT, - finished_at: FINISHED_AT_TEN_SECONDS, - duration: duration(10, 'seconds'), - count_errors: 0, - count_segments: 2, - error_ids: [], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/replays/${updatedReplayRecord.id}/`, - body: {data: updatedReplayRecord}, - }); - - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/replays/${updatedReplayRecord.id}/recording-segments/`, - body: [ - RRWebInitFrameEventsFixture({ - timestamp: STARTED_AT, - }), - ReplayConsoleEventFixture({timestamp: STARTED_AT}), - ReplayNavigateEventFixture({ - startTimestamp: STARTED_AT, - endTimestamp: FINISHED_AT_TEN_SECONDS, - }), - ], - match: [(_url, options) => options.query?.cursor === '0:0:0'], - }); - - await act(async () => { - // advance to next polling interval - await jest.advanceTimersByTimeAsync(1000 * 30 + 1); - }); - - expect(screen.queryByTestId('refresh-button')).toBeVisible(); }); it('should show LIVE badge when last received segment is within 5 minutes', async () => { @@ -190,7 +52,7 @@ describe('replayDetailsUserBadge', () => { count_segments: 1, error_ids: [], }); - const replayRecordEndpoint = MockApiClient.addMockResponse({ + MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/replays/${replayRecord.id}/`, body: {data: replayRecord}, }); @@ -232,8 +94,7 @@ describe('replayDetailsUserBadge', () => { render(, {organization}); - await waitFor(() => expect(replayRecordEndpoint).toHaveBeenCalledTimes(2)); - expect(screen.queryByTestId('live-badge')).toBeVisible(); + expect(screen.getByTestId('live-badge')).toBeVisible(); }); it('should hide LIVE badge when last received segment is more than 5 minutes ago', async () => { @@ -249,7 +110,7 @@ describe('replayDetailsUserBadge', () => { count_segments: 1, error_ids: [], }); - const replayRecordEndpoint = MockApiClient.addMockResponse({ + MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/replays/${replayRecord.id}/`, body: {data: replayRecord}, }); @@ -289,162 +150,18 @@ describe('replayDetailsUserBadge', () => { }, }); - // useLoadReplayReader calls this endpoint - await waitFor(() => expect(replayRecordEndpoint).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(result.current.replayRecord?.count_segments).toBeDefined() ); render(, {organization}); - // usePollReplayRecord calls this endpoint - await waitFor(() => expect(replayRecordEndpoint).toHaveBeenCalledTimes(2)); - expect(screen.queryByTestId('live-badge')).toBeVisible(); + // Live badge should be visible initially + expect(screen.getByTestId('live-badge')).toBeVisible(); // let 5 minutes and 1/1000 second pass await act(async () => jest.advanceTimersByTimeAsync(5 * 60 * 1000 + 1)); expect(screen.queryByTestId('live-badge')).not.toBeInTheDocument(); }); - - it('should cause useLoadReplayReader to refetch when the refresh button is pressed', async () => { - const queryClient = makeTestQueryClient(); - queryClient.invalidateQueries = mockInvalidateQueries; - - function sharedQueryClientWrapper({children}: {children?: ReactNode}) { - return ( - - {children} - - ); - } - const now = Date.now(); - const STARTED_AT = new Date(now - 10 * 1000); - const FINISHED_AT_FIVE_SECONDS = new Date(now - 5 * 1000); - const FINISHED_AT_TEN_SECONDS = new Date(now); - - const replayRecord = replayRecordFixture({ - started_at: STARTED_AT, - finished_at: FINISHED_AT_FIVE_SECONDS, - duration: duration(5, 'seconds'), - count_errors: 0, - count_segments: 1, - error_ids: [], - }); - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/replays/${replayRecord.id}/`, - body: {data: replayRecord}, - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/replays-events-meta/`, - body: { - data: [], - }, - headers: { - Link: [ - '; rel="previous"; results="false"; cursor="0:1:0"', - '; rel="next"; results="false"; cursor="0:1:0"', - ].join(','), - }, - }); - - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/replays/${replayRecord.id}/recording-segments/`, - body: [ - RRWebInitFrameEventsFixture({ - timestamp: STARTED_AT, - }), - ReplayConsoleEventFixture({timestamp: STARTED_AT}), - ], - match: [(_url, options) => options.query?.cursor === '0:0:0'], - }); - - const {result} = await act(() => - renderHook(useLoadReplayReader, { - wrapper: sharedQueryClientWrapper, - initialProps: { - orgSlug: organization.slug, - replaySlug: `${project.slug}:${replayRecord.id}`, - }, - }) - ); - - await waitFor(() => - expect(result.current.replayRecord?.count_segments).toBeDefined() - ); - - act(() => - render( - sharedQueryClientWrapper({ - children: , - }), - { - organization, - } - ) - ); - - expect(screen.queryByTestId('refresh-button')).not.toBeVisible(); - - const updatedReplayRecord = replayRecordFixture({ - started_at: STARTED_AT, - finished_at: FINISHED_AT_TEN_SECONDS, - duration: duration(10, 'seconds'), - count_errors: 0, - count_segments: 2, - error_ids: [], - }); - - const updatedReplayRecordEndpoint = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/replays/${updatedReplayRecord.id}/`, - body: {data: updatedReplayRecord}, - }); - - MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/replays/${updatedReplayRecord.id}/recording-segments/`, - body: [ - RRWebInitFrameEventsFixture({ - timestamp: STARTED_AT, - }), - ReplayConsoleEventFixture({timestamp: STARTED_AT}), - ReplayNavigateEventFixture({ - startTimestamp: STARTED_AT, - endTimestamp: FINISHED_AT_TEN_SECONDS, - }), - ], - match: [(_url, options) => options.query?.cursor === '0:0:0'], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/replays-events-meta/`, - body: { - data: [], - }, - headers: { - Link: [ - '; rel="previous"; results="false"; cursor="0:1:0"', - '; rel="next"; results="false"; cursor="0:1:0"', - ].join(','), - }, - }); - - await act(async () => { - // advance to next polling interval - await jest.advanceTimersByTimeAsync(1000 * 30 + 1); - }); - - await waitFor(() => expect(updatedReplayRecordEndpoint).toHaveBeenCalledTimes(1)); - - act(() => { - screen.queryByTestId('refresh-button')?.click(); - }); - - expect(updatedReplayRecordEndpoint).toHaveBeenCalledTimes(2); - - await waitFor(() => - expect(result.current.replayRecord?.finished_at).toEqual(FINISHED_AT_TEN_SECONDS) - ); - }); }); diff --git a/static/app/views/replays/detail/header/replayDetailsUserBadge.tsx b/static/app/views/replays/detail/header/replayDetailsUserBadge.tsx index 537a6ce3604aab..6458f7dbbb1af4 100644 --- a/static/app/views/replays/detail/header/replayDetailsUserBadge.tsx +++ b/static/app/views/replays/detail/header/replayDetailsUserBadge.tsx @@ -1,133 +1,35 @@ -import styled from '@emotion/styled'; +import invariant from 'invariant'; 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 {UserAvatar} from 'sentry/components/core/avatar/userAvatar'; +import {Grid} from 'sentry/components/core/layout'; import {Link} from 'sentry/components/core/link'; -import UserBadge from 'sentry/components/idBadge/userBadge'; -import * as Layout from 'sentry/components/layouts/thirds'; +import {Text} from 'sentry/components/core/text'; +import {DateTime} from 'sentry/components/dateTime'; import Placeholder from 'sentry/components/placeholder'; import ReplayLoadingState from 'sentry/components/replays/player/replayLoadingState'; -import { - getLiveDurationMs, - getReplayExpiresAtMs, - LIVE_TOOLTIP_MESSAGE, - LiveIndicator, - useLiveRefresh, -} from 'sentry/components/replays/replayLiveIndicator'; +import {LiveBadge, useLiveBadge} from 'sentry/components/replays/replayLiveIndicator'; import TimeSince from 'sentry/components/timeSince'; -import {IconCalendar, IconRefresh} from 'sentry/icons'; +import {IconCalendar} from 'sentry/icons/iconCalendar'; +import {IconDelete} from 'sentry/icons/iconDelete'; import {t} from 'sentry/locale'; -import {space} from 'sentry/styles/space'; import type useLoadReplayReader from 'sentry/utils/replays/hooks/useLoadReplayReader'; +import {useReplayPrefs} from 'sentry/utils/replays/playback/providers/replayPreferencesContext'; import useOrganization from 'sentry/utils/useOrganization'; import {makeReplaysPathname} from 'sentry/views/replays/pathnames'; +import type {ReplayRecord} from 'sentry/views/replays/types'; interface Props { readerResult: ReturnType; } - export default function ReplayDetailsUserBadge({readerResult}: Props) { - const organization = useOrganization(); const replayRecord = readerResult.replayRecord; - const {shouldShowRefreshButton, doRefresh} = useLiveRefresh({replay: replayRecord}); - - // Generate search query based on available user data - const getUserSearchQuery = () => { - if (!replayRecord?.user) { - return null; - } - - const user = replayRecord.user; - // Prefer email over id for search query - if (user.email) { - return `user.email:"${user.email}"`; - } - if (user.id) { - return `user.id:"${user.id}"`; - } - return null; - }; - - const searchQuery = getUserSearchQuery(); - const userDisplayName = replayRecord?.user.display_name || t('Anonymous User'); - - const isReplayExpired = - Date.now() > getReplayExpiresAtMs(replayRecord?.started_at ?? null); - - const showLiveIndicator = - !isReplayExpired && replayRecord && getLiveDurationMs(replayRecord.finished_at) > 0; const badge = replayRecord ? ( - - - {searchQuery ? ( - - {userDisplayName} - - ) : ( - userDisplayName - )} - - {replayRecord.started_at ? ( - - - - {showLiveIndicator ? ( - - - - {t('LIVE')} - - - - - ) : null} - - - - - ) : null} - - } - user={{ - name: replayRecord.user.display_name || '', - email: replayRecord.user.email || '', - username: replayRecord.user.username || '', - ip_address: replayRecord.user.ip || '', - id: replayRecord.user.id || '', - }} - hideEmail - /> + + + ) : null; return ( @@ -137,7 +39,7 @@ export default function ReplayDetailsUserBadge({readerResult}: Props) { renderError={() => null} renderThrottled={() => null} renderLoading={() => - replayRecord ? badge : + replayRecord ? badge : } renderMissing={() => null} renderProcessingError={() => badge} @@ -147,16 +49,115 @@ export default function ReplayDetailsUserBadge({readerResult}: Props) { ); } -const TimeContainer = styled('div')` - display: flex; - gap: ${space(1)}; - align-items: center; - color: ${p => p.theme.subText}; - font-size: ${p => p.theme.fontSize.md}; - line-height: 1.4; -`; - -const DisplayHeader = styled('div')` - display: flex; - flex-direction: column; -`; +/** + * Modified that is only used in header of Replay Details + */ +function ReplayBadge({replay}: {replay: ReplayRecord}) { + const organization = useOrganization(); + const [prefs] = useReplayPrefs(); + const timestampType = prefs.timestampType; + + const {isLive} = useLiveBadge({ + startedAt: replay.started_at, + finishedAt: replay.finished_at, + }); + + if (replay.is_archived) { + return ( + + + + + + + + {t('Deleted Replay')} + + + + ); + } + + invariant( + replay.started_at, + 'For TypeScript: replay.started_at is implied because replay.is_archived is false' + ); + + // Generate search query based on available user data + const searchQuery = getUserSearchQuery({user: replay.user}); + + const replaysIndexUrl = searchQuery + ? { + pathname: makeReplaysPathname({ + path: '/', + organization, + }), + query: { + query: searchQuery, + }, + } + : null; + + const replaysIndexLinkText = ( + + {replay.user.display_name || t('Anonymous User')} + + ); + + return ( + + + + + + {/* We use div here because the Text component has width 100% and will take up the + full width of the container, causing a gap between the text and the badge */} + {replaysIndexUrl ? ( + {replaysIndexLinkText} + ) : ( + {replaysIndexLinkText} + )} + + + + + + + + {timestampType === 'absolute' ? ( + + ) : ( + + )} + + + {isLive ? : null} + + + + ); +} + +function getUserSearchQuery({user}: {user: ReplayRecord['user']}) { + if (!user) { + return null; + } + + // Prefer email over id for search query + if (user.email) { + return `user.email:"${user.email}"`; + } + if (user.id) { + return `user.id:"${user.id}"`; + } + return null; +} diff --git a/static/app/views/replays/details.tsx b/static/app/views/replays/details.tsx index ba850b00c644cf..ba17406eedad32 100644 --- a/static/app/views/replays/details.tsx +++ b/static/app/views/replays/details.tsx @@ -12,7 +12,6 @@ import { } from 'sentry/components/replays/replayAccess'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import {t} from 'sentry/locale'; -import {space} from 'sentry/styles/space'; import {decodeScalar} from 'sentry/utils/queryString'; import useLoadReplayReader from 'sentry/utils/replays/hooks/useLoadReplayReader'; import useReplayPageview from 'sentry/utils/replays/hooks/useReplayPageview'; @@ -35,7 +34,9 @@ export default function ReplayDetails() { - {t('Replay Details')} + + {t('Replay Details')} + @@ -79,30 +80,20 @@ function ReplayDetailsContent() { ? `${replayRecord.user.display_name ?? t('Anonymous User')} — Session Replay — ${orgSlug}` : `Session Replay — ${orgSlug}`; - const content = organization.features.includes('replay-details-new-ui') ? ( + const content = ( - + - - + + - + - ) : ( - - - - - - - - - ); return ( @@ -120,28 +111,13 @@ function ReplayDetailsContent() { ); } -const Header = styled(Layout.Header)` - gap: ${space(1)}; - padding-bottom: ${space(1.5)}; - @media (min-width: ${p => p.theme.breakpoints.md}) { - gap: ${space(1)} ${space(3)}; - padding: ${space(2)} ${space(2)} ${space(1.5)} ${space(2)}; - } -`; - -const NewTopHeader = styled('div')` - padding-left: ${p => p.theme.space.lg}; - padding-right: ${p => p.theme.space.lg}; +const TopHeader = styled(Flex)` + padding: ${p => p.theme.space.sm} ${p => p.theme.space.lg}; border-bottom: 1px solid ${p => p.theme.innerBorder}; - display: flex; - align-items: center; - justify-content: space-between; - gap: ${space(1)}; - flex-flow: row wrap; - height: 44px; + flex-wrap: wrap; `; -const NewBottonHeader = styled(Flex)` +const BottonHeader = styled(Flex)` padding: ${p => p.theme.space.md} ${p => p.theme.space.lg}; border-bottom: 1px solid ${p => p.theme.innerBorder}; `;