Skip to content

Commit

Permalink
Merge branch 'master' into support-sidebar-max-integration
Browse files Browse the repository at this point in the history
  • Loading branch information
slshults committed Jan 21, 2025
2 parents e821631 + e56aead commit 12f793c
Show file tree
Hide file tree
Showing 15 changed files with 393 additions and 150 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ function Header(): JSX.Element {
<LemonMenu
items={[
{
label: 'Playback from file',
label: 'Playback from PostHog JSON file',
to: urls.replayFilePlayback(),
},
]}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import clsx from 'clsx'
import { LemonButton, LemonButtonProps } from 'lib/lemon-ui/LemonButton'
import {
LemonButton,
LemonButtonWithoutSideActionProps,
LemonButtonWithSideActionProps,
} from 'lib/lemon-ui/LemonButton'
import { LemonMenu, LemonMenuItem, LemonMenuProps } from 'lib/lemon-ui/LemonMenu/LemonMenu'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
import { PropsWithChildren } from 'react'
Expand Down Expand Up @@ -73,7 +77,10 @@ export function SettingsMenu({
)
}

type SettingsButtonProps = Omit<LemonButtonProps, 'status' | 'sideAction' | 'className'> & {
type SettingsButtonProps = (
| Omit<LemonButtonWithoutSideActionProps, 'status' | 'className'>
| Omit<LemonButtonWithSideActionProps, 'status' | 'className'>
) & {
title?: string
icon?: JSX.Element | null
label: JSX.Element | string
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
import clsx from 'clsx'
import { Dayjs, dayjs } from 'lib/dayjs'
import { shortTimeZone } from 'lib/utils'
import { memo } from 'react'
import { TimestampFormat } from 'scenes/session-recordings/player/playerSettingsLogic'

function formattedReplayTime(
time: string | number | Dayjs | null | undefined,
timestampFormat: TimestampFormat
): string {
if (time == null) {
return '--/--/----, 00:00:00'
}

let d = dayjs(time)
const isUTC = timestampFormat === TimestampFormat.UTC
if (isUTC) {
d = d.tz('UTC')
}
const formatted = d.format(formatStringFor(d))
const timezone = isUTC ? 'UTC' : shortTimeZone(undefined, d.toDate())
return `${formatted} ${timezone}`
}

function formatStringFor(d: Dayjs): string {
const today = dayjs()
Expand All @@ -10,22 +30,28 @@ function formatStringFor(d: Dayjs): string {
return 'DD/MM/YYYY HH:mm:ss'
}

export function SimpleTimeLabel({
const truncateToSeconds = (time: string | number | Dayjs): number => {
switch (typeof time) {
case 'number':
return Math.floor(time / 1000) * 1000
case 'string':
return Math.floor(new Date(time).getTime() / 1000) * 1000
default:
return time.startOf('second').valueOf()
}
}

export function _SimpleTimeLabel({
startTime,
isUTC,
timestampFormat,
muted = true,
size = 'xsmall',
}: {
startTime: string | number | Dayjs
isUTC: boolean
startTime: string | number | Dayjs | undefined
timestampFormat: TimestampFormat
muted?: boolean
size?: 'small' | 'xsmall'
}): JSX.Element {
let d = dayjs(startTime)
if (isUTC) {
d = d.tz('UTC')
}

return (
<div
className={clsx(
Expand All @@ -35,7 +61,26 @@ export function SimpleTimeLabel({
size === 'small' && 'text-sm'
)}
>
{d.format(formatStringFor(d))} {isUTC ? 'UTC' : shortTimeZone(undefined, dayjs(d).toDate())}
{formattedReplayTime(startTime, timestampFormat)}
</div>
)
}

export const SimpleTimeLabel = memo(
_SimpleTimeLabel,
// we can truncate time when considering whether to re-render the component as we only go down to seconds in dispay,
// but it will be called with multiple millisecond values between each second
// in local tests this rendered at least 4x less (400 vs 1600 renders)
// this gets better for recordings with more activity
(prevProps, nextProps) => {
const prevStartTimeTruncated = prevProps.startTime ? truncateToSeconds(prevProps.startTime) : null
const nextStartTimeTruncated = nextProps.startTime ? truncateToSeconds(nextProps.startTime) : null

return (
prevStartTimeTruncated === nextStartTimeTruncated &&
prevProps.timestampFormat === nextProps.timestampFormat &&
prevProps.muted === nextProps.muted &&
prevProps.size === nextProps.size
)
}
)
86 changes: 49 additions & 37 deletions frontend/src/scenes/session-recordings/player/PlayerMetaLinks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { FEATURE_FLAGS } from 'lib/constants'
import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
import { IconComment } from 'lib/lemon-ui/icons'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { Fragment } from 'react'
import { Fragment, useMemo } from 'react'
import { useNotebookNode } from 'scenes/notebooks/Nodes/NotebookNodeContext'
import { NotebookSelectButton } from 'scenes/notebooks/NotebookSelectButton/NotebookSelectButton'
import {
Expand Down Expand Up @@ -163,48 +163,60 @@ export function PlayerMetaLinks({ iconsOnly }: { iconsOnly: boolean }): JSX.Elem

const MenuActions = (): JSX.Element => {
const { logicProps } = useValues(sessionRecordingPlayerLogic)
const { exportRecordingToFile, deleteRecording, setIsFullScreen } = useActions(sessionRecordingPlayerLogic)
const { deleteRecording, setIsFullScreen, exportRecordingToFile } = useActions(sessionRecordingPlayerLogic)

const hasMobileExportFlag = useFeatureFlag('SESSION_REPLAY_EXPORT_MOBILE_DATA')
const hasMobileExport = window.IMPERSONATED_SESSION || hasMobileExportFlag

const onDelete = (): void => {
setIsFullScreen(false)
LemonDialog.open({
title: 'Delete recording',
description: 'Are you sure you want to delete this recording? This cannot be undone.',
secondaryButton: {
children: 'Cancel',
const onDelete = useMemo(
() => () => {
setIsFullScreen(false)
LemonDialog.open({
title: 'Delete recording',
description: 'Are you sure you want to delete this recording? This cannot be undone.',
secondaryButton: {
children: 'Cancel',
},
primaryButton: {
children: 'Delete',
status: 'danger',
onClick: deleteRecording,
},
})
},
[deleteRecording, setIsFullScreen]
)

const items: LemonMenuItems = useMemo(() => {
const itemsArray: LemonMenuItems = [
{
label: '.json',
status: 'default',
icon: <IconDownload />,
onClick: () => exportRecordingToFile(false),
tooltip: 'Export recording to a JSON file. This can be loaded later into PostHog for playback.',
},
primaryButton: {
children: 'Delete',
]
if (hasMobileExport) {
itemsArray.push({
label: 'DEBUG - mobile.json',
status: 'default',
icon: <IconDownload />,
onClick: () => exportRecordingToFile(true),
tooltip:
'DEBUG - ONLY VISIBLE TO POSTHOG STAFF - Export untransformed recording to a file. This can be loaded later into PostHog for playback.',
})
}
if (logicProps.playerKey !== 'modal') {
itemsArray.push({
label: 'Delete recording',
status: 'danger',
onClick: deleteRecording,
},
})
}

const items: LemonMenuItems = [
{
label: 'Export to file',
onClick: () => exportRecordingToFile(false),
icon: <IconDownload />,
tooltip: 'Export recording to a file. This can be loaded later into PostHog for playback.',
},
hasMobileExport && {
label: 'Export mobile replay to file',
onClick: () => exportRecordingToFile(true),
tooltip:
'DEBUG ONLY - Export untransformed recording to a file. This can be loaded later into PostHog for playback.',
icon: <IconDownload />,
},
logicProps.playerKey !== 'modal' && {
label: 'Delete recording',
status: 'danger',
onClick: onDelete,
icon: <IconTrash />,
},
]
onClick: onDelete,
icon: <IconTrash />,
})
}
return itemsArray
}, [logicProps.playerKey, onDelete, exportRecordingToFile, hasMobileExport])

return (
<LemonMenu items={items} buttonSize="xsmall">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,42 @@ import { HotKeyOrModifier } from '~/types'
import { playerSettingsLogic, TimestampFormat } from '../playerSettingsLogic'
import { seekbarLogic } from './seekbarLogic'

export function Timestamp(): JSX.Element {
const { logicProps, currentPlayerTime, currentTimestamp, sessionPlayerData } =
useValues(sessionRecordingPlayerLogic)
function RelativeTimestampLabel(): JSX.Element {
const { logicProps, currentPlayerTime, sessionPlayerData } = useValues(sessionRecordingPlayerLogic)
const { isScrubbing, scrubbingTime } = useValues(seekbarLogic(logicProps))
const { timestampFormat } = useValues(playerSettingsLogic)

const startTimeSeconds = ((isScrubbing ? scrubbingTime : currentPlayerTime) ?? 0) / 1000
const endTimeSeconds = Math.floor(sessionPlayerData.durationMs / 1000)

const fixedUnits = endTimeSeconds > 3600 ? 3 : 2

return (
<div className="flex gap-0.5">
<span>{colonDelimitedDuration(startTimeSeconds, fixedUnits)}</span>
<span>/</span>
<span>{colonDelimitedDuration(endTimeSeconds, fixedUnits)}</span>
</div>
)
}

export function Timestamp(): JSX.Element {
const { logicProps, currentTimestamp, sessionPlayerData } = useValues(sessionRecordingPlayerLogic)
const { isScrubbing, scrubbingTime } = useValues(seekbarLogic(logicProps))
const { timestampFormat } = useValues(playerSettingsLogic)

const scrubbingTimestamp = sessionPlayerData.start?.valueOf()
? scrubbingTime + sessionPlayerData.start?.valueOf()
: undefined

return (
<div data-attr="recording-timestamp" className="text-center whitespace-nowrap font-mono text-xs">
{timestampFormat === TimestampFormat.Relative ? (
<div className="flex gap-0.5">
<span>{colonDelimitedDuration(startTimeSeconds, fixedUnits)}</span>
<span>/</span>
<span>{colonDelimitedDuration(endTimeSeconds, fixedUnits)}</span>
</div>
) : currentTimestamp ? (
<SimpleTimeLabel startTime={currentTimestamp} isUTC={timestampFormat === TimestampFormat.UTC} />
<RelativeTimestampLabel />
) : (
'--/--/----, 00:00:00'
<SimpleTimeLabel
startTime={isScrubbing ? scrubbingTimestamp : currentTimestamp}
timestampFormat={timestampFormat}
/>
)}
</div>
)
Expand Down
Loading

0 comments on commit 12f793c

Please sign in to comment.