Skip to content

Commit

Permalink
feat: Notebook playlist comments (#17671)
Browse files Browse the repository at this point in the history
  • Loading branch information
benjackwhite authored Oct 5, 2023
1 parent 03407f3 commit 6e3ce74
Show file tree
Hide file tree
Showing 10 changed files with 159 additions and 57 deletions.
22 changes: 19 additions & 3 deletions frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { notebookNodeLogic } from './notebookNodeLogic'
import { JSONContent, NotebookNodeViewProps, NotebookNodeAttributeProperties } from '../Notebook/utils'
import { SessionRecordingsFilters } from 'scenes/session-recordings/filters/SessionRecordingsFilters'
import { ErrorBoundary } from '@sentry/react'
import { sessionRecordingPlayerLogic } from 'scenes/session-recordings/player/sessionRecordingPlayerLogic'

const Component = (props: NotebookNodeViewProps<NotebookNodePlaylistAttributes>): JSX.Element => {
const { filters, nodeId } = props.attributes
Expand All @@ -41,10 +42,11 @@ const Component = (props: NotebookNodeViewProps<NotebookNodePlaylistAttributes>)
)

const { expanded } = useValues(notebookNodeLogic)
const { setActions, insertAfter } = useActions(notebookNodeLogic)
const { setActions, insertAfter, setMessageListeners, scrollIntoView } = useActions(notebookNodeLogic)

const logic = sessionRecordingsListLogic(recordingPlaylistLogicProps)
const { activeSessionRecording, nextSessionRecording, matchingEventsMatchType } = useValues(logic)
const { activeSessionRecording, nextSessionRecording, matchingEventsMatchType, sessionRecordings } =
useValues(logic)
const { setSelectedRecordingId } = useActions(logic)

useEffect(() => {
Expand All @@ -67,8 +69,22 @@ const Component = (props: NotebookNodeViewProps<NotebookNodePlaylistAttributes>)
)
}, [activeSessionRecording])

useEffect(() => {
setMessageListeners({
'play-replay': ({ sessionRecordingId, time }) => {
setSelectedRecordingId(sessionRecordingId)
scrollIntoView()

setTimeout(() => {
// NOTE: This is a hack but we need a delay to give time for the player to mount
sessionRecordingPlayerLogic.findMounted({ playerKey, sessionRecordingId })?.actions.seekToTime(time)
}, 100)
},
})
}, [])

if (!expanded) {
return <div className="p-4">20+ recordings </div>
return <div className="p-4">{sessionRecordings.length}+ recordings </div>
}

const content = !activeSessionRecording?.id ? (
Expand Down
28 changes: 26 additions & 2 deletions frontend/src/scenes/notebooks/Nodes/NotebookNodeRecording.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { urls } from 'scenes/urls'
import {
SessionRecordingPlayerMode,
getCurrentPlayerTime,
sessionRecordingPlayerLogic,
} from 'scenes/session-recordings/player/sessionRecordingPlayerLogic'
import { useActions, useValues } from 'kea'
import { sessionRecordingDataLogic } from 'scenes/session-recordings/player/sessionRecordingDataLogic'
Expand Down Expand Up @@ -37,10 +38,19 @@ const Component = (props: NotebookNodeViewProps<NotebookNodeRecordingAttributes>
noInspector: noInspector,
}

const { expanded } = useValues(notebookNodeLogic)
const {
setActions,
insertAfter,
insertReplayCommentByTimestamp,
setMessageListeners,
setExpanded,
scrollIntoView,
} = useActions(notebookNodeLogic)

const { sessionPlayerMetaData } = useValues(sessionRecordingDataLogic(recordingLogicProps))
const { loadRecordingMeta } = useActions(sessionRecordingDataLogic(recordingLogicProps))
const { expanded } = useValues(notebookNodeLogic)
const { setActions, insertAfter, insertReplayCommentByTimestamp } = useActions(notebookNodeLogic)
const { seekToTime, setPlay } = useActions(sessionRecordingPlayerLogic(recordingLogicProps))

useEffect(() => {
loadRecordingMeta()
Expand Down Expand Up @@ -76,6 +86,20 @@ const Component = (props: NotebookNodeViewProps<NotebookNodeRecordingAttributes>
])
}, [sessionPlayerMetaData?.person?.id])

useEffect(() => {
setMessageListeners({
'play-replay': ({ time }) => {
if (!expanded) {
setExpanded(true)
}
setPlay()

seekToTime(time)
scrollIntoView()
},
})
}, [])

return !expanded ? (
<div>
{sessionPlayerMetaData ? (
Expand Down
57 changes: 21 additions & 36 deletions frontend/src/scenes/notebooks/Nodes/NotebookNodeReplayTimestamp.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,39 @@
import { mergeAttributes, Node, NodeViewProps } from '@tiptap/core'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { NotebookNodeType, NotebookTarget } from '~/types'
import {
sessionRecordingPlayerLogic,
SessionRecordingPlayerLogicProps,
} from 'scenes/session-recordings/player/sessionRecordingPlayerLogic'
import { dayjs } from 'lib/dayjs'
import { JSONContent } from '../Notebook/utils'
import clsx from 'clsx'
import { findPositionOfClosestNodeMatchingAttrs } from '../Notebook/Editor'
import { urls } from 'scenes/urls'
import { LemonButton } from '@posthog/lemon-ui'
import { notebookLogic } from '../Notebook/notebookLogic'
import { useValues } from 'kea'
import { sessionRecordingPlayerProps } from './NotebookNodeRecording'
import { useMemo } from 'react'
import { openNotebook } from '~/models/notebooksModel'

export interface NotebookNodeReplayTimestampAttrs {
playbackTime?: number
sessionRecordingId: string
sourceNodeId?: string
}

const Component = (props: NodeViewProps): JSX.Element => {
const { shortId, findNodeLogic } = useValues(notebookLogic)
const sessionRecordingId: string = props.node.attrs.sessionRecordingId
const playbackTime: number = props.node.attrs.playbackTime
const { shortId, findNodeLogic, findNodeLogicById } = useValues(notebookLogic)
const { sessionRecordingId, playbackTime = 0, sourceNodeId } = props.node.attrs as NotebookNodeReplayTimestampAttrs

const recordingNodeInNotebook = useMemo(() => {
return findNodeLogic(NotebookNodeType.Recording, { id: sessionRecordingId })
const relatedNodeInNotebook = useMemo(() => {
const logicById = sourceNodeId ? findNodeLogicById(sourceNodeId) : null

return logicById ?? findNodeLogic(NotebookNodeType.Recording, { id: sessionRecordingId })
}, [findNodeLogic])

const handlePlayInNotebook = (): void => {
recordingNodeInNotebook?.actions.setExpanded(true)

// TODO: Move all of the above into the logic / Node context for the recording node
const logicProps: SessionRecordingPlayerLogicProps = sessionRecordingPlayerProps(sessionRecordingId)
const logic = sessionRecordingPlayerLogic(logicProps)

logic.actions.seekToTime(props.node.attrs.playbackTime)
logic.actions.setPlay()
// TODO: Figure out how to send this action info to the playlist OR the replay node...

const recordingNodePosition = findPositionOfClosestNodeMatchingAttrs(props.editor, props.getPos(), {
id: sessionRecordingId,
relatedNodeInNotebook?.values.sendMessage('play-replay', {
sessionRecordingId,
time: playbackTime ?? 0,
})

const domEl = props.editor.view.nodeDOM(recordingNodePosition) as HTMLElement
domEl.scrollIntoView()
}

return (
Expand All @@ -55,10 +47,10 @@ const Component = (props: NodeViewProps): JSX.Element => {
type="secondary"
status="primary-alt"
onClick={
recordingNodeInNotebook ? handlePlayInNotebook : () => openNotebook(shortId, NotebookTarget.Popover)
relatedNodeInNotebook ? handlePlayInNotebook : () => openNotebook(shortId, NotebookTarget.Popover)
}
to={
!recordingNodeInNotebook
!relatedNodeInNotebook
? urls.replaySingle(sessionRecordingId) + `?t=${playbackTime / 1000}`
: undefined
}
Expand All @@ -85,6 +77,7 @@ export const NotebookNodeReplayTimestamp = Node.create({
return {
playbackTime: { default: null, keepOnSplit: false },
sessionRecordingId: { default: null, keepOnSplit: true, isRequired: true },
sourceNodeId: { default: null, keepOnSplit: true },
}
},

Expand All @@ -105,21 +98,13 @@ export function formatTimestamp(time: number): string {
return dayjs.duration(time, 'milliseconds').format('HH:mm:ss').replace(/^00:/, '').trim()
}

export interface NotebookNodeReplayTimestampAttrs {
playbackTime: number | null
sessionRecordingId: string
}

export function buildTimestampCommentContent(
currentPlayerTime: number | null,
sessionRecordingId: string
): JSONContent {
export function buildTimestampCommentContent(attrs: NotebookNodeReplayTimestampAttrs): JSONContent {
return {
type: 'paragraph',
content: [
{
type: NotebookNodeType.ReplayTimestamp,
attrs: { playbackTime: currentPlayerTime, sessionRecordingId: sessionRecordingId },
attrs,
},
{ type: 'text', text: ' ' },
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* NotebookNode messaging
*
* To help communication between nodes, you can register a message handler from any of the defined list.
* Typing wise it is tricky to scope so all events handlers are typed as possibly undefined.
*/

export type NotebookNodeMessages = {
'play-replay': {
sessionRecordingId: string
time: number
}
// Not used yet but as a future idea - you could "ping" a node to have it highlight or something.
ping: {
message: string
}
}

export type NotebookNodeMessagesNames = keyof NotebookNodeMessages

export type NotebookNodeMessagesListener<MessageName extends NotebookNodeMessagesNames> = (
e: NotebookNodeMessages[MessageName]
) => void

export type NotebookNodeMessagesListeners = {
[MessageName in NotebookNodeMessagesNames]?: NotebookNodeMessagesListener<MessageName>
}
38 changes: 35 additions & 3 deletions frontend/src/scenes/notebooks/Nodes/notebookNodeLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from '../Notebook/utils'
import { NotebookNodeType } from '~/types'
import posthog from 'posthog-js'
import { NotebookNodeMessages, NotebookNodeMessagesListeners } from './messaging/notebook-node-messages'

export type NotebookNodeLogicProps = {
node: NotebookNode
Expand All @@ -36,6 +37,7 @@ export type NotebookNodeLogicProps = {
getPos: () => number
resizeable: boolean | ((attributes: CustomNotebookNodeAttributes) => boolean)
widgets: NotebookNodeWidget[]
messageListeners?: NotebookNodeMessagesListeners
startExpanded: boolean
} & NotebookNodeAttributeProperties<any>

Expand Down Expand Up @@ -63,6 +65,8 @@ export const notebookNodeLogic = kea<notebookNodeLogicType>([
setNextNode: (node: Node | null) => ({ node }),
deleteNode: true,
selectNode: true,
scrollIntoView: true,
setMessageListeners: (listeners: NotebookNodeMessagesListeners) => ({ listeners }),
}),

connect((props: NotebookNodeLogicProps) => ({
Expand Down Expand Up @@ -101,12 +105,35 @@ export const notebookNodeLogic = kea<notebookNodeLogicType>([
setActions: (_, { actions }) => actions.filter((x) => !!x) as NotebookNodeAction[],
},
],
messageListeners: [
props.messageListeners as NotebookNodeMessagesListeners,
{
setMessageListeners: (_, { listeners }) => listeners,
},
],
})),

selectors({
notebookLogic: [(_, p) => [p.notebookLogic], (notebookLogic) => notebookLogic],
nodeAttributes: [(_, p) => [p.attributes], (nodeAttributes) => nodeAttributes],
widgets: [(_, p) => [p.widgets], (widgets) => widgets],

sendMessage: [
(s) => [s.messageListeners],
(messageListeners) => {
return <T extends keyof NotebookNodeMessages>(
message: T,
payload: NotebookNodeMessages[T]
): boolean => {
if (!messageListeners[message]) {
return false
}

messageListeners[message]?.(payload)
return true
}
},
],
}),

listeners(({ actions, values, props }) => ({
Expand Down Expand Up @@ -142,18 +169,23 @@ export const notebookNodeLogic = kea<notebookNodeLogicType>([
}
},

scrollIntoView: () => {
values.editor?.scrollToPosition(props.getPos())
},

insertAfterLastNodeOfType: ({ nodeType, content }) => {
const insertionPosition = props.getPos()
values.notebookLogic.actions.insertAfterLastNodeOfType(nodeType, content, insertionPosition)
},

insertReplayCommentByTimestamp: ({ timestamp, sessionRecordingId }) => {
const insertionPosition = props.getPos()
values.notebookLogic.actions.insertReplayCommentByTimestamp(
values.notebookLogic.actions.insertReplayCommentByTimestamp({
timestamp,
sessionRecordingId,
insertionPosition
)
knownStartingPosition: insertionPosition,
nodeId: props.nodeId,
})
},

setExpanded: ({ expanded }) => {
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/scenes/notebooks/Notebook/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ export function Editor({
domEl.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
})
},
scrollToPosition(position) {
queueMicrotask(() => {
const domEl = editor.view.nodeDOM(position) as HTMLElement
domEl.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
})
},
})
},
onUpdate: onUpdate,
Expand Down
30 changes: 19 additions & 11 deletions frontend/src/scenes/notebooks/Notebook/notebookLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,12 @@ export const notebookLogic = kea<notebookLogicType>([
nodeType,
knownStartingPosition,
}),
insertReplayCommentByTimestamp: (
timestamp: number,
sessionRecordingId: string,
insertReplayCommentByTimestamp: (options: {
timestamp: number
sessionRecordingId: string
knownStartingPosition?: number
) => ({
timestamp,
sessionRecordingId,
// if operating on a particular instance of a replay comment, we can pass the known starting position
knownStartingPosition,
}),
nodeId?: string
}) => options,
setShowHistory: (showHistory: boolean) => ({ showHistory }),
}),
reducers({
Expand Down Expand Up @@ -344,6 +340,14 @@ export const notebookLogic = kea<notebookLogicType>([
}
},
],
findNodeLogicById: [
(s) => [s.nodeLogics],
(nodeLogics) => {
return (id: string): notebookNodeLogicType | null => {
return Object.values(nodeLogics).find((nodeLogic) => nodeLogic.props.nodeId === id) ?? null
}
},
],

isShowingSidebar: [
(s) => [s.editingNodeId, s.showHistory],
Expand Down Expand Up @@ -403,7 +407,7 @@ export const notebookLogic = kea<notebookLogicType>([
}
)
},
insertReplayCommentByTimestamp: async ({ timestamp, sessionRecordingId, knownStartingPosition }) => {
insertReplayCommentByTimestamp: async ({ timestamp, sessionRecordingId, knownStartingPosition, nodeId }) => {
await runWhenEditorIsReady(
() => !!values.editor,
() => {
Expand All @@ -424,7 +428,11 @@ export const notebookLogic = kea<notebookLogicType>([

values.editor?.insertContentAfterNode(
insertionPosition,
buildTimestampCommentContent(timestamp, sessionRecordingId)
buildTimestampCommentContent({
playbackTime: timestamp,
sessionRecordingId,
sourceNodeId: nodeId,
})
)
}
)
Expand Down
Loading

0 comments on commit 6e3ce74

Please sign in to comment.