feat: move replay annotations to comments (#35539)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: David Newell <d.newell1@outlook.com>
This commit is contained in:
Paul D'Ambra
2025-07-24 18:25:56 +01:00
committed by GitHub
parent 7e634f676d
commit 6468eb034c
28 changed files with 460 additions and 604 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -16,7 +16,7 @@ import { annotationModalLogic, annotationScopeToName } from 'scenes/annotations/
import { insightLogic } from 'scenes/insights/insightLogic'
import { annotationsModel } from '~/models/annotationsModel'
import { AnnotationScope, AnnotationType, IntervalType } from '~/types'
import { AnnotationType, IntervalType } from '~/types'
import {
annotationsOverlayLogic,
@@ -24,7 +24,6 @@ import {
determineAnnotationsDateGroup,
} from './annotationsOverlayLogic'
import { useAnnotationsPositioning } from './useAnnotationsPositioning'
import ViewRecordingButton from 'lib/components/ViewRecordingButton/ViewRecordingButton'
import { TextContent } from 'lib/components/Cards/TextCard/TextCard'
/** User-facing format for annotation groups. */
@@ -252,7 +251,7 @@ function AnnotationCard({ annotation }: { annotation: AnnotationType }): JSX.Ele
<h5 className="grow m-0 text-secondary">
{annotation.date_marker?.format('MMM DD, YYYY h:mm A')} ({shortTimeZone(timezone)}) {' '}
{annotationScopeToName[annotation.scope]}
{annotation.scope === AnnotationScope.Recording ? ' comment' : '-level'}
-level
</h5>
<LemonButton
size="small"
@@ -288,15 +287,6 @@ function AnnotationCard({ annotation }: { annotation: AnnotationType }): JSX.Ele
/>{' '}
{humanFriendlyDetailedTime(annotation.created_at, 'MMMM DD, YYYY', 'h:mm A')}
</div>
{annotation.scope === AnnotationScope.Recording &&
!!annotation.recording_id &&
!!annotation.date_marker ? (
<ViewRecordingButton
sessionId={annotation.recording_id}
timestamp={annotation.date_marker}
inModal={true}
/>
) : null}
</div>
</li>
)

View File

@@ -152,7 +152,6 @@ export const annotationsOverlayLogic = kea<annotationsOverlayLogicType>([
dateRange
? annotations.filter(
(annotation) =>
annotation.scope !== AnnotationScope.Recording &&
(annotation.scope !== AnnotationScope.Insight ||
annotation.dashboard_item === insightNumericId) &&
(annotation.scope !== AnnotationScope.Dashboard ||

View File

@@ -18,7 +18,6 @@ import { urls } from 'scenes/urls'
import { AnnotationScope, AnnotationType } from '~/types'
import { annotationModalLogic, annotationScopeToName } from './annotationModalLogic'
import ViewRecordingButton from 'lib/components/ViewRecordingButton/ViewRecordingButton'
export function NewAnnotationButton(): JSX.Element {
const { openModalToCreateAnnotation } = useActions(annotationModalLogic)
@@ -95,13 +94,6 @@ export function AnnotationModal({
value: AnnotationScope.Organization,
label: annotationScopeToName[AnnotationScope.Organization],
},
{
value: AnnotationScope.Recording,
label: annotationScopeToName[AnnotationScope.Recording],
disabledReason: annotationModal.recordingId
? undefined
: 'To select this scope, open this annotation on the target recording',
},
]
return (
@@ -111,7 +103,7 @@ export function AnnotationModal({
isOpen={isModalOpen}
onClose={closeModal}
title={existingModalAnnotation ? 'Edit annotation' : 'New annotation'}
description="Use annotations to comment on insights, dashboards, and recordings."
description="Use annotations to comment on insights, dashboards"
footer={
<div className="flex-1 flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -183,19 +175,6 @@ export function AnnotationModal({
maxLength={400}
/>
</LemonField>
{!!existingModalAnnotation &&
existingModalAnnotation.scope === AnnotationScope.Recording &&
!!existingModalAnnotation.recording_id &&
!!existingModalAnnotation.date_marker ? (
<div className="flex flex-row justify-end">
<ViewRecordingButton
sessionId={existingModalAnnotation.recording_id}
timestamp={existingModalAnnotation.date_marker}
inModal={true}
type="secondary"
/>
</div>
) : null}
</Form>
</LemonModal>
)

View File

@@ -20,7 +20,6 @@ export const annotationScopeToName: Record<AnnotationScope, string> = {
[AnnotationScope.Dashboard]: 'Dashboard',
[AnnotationScope.Project]: 'Project',
[AnnotationScope.Organization]: 'Organization',
[AnnotationScope.Recording]: 'Recording',
}
export const annotationScopeToLevel: Record<AnnotationScope, number> = {
@@ -28,7 +27,6 @@ export const annotationScopeToLevel: Record<AnnotationScope, number> = {
[AnnotationScope.Dashboard]: 1,
[AnnotationScope.Project]: 2,
[AnnotationScope.Organization]: 3,
[AnnotationScope.Recording]: 4,
}
export interface AnnotationModalForm {
@@ -37,7 +35,6 @@ export interface AnnotationModalForm {
content: AnnotationType['content']
dashboardItemId: AnnotationType['dashboard_item'] | null
dashboardId: AnnotationType['dashboard_id'] | null
recordingId: AnnotationType['recording_id'] | null
}
export const annotationModalLogic = kea<annotationModalLogicType>([

View File

@@ -15,21 +15,21 @@ const PlayerFrameCommentOverlayContent = (): JSX.Element | null => {
const { setIsCommenting } = useActions(sessionRecordingPlayerLogic)
const theBuiltOverlayLogic = playerCommentOverlayLogic({ recordingId: sessionRecordingId, ...logicProps })
const { recordingAnnotation, isRecordingAnnotationSubmitting } = useValues(theBuiltOverlayLogic)
const { submitRecordingAnnotation, resetRecordingAnnotation } = useActions(theBuiltOverlayLogic)
const { recordingComment, isRecordingCommentSubmitting } = useValues(theBuiltOverlayLogic)
const { submitRecordingComment, resetRecordingComment } = useActions(theBuiltOverlayLogic)
return isCommenting ? (
<div className="absolute bottom-4 left-4 z-20 w-90">
<div className="flex flex-col bg-primary border border-border rounded p-2 shadow-lg">
<Form
logic={playerCommentOverlayLogic}
formKey="recordingAnnotation"
id="recording-annotation-form"
formKey="recordingComment"
id="recording-comment-form"
enableFormOnSubmit
className="flex flex-col gap-y-1"
>
<div className="flex flex-col gap-y-1">
<LemonField name="annotationId" className="hidden">
<LemonField name="commentId" className="hidden">
<input type="hidden" />
</LemonField>
<LemonField
@@ -45,31 +45,31 @@ const PlayerFrameCommentOverlayContent = (): JSX.Element | null => {
<LemonField name="content">
<LemonTextAreaMarkdown
placeholder="Comment on this recording?"
data-attr="create-annotation-input"
data-attr="create-recording-comment-input"
maxLength={400}
/>
</LemonField>
</div>
<div className="flex gap-2 mt-2 justify-between">
<LemonButton
data-attr="cancel-recording-annotation"
data-attr="cancel-recording-comment"
type="secondary"
onClick={() => {
resetRecordingAnnotation()
resetRecordingComment()
setIsCommenting(false)
}}
>
Cancel
</LemonButton>
<LemonButton
form="recording-annotation-form"
form="recording-comment-form"
type="primary"
onClick={submitRecordingAnnotation}
data-attr="create-recording-annotation-submit"
onClick={submitRecordingComment}
data-attr="create-recording-comment-submit"
size="small"
loading={isRecordingAnnotationSubmitting}
loading={isRecordingCommentSubmitting}
>
{recordingAnnotation.annotationId ? 'Update' : 'Save'}
{recordingComment.commentId ? 'Update' : 'Save'}
</LemonButton>
</div>
</Form>

View File

@@ -1,5 +1,5 @@
import { actions, kea, path } from 'kea'
import { RecordingAnnotationForm } from 'scenes/session-recordings/player/commenting/playerFrameCommentOverlayLogic'
import { RecordingCommentForm } from 'scenes/session-recordings/player/commenting/playerFrameCommentOverlayLogic'
import type { playerCommentModelType } from './playerCommentModelType'
@@ -10,6 +10,7 @@ import type { playerCommentModelType } from './playerCommentModelType'
export const playerCommentModel = kea<playerCommentModelType>([
path(['scenes', 'session-recordings', 'player', 'playerCommentModel']),
actions({
startCommenting: (annotation: RecordingAnnotationForm | null) => ({ annotation }),
startCommenting: (comment: RecordingCommentForm | null) => ({ comment }),
commentEdited: (recordingId: string) => ({ recordingId }),
}),
])

View File

@@ -5,26 +5,25 @@ import api from 'lib/api'
import { Dayjs, dayjs } from 'lib/dayjs'
import { colonDelimitedDuration } from 'lib/utils'
import { annotationsModel } from '~/models/annotationsModel'
import { AnnotationScope, AnnotationType } from '~/types'
import { CommentType } from '~/types'
import type { playerCommentOverlayLogicType } from './playerFrameCommentOverlayLogicType'
import { sessionRecordingPlayerLogic, SessionRecordingPlayerLogicProps } from '../sessionRecordingPlayerLogic'
import { lemonToast } from 'lib/lemon-ui/LemonToast'
import { isSingleEmoji } from 'scenes/session-recordings/utils'
import { playerCommentModel } from 'scenes/session-recordings/player/commenting/playerCommentModel'
export interface RecordingAnnotationForm {
export interface RecordingCommentForm {
// formatted time in recording, e.g. 00:00:00, 00:00:01, 00:00:02, etc.
// this is a string because we want to be able to display the time in the recording
timeInRecording: string | null
timeInRecording?: string | null
// number of seconds into recording
timestampInRecording?: number | null
// the date that the timeInRecording represents
dateForTimestamp?: Dayjs | null
content: string
recordingId: string | null
annotationId: AnnotationType['id'] | null
scope: AnnotationType['scope'] | null
commentId: CommentType['id'] | null
}
export interface PlayerCommentOverlayLogicProps extends SessionRecordingPlayerLogicProps {
@@ -36,15 +35,10 @@ export const playerCommentOverlayLogic = kea<playerCommentOverlayLogicType>([
props({} as PlayerCommentOverlayLogicProps),
connect((props: PlayerCommentOverlayLogicProps) => ({
values: [sessionRecordingPlayerLogic(props), ['currentPlayerTime', 'currentTimestamp', 'sessionPlayerData']],
actions: [
annotationsModel,
['appendAnnotations', 'replaceAnnotation'],
sessionRecordingPlayerLogic(props),
['setIsCommenting'],
],
actions: [sessionRecordingPlayerLogic(props), ['setIsCommenting']],
})),
actions({
editAnnotation: (annotation: RecordingAnnotationForm) => ({ annotation }),
editComment: (comment: RecordingCommentForm) => ({ comment }),
addEmojiComment: (emoji: string) => ({ emoji }),
setLoading: (isLoading: boolean) => ({ isLoading }),
}),
@@ -74,24 +68,22 @@ export const playerCommentOverlayLogic = kea<playerCommentOverlayLogicType>([
subscriptions(({ actions, values }) => ({
formattedTimestamp: (formattedTimestamp) => {
// as the timestamp from the player changes we track three representations of it
actions.setRecordingAnnotationValue('timeInRecording', formattedTimestamp)
actions.setRecordingAnnotationValue('timestampInRecording', values.currentPlayerTime)
actions.setRecordingAnnotationValue('dateForTimestamp', dayjs(values.currentTimestamp))
actions.setRecordingCommentValue('timeInRecording', formattedTimestamp)
actions.setRecordingCommentValue('timestampInRecording', values.currentPlayerTime)
actions.setRecordingCommentValue('dateForTimestamp', dayjs(values.currentTimestamp))
},
})),
listeners(({ actions, props, values }) => ({
editAnnotation: ({ annotation }) => {
actions.setRecordingAnnotationValue('content', annotation.content)
actions.setRecordingAnnotationValue('recordingId', annotation.recordingId)
// don't change the scope if it has one
actions.setRecordingAnnotationValue('scope', annotation.scope || AnnotationScope.Recording)
actions.setRecordingAnnotationValue('annotationId', annotation.annotationId)
editComment: ({ comment }) => {
actions.setRecordingCommentValue('content', comment.content)
actions.setRecordingCommentValue('recordingId', comment.recordingId)
actions.setRecordingCommentValue('commentId', comment.commentId)
// opening to edit also sets the player timestamp, which will update the timestamps in the form
actions.setIsCommenting(true)
},
setIsCommenting: ({ isCommenting }) => {
if (!isCommenting) {
actions.resetRecordingAnnotation()
actions.resetRecordingComment()
}
},
addEmojiComment: async ({ emoji }) => {
@@ -104,15 +96,16 @@ export const playerCommentOverlayLogic = kea<playerCommentOverlayLogicType>([
}, 250)
try {
const apiPayload = {
date_marker: dayjs(values.currentTimestamp).toISOString(),
await api.comments.create({
content: emoji,
scope: AnnotationScope.Recording,
recording_id: props.recordingId,
is_emoji: true,
}
const createdAnnotation = await api.annotations.create(apiPayload)
actions.appendAnnotations([createdAnnotation])
scope: 'recording',
item_id: props.recordingId,
item_context: {
is_emoji: true,
time_in_recording: dayjs(values.currentTimestamp).toISOString(),
},
})
playerCommentModel.actions.commentEdited(props.recordingId)
} finally {
if (loadingTimeout) {
clearTimeout(loadingTimeout)
@@ -122,45 +115,44 @@ export const playerCommentOverlayLogic = kea<playerCommentOverlayLogicType>([
},
})),
forms(({ props, values, actions }) => ({
recordingAnnotation: {
recordingComment: {
defaults: {
timeInRecording: values.formattedTimestamp ?? '00:00:00',
dateForTimestamp: null,
content: '',
scope: AnnotationScope.Recording,
recordingId: null,
annotationId: null,
} as RecordingAnnotationForm,
commentId: null,
} as RecordingCommentForm,
errors: ({ content }) => ({
content: !content?.trim()
? 'An annotation must have text content.'
? 'A comment must have text content.'
: content.length > 400
? 'Must be 400 characters or less'
: null,
}),
submit: async (data) => {
const { annotationId, content, dateForTimestamp, scope } = data
const { commentId, content, dateForTimestamp } = data
if (!dateForTimestamp) {
throw new Error('Cannot comment without a timestamp.')
}
const apiPayload = {
date_marker: dateForTimestamp.toISOString(),
content,
scope: scope || AnnotationScope.Recording,
recording_id: props.recordingId,
scope: 'recording',
item_id: props.recordingId,
item_context: {
time_in_recording: dateForTimestamp.toISOString(),
},
}
if (annotationId) {
const updatedAnnotation = await api.annotations.update(annotationId, apiPayload)
actions.replaceAnnotation(updatedAnnotation)
if (commentId) {
await api.comments.update(commentId, apiPayload)
} else {
const createdAnnotation = await api.annotations.create(apiPayload)
actions.appendAnnotations([createdAnnotation])
await api.comments.create(apiPayload)
}
actions.resetRecordingAnnotation()
playerCommentModel.actions.commentEdited(props.recordingId)
actions.resetRecordingComment()
actions.setIsCommenting(false)
},
},

View File

@@ -7,7 +7,6 @@ import { autoCaptureEventToDescription } from 'lib/utils'
import React, { memo, MutableRefObject } from 'react'
import {
InspectorListItem,
InspectorListItemAnnotationComment,
InspectorListItemComment,
InspectorListItemEvent,
InspectorListItemNotebookComment,
@@ -23,19 +22,25 @@ function isEventItem(x: InspectorListItem): x is InspectorListItemEvent {
}
function isNotebookComment(x: InspectorListItem): x is InspectorListItemNotebookComment {
return x.type === 'comment' && x.source === 'notebook'
}
function isAnnotationComment(x: InspectorListItem): x is InspectorListItemAnnotationComment {
return x.type === 'comment' && x.source === 'annotation'
if (x.type !== 'comment') {
return false
}
return 'source' in x && x.source === 'notebook'
}
function isComment(x: InspectorListItem): x is InspectorListItemComment {
return isAnnotationComment(x) || isNotebookComment(x)
if (x.type !== 'comment') {
return false
}
return 'source' in x && x.source === 'comment'
}
function isAnnotationEmojiComment(x: InspectorListItem): x is InspectorListItemAnnotationComment {
return isAnnotationComment(x) && !!x.data.is_emoji && !!x.data.content && isSingleEmoji(x.data.content)
function isAnyComment(x: InspectorListItem): x is InspectorListItemComment | InspectorListItemNotebookComment {
return x.type === 'comment'
}
function isEmojiComment(x: InspectorListItem): x is InspectorListItemComment {
return isComment(x) && !!x.data.item_context?.is_emoji && !!x.data.content && isSingleEmoji(x.data.content)
}
function PlayerSeekbarTick({
@@ -44,7 +49,7 @@ function PlayerSeekbarTick({
zIndex,
onClick,
}: {
item: InspectorListItemComment | InspectorListItemEvent
item: InspectorListItemComment | InspectorListItemNotebookComment | InspectorListItemEvent
endTimeMs: number
zIndex: number
onClick: (e: React.MouseEvent) => void
@@ -95,23 +100,14 @@ function PlayerSeekbarTick({
) : (
<div className="flex flex-col px-4 py-2 gap-y-2">
<TextContent text={item.data.content ?? ''} data-attr="PlayerSeekbarTicks--text-content" />
<ProfilePicture
user={
item.data.creation_type === 'GIT'
? { first_name: 'GitHub automation' }
: item.data.created_by
}
showName
size="md"
type={item.data.creation_type === 'GIT' ? 'bot' : 'person'}
/>{' '}
<ProfilePicture user={item.data.created_by} showName size="md" type="person" />{' '}
</div>
)
}
>
{isAnnotationEmojiComment(item) ? (
{isEmojiComment(item) ? (
<div className="PlayerSeekbarTick__emoji">{item.data.content}</div>
) : isComment(item) ? (
) : isAnyComment(item) ? (
<div className="PlayerSeekbarTick__comment">
<IconComment />
</div>
@@ -130,7 +126,7 @@ export const PlayerSeekbarTicks = memo(
seekToTime,
hoverRef,
}: {
seekbarItems: (InspectorListItemEvent | InspectorListItemComment)[]
seekbarItems: (InspectorListItemEvent | InspectorListItemComment | InspectorListItemNotebookComment)[]
endTimeMs: number
seekToTime: (timeInMilliseconds: number) => void
hoverRef: MutableRefObject<HTMLDivElement | null>

View File

@@ -18,40 +18,35 @@ const meta: Meta<typeof PlayerInspector> = {
decorators: [
mswDecorator({
get: {
'/api/projects/:team_id/annotations': {
'/api/projects/:team_id/comments': {
count: 1,
results: [
{
id: 21,
content: 'about seven seconds in there is this comment which is too long',
date_marker: '2024-11-15T09:19:35.620000Z',
creation_type: 'USR',
dashboard_item: null,
dashboard_id: null,
dashboard_name: null,
insight_short_id: null,
insight_name: null,
insight_derived_name: null,
id: '019838f3-1bab-0000-fce8-04be1d6b6fe3',
created_by: {
id: 1,
uuid: '0196b443-26f4-0000-5d24-b982365fe43d',
distinct_id: 'BpwPZw8BGaeISf7DlDprsui5J9DMIYjhE3fTFMJiEMF',
first_name: 'fasdadafsfasdadafsfasdadafsfasdadafsfasdadafsfasdadafs',
uuid: '019838c5-64ac-0000-9f43-17f1bf64f508',
distinct_id: 'xugZUZjVMSe5Ceo67Y1KX85kiQqB4Gp5OSdC02cjsWl',
first_name: 'fasda',
last_name: '',
email: 'paul@posthog.com',
is_email_verified: false,
hedgehog_config: null,
role_at_organization: 'data',
role_at_organization: 'other',
},
created_at: '2025-06-11T13:15:06.976791Z',
updated_at: '2025-06-11T13:15:06.977228Z',
deleted: false,
content: 'about seven seconds in there is this comment which is too long',
version: 0,
created_at: '2025-07-23T20:21:53.197354Z',
item_id: '01975ab7-e00e-726f-aada-988b2f7fa053',
item_context: {
is_emoji: false,
time_in_recording: '2024-11-15T09:19:35.620000Z',
},
scope: 'recording',
recording_id: '01975ab7-e00e-726f-aada-988b2f7fa053',
source_comment: null,
},
],
next: null,
previous: null,
},
'/api/environments/:team_id/session_recordings/:id': largeRecordingMetaJson,
'/api/environments/:team_id/session_recordings/:id/snapshots': (req, res, ctx) => {

View File

@@ -1,21 +1,23 @@
import { IconInfo, IconPencil } from '@posthog/icons'
import { IconPencil } from '@posthog/icons'
import { useActions } from 'kea'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { ProfilePicture } from 'lib/lemon-ui/ProfilePicture'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
import { toSentenceCase } from 'lib/utils'
import { notebookPanelLogic } from 'scenes/notebooks/NotebookPanel/notebookPanelLogic'
import {
InspectorListItemAnnotationComment,
InspectorListItemComment,
InspectorListItemNotebookComment,
} from 'scenes/session-recordings/player/inspector/playerInspectorLogic'
import { playerCommentModel } from 'scenes/session-recordings/player/commenting/playerCommentModel'
import { RecordingAnnotationForm } from 'scenes/session-recordings/player/commenting/playerFrameCommentOverlayLogic'
import { RecordingCommentForm } from 'scenes/session-recordings/player/commenting/playerFrameCommentOverlayLogic'
import { TextContent } from 'lib/components/Cards/TextCard/TextCard'
export interface ItemCommentProps {
item: InspectorListItemComment
item: InspectorListItemComment | InspectorListItemNotebookComment
}
function isInspectorListItemNotebookComment(x: ItemCommentProps['item']): x is InspectorListItemNotebookComment {
return 'comment' in x.data
}
function ItemNotebookComment({ item }: { item: InspectorListItemNotebookComment }): JSX.Element {
@@ -30,7 +32,7 @@ function ItemNotebookComment({ item }: { item: InspectorListItemNotebookComment
)
}
function ItemAnnotationComment({ item }: { item: InspectorListItemAnnotationComment }): JSX.Element {
function ItemComment({ item }: { item: InspectorListItemComment }): JSX.Element {
// lazy but good enough check for markdown image urls
// we don't want to render markdown in the list row if there's an image since it won't fit
const hasMarkdownImage = (item.data.content ?? '').includes('![')
@@ -61,16 +63,8 @@ function ItemAnnotationComment({ item }: { item: InspectorListItemAnnotationComm
)
}
function isInspectorListItemNotebookComment(x: ItemCommentProps['item']): x is InspectorListItemNotebookComment {
return 'comment' in x.data
}
export function ItemComment({ item }: ItemCommentProps): JSX.Element {
return isInspectorListItemNotebookComment(item) ? (
<ItemNotebookComment item={item} />
) : (
<ItemAnnotationComment item={item} />
)
export function ItemAnyComment({ item }: ItemCommentProps): JSX.Element {
return isInspectorListItemNotebookComment(item) ? <ItemNotebookComment item={item} /> : <ItemComment item={item} />
}
function ItemCommentNotebookDetail({ item }: { item: InspectorListItemNotebookComment }): JSX.Element {
@@ -97,39 +91,31 @@ function ItemCommentNotebookDetail({ item }: { item: InspectorListItemNotebookCo
)
}
function ItemCommentAnnotationDetail({ item }: { item: InspectorListItemAnnotationComment }): JSX.Element {
function ItemCommentDetail({ item }: { item: InspectorListItemComment }): JSX.Element {
const { startCommenting } = useActions(playerCommentModel)
return (
<div data-attr="item-annotation-comment" className="font-light w-full flex flex-col gap-y-1">
<div className="px-2 py-1 text-xs border-t w-full flex justify-between items-center">
<Tooltip title="Annotations can be scoped to the project or organization, or to individual insights or dashboards. Project and organization scoped annotations are shown in the recording timeline.">
<div className="flex flex-row items-center gap-2">
<IconInfo className="text-muted text-xs" />
Scope: {toSentenceCase(item.data.scope)}
</div>
</Tooltip>
<div className="px-2 py-1 text-xs border-t w-full flex justify-end items-center">
<LemonButton
type="secondary"
onClick={() => {
void (async () => {
// relying on the click here to set the player timestamp
// so this shouldn't swallow the click
const annotationEditPayload: RecordingAnnotationForm = {
annotationId: item.data.id,
scope: item.data.scope,
const commentEditPayload: RecordingCommentForm = {
commentId: item.data.id,
content: item.data.content ?? '',
dateForTimestamp: item.data.date_marker,
recordingId: item.data.recording_id ?? null,
timeInRecording: null,
dateForTimestamp: item.timestamp,
recordingId: item.data.item_id ?? null,
timestampInRecording: item.timeInRecording,
}
startCommenting(annotationEditPayload)
startCommenting(commentEditPayload)
})()
}}
size="xsmall"
icon={<IconPencil />}
>
Edit annotation
Edit
</LemonButton>
</div>
@@ -140,20 +126,15 @@ function ItemCommentAnnotationDetail({ item }: { item: InspectorListItemAnnotati
/>
</div>
<ProfilePicture
user={item.data.creation_type === 'GIT' ? { first_name: 'GitHub automation' } : item.data.created_by}
showName
size="md"
type={item.data.creation_type === 'GIT' ? 'bot' : 'person'}
/>
<ProfilePicture user={item.data.created_by} showName size="md" type="person" />
</div>
)
}
export function ItemCommentDetail({ item }: ItemCommentProps): JSX.Element {
export function ItemAnyCommentDetail({ item }: ItemCommentProps): JSX.Element {
return isInspectorListItemNotebookComment(item) ? (
<ItemCommentNotebookDetail item={item} />
) : (
<ItemCommentAnnotationDetail item={item} />
<ItemCommentDetail item={item} />
)
}

View File

@@ -2,23 +2,23 @@ import { Meta, StoryFn, StoryObj } from '@storybook/react'
import { now } from 'lib/dayjs'
import { LemonDivider } from 'lib/lemon-ui/LemonDivider'
import {
ItemComment,
ItemCommentDetail,
ItemAnyComment,
ItemAnyCommentDetail,
ItemCommentProps,
} from 'scenes/session-recordings/player/inspector/components/ItemComment'
} from 'scenes/session-recordings/player/inspector/components/ItemAnyComment'
import {
InspectorListItemAnnotationComment,
InspectorListItemComment,
InspectorListItemNotebookComment,
RecordingComment,
} from 'scenes/session-recordings/player/inspector/playerInspectorLogic'
import { mswDecorator } from '~/mocks/browser'
import { AnnotationScope, AnnotationType } from '~/types'
import { CommentType } from '~/types'
type Story = StoryObj<typeof ItemComment>
const meta: Meta<typeof ItemComment> = {
type Story = StoryObj<typeof ItemAnyComment>
const meta: Meta<typeof ItemAnyComment> = {
title: 'Components/PlayerInspector/ItemComment',
component: ItemComment,
component: ItemAnyComment,
decorators: [
mswDecorator({
get: {},
@@ -49,18 +49,18 @@ function makeNotebookItem(
}
}
function makeAnnotationItem(
itemOverrides: Partial<InspectorListItemAnnotationComment> = {},
dataOverrides: Partial<AnnotationType> = {}
): InspectorListItemAnnotationComment {
function makeCommentItem(
itemOverrides: Partial<InspectorListItemComment> = {},
dataOverrides: Partial<CommentType> = {}
): InspectorListItemComment {
return {
data: {
id: 0,
created_at: now(),
date_marker: now(),
updated_at: now().toISOString(),
scope: AnnotationScope.Project,
id: '0',
version: 0,
created_at: now().toISOString(),
scope: 'recording',
content: '🪓😍🪓😍🪓😍🪓😍',
item_context: {},
created_by: {
id: 1,
uuid: '0196b443-26f4-0000-5d24-b982365fe43d',
@@ -75,13 +75,13 @@ function makeAnnotationItem(
timeInRecording: 0,
timestamp: now(),
type: 'comment',
source: 'annotation',
source: 'comment',
search: '',
...itemOverrides,
}
}
const BasicTemplate: StoryFn<typeof ItemComment> = (props: Partial<ItemCommentProps>) => {
const BasicTemplate: StoryFn<typeof ItemAnyComment> = (props: Partial<ItemCommentProps>) => {
props.item = props.item || makeNotebookItem()
const propsToUse = props as ItemCommentProps
@@ -89,14 +89,14 @@ const BasicTemplate: StoryFn<typeof ItemComment> = (props: Partial<ItemCommentPr
return (
<div className="flex flex-col gap-2 min-w-96">
<h3>Collapsed</h3>
<ItemComment {...propsToUse} />
<ItemAnyComment {...propsToUse} />
<LemonDivider />
<h3>Expanded</h3>
<ItemCommentDetail {...propsToUse} />
<ItemAnyCommentDetail {...propsToUse} />
<LemonDivider />
<h3>Expanded with overflowing comment</h3>
<div className="w-52">
<ItemCommentDetail
<ItemAnyCommentDetail
{...propsToUse}
item={
{
@@ -113,7 +113,7 @@ const BasicTemplate: StoryFn<typeof ItemComment> = (props: Partial<ItemCommentPr
<LemonDivider />
<h3>Collapsed with overflowing comment</h3>
<div className="w-52">
<ItemComment
<ItemAnyComment
{...propsToUse}
item={
{
@@ -138,5 +138,5 @@ NotebookComment.args = {
export const AnnotationComment: Story = BasicTemplate.bind({})
AnnotationComment.args = {
item: makeAnnotationItem(),
item: makeCommentItem(),
}

View File

@@ -22,7 +22,10 @@ import { Tooltip } from 'lib/lemon-ui/Tooltip'
import { ceilMsToClosestSecond } from 'lib/utils'
import { FunctionComponent, isValidElement, useEffect, useRef } from 'react'
import { ItemTimeDisplay } from 'scenes/session-recordings/components/ItemTimeDisplay'
import { ItemComment, ItemCommentDetail } from 'scenes/session-recordings/player/inspector/components/ItemComment'
import {
ItemAnyComment,
ItemAnyCommentDetail,
} from 'scenes/session-recordings/player/inspector/components/ItemAnyComment'
import { ItemInactivity } from 'scenes/session-recordings/player/inspector/components/ItemInactivity'
import { ItemSummary } from 'scenes/session-recordings/player/inspector/components/ItemSummary'
import { useDebouncedCallback } from 'use-debounce'
@@ -164,7 +167,7 @@ function RowItemTitle({
) : item.type === 'doctor' ? (
<ItemDoctor item={item} />
) : item.type === 'comment' ? (
<ItemComment item={item} />
<ItemAnyComment item={item} />
) : item.type === 'inspector-summary' ? (
<ItemSummary item={item} />
) : item.type === 'inactivity' ? (
@@ -195,7 +198,7 @@ function RowItemDetail({
'doctor' ? (
<ItemDoctorDetail item={item} />
) : item.type === 'comment' ? (
<ItemCommentDetail item={item} />
<ItemAnyCommentDetail item={item} />
) : null}
</div>
)

View File

@@ -6,6 +6,7 @@ import {
InspectorListItemComment,
InspectorListItemDoctor,
InspectorListItemEvent,
InspectorListItemNotebookComment,
InspectorListOfflineStatusChange,
} from 'scenes/session-recordings/player/inspector/playerInspectorLogic'
@@ -93,10 +94,10 @@ describe('filtering inspector list items', () => {
{
type: 'comment',
source: 'notebook',
} as InspectorListItemComment,
} as InspectorListItemNotebookComment,
{
type: 'comment',
source: 'annotation',
source: 'comment',
} as InspectorListItemComment,
],
miniFiltersByKey: { comment: { enabled } as unknown as SharedListMiniFilter },

View File

@@ -27,6 +27,60 @@ describe('playerInspectorLogic', () => {
},
],
},
'api/projects/:team/comments': {
results: [
{
id: '019838f3-1bab-0000-fce8-04be1d6b6fe3',
created_by: {
id: 1,
uuid: '019838c5-64ac-0000-9f43-17f1bf64f508',
distinct_id: 'xugZUZjVMSe5Ceo67Y1KX85kiQqB4Gp5OSdC02cjsWl',
first_name: 'fasda',
last_name: '',
email: 'paul@posthog.com',
is_email_verified: false,
hedgehog_config: null,
role_at_organization: 'other',
},
deleted: false,
content: '🥶',
version: 0,
created_at: '2025-07-23T20:21:53.197354Z',
item_id: '019838c8-8f12-7dfa-b651-abf957639b4b',
item_context: {
is_emoji: true,
time_in_recording: '2025-07-23T19:37:25.284Z',
},
scope: 'recording',
source_comment: null,
},
{
id: '019838c9-d3bb-0000-dae0-18031d78ad67',
created_by: {
id: 1,
uuid: '019838c5-64ac-0000-9f43-17f1bf64f508',
distinct_id: 'xugZUZjVMSe5Ceo67Y1KX85kiQqB4Gp5OSdC02cjsWl',
first_name: 'fasda',
last_name: '',
email: 'paul@posthog.com',
is_email_verified: false,
hedgehog_config: null,
role_at_organization: 'other',
},
deleted: false,
content: '😏',
version: 0,
created_at: '2025-07-23T19:36:47.813482Z',
item_id: '019838c8-8f12-7dfa-b651-abf957639b4b',
item_context: {
is_emoji: true,
time_in_recording: '2025-07-23T19:35:47.216Z',
},
scope: 'recording',
source_comment: null,
},
],
},
},
})
initKeaTests()
@@ -42,10 +96,29 @@ describe('playerInspectorLogic', () => {
describe('item comments', () => {
it('does not load comments without prompting', async () => {
await expectLogic(logic).toMatchValues({
sessionNotebookComments: null,
sessionComments: null,
})
})
it('reads notebook comments from data logic', async () => {
await expectLogic(dataLogic, () => {
dataLogic.actions.maybeLoadRecordingMeta()
}).toDispatchActions(['loadRecordingNotebookCommentsSuccess'])
await expectLogic(logic).toMatchValues({
sessionNotebookComments: [
{
timeInRecording: 12,
comment: 'The comment',
notebookShortId: '12345',
notebookTitle: 'The notebook',
id: 'abcdefg',
},
],
})
})
it('reads comments from data logic', async () => {
await expectLogic(dataLogic, () => {
dataLogic.actions.maybeLoadRecordingMeta()
@@ -54,11 +127,54 @@ describe('playerInspectorLogic', () => {
await expectLogic(logic).toMatchValues({
sessionComments: [
{
timeInRecording: 12,
comment: 'The comment',
notebookShortId: '12345',
notebookTitle: 'The notebook',
id: 'abcdefg',
content: '🥶',
created_at: '2025-07-23T20:21:53.197354Z',
created_by: {
distinct_id: 'xugZUZjVMSe5Ceo67Y1KX85kiQqB4Gp5OSdC02cjsWl',
email: 'paul@posthog.com',
first_name: 'fasda',
hedgehog_config: null,
id: 1,
is_email_verified: false,
last_name: '',
role_at_organization: 'other',
uuid: '019838c5-64ac-0000-9f43-17f1bf64f508',
},
deleted: false,
id: '019838f3-1bab-0000-fce8-04be1d6b6fe3',
item_context: {
is_emoji: true,
time_in_recording: '2025-07-23T19:37:25.284Z',
},
item_id: '019838c8-8f12-7dfa-b651-abf957639b4b',
scope: 'recording',
source_comment: null,
version: 0,
},
{
content: '😏',
created_at: '2025-07-23T19:36:47.813482Z',
created_by: {
distinct_id: 'xugZUZjVMSe5Ceo67Y1KX85kiQqB4Gp5OSdC02cjsWl',
email: 'paul@posthog.com',
first_name: 'fasda',
hedgehog_config: null,
id: 1,
is_email_verified: false,
last_name: '',
role_at_organization: 'other',
uuid: '019838c5-64ac-0000-9f43-17f1bf64f508',
},
deleted: false,
id: '019838c9-d3bb-0000-dae0-18031d78ad67',
item_context: {
is_emoji: true,
time_in_recording: '2025-07-23T19:35:47.216Z',
},
item_id: '019838c8-8f12-7dfa-b651-abf957639b4b',
scope: 'recording',
source_comment: null,
version: 0,
},
],
})

View File

@@ -29,7 +29,7 @@ import { sessionRecordingEventUsageLogic } from 'scenes/session-recordings/sessi
import { RecordingsQuery } from '~/queries/schema/schema-general'
import { getCoreFilterDefinition } from '~/taxonomy/helpers'
import {
AnnotationType,
CommentType,
MatchedRecordingEvent,
PerformanceEvent,
RecordingConsoleLogV2,
@@ -110,14 +110,12 @@ export type InspectorListItemNotebookComment = InspectorListItemBase & {
data: RecordingComment
}
export type InspectorListItemAnnotationComment = InspectorListItemBase & {
export type InspectorListItemComment = InspectorListItemBase & {
type: 'comment'
source: 'annotation'
data: AnnotationType
source: 'comment'
data: CommentType
}
export type InspectorListItemComment = InspectorListItemNotebookComment | InspectorListItemAnnotationComment
export type InspectorListItemConsole = InspectorListItemBase & {
type: 'console'
data: RecordingConsoleLogV2
@@ -155,6 +153,7 @@ export type InspectorListItem =
| InspectorListItemDoctor
| InspectorListBrowserVisibility
| InspectorListItemComment
| InspectorListItemNotebookComment
| InspectorListItemSummary
| InspectorListItemInactivity
@@ -226,15 +225,25 @@ function getPayloadFor(customEvent: customEvent, tag: string): Record<string, an
return customEvent.data.payload as Record<string, any>
}
function commentTimestamp(
comment: RecordingComment,
function notebookCommentTimestamp(
timeInRecording: number,
start: Dayjs | null
): {
timeInRecording: number
timestamp: dayjs.Dayjs | undefined
} {
const timestamp = start?.add(comment.timeInRecording, 'ms')
return { timestamp, timeInRecording: comment.timeInRecording }
const timestamp = start?.add(timeInRecording, 'ms')
return { timestamp, timeInRecording }
}
function commentTimestamp(
commentTime: Dayjs,
start: Dayjs | null
): {
timeInRecording: number
timestamp: dayjs.Dayjs | undefined
} {
return { timestamp: commentTime, timeInRecording: commentTime.valueOf() - (start?.valueOf() ?? 0) }
}
export const playerInspectorLogic = kea<playerInspectorLogicType>([
@@ -264,12 +273,12 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
'start',
'end',
'durationMs',
'sessionNotebookComments',
'sessionComments',
'sessionCommentsLoading',
'windowIdForTimestamp',
'sessionPlayerMetaData',
'segments',
'sessionAnnotations',
'annotationsLoading',
],
sessionRecordingPlayerLogic(props),
['currentPlayerTime'],
@@ -558,11 +567,16 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
],
notebookCommentItems: [
(s) => [s.sessionComments, s.windowIdForTimestamp, s.windowNumberForID, s.start],
(sessionComments, windowIdForTimestamp, windowNumberForID, start): InspectorListItemNotebookComment[] => {
(s) => [s.sessionNotebookComments, s.windowIdForTimestamp, s.windowNumberForID, s.start],
(
sessionNotebookComments,
windowIdForTimestamp,
windowNumberForID,
start
): InspectorListItemNotebookComment[] => {
const items: InspectorListItemNotebookComment[] = []
for (const comment of sessionComments || []) {
const { timestamp, timeInRecording } = commentTimestamp(comment, start)
for (const comment of sessionNotebookComments || []) {
const { timestamp, timeInRecording } = notebookCommentTimestamp(comment.timeInRecording, start)
if (timestamp) {
items.push({
highlightColor: 'primary',
@@ -581,20 +595,33 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
},
],
annotationItems: [
(s) => [s.sessionAnnotations, s.windowIdForTimestamp, s.windowNumberForID],
(sessionAnnotations, windowIdForTimestamp, windowNumberForID): InspectorListItemAnnotationComment[] => {
const items: InspectorListItemAnnotationComment[] = []
for (const annotation of sessionAnnotations || []) {
const windowId = windowIdForTimestamp(annotation.timestamp.valueOf())
items.push({
...annotation,
type: 'comment',
source: 'annotation',
highlightColor: 'primary',
windowId: windowId,
windowNumber: windowNumberForID(windowId),
})
commentItems: [
(s) => [s.sessionComments, s.windowIdForTimestamp, s.windowNumberForID, s.start],
(sessionComments, windowIdForTimestamp, windowNumberForID, start): InspectorListItemComment[] => {
const items: InspectorListItemComment[] = []
for (const comment of sessionComments || []) {
if (!comment.item_context.time_in_recording) {
continue
}
const { timestamp, timeInRecording } = commentTimestamp(
dayjs(comment.item_context.time_in_recording),
start
)
if (timestamp) {
const item: InspectorListItemComment = {
timestamp,
timeInRecording,
type: 'comment',
source: 'comment',
highlightColor: 'primary',
windowId: windowIdForTimestamp(timestamp.valueOf()),
windowNumber: windowNumberForID(windowIdForTimestamp(timestamp.valueOf())),
data: comment,
search: comment.content,
}
items.push(item)
}
}
return items
},
@@ -686,7 +713,7 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
s.matchingEventUUIDs,
s.windowNumberForID,
s.allContextItems,
s.annotationItems,
s.commentItems,
s.notebookCommentItems,
],
(
@@ -697,7 +724,7 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
matchingEventUUIDs,
windowNumberForID,
allContextItems,
annotationItems,
commentItems,
notebookCommentItems
): InspectorListItem[] => {
// NOTE: Possible perf improvement here would be to have a selector to parse the items
@@ -786,8 +813,8 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
items.push(event)
}
for (const annotation of annotationItems || []) {
items.push(annotation)
for (const comment of commentItems || []) {
items.push(comment)
}
for (const notebookComment of notebookCommentItems || []) {
@@ -928,8 +955,8 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
s.consoleLogs,
s.allPerformanceEvents,
s.doctorEvents,
s.sessionAnnotations,
s.annotationsLoading,
s.sessionComments,
s.sessionCommentsLoading,
],
(
sessionEventsDataLoading,
@@ -939,8 +966,8 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
logs,
performanceEvents,
doctorEvents,
sessionAnnotations,
annotationsLoading
sessionComments,
sessionCommentsLoading
): Record<FilterableInspectorListItemTypes, 'loading' | 'ready' | 'empty'> => {
const dataForEventsState = sessionEventsDataLoading ? 'loading' : events?.length ? 'ready' : 'empty'
const dataForConsoleState =
@@ -963,9 +990,9 @@ export const playerInspectorLogic = kea<playerInspectorLogicType>([
: 'empty'
// TODO include notebook comments here?
const dataForCommentState = annotationsLoading
const dataForCommentState = sessionCommentsLoading
? 'loading'
: sessionAnnotations?.length
: sessionComments?.length
? 'ready'
: 'empty'

View File

@@ -8,10 +8,7 @@ import { Dayjs, dayjs } from 'lib/dayjs'
import { featureFlagLogic, FeatureFlagsSet } from 'lib/logic/featureFlagLogic'
import { chainToElements } from 'lib/utils/elements-chain'
import posthog from 'posthog-js'
import {
InspectorListItemAnnotationComment,
RecordingComment,
} from 'scenes/session-recordings/player/inspector/playerInspectorLogic'
import { RecordingComment } from 'scenes/session-recordings/player/inspector/playerInspectorLogic'
import {
parseEncodedSnapshots,
processAllSnapshots,
@@ -23,7 +20,7 @@ import { teamLogic } from 'scenes/teamLogic'
import { annotationsModel } from '~/models/annotationsModel'
import { hogql, HogQLQueryString } from '~/queries/utils'
import {
AnnotationScope,
CommentType,
RecordingEventsFilters,
RecordingEventType,
RecordingSegment,
@@ -43,6 +40,7 @@ import { sessionRecordingEventUsageLogic } from '../sessionRecordingEventUsageLo
import type { sessionRecordingDataLogicType } from './sessionRecordingDataLogicType'
import { getHrefFromSnapshot, ViewportResolution } from './snapshot-processing/patch-meta-event'
import { createSegments, mapSnapshotsToWindowId } from './utils/segmenter'
import { playerCommentModel } from 'scenes/session-recordings/player/commenting/playerCommentModel'
const IS_TEST_MODE = process.env.NODE_ENV === 'test'
const TWENTY_FOUR_HOURS_IN_MS = 24 * 60 * 60 * 1000 // +- before and after start and end of a recording to query for session linked events.
@@ -80,8 +78,9 @@ export const sessionRecordingDataLogic = kea<sessionRecordingDataLogicType>([
actions({
setFilters: (filters: Partial<RecordingEventsFilters>) => ({ filters }),
loadRecordingMeta: true,
loadRecordingComments: true,
maybeLoadRecordingMeta: true,
loadRecordingComments: true,
loadRecordingNotebookComments: true,
loadSnapshots: true,
loadSnapshotSources: (breakpointLength?: number) => ({ breakpointLength }),
loadNextSnapshotSource: true,
@@ -143,6 +142,19 @@ export const sessionRecordingDataLogic = kea<sessionRecordingDataLogicType>([
loaders(({ values, props, cache }) => ({
sessionComments: {
loadRecordingComments: async (_, breakpoint) => {
const empty: CommentType[] = []
if (!props.sessionRecordingId) {
return empty
}
const response = await api.comments.list({ item_id: props.sessionRecordingId })
breakpoint()
return response.results || empty
},
},
sessionNotebookComments: {
loadRecordingNotebookComments: async (_, breakpoint) => {
const empty: RecordingComment[] = []
if (!props.sessionRecordingId) {
return empty
@@ -255,7 +267,7 @@ export const sessionRecordingDataLogic = kea<sessionRecordingDataLogicType>([
(a, b) => a.timestamp - b.timestamp
)
// we store the data in the cache because we want to avoid copying this data as much as possible
// and kea's immutability means we were copying all of the data on every snapshot call
// and kea's immutability means we were copying all the data on every snapshot call
cache.snapshotsBySource = cache.snapshotsBySource || {}
// it doesn't matter which source we use as the key, since we combine the snapshots anyway
cache.snapshotsBySource[keyForSource(sources[0])] = { snapshots: parsedSnapshots }
@@ -429,6 +441,11 @@ AND properties.$lib != 'web'`
],
})),
listeners(({ values, actions, cache, props }) => ({
[playerCommentModel.actionTypes.commentEdited]: ({ recordingId }) => {
if (props.sessionRecordingId === recordingId) {
actions.loadRecordingComments()
}
},
loadSnapshots: () => {
// This kicks off the loading chain
if (!values.snapshotSourcesLoading) {
@@ -442,6 +459,9 @@ AND properties.$lib != 'web'`
if (!values.sessionCommentsLoading) {
actions.loadRecordingComments()
}
if (!values.sessionNotebookCommentsLoading) {
actions.loadRecordingNotebookComments()
}
},
loadSnapshotSources: () => {
// We only load events once we actually start loading the recording
@@ -492,7 +512,7 @@ AND properties.$lib != 'web'`
},
loadNextSnapshotSource: () => {
// yes this is ugly duplication but we're going to deprecate v1 and I want it to be clear which is which
// yes this is ugly duplication, but we're going to deprecate v1 and I want it to be clear which is which
if (values.snapshotSources?.some((s) => s.source === SnapshotSourceType.blob_v2)) {
const nextSourcesToLoad =
values.snapshotSources?.filter((s) => {
@@ -591,42 +611,6 @@ AND properties.$lib != 'web'`
},
})),
selectors(({ cache }) => ({
sessionAnnotations: [
(s) => [s.annotations, s.start, s.end],
(annotations, start, end): InspectorListItemAnnotationComment[] => {
const allowedScopes = [AnnotationScope.Recording, AnnotationScope.Project, AnnotationScope.Organization]
const startValue = start?.valueOf()
const endValue = end?.valueOf()
const result: InspectorListItemAnnotationComment[] = []
for (const annotation of annotations) {
if (!allowedScopes.includes(annotation.scope)) {
continue
}
if (!annotation.date_marker || !startValue || !endValue || !annotation.content) {
continue
}
const annotationTime = dayjs(annotation.date_marker).valueOf()
if (annotationTime < startValue || annotationTime > endValue) {
continue
}
result.push({
type: 'comment',
source: 'annotation',
data: annotation,
timestamp: dayjs(annotation.date_marker),
timeInRecording: annotation.date_marker.valueOf() - startValue,
search: annotation.content,
highlightColor: 'primary',
})
}
return result
},
],
webVitalsEvents: [
(s) => [s.sessionEventsData],
(sessionEventsData): RecordingEventType[] =>
@@ -770,14 +754,30 @@ AND properties.$lib != 'web'`
snapshotsLoaded: [(s) => [s.snapshotSources], (snapshotSources): boolean => !!snapshotSources],
fullyLoaded: [
(s) => [s.snapshots, s.sessionPlayerMetaDataLoading, s.snapshotsLoading, s.sessionEventsDataLoading],
(snapshots, sessionPlayerMetaDataLoading, snapshotsLoading, sessionEventsDataLoading): boolean => {
(s) => [
s.snapshots,
s.sessionPlayerMetaDataLoading,
s.snapshotsLoading,
s.sessionEventsDataLoading,
s.sessionCommentsLoading,
s.sessionNotebookCommentsLoading,
],
(
snapshots,
sessionPlayerMetaDataLoading,
snapshotsLoading,
sessionEventsDataLoading,
sessionCommentsLoading,
sessionNotebookCommentsLoading
): boolean => {
// TODO: Do a proper check for all sources having been loaded
return (
!!snapshots?.length &&
!sessionPlayerMetaDataLoading &&
!snapshotsLoading &&
!sessionEventsDataLoading
!sessionEventsDataLoading &&
!sessionCommentsLoading &&
!sessionNotebookCommentsLoading
)
},
],

View File

@@ -817,9 +817,9 @@ export const sessionRecordingPlayerLogic = kea<sessionRecordingPlayerLogicType>(
],
}),
listeners(({ props, values, actions, cache }) => ({
[playerCommentModel.actionTypes.startCommenting]: async ({ annotation }) => {
[playerCommentModel.actionTypes.startCommenting]: async ({ comment }) => {
actions.setIsCommenting(true)
if (annotation) {
if (comment) {
// and we need a short wait until the logic is mounted after calling setIsCommenting
const waitForLogic = async (): Promise<BuiltLogic<playerCommentOverlayLogicType> | null> => {
for (let attempts = 0; attempts < 5; attempts++) {
@@ -835,9 +835,9 @@ export const sessionRecordingPlayerLogic = kea<sessionRecordingPlayerLogicType>(
const theMountedLogic = await waitForLogic()
if (theMountedLogic) {
theMountedLogic.actions.editAnnotation(annotation)
theMountedLogic.actions.editComment(comment)
} else {
lemonToast.error('Could not start editing annotation 😓, please refresh the page and try again.')
lemonToast.error('Could not start editing that comment 😓, please refresh the page and try again.')
}
}
},

View File

@@ -2322,7 +2322,6 @@ export enum AnnotationScope {
Dashboard = 'dashboard',
Project = 'project',
Organization = 'organization',
Recording = 'recording',
}
export interface RawAnnotationType {
@@ -2341,9 +2340,6 @@ export interface RawAnnotationType {
dashboard_name?: DashboardBasicType['name'] | null
deleted?: boolean
creation_type?: 'USR' | 'GIT'
recording_id?: string | null
// convenience flag that indicates the content _should_ be a single emoji
is_emoji?: boolean
}
export interface AnnotationType extends Omit<RawAnnotationType, 'created_at' | 'date_marker'> {

View File

@@ -1,7 +1,6 @@
from datetime import datetime
from typing import Any
import emoji
from django.db.models import Q, QuerySet
from django.db.models.signals import post_save
from django.dispatch import receiver
@@ -35,8 +34,6 @@ class AnnotationSerializer(serializers.ModelSerializer):
"updated_at",
"deleted",
"scope",
"recording_id",
"is_emoji",
]
read_only_fields = [
"id",
@@ -54,15 +51,10 @@ class AnnotationSerializer(serializers.ModelSerializer):
return super().update(instance, validated_data)
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
is_emoji = attrs.get("is_emoji", False)
content = attrs.get("content", "")
scope = attrs.get("scope", None)
if is_emoji and content:
# Check if content is an emoji
if not emoji.is_emoji(content):
raise serializers.ValidationError("When is_emoji is True, content must be a single emoji")
elif is_emoji and not content:
raise serializers.ValidationError("When is_emoji is True, content cannot be empty")
if scope == Annotation.Scope.RECORDING.value:
raise serializers.ValidationError("Recording scope is deprecated")
return attrs

View File

@@ -260,8 +260,6 @@
"posthog_annotation"."creation_type",
"posthog_annotation"."date_marker",
"posthog_annotation"."deleted",
"posthog_annotation"."recording_id",
"posthog_annotation"."is_emoji",
"posthog_annotation"."apply_all",
"posthog_dashboarditem"."id",
"posthog_dashboarditem"."name",
@@ -614,8 +612,6 @@
"posthog_annotation"."creation_type",
"posthog_annotation"."date_marker",
"posthog_annotation"."deleted",
"posthog_annotation"."recording_id",
"posthog_annotation"."is_emoji",
"posthog_annotation"."apply_all",
"posthog_dashboarditem"."id",
"posthog_dashboarditem"."name",

View File

@@ -307,77 +307,6 @@ class TestAnnotation(APIBaseTest, QueryMatchingTest):
else:
assert len(results) == 0
def test_filter_annotations_for_session_replay_scenario(self) -> None:
"""Test a realistic session replay scenario with multiple annotations and scopes."""
# Session replay scenario: 1-hour recording from 10:00 to 11:00
session_start = datetime(2023, 1, 1, 10, 0, 0, tzinfo=ZoneInfo("UTC"))
session_end = datetime(2023, 1, 1, 11, 0, 0, tzinfo=ZoneInfo("UTC"))
# Create annotations at different times and scopes
annotations_data = [
# Inside session time range
(session_start + timedelta(minutes=15), Annotation.Scope.PROJECT, "Project annotation at 15min"),
(session_start + timedelta(minutes=30), Annotation.Scope.ORGANIZATION, "Org annotation at 30min"),
(session_start + timedelta(minutes=45), Annotation.Scope.PROJECT, "Project annotation at 45min"),
# Outside session time range (should not appear)
(session_start - timedelta(minutes=30), Annotation.Scope.PROJECT, "Before session"),
(session_end + timedelta(minutes=30), Annotation.Scope.ORGANIZATION, "After session"),
]
for date_marker, scope, content in annotations_data:
Annotation.objects.create(
organization=self.organization,
team=self.team,
created_by=self.user,
content=content,
scope=scope,
date_marker=date_marker,
)
# Test: Get all annotations within the session time range
response = self.client.get(
f"/api/projects/{self.team.id}/annotations/",
{
"date_from": session_start.isoformat(),
"date_to": session_end.isoformat(),
},
)
assert response.status_code == 200
results = response.json()["results"]
# Should get 3 annotations (the ones within the time range)
assert len(results) == 3
# Verify they're ordered by date_marker (newest first based on existing ordering)
contents = [r["content"] for r in results]
assert "Project annotation at 15min" in contents
assert "Org annotation at 30min" in contents
assert "Project annotation at 45min" in contents
assert "Before session" not in contents
assert "After session" not in contents
# Test: Filter by specific scope within time range
response_project_only = self.client.get(
f"/api/projects/{self.team.id}/annotations/",
{
"date_from": session_start.isoformat(),
"date_to": session_end.isoformat(),
"scope": Annotation.Scope.PROJECT,
},
)
assert response_project_only.status_code == 200
project_results = response_project_only.json()["results"]
# Should get 2 project-scoped annotations
assert len(project_results) == 2
project_contents = [r["content"] for r in project_results]
assert "Project annotation at 15min" in project_contents
assert "Project annotation at 45min" in project_contents
assert "Org annotation at 30min" not in project_contents
def test_filter_annotations_400_for_invalid_scope(self) -> None:
response = self.client.get(
f"/api/projects/{self.team.id}/annotations/",
@@ -386,6 +315,14 @@ class TestAnnotation(APIBaseTest, QueryMatchingTest):
assert response.status_code == 400
assert response.json()["detail"] == "Invalid scope: invalid_scope"
def test_create_annotations_400_for_recording_scope(self) -> None:
response = self.client.post(
f"/api/projects/{self.team.id}/annotations/",
{"scope": "recording"},
)
assert response.status_code == 400
assert response.json()["detail"] == "Recording scope is deprecated"
@parameterized.expand(
[
("invalid_date", "2024-01-01T11:00:00Z", "date_from must be a valid ISO 8601 date"),
@@ -402,203 +339,3 @@ class TestAnnotation(APIBaseTest, QueryMatchingTest):
)
assert response.status_code == 400
assert response.json()["detail"] == f"Invalid date range: {error_message}"
def test_filter_annotations_by_specific_recording(self) -> None:
annotation = Annotation.objects.create(
organization=self.organization,
team=self.team,
created_by=self.user,
content="Test annotation",
scope=Annotation.Scope.RECORDING,
recording_id="123e4567-e89b-12d3-a456-426614174000",
)
response = self.client.get(
f"/api/projects/{self.team.id}/annotations/",
{"recording": "123e4567-e89b-12d3-a456-426614174000"},
)
assert response.status_code == 200
assert len(response.json()["results"]) == 1
assert response.json()["results"][0]["id"] == annotation.id
@parameterized.expand(
[
("true", True), # Filter for emoji annotations
("false", False), # Filter for non-emoji annotations
("1", True), # Alternative true value
("0", False), # Alternative false value
]
)
def test_filter_annotations_by_is_emoji(self, is_emoji_param: str, should_return_emoji: bool) -> None:
"""Test filtering annotations by is_emoji parameter."""
Annotation.objects.create(
organization=self.organization,
team=self.team,
created_by=self.user,
content="😊",
is_emoji=True,
scope=Annotation.Scope.DASHBOARD,
recording_id="123e4567-e89b-12d3-a456-426614174000",
)
emoji_annotation = Annotation.objects.create(
organization=self.organization,
team=self.team,
created_by=self.user,
content="😊",
is_emoji=True,
scope=Annotation.Scope.RECORDING,
recording_id="123e4567-e89b-12d3-a456-426614174000",
)
# Create non-emoji annotation
text_annotation = Annotation.objects.create(
organization=self.organization,
team=self.team,
created_by=self.user,
content="Text annotation",
is_emoji=False,
scope=Annotation.Scope.RECORDING,
recording_id="123e4567-e89b-12d3-a456-426614174000",
)
# Test filtering by is_emoji parameter
response = self.client.get(
f"/api/projects/{self.team.id}/annotations/",
{"is_emoji": is_emoji_param, "scope": "recording"},
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 1
if should_return_emoji:
assert results[0]["id"] == emoji_annotation.id
assert results[0]["content"] == "😊"
assert results[0]["is_emoji"] is True
else:
assert results[0]["id"] == text_annotation.id
assert results[0]["content"] == "Text annotation"
assert results[0]["is_emoji"] is False
def test_filter_annotations_by_is_emoji_combined_with_other_filters(self) -> None:
"""Test combining is_emoji filter with scope and recording filters."""
# Create emoji annotation for recording - this should be returned
emoji_recording = Annotation.objects.create(
organization=self.organization,
team=self.team,
created_by=self.user,
content="🚀",
is_emoji=True,
scope=Annotation.Scope.RECORDING,
recording_id="123e4567-e89b-12d3-a456-426614174000",
)
# Create emoji annotation for project (different scope) - should not be returned
Annotation.objects.create(
organization=self.organization,
team=self.team,
created_by=self.user,
content="",
is_emoji=True,
scope=Annotation.Scope.PROJECT,
)
# Create non-emoji annotation for recording - should not be returned
Annotation.objects.create(
organization=self.organization,
team=self.team,
created_by=self.user,
content="Text note",
is_emoji=False,
scope=Annotation.Scope.RECORDING,
recording_id="123e4567-e89b-12d3-a456-426614174000",
)
# Test: Filter for emoji annotations on recordings only
response = self.client.get(
f"/api/projects/{self.team.id}/annotations/",
{
"is_emoji": "true",
"scope": "recording",
"recording": "123e4567-e89b-12d3-a456-426614174000",
},
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 1
assert results[0]["id"] == emoji_recording.id
assert results[0]["content"] == "🚀"
assert results[0]["is_emoji"] is True
assert results[0]["scope"] == "recording"
@parameterized.expand(
[
("Standard emoji", "😊", True, None),
("Another emoji", "🚀", True, None),
("Single letter", "a", False, "When is_emoji is True, content must be a single emoji"),
("Single digit", "1", False, "When is_emoji is True, content must be a single emoji"),
("Single symbol", "!", False, "When is_emoji is True, content must be a single emoji"),
("Empty string", "", False, "When is_emoji is True, content cannot be empty"),
("Multiple characters", "ab", False, "When is_emoji is True, content must be a single emoji"),
("Multiple emojis", "😊😊", False, "When is_emoji is True, content must be a single emoji"),
("No content", None, False, "When is_emoji is True, content cannot be empty"),
]
)
def test_create_annotation_emoji_validation(
self, _name: str, content: Optional[str], should_succeed: bool, error: str
) -> None:
"""Test emoji validation when creating annotations with is_emoji=True."""
data = {
"is_emoji": True,
"scope": "project",
"date_marker": "2020-01-01T00:00:00.000000Z",
}
if content is not None:
data["content"] = content
response = self.client.post(f"/api/projects/{self.team.id}/annotations/", data)
if should_succeed:
assert response.status_code == status.HTTP_201_CREATED
instance = Annotation.objects.get(pk=response.json()["id"])
assert instance.content == content
assert instance.is_emoji is True
else:
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["detail"] == error
@parameterized.expand(
[
("Valid emoji", "🚀", True, None),
("Invalid for emoji", "multiple chars", False, "When is_emoji is True, content must be a single emoji"),
("Empty string", "", False, "When is_emoji is True, content cannot be empty"),
("Single symbol", "!", False, "When is_emoji is True, content must be a single emoji"),
]
)
def test_update_annotation_emoji_validation(
self, _name: str, content: str, should_succeed: bool, error: str | None
) -> None:
"""Test emoji validation when updating annotations with is_emoji=True."""
annotation = Annotation.objects.create(
organization=self.organization,
team=self.team,
created_by=self.user,
content="old content",
is_emoji=False,
)
response = self.client.patch(
f"/api/projects/{self.team.id}/annotations/{annotation.pk}/",
{"content": content, "is_emoji": True},
)
if should_succeed:
assert response.status_code == status.HTTP_200_OK
annotation.refresh_from_db()
assert annotation.content == content
assert annotation.is_emoji is True
else:
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["detail"] == error

View File

@@ -0,0 +1,58 @@
# Generated by Django 4.2.22 on 2025-07-23 22:32
from django.db import migrations
# Bulk update every CHUNK_SIZE items
CHUNK_SIZE = 500
def migrate_recording_annotations(apps, schema_editor):
Annotation = apps.get_model("posthog", "Annotation")
Comment = apps.get_model("posthog", "Comment")
recording_annotations = Annotation.objects.filter(scope="recording", deleted=False)
comment_chunk = []
annotation_chunk = []
for annotation in recording_annotations.iterator(chunk_size=CHUNK_SIZE):
# technically date_marker can be null, but a recording comment without one is invalid
if annotation.date_marker:
comment_chunk.append(
Comment(
content=annotation.content,
scope="recording",
item_id=annotation.recording_id,
item_context={
"is_emoji": annotation.is_emoji,
"time_in_recording": annotation.date_marker.isoformat(),
"migrated_from_annotation": annotation.id,
},
team_id=annotation.team_id,
created_by=annotation.created_by,
created_at=annotation.created_at,
)
)
annotation.deleted = True
annotation_chunk.append(annotation)
# Bulk update every CHUNK_SIZE items
if len(comment_chunk) == CHUNK_SIZE or len(annotation_chunk) == CHUNK_SIZE:
Comment.objects.bulk_create(comment_chunk)
Annotation.objects.bulk_update(annotation_chunk, fields=["deleted"])
comment_chunk = []
annotation_chunk = []
if comment_chunk: # Handle remaining items if length is less than CHUNK_SIZE
Comment.objects.bulk_create(comment_chunk)
Annotation.objects.bulk_update(annotation_chunk, fields=["deleted"])
class Migration(migrations.Migration):
dependencies = [
("posthog", "0802_messagecategory_category_type_and_more_fix"),
]
operations = [
migrations.RunPython(migrate_recording_annotations, reverse_code=migrations.RunPython.noop),
]

View File

@@ -1 +1 @@
0802_messagecategory_category_type_and_more_fix
0803_move_recording_annotation_to_comments

View File

@@ -2,6 +2,7 @@ from typing import Optional
from django.db import models
from django.utils import timezone
from django_deprecate_fields import deprecate_field
class Annotation(models.Model):
@@ -28,14 +29,13 @@ class Annotation(models.Model):
creation_type = models.CharField(max_length=3, choices=CreationType.choices, default=CreationType.USER)
date_marker = models.DateTimeField(null=True, blank=True)
deleted = models.BooleanField(default=False)
# we don't want a foreign key, since not all recordings will be in postgres
recording_id = models.UUIDField(null=True, blank=True)
# convenience so that we can load just emoji annotations, without checking the content
is_emoji = models.BooleanField(default=False, null=True, blank=True)
# DEPRECATED: replaced by scope
apply_all = models.BooleanField(null=True)
# DEPRECATED: moved to the comment model
recording_id = deprecate_field(models.UUIDField(null=True, blank=True))
# DEPRECATED: moved to the comment model
is_emoji = deprecate_field(models.BooleanField(default=False, null=True, blank=True))
@property
def insight_short_id(self) -> Optional[str]: