diff --git a/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx b/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx index e057be6b61567..fff0231e74034 100644 --- a/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx +++ b/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx @@ -1,7 +1,7 @@ import { createPostHogWidgetNode } from 'scenes/notebooks/Nodes/NodeWrapper' import { FilterType, NotebookNodeType, RecordingFilters } from '~/types' -import { SessionRecordingsPlaylistProps } from 'scenes/session-recordings/playlist/SessionRecordingsPlaylist' import { + SessionRecordingPlaylistLogicProps, addedAdvancedFilters, getDefaultFilters, sessionRecordingsPlaylistLogic, @@ -19,10 +19,10 @@ import { sessionRecordingPlayerLogic } from 'scenes/session-recordings/player/se import { summarizePlaylistFilters } from 'scenes/session-recordings/playlist/playlistUtils' const Component = (props: NotebookNodeViewProps): JSX.Element => { - const { filters, nodeId } = props.attributes + const { filters, pinned, nodeId } = props.attributes const playerKey = `notebook-${nodeId}` - const recordingPlaylistLogicProps: SessionRecordingsPlaylistProps = useMemo( + const recordingPlaylistLogicProps: SessionRecordingPlaylistLogicProps = useMemo( () => ({ logicKey: playerKey, filters, @@ -33,8 +33,16 @@ const Component = (props: NotebookNodeViewProps) filters: newFilters, }) }, + pinnedRecordings: pinned, + onPinnedChange(recording, isPinned) { + props.updateAttributes({ + pinned: isPinned + ? [...(pinned || []), String(recording.id)] + : pinned?.filter((id) => id !== recording.id), + }) + }, }), - [playerKey, filters] + [playerKey, filters, pinned] ) const { expanded } = useValues(notebookNodeLogic) @@ -126,6 +134,7 @@ export const Settings = ({ type NotebookNodePlaylistAttributes = { filters: RecordingFilters + pinned?: string[] } export const NotebookNodePlaylist = createPostHogWidgetNode({ @@ -143,6 +152,9 @@ export const NotebookNodePlaylist = createPostHogWidgetNodeShare - {nodeLogic ? ( - nodeLogic.props.nodeType !== NotebookNodeType.Recording ? ( - } - size="small" - onClick={() => { - nodeLogic.actions.insertAfter({ - type: NotebookNodeType.Recording, - attrs: { id: sessionRecordingId }, - }) - }} - /> - ) : null + {nodeLogic?.props.nodeType === NotebookNodeType.RecordingPlaylist ? ( + } + size="small" + onClick={() => { + nodeLogic.actions.insertAfter({ + type: NotebookNodeType.Recording, + attrs: { id: sessionRecordingId }, + }) + }} + /> + ) : null} + + {logicProps.setPinned ? ( + { + logicProps.setPinned?.(!logicProps.pinned) + }} + size="small" + icon={logicProps.pinned ? : } + /> ) : ( Pin diff --git a/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx b/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx index 46b670a82fbc7..3097dbe5e7119 100644 --- a/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx +++ b/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx @@ -49,6 +49,8 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX. autoPlay = true, playlistLogic, mode = SessionRecordingPlayerMode.Standard, + pinned, + setPinned, } = props const playerRef = useRef(null) @@ -62,6 +64,8 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX. playlistLogic, mode, playerRef, + pinned, + setPinned, } const { incrementClickCount, diff --git a/frontend/src/scenes/session-recordings/player/controller/seekbarLogic.ts b/frontend/src/scenes/session-recordings/player/controller/seekbarLogic.ts index 65756888007a5..b736e418e4663 100644 --- a/frontend/src/scenes/session-recordings/player/controller/seekbarLogic.ts +++ b/frontend/src/scenes/session-recordings/player/controller/seekbarLogic.ts @@ -2,7 +2,7 @@ import { MutableRefObject } from 'react' import { actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' import type { seekbarLogicType } from './seekbarLogicType' import { - SessionRecordingLogicProps, + SessionRecordingPlayerLogicProps, sessionRecordingPlayerLogic, } from 'scenes/session-recordings/player/sessionRecordingPlayerLogic' import { clamp } from 'lib/utils' @@ -11,9 +11,9 @@ import { getXPos, InteractEvent, ReactInteractEvent, THUMB_OFFSET, THUMB_SIZE } export const seekbarLogic = kea([ path((key) => ['scenes', 'session-recordings', 'player', 'seekbarLogic', key]), - props({} as SessionRecordingLogicProps), - key((props: SessionRecordingLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), - connect((props: SessionRecordingLogicProps) => ({ + props({} as SessionRecordingPlayerLogicProps), + key((props: SessionRecordingPlayerLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), + connect((props: SessionRecordingPlayerLogicProps) => ({ values: [sessionRecordingPlayerLogic(props), ['sessionPlayerData', 'currentPlayerTime']], actions: [sessionRecordingPlayerLogic(props), ['seekToTime', 'startScrub', 'endScrub', 'setCurrentTimestamp']], })), diff --git a/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts b/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts index ac8c24348ef9f..5b4cec5c21332 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts +++ b/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts @@ -9,7 +9,7 @@ import { } from '~/types' import type { playerInspectorLogicType } from './playerInspectorLogicType' import { playerSettingsLogic } from 'scenes/session-recordings/player/playerSettingsLogic' -import { SessionRecordingLogicProps, sessionRecordingPlayerLogic } from '../sessionRecordingPlayerLogic' +import { SessionRecordingPlayerLogicProps, sessionRecordingPlayerLogic } from '../sessionRecordingPlayerLogic' import { sessionRecordingDataLogic } from '../sessionRecordingDataLogic' import FuseClass from 'fuse.js' import { Dayjs, dayjs } from 'lib/dayjs' @@ -120,7 +120,7 @@ export type InspectorListItemPerformance = InspectorListItemBase & { export type InspectorListItem = InspectorListItemEvent | InspectorListItemConsole | InspectorListItemPerformance -export interface PlayerInspectorLogicProps extends SessionRecordingLogicProps { +export interface PlayerInspectorLogicProps extends SessionRecordingPlayerLogicProps { matchingEventsMatchType?: MatchingEventsMatchType } diff --git a/frontend/src/scenes/session-recordings/player/playerMetaLogic.ts b/frontend/src/scenes/session-recordings/player/playerMetaLogic.ts index 9d458cb0d7c19..9c55ee262de76 100644 --- a/frontend/src/scenes/session-recordings/player/playerMetaLogic.ts +++ b/frontend/src/scenes/session-recordings/player/playerMetaLogic.ts @@ -2,7 +2,7 @@ import { connect, kea, key, listeners, path, props, selectors } from 'kea' import type { playerMetaLogicType } from './playerMetaLogicType' import { sessionRecordingDataLogic } from 'scenes/session-recordings/player/sessionRecordingDataLogic' import { - SessionRecordingLogicProps, + SessionRecordingPlayerLogicProps, sessionRecordingPlayerLogic, } from 'scenes/session-recordings/player/sessionRecordingPlayerLogic' import { eventWithTime } from '@rrweb/types' @@ -12,9 +12,9 @@ import { sessionRecordingsListPropertiesLogic } from '../playlist/sessionRecordi export const playerMetaLogic = kea([ path((key) => ['scenes', 'session-recordings', 'player', 'playerMetaLogic', key]), - props({} as SessionRecordingLogicProps), - key((props: SessionRecordingLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), - connect((props: SessionRecordingLogicProps) => ({ + props({} as SessionRecordingPlayerLogicProps), + key((props: SessionRecordingPlayerLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), + connect((props: SessionRecordingPlayerLogicProps) => ({ values: [ sessionRecordingDataLogic(props), [ diff --git a/frontend/src/scenes/session-recordings/player/playlist-popover/playlistPopoverLogic.ts b/frontend/src/scenes/session-recordings/player/playlist-popover/playlistPopoverLogic.ts index 424efa60707cc..9207b418aae84 100644 --- a/frontend/src/scenes/session-recordings/player/playlist-popover/playlistPopoverLogic.ts +++ b/frontend/src/scenes/session-recordings/player/playlist-popover/playlistPopoverLogic.ts @@ -3,7 +3,7 @@ import { loaders } from 'kea-loaders' import api from 'lib/api' import { toParams } from 'lib/utils' import { - SessionRecordingLogicProps, + SessionRecordingPlayerLogicProps, sessionRecordingPlayerLogic, } from 'scenes/session-recordings/player/sessionRecordingPlayerLogic' @@ -18,9 +18,9 @@ import { sessionRecordingsPlaylistSceneLogic } from 'scenes/session-recordings/p export const playlistPopoverLogic = kea([ path((key) => ['scenes', 'session-recordings', 'player', 'playlist-popover', 'playlistPopoverLogic', key]), - props({} as SessionRecordingLogicProps), - key((props: SessionRecordingLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), - connect((props: SessionRecordingLogicProps) => ({ + props({} as SessionRecordingPlayerLogicProps), + key((props: SessionRecordingPlayerLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), + connect((props: SessionRecordingPlayerLogicProps) => ({ actions: [ sessionRecordingPlayerLogic(props), ['setPause'], diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts index 1911e7925acd3..fbce49b2dbcf3 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts @@ -16,9 +16,12 @@ import { windowValues } from 'kea-window-values' import type { sessionRecordingPlayerLogicType } from './sessionRecordingPlayerLogicType' import { Replayer } from 'rrweb' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' -import { AvailableFeature, RecordingSegment, SessionPlayerData, SessionPlayerState, SessionRecordingId } from '~/types' +import { AvailableFeature, RecordingSegment, SessionPlayerData, SessionPlayerState } from '~/types' import { getBreakpoint } from 'lib/utils/responsiveUtils' -import { sessionRecordingDataLogic } from 'scenes/session-recordings/player/sessionRecordingDataLogic' +import { + SessionRecordingDataLogicProps, + sessionRecordingDataLogic, +} from 'scenes/session-recordings/player/sessionRecordingDataLogic' import { deleteRecording } from './utils/playerUtils' import { playerSettingsLogic } from './playerSettingsLogic' import { clamp, downloadFile, fromParamsGivenUrl } from 'lib/utils' @@ -74,19 +77,16 @@ export enum SessionRecordingPlayerMode { Preview = 'preview', } -// This is the basic props used by most sub-logics -export interface SessionRecordingLogicProps { - sessionRecordingId: SessionRecordingId +export interface SessionRecordingPlayerLogicProps extends SessionRecordingDataLogicProps { playerKey: string -} - -export interface SessionRecordingPlayerLogicProps extends SessionRecordingLogicProps { sessionRecordingData?: SessionPlayerData matchingEventsMatchType?: MatchingEventsMatchType playlistLogic?: BuiltLogic autoPlay?: boolean mode?: SessionRecordingPlayerMode playerRef?: RefObject + pinned?: boolean + setPinned?: (pinned: boolean) => void } const isMediaElementPlaying = (element: HTMLMediaElement): boolean => @@ -1013,7 +1013,7 @@ export const sessionRecordingPlayerLogic = kea( }), ]) -export const getCurrentPlayerTime = (logicProps: SessionRecordingLogicProps): number => { +export const getCurrentPlayerTime = (logicProps: SessionRecordingPlayerLogicProps): number => { // NOTE: We pull this value at call time as otherwise it would trigger re-renders if pulled from the hook const playerTime = sessionRecordingPlayerLogic.findMounted(logicProps)?.values.currentPlayerTime || 0 return Math.floor(playerTime / 1000) diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreview.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreview.tsx index 8dae8ab8f0bc7..6df4c84b31b25 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreview.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreview.tsx @@ -18,6 +18,7 @@ export interface SessionRecordingPreviewProps { onPropertyClick?: (property: string, value?: string) => void isActive?: boolean onClick?: () => void + pinned?: boolean } function RecordingDuration({ @@ -150,12 +151,12 @@ function FirstURL(props: { startUrl: string | undefined }): JSX.Element { ) } -function PinnedIndicator(props: { pinnedCount: number | undefined }): JSX.Element | null { - return (props.pinnedCount ?? 0) > 0 ? ( - +function PinnedIndicator(): JSX.Element | null { + return ( + - ) : null + ) } function ViewedIndicator(props: { viewed: boolean }): JSX.Element | null { @@ -179,6 +180,7 @@ export function SessionRecordingPreview({ isActive, onClick, onPropertyClick, + pinned, }: SessionRecordingPreviewProps): JSX.Element { const { durationTypeToShow } = useValues(playerSettingsLogic) @@ -220,7 +222,7 @@ export function SessionRecordingPreview({
- + {pinned ? : null}
diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx index 52eb6b6f8adb5..39f06a5003b64 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx @@ -62,7 +62,7 @@ function RecordingsLists(): JSX.Element { hasNext, recordings, sessionRecordingsResponseLoading, - activeSessionRecording, + activeSessionRecordingId, showFilters, showSettings, totalFiltersCount, @@ -71,6 +71,7 @@ function RecordingsLists(): JSX.Element { showAdvancedFilters, hasAdvancedFilters, logicProps, + pinnedRecordingIds, } = useValues(sessionRecordingsPlaylistLogic) const { setSelectedRecordingId, @@ -208,7 +209,8 @@ function RecordingsLists(): JSX.Element { recording={rec} onClick={() => onRecordingClick(rec)} onPropertyClick={onPropertyClick} - isActive={activeSessionRecording?.id === rec.id} + isActive={activeSessionRecordingId === rec.id} + pinned={pinnedRecordingIds.includes(rec.id)} /> ))} @@ -275,7 +277,8 @@ export function SessionRecordingsPlaylist(props: SessionRecordingPlaylistLogicPr autoPlay: props.autoPlay ?? true, } const logic = sessionRecordingsPlaylistLogic(logicProps) - const { activeSessionRecording, matchingEventsMatchType } = useValues(logic) + const { activeSessionRecording, activeSessionRecordingId, matchingEventsMatchType, pinnedRecordingIds } = + useValues(logic) const { ref: playlistRef, size } = useResizeBreakpoints({ 0: 'small', @@ -299,13 +302,24 @@ export function SessionRecordingsPlaylist(props: SessionRecordingPlaylistLogicPr
- {activeSessionRecording?.id ? ( + {activeSessionRecordingId ? ( { + if (!activeSessionRecording?.id) { + return + } + props.onPinnedChange?.(activeSessionRecording, pinned) + } + : undefined + } /> ) : (
diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx index f623fe7a04745..1982b63429f48 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx @@ -24,7 +24,7 @@ export function SessionRecordingsPlaylistScene(): JSX.Element { const { playlist, playlistLoading, pinnedRecordings, hasChanges, derivedName } = useValues( sessionRecordingsPlaylistSceneLogic ) - const { setFilters, updatePlaylist, duplicatePlaylist, deletePlaylist } = useActions( + const { setFilters, updatePlaylist, duplicatePlaylist, deletePlaylist, onPinnedChange } = useActions( sessionRecordingsPlaylistSceneLogic ) @@ -150,6 +150,7 @@ export function SessionRecordingsPlaylistScene(): JSX.Element {
diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts index 2ddb1ae0691fc..0a55dc3548ddf 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts @@ -165,7 +165,8 @@ export interface SessionRecordingPlaylistLogicProps { updateSearchParams?: boolean autoPlay?: boolean onFiltersChange?: (filters: RecordingFilters) => void - pinnedRecordings?: SessionRecordingType[] + pinnedRecordings?: (SessionRecordingType | string)[] + onPinnedChange?: (recording: SessionRecordingType, pinned: boolean) => void } export const sessionRecordingsPlaylistLogic = kea([ @@ -283,11 +284,30 @@ export const sessionRecordingsPlaylistLogic = kea { await breakpoint(100) - const fromProps = props.pinnedRecordings ?? [] - // TODO: React to props changing and order appropriately + + // props.pinnedRecordings can be strings or objects. + // If objects we can simply use them, if strings we need to fetch them + + let recordings = props.pinnedRecordings?.filter( + (x) => typeof x !== 'string' + ) as SessionRecordingType[] + const recordingIds = props.pinnedRecordings?.filter((x) => typeof x === 'string') as string[] + + if (recordingIds) { + // TODO: This is broken - we don't return only certain session_ids for some reason.... + const fetchedRecordings = await api.recordings.list( + toParams({ + filters: { + session_ids: recordingIds, + }, + }) + ) + + recordings = [...recordings, ...fetchedRecordings.results] + } // TODO: Check for pinnedRecordings being IDs and fetch them, returnig the merged list - return fromProps + return recordings }, }, ], @@ -516,16 +536,28 @@ export const sessionRecordingsPlaylistLogic = kea [s.selectedRecordingId, s.recordings, (_, props) => props.autoPlay], - (selectedRecordingId, recordings, autoPlay): Partial | undefined => { + (selectedRecordingId, recordings, autoPlay): SessionRecordingId | undefined => { return selectedRecordingId - ? recordings.find((rec) => rec.id === selectedRecordingId) || { id: selectedRecordingId } + ? recordings.find((rec) => rec.id === selectedRecordingId)?.id || selectedRecordingId : autoPlay - ? recordings[0] + ? recordings[0]?.id : undefined }, ], + activeSessionRecording: [ + (s) => [s.activeSessionRecordingId, s.recordings], + (activeSessionRecordingId, recordings): SessionRecordingType | undefined => { + return recordings.find((rec) => rec.id === activeSessionRecordingId) + }, + ], + pinnedRecordingIds: [ + (s) => [s.pinnedRecordings], + (pinnedRecordings): string[] => { + return pinnedRecordings?.map((x) => x.id) ?? [] + }, + ], nextSessionRecording: [ (s) => [s.activeSessionRecording, s.recordings, s.autoplayDirection], (activeSessionRecording, recordings, autoplayDirection): Partial | undefined => { diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.ts index f51effb13f018..d7c70337b9632 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.ts @@ -5,6 +5,7 @@ import { SessionRecordingPlaylistType, ReplayTabs, SessionRecordingsResponse, + SessionRecordingType, } from '~/types' import { urls } from 'scenes/urls' import equal from 'fast-deep-equal' @@ -22,6 +23,7 @@ import { loaders } from 'kea-loaders' import type { sessionRecordingsPlaylistSceneLogicType } from './sessionRecordingsPlaylistSceneLogicType' import { PINNED_RECORDINGS_LIMIT } from './sessionRecordingsPlaylistLogic' import api from 'lib/api' +import { addRecordingToPlaylist, removeRecordingFromPlaylist } from '../player/utils/playerUtils' export interface SessionRecordingsPlaylistLogicProps { shortId: string @@ -41,6 +43,7 @@ export const sessionRecordingsPlaylistSceneLogic = kea ({ filters }), loadPinnedRecordings: true, + onPinnedChange: (recording: SessionRecordingType, pinned: boolean) => ({ pinned, recording }), }), loaders(({ values, props }) => ({ playlist: [ @@ -88,6 +91,24 @@ export const sessionRecordingsPlaylistSceneLogic = kea { + let newResults = values.pinnedRecordings?.results || [] + + newResults = newResults.filter((r) => r.id !== recording.id) + + if (pinned) { + await addRecordingToPlaylist(props.shortId, recording.id) + newResults.push(recording) + } else { + await removeRecordingFromPlaylist(props.shortId, recording.id) + } + + return { + results: newResults, + has_next: false, + } + }, }, ], })), diff --git a/posthog/session_recordings/session_recording_api.py b/posthog/session_recordings/session_recording_api.py index cf3505c556752..99e04c14b1911 100644 --- a/posthog/session_recordings/session_recording_api.py +++ b/posthog/session_recordings/session_recording_api.py @@ -502,6 +502,7 @@ def list_recordings(filter: SessionRecordingsFilter, request: request.Request, c """ all_session_ids = filter.session_ids + recordings: List[SessionRecording] = [] more_recordings_available = False team = context["get_team"]()