From b97c8949ae117a51269855d1d3972652fba2ef3e Mon Sep 17 00:00:00 2001 From: Hayne Ryu Date: Mon, 2 Nov 2026 19:02:09 +0000 Subject: [PATCH] feat(replay): move compact and feed recording rows to quill --- .../playlist/ItemCheckbox.tsx | 22 +--- .../playlist/QuillItemCheckbox.tsx | 22 ++++ .../playlist/QuillUnwatchedIndicator.tsx | 32 +++++ .../playlist/RecordingRowStat.tsx | 35 ++++++ .../SessionRecordingPreviewCompact.tsx | 113 +++++++++--------- .../playlist/SessionRecordingPreviewFeed.tsx | 95 ++++++++------- .../playlist/UnwatchedIndicator.tsx | 27 +---- .../playlist/unwatchedIndicatorTooltip.tsx | 21 ++++ .../playlist/useRecordingSelection.ts | 29 +++++ 9 files changed, 259 insertions(+), 137 deletions(-) create mode 100644 frontend/src/scenes/session-recordings/playlist/QuillItemCheckbox.tsx create mode 100644 frontend/src/scenes/session-recordings/playlist/QuillUnwatchedIndicator.tsx create mode 100644 frontend/src/scenes/session-recordings/playlist/RecordingRowStat.tsx create mode 100644 frontend/src/scenes/session-recordings/playlist/unwatchedIndicatorTooltip.tsx create mode 100644 frontend/src/scenes/session-recordings/playlist/useRecordingSelection.ts diff --git a/frontend/src/scenes/session-recordings/playlist/ItemCheckbox.tsx b/frontend/src/scenes/session-recordings/playlist/ItemCheckbox.tsx index bfabb9802256..98705546beb9 100644 --- a/frontend/src/scenes/session-recordings/playlist/ItemCheckbox.tsx +++ b/frontend/src/scenes/session-recordings/playlist/ItemCheckbox.tsx @@ -1,33 +1,19 @@ -import { useActions, useValues } from 'kea' - import { LemonCheckbox } from 'lib/lemon-ui/LemonCheckbox' import { SessionRecordingType } from '~/types' -import { MAX_SELECTED_RECORDINGS, sessionRecordingsPlaylistLogic } from './sessionRecordingsPlaylistLogic' +import { useRecordingSelection } from './useRecordingSelection' export function ItemCheckbox({ recording }: { recording: SessionRecordingType }): JSX.Element { - const { selectedRecordingsIds } = useValues(sessionRecordingsPlaylistLogic) - const { setSelectedRecordingsIds } = useActions(sessionRecordingsPlaylistLogic) + const { checked, disabledReason, toggle } = useRecordingSelection(recording) - const checked = selectedRecordingsIds.some((s) => s === recording.id) return ( = MAX_SELECTED_RECORDINGS - ? `Cannot select more than ${MAX_SELECTED_RECORDINGS} recordings at once` - : undefined - } - onChange={() => { - if (checked) { - setSelectedRecordingsIds(selectedRecordingsIds.filter((r) => r !== recording.id)) - } else { - setSelectedRecordingsIds([...selectedRecordingsIds, recording.id]) - } - }} + disabledReason={disabledReason} + onChange={toggle} stopPropagation /> ) diff --git a/frontend/src/scenes/session-recordings/playlist/QuillItemCheckbox.tsx b/frontend/src/scenes/session-recordings/playlist/QuillItemCheckbox.tsx new file mode 100644 index 000000000000..adfbc7856ace --- /dev/null +++ b/frontend/src/scenes/session-recordings/playlist/QuillItemCheckbox.tsx @@ -0,0 +1,22 @@ +import { Checkbox } from '@posthog/quill' + +import { SessionRecordingType } from '~/types' + +import { useRecordingSelection } from './useRecordingSelection' + +export function QuillItemCheckbox({ recording }: { recording: SessionRecordingType }): JSX.Element { + const { checked, disabledReason, toggle } = useRecordingSelection(recording) + + return ( + // The row behind the checkbox opens the recording on click, so the click must stop here. + e.stopPropagation()}> + + + ) +} diff --git a/frontend/src/scenes/session-recordings/playlist/QuillUnwatchedIndicator.tsx b/frontend/src/scenes/session-recordings/playlist/QuillUnwatchedIndicator.tsx new file mode 100644 index 000000000000..01aa31010e3d --- /dev/null +++ b/frontend/src/scenes/session-recordings/playlist/QuillUnwatchedIndicator.tsx @@ -0,0 +1,32 @@ +import { useValues } from 'kea' + +import { Dot, Tooltip, TooltipContent, TooltipTrigger } from '@posthog/quill' + +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { unwatchedIndicatorAriaLabel, unwatchedIndicatorTooltip } from './unwatchedIndicatorTooltip' + +export function QuillUnwatchedIndicator({ otherViewersCount }: { otherViewersCount: number }): JSX.Element { + const { featureFlags } = useValues(featureFlagLogic) + const isExcludedFromHideRecordingsMenu = !!featureFlags[FEATURE_FLAGS.REPLAY_EXCLUDE_FROM_HIDE_RECORDINGS_MENU] + const watchedByOthers = !isExcludedFromHideRecordingsMenu && otherViewersCount > 0 + + return ( + + + } + > + + + + {unwatchedIndicatorTooltip(otherViewersCount, isExcludedFromHideRecordingsMenu)} + + + ) +} diff --git a/frontend/src/scenes/session-recordings/playlist/RecordingRowStat.tsx b/frontend/src/scenes/session-recordings/playlist/RecordingRowStat.tsx new file mode 100644 index 000000000000..5dc01214c489 --- /dev/null +++ b/frontend/src/scenes/session-recordings/playlist/RecordingRowStat.tsx @@ -0,0 +1,35 @@ +import clsx from 'clsx' +import { ReactNode } from 'react' + +import { Tooltip, TooltipContent, TooltipTrigger } from '@posthog/quill' + +export function RecordingRowStat({ + icon, + value, + label, + destructive = false, +}: { + icon: ReactNode + value: ReactNode + label: string + destructive?: boolean +}): JSX.Element { + return ( + + + } + > + {icon} + {value} + + {label} + + ) +} diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreviewCompact.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreviewCompact.tsx index 64b3fe67ab79..800427a7fff2 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreviewCompact.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreviewCompact.tsx @@ -3,12 +3,10 @@ import { useValues } from 'kea' import { memo } from 'react' import { IconBug, IconCursorClick, IconKeyboard, IconLive } from '@posthog/icons' -import { LemonTag } from '@posthog/lemon-ui' +import { Badge, Skeleton, Tooltip, TooltipContent, TooltipTrigger } from '@posthog/quill' import { PropertyIcon } from 'lib/components/PropertyIcon/PropertyIcon' import { TZLabel } from 'lib/components/TZLabel' -import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' -import { Tooltip } from 'lib/lemon-ui/Tooltip' import { DraggableToNotebook } from 'scenes/notebooks/AddToNotebook/DraggableToNotebook' import { SimpleTimeLabel } from 'scenes/session-recordings/components/SimpleTimeLabel' import { TimestampFormat, playerSettingsLogic } from 'scenes/session-recordings/player/playerSettingsLogic' @@ -19,11 +17,12 @@ import { SessionRecordingType } from '~/types' import { asDisplay } from 'products/persons/frontend/person-utils' import { gatherIconProperties } from './gatherIconProperties' -import { ItemCheckbox } from './ItemCheckbox' +import { QuillItemCheckbox } from './QuillItemCheckbox' +import { QuillUnwatchedIndicator } from './QuillUnwatchedIndicator' +import { RecordingRowStat } from './RecordingRowStat' import { sessionRecordingsListPropertiesLogic } from './sessionRecordingsListPropertiesLogic' import { DEFAULT_RECORDING_FILTERS_ORDER_BY, sessionRecordingsPlaylistLogic } from './sessionRecordingsPlaylistLogic' import { SortMetric } from './SortMetric' -import { UnwatchedIndicator } from './UnwatchedIndicator' export interface SessionRecordingPreviewCompactProps { recording: SessionRecordingType @@ -46,6 +45,7 @@ export const SessionRecordingPreviewCompact = memo( const loading = !recordingProperties && recordingPropertiesLoading const iconProperties = gatherIconProperties(recordingProperties, recording) const errorCount = recording.console_error_count ?? 0 + const tileDetails: { label: string; value: string | undefined }[] = [ { label: 'Location', @@ -69,45 +69,47 @@ export const SessionRecordingPreviewCompact = memo( return (
{selectable && ( - - + + )} {loading ? ( - + ) : ( - - {tileDetails.map(({ label, value }) => ( - - {label}: - {value} - - ))} -
- } - > -
+ + + } + > {iconProperties.map(({ property, value }) => ( ))} -
+ + +
+ {tileDetails.map(({ label, value }) => ( + + {label}: + {value} + + ))} +
+
)} -
{recording.matches_filters === false && ( - - + + }> Doesn't match filters - + + + This recording is listed only because it's open. It doesn't match the current + filters. + )} {playlistTimestampFormat === TimestampFormat.Relative ? ( @@ -138,27 +144,20 @@ export const SessionRecordingPreviewCompact = memo( /> )}
- -
- - - - {recording.click_count} - - - - - - {recording.keypress_count} - - +
+ } value={recording.click_count} label="Clicks" /> + } + value={recording.keypress_count} + label="Key presses" + /> {errorCount > 0 && order !== 'console_error_count' && ( - - - - {errorCount} - - + } + value={errorCount} + label="Console errors" + destructive + /> )} @@ -166,21 +165,25 @@ export const SessionRecordingPreviewCompact = memo( {isActive && recording.start_url && ( {recording.start_url.replace(/https?:\/\//g, '').split(/[?|#]/)[0]} )}
-
{recording.ongoing ? ( - - + + }> + + + + This recording is still ongoing - we received data within the last 5 minutes. + ) : null} {!recording.viewed ? ( - + ) : null}
diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreviewFeed.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreviewFeed.tsx index 695cf8610190..98c9bf520313 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreviewFeed.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreviewFeed.tsx @@ -3,12 +3,10 @@ import { useValues } from 'kea' import { memo } from 'react' import { IconBug, IconCursorClick, IconKeyboard, IconLive } from '@posthog/icons' -import { LemonTag } from '@posthog/lemon-ui' +import { Badge, Skeleton, Tooltip, TooltipContent, TooltipTrigger } from '@posthog/quill' import { PropertyIcon } from 'lib/components/PropertyIcon/PropertyIcon' import { TZLabel } from 'lib/components/TZLabel' -import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' -import { Tooltip } from 'lib/lemon-ui/Tooltip' import { DraggableToNotebook } from 'scenes/notebooks/AddToNotebook/DraggableToNotebook' import { SimpleTimeLabel } from 'scenes/session-recordings/components/SimpleTimeLabel' import { TimestampFormat, playerSettingsLogic } from 'scenes/session-recordings/player/playerSettingsLogic' @@ -19,11 +17,12 @@ import { SessionRecordingType } from '~/types' import { asDisplay } from 'products/persons/frontend/person-utils' import { gatherIconProperties } from './gatherIconProperties' -import { ItemCheckbox } from './ItemCheckbox' +import { QuillItemCheckbox } from './QuillItemCheckbox' +import { QuillUnwatchedIndicator } from './QuillUnwatchedIndicator' +import { RecordingRowStat } from './RecordingRowStat' import { sessionRecordingsListPropertiesLogic } from './sessionRecordingsListPropertiesLogic' import { DEFAULT_RECORDING_FILTERS_ORDER_BY, sessionRecordingsPlaylistLogic } from './sessionRecordingsPlaylistLogic' import { SortMetric } from './SortMetric' -import { UnwatchedIndicator } from './UnwatchedIndicator' export interface SessionRecordingPreviewFeedProps { recording: SessionRecordingType @@ -53,25 +52,31 @@ export const SessionRecordingPreviewFeed = memo( return (
{selectable && ( - - + + )} {loading ? ( - + ) : ( -
+
{iconProperties.map(({ property, value, label }) => ( - - + + + } + > - + + {label} ))}
@@ -87,50 +92,56 @@ export const SessionRecordingPreviewFeed = memo( {asDisplay(recording.person)} {upNext && ( - - + + }> Up next - + + + Autoplay opens this recording when the current one ends. + )} {recording.ongoing && ( - - + + }> + + + + This recording is still ongoing - we received data within the last 5 minutes. + )} {!recording.viewed && ( - + )}
{recording.matches_filters === false && ( - - + + }> Doesn't match filters - + + + This recording is listed only because it's open. It doesn't match the current + filters. + )} -
- - - - {recording.click_count} - - - - - - {recording.keypress_count} - - +
+ } value={recording.click_count} label="Clicks" /> + } + value={recording.keypress_count} + label="Key presses" + /> {errorCount > 0 && order !== 'console_error_count' && ( - - - - {errorCount} - - + } + value={errorCount} + label="Console errors" + destructive + /> )} {playlistTimestampFormat === TimestampFormat.Relative ? ( @@ -148,7 +159,7 @@ export const SessionRecordingPreviewFeed = memo( {startUrl && ( {startUrl} diff --git a/frontend/src/scenes/session-recordings/playlist/UnwatchedIndicator.tsx b/frontend/src/scenes/session-recordings/playlist/UnwatchedIndicator.tsx index 4c0cc801e5b5..67845ff232c2 100644 --- a/frontend/src/scenes/session-recordings/playlist/UnwatchedIndicator.tsx +++ b/frontend/src/scenes/session-recordings/playlist/UnwatchedIndicator.tsx @@ -7,25 +7,14 @@ import { FEATURE_FLAGS } from 'lib/constants' import { Tooltip } from 'lib/lemon-ui/Tooltip' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { unwatchedIndicatorAriaLabel, unwatchedIndicatorTooltip } from './unwatchedIndicatorTooltip' + export function UnwatchedIndicator({ otherViewersCount }: { otherViewersCount: number }): JSX.Element { const { featureFlags } = useValues(featureFlagLogic) - - const isExcludedFromHideRecordingsMenu = featureFlags[FEATURE_FLAGS.REPLAY_EXCLUDE_FROM_HIDE_RECORDINGS_MENU] - - // If person wished to be excluded from the hide recordings menu, we don't show the tooltip - const tooltip = isExcludedFromHideRecordingsMenu ? ( - You have not watched this recording yet. - ) : otherViewersCount ? ( - - You have not watched this recording yet. {otherViewersCount} other{' '} - {otherViewersCount === 1 ? 'person has' : 'people have'}. - - ) : ( - Nobody has watched this recording yet. - ) + const isExcludedFromHideRecordingsMenu = !!featureFlags[FEATURE_FLAGS.REPLAY_EXCLUDE_FROM_HIDE_RECORDINGS_MENU] return ( - +
) diff --git a/frontend/src/scenes/session-recordings/playlist/unwatchedIndicatorTooltip.tsx b/frontend/src/scenes/session-recordings/playlist/unwatchedIndicatorTooltip.tsx new file mode 100644 index 000000000000..cc5c254dfe2a --- /dev/null +++ b/frontend/src/scenes/session-recordings/playlist/unwatchedIndicatorTooltip.tsx @@ -0,0 +1,21 @@ +export function unwatchedIndicatorTooltip(otherViewersCount: number, excludedFromHideMenu: boolean): JSX.Element { + // If person wished to be excluded from the hide recordings menu, we don't show the tooltip + if (excludedFromHideMenu) { + return You have not watched this recording yet. + } + if (otherViewersCount) { + return ( + + You have not watched this recording yet. {otherViewersCount} other{' '} + {otherViewersCount === 1 ? 'person has' : 'people have'}. + + ) + } + return Nobody has watched this recording yet. +} + +export function unwatchedIndicatorAriaLabel(otherViewersCount: number, excludedFromHideMenu: boolean): string { + return excludedFromHideMenu || otherViewersCount + ? 'unwatched-recording-by-you-label' + : 'unwatched-recording-by-everyone-label' +} diff --git a/frontend/src/scenes/session-recordings/playlist/useRecordingSelection.ts b/frontend/src/scenes/session-recordings/playlist/useRecordingSelection.ts new file mode 100644 index 000000000000..75484834bf15 --- /dev/null +++ b/frontend/src/scenes/session-recordings/playlist/useRecordingSelection.ts @@ -0,0 +1,29 @@ +import { useActions, useValues } from 'kea' + +import { SessionRecordingType } from '~/types' + +import { MAX_SELECTED_RECORDINGS, sessionRecordingsPlaylistLogic } from './sessionRecordingsPlaylistLogic' + +export function useRecordingSelection(recording: SessionRecordingType): { + checked: boolean + disabledReason: string | undefined + toggle: () => void +} { + const { selectedRecordingsIds } = useValues(sessionRecordingsPlaylistLogic) + const { setSelectedRecordingsIds } = useActions(sessionRecordingsPlaylistLogic) + const checked = selectedRecordingsIds.some((s) => s === recording.id) + return { + checked, + disabledReason: + !checked && selectedRecordingsIds.length >= MAX_SELECTED_RECORDINGS + ? `Cannot select more than ${MAX_SELECTED_RECORDINGS} recordings at once` + : undefined, + toggle: () => { + if (checked) { + setSelectedRecordingsIds(selectedRecordingsIds.filter((r) => r !== recording.id)) + } else { + setSelectedRecordingsIds([...selectedRecordingsIds, recording.id]) + } + }, + } +}