Skip to content

Commit

Permalink
feat: indicate when others have watched a recording (PostHog#29067)
Browse files Browse the repository at this point in the history
  • Loading branch information
pauldambra authored Feb 21, 2025
1 parent 0665850 commit be54ed7
Show file tree
Hide file tree
Showing 16 changed files with 1,351 additions and 2,508 deletions.
15 changes: 8 additions & 7 deletions frontend/src/lib/lemon-ui/LemonBadge/LemonBadge.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { IconPlusSmall } from '@posthog/icons'
import { Meta, StoryFn, StoryObj } from '@storybook/react'
import { LemonButton } from 'lib/lemon-ui/LemonButton'

import { LemonBadge } from './LemonBadge'
import { LemonBadge, LemonBadgeProps } from './LemonBadge'

type Story = StoryObj<typeof LemonBadge>
const meta: Meta<typeof LemonBadge> = {
Expand Down Expand Up @@ -61,14 +61,15 @@ export const Sizes: StoryFn<typeof LemonBadge> = () => {
}

export const Status: StoryFn<typeof LemonBadge> = () => {
const statuses = ['primary', 'success', 'warning', 'danger', 'muted', 'data']
return (
<div className="flex space-x-2 items-center">
<span>primary:</span>
<LemonBadge content={<IconPlusSmall />} status="primary" />
<span>danger:</span>
<LemonBadge content={<IconPlusSmall />} status="danger" />
<span>muted:</span>
<LemonBadge content={<IconPlusSmall />} status="muted" />
{statuses.map((status) => (
<>
<span>{status}</span>
<LemonBadge content={<IconPlusSmall />} status={status as LemonBadgeProps['status']} />
</>
))}
</div>
)
}
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/queries/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -13864,11 +13864,18 @@
"type": "string"
},
"viewed": {
"description": "Whether this recording has been viewed already.",
"description": "Whether this recording has been viewed by you already.",
"type": "boolean"
},
"viewers": {
"description": "user ids of other users who have viewed this recording",
"items": {
"type": "string"
},
"type": "array"
}
},
"required": ["id", "viewed", "recording_duration", "start_time", "end_time", "snapshot_source"],
"required": ["id", "viewed", "viewers", "recording_duration", "start_time", "end_time", "snapshot_source"],
"type": "object"
},
"SessionsTimelineQuery": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function asRecording(param: Partial<SessionRecordingType>): SessionRecordingType
recording_duration: 0,
matching_events: [],
viewed: false,
viewers: [],
start_time: '2024-11-01 12:34',
end_time: '2024-11-01 13:34',
snapshot_source: 'web',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export const sessionRecordingFilePlaybackSceneLogic = kea<sessionRecordingFilePl
dataLogic.actions.loadRecordingMetaSuccess({
id: values.sessionRecording.id,
viewed: false,
viewers: [],
recording_duration: snapshots[snapshots.length - 1].timestamp - snapshots[0].timestamp,
person: values.sessionRecording.person || undefined,
start_time: dayjs(snapshots[0].timestamp).toISOString(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { IconPeople } from '@posthog/icons'
import { useValues } from 'kea'
import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton'
import { ProfileBubbles } from 'lib/lemon-ui/ProfilePicture'

import { SessionRecordingType } from '~/types'

import { sessionRecordingPlayerLogic } from '../sessionRecordingPlayerLogic'

function OtherWatchersLoading(): JSX.Element {
return (
<div className="flex flex-row space-x-2 items-center justify-center px-2 py-1">
<IconPeople />
<LemonSkeleton.Row repeat={1} className="h-5" />
</div>
)
}

function OtherWatchersDisplay({ metadata }: { metadata?: SessionRecordingType }): JSX.Element | null {
if (!metadata?.viewers) {
// to keep TS happy
return null
}

const count = metadata.viewers.length
const varyingText = count > 1 ? 'users have' : 'user has'
const label = `${count} other ${varyingText} watched this recording.`
return (
<div className="flex flex-row space-x-2 items-center justify-center px-2 py-1">
<ProfileBubbles people={metadata.viewers.map((v) => ({ email: v }))} />
<span>{label}</span>
</div>
)
}

function NoOtherWatchers(): JSX.Element {
return (
<div className="flex flex-row space-x-2 items-center justify-center px-2 py-1">
<IconPeople />
<span>Nobody else has watched this recording.</span>
</div>
)
}

export function PlayerSidebarOverviewOtherWatchers(): JSX.Element {
const { sessionPlayerMetaDataLoading, sessionPlayerMetaData } = useValues(sessionRecordingPlayerLogic)

return (
<div className="rounded border bg-surface-primary">
{sessionPlayerMetaDataLoading ? (
<OtherWatchersLoading />
) : sessionPlayerMetaData?.viewers ? (
<OtherWatchersDisplay metadata={sessionPlayerMetaData} />
) : (
<NoOtherWatchers />
)}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,19 @@ const meta: Meta = {
},
]
},
'/api/environments/:team_id/session_recordings/:id': recordingMetaJson,
'/api/environments/:team_id/session_recordings/:id': (req, res, ctx) => {
if (req.params.id === '12345') {
return res(ctx.json(recordingMetaJson))
} else if (req.params.id === 'thirty_others') {
return res(
ctx.json({
...recordingMetaJson,
viewers: Array.from({ length: 30 }, (_, i) => `${i}@example.com`),
})
)
}
return res(ctx.json({ ...recordingMetaJson, viewers: ['abcdefg'] }))
},
'api/projects/:team/notebooks': {
count: 0,
next: null,
Expand Down Expand Up @@ -170,15 +182,22 @@ export default meta

interface OverviewTabProps {
width: number
sessionId?: string
}

const OverviewTabTemplate: StoryFn<OverviewTabProps> = ({ width }: { width: number }) => {
const OverviewTabTemplate: StoryFn<OverviewTabProps> = ({
width,
sessionId = '12345',
}: {
width: number
sessionId?: string
}) => {
return (
// eslint-disable-next-line react/forbid-dom-props
<div style={{ width: `${width}px`, height: '100vh' }}>
<BindLogic
logic={sessionRecordingPlayerLogic}
props={{ playerKey: 'storybook', sessionRecordingId: '12345' }}
props={{ playerKey: 'storybook', sessionRecordingId: sessionId }}
>
<PlayerSidebarOverviewTab />
</BindLogic>
Expand All @@ -191,3 +210,9 @@ NarrowOverviewTab.args = { width: 320 }

export const WideOverviewTab = OverviewTabTemplate.bind({})
WideOverviewTab.args = { width: 500 }

export const OneOtherWatchersOverviewTab = OverviewTabTemplate.bind({})
OneOtherWatchersOverviewTab.args = { width: 400, sessionId: '34567' }

export const ManyOtherWatchersOverviewTab = OverviewTabTemplate.bind({})
ManyOtherWatchersOverviewTab.args = { width: 400, sessionId: 'thirty_others' }
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { PlayerSidebarSessionSummary } from 'scenes/session-recordings/player/si
import { playerMetaLogic } from '../playerMetaLogic'
import { sessionRecordingPlayerLogic } from '../sessionRecordingPlayerLogic'
import { PlayerSidebarOverviewGrid } from './PlayerSidebarOverviewGrid'
import { PlayerSidebarOverviewOtherWatchers } from './PlayerSidebarOverviewOtherWatchers'

export function PlayerSidebarOverviewTab(): JSX.Element {
const { logicProps } = useValues(sessionRecordingPlayerLogic)
Expand All @@ -14,6 +15,7 @@ export function PlayerSidebarOverviewTab(): JSX.Element {
<div className="flex flex-col overflow-auto bg-primary px-2 py-1 h-full space-y-1">
<PersonDisplay person={sessionPerson} withIcon withCopyButton placement="bottom" />
<PlayerSidebarOverviewGrid />
<PlayerSidebarOverviewOtherWatchers />
<PlayerSidebarSessionSummary />
</div>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.UnwatchedIndicator {
&.UnwatchedIndicator--primary {
background: var(--danger);
}

&.UnwatchedIndicator--secondary {
background: var(--accent-primary);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import './SessionRecordingPreview.scss'

import { IconBug, IconCursorClick, IconKeyboard, IconLive, IconPinFilled } from '@posthog/icons'
import clsx from 'clsx'
import { useValues } from 'kea'
Expand Down Expand Up @@ -150,10 +152,27 @@ function RecordingOngoingIndicator(): JSX.Element {
)
}

function UnwatchedIndicator(): JSX.Element {
function UnwatchedIndicator({ otherViewers }: { otherViewers: SessionRecordingType['viewers'] }): JSX.Element {
const tooltip = otherViewers.length ? (
<span>
You have not watched this recording yet. {otherViewers.length} other{' '}
{otherViewers.length === 1 ? 'person has' : 'people have'}.
</span>
) : (
<span>Nobody has watched this recording yet.</span>
)

return (
<Tooltip title="Indicates the recording has not been watched yet">
<div className="w-2 h-2 rounded-full bg-primary-3000" aria-label="unwatched-recording-label" />
<Tooltip title={tooltip}>
<div
className={clsx(
'UnwatchedIndicator w-2 h-2 rounded-full',
otherViewers.length ? 'UnwatchedIndicator--secondary' : 'UnwatchedIndicator--primary'
)}
aria-label={
otherViewers.length ? 'unwatched-recording-by-you-label' : 'unwatched-recording-by-everyone-label'
}
/>
</Tooltip>
)
}
Expand Down Expand Up @@ -261,7 +280,7 @@ export function SessionRecordingPreview({
>
{recording.ongoing ? <RecordingOngoingIndicator /> : null}
{pinned ? <PinnedIndicator /> : null}
{!recording.viewed ? <UnwatchedIndicator /> : null}
{!recording.viewed ? <UnwatchedIndicator otherViewers={recording.viewers} /> : null}
</div>
</div>
</DraggableToNotebook>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const mockSessons: SessionRecordingType[] = [
start_time: '2021-01-01T00:00:00Z',
end_time: '2021-01-01T01:00:00Z',
viewed: false,
viewers: [],
recording_duration: 0,
snapshot_source: 'web',
},
Expand All @@ -63,6 +64,7 @@ const mockSessons: SessionRecordingType[] = [
start_time: '2021-01-01T02:00:00Z',
end_time: '2021-01-01T03:00:00Z',
viewed: false,
viewers: [],
recording_duration: 0,
snapshot_source: 'mobile',
},
Expand All @@ -72,6 +74,7 @@ const mockSessons: SessionRecordingType[] = [
start_time: '2021-01-01T03:00:00Z',
end_time: '2021-01-01T04:00:00Z',
viewed: false,
viewers: [],
recording_duration: 0,
snapshot_source: 'unknown',
},
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1452,8 +1452,10 @@ export interface SessionRecordingSegmentType {

export interface SessionRecordingType {
id: string
/** Whether this recording has been viewed already. */
/** Whether this recording has been viewed by you already. */
viewed: boolean
/** user ids of other users who have viewed this recording */
viewers: string[]
/** Length of recording in seconds. */
recording_duration: number
active_seconds?: number
Expand Down
3 changes: 2 additions & 1 deletion posthog/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -2739,7 +2739,8 @@ class SessionRecordingType(BaseModel):
start_url: Optional[str] = None
storage: Optional[Storage] = Field(default=None, description="Where this recording information was loaded from")
summary: Optional[str] = None
viewed: bool = Field(..., description="Whether this recording has been viewed already.")
viewed: bool = Field(..., description="Whether this recording has been viewed by you already.")
viewers: list[str] = Field(..., description="user ids of other users who have viewed this recording")


class SessionsTimelineQueryResponse(BaseModel):
Expand Down
1 change: 1 addition & 0 deletions posthog/session_recordings/models/session_recording.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class Meta:
# DYNAMIC FIELDS

viewed: Optional[bool] = False
viewers: Optional[list[str]] = None
_person: Optional[Person] = None
matching_events: Optional[RecordingMatchingEvents] = None
ongoing: Optional[bool] = None
Expand Down
Loading

0 comments on commit be54ed7

Please sign in to comment.