feat ui: prefetch mobfiles on hover, use prefetched if exist (#2098)

This commit is contained in:
Delirium 2024-04-17 09:03:50 +02:00 committed by GitHub
parent 156e1f94d6
commit ceb714617e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 440 additions and 204 deletions

View file

@ -1,86 +1,99 @@
import withPermissions from 'HOCs/withPermissions';
import React from 'react'; import React from 'react';
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import usePageTitle from 'App/hooks/usePageTitle';
import { fetchV2, clearCurrentSession } from "Duck/sessions";
import { fetchList as fetchSlackList } from 'Duck/integrations/slack';
import { Link, NoContent, Loader } from 'UI';
import { sessions as sessionsRoute } from 'App/routes';
import withPermissions from 'HOCs/withPermissions'
import WebPlayer from './WebPlayer';
import { useStore } from 'App/mstore';
import { clearLogs } from 'App/dev/console';
import MobilePlayer from "Components/Session/MobilePlayer"; import { clearLogs } from 'App/dev/console';
import usePageTitle from 'App/hooks/usePageTitle';
import { useStore } from 'App/mstore';
import { sessions as sessionsRoute } from 'App/routes';
import MobilePlayer from 'Components/Session/MobilePlayer';
import { fetchList as fetchSlackList } from 'Duck/integrations/slack';
import { clearCurrentSession, fetchV2 } from 'Duck/sessions';
import { Link, Loader, NoContent } from 'UI';
import WebPlayer from './WebPlayer';
const SESSIONS_ROUTE = sessionsRoute(); const SESSIONS_ROUTE = sessionsRoute();
interface Props { interface Props {
sessionId: string; sessionId: string;
loading: boolean; loading: boolean;
hasErrors: boolean; hasErrors: boolean;
fetchV2: (sessionId: string) => void; fetchV2: (sessionId: string) => void;
clearCurrentSession: () => void; clearCurrentSession: () => void;
session: Record<string, any>; session: Record<string, any>;
} }
function Session({ function Session({
sessionId, sessionId,
loading, hasErrors,
hasErrors, fetchV2,
fetchV2, clearCurrentSession,
clearCurrentSession, session,
session, }: Props) {
}: Props) { usePageTitle('OpenReplay Session Player');
usePageTitle("OpenReplay Session Player"); const { sessionStore } = useStore();
const [ initializing, setInitializing ] = useState(true) useEffect(() => {
const { sessionStore } = useStore(); if (sessionId != null) {
useEffect(() => { fetchV2(sessionId);
if (sessionId != null) { } else {
fetchV2(sessionId) console.error('No sessionID in route.');
} else { }
console.error("No sessionID in route.") return () => {
} clearCurrentSession();
setInitializing(false) };
return () => { }, [sessionId]);
clearCurrentSession();
}
},[ sessionId ]);
useEffect(() => { useEffect(() => {
clearLogs() clearLogs();
sessionStore.resetUserFilter(); sessionStore.resetUserFilter();
} ,[]) }, []);
const player = session.isMobileNative ? <MobilePlayer /> : <WebPlayer /> const player = session.isMobileNative ? <MobilePlayer /> : <WebPlayer />;
return ( return (
<NoContent <NoContent
show={ hasErrors } show={hasErrors}
title="Session not found." title="Session not found."
subtext={ subtext={
<span> <span>
{'Please check your data retention plan, or try '} {'Please check your data retention plan, or try '}
<Link to={ SESSIONS_ROUTE } className="link">{'another one'}</Link> <Link to={SESSIONS_ROUTE} className="link">
</span> {'another one'}
} </Link>
> </span>
<Loader className="flex-1" loading={ loading || initializing }> }
{player} >
</Loader> <Loader className="flex-1" loading={!session.sessionId}>
</NoContent> {player}
); </Loader>
</NoContent>
);
} }
export default withPermissions(['SESSION_REPLAY'], '', true)(connect((state: any, props: any) => { export default withPermissions(
const { match: { params: { sessionId } } } = props; ['SESSION_REPLAY'],
return { '',
sessionId, true
loading: state.getIn([ 'sessions', 'loading' ]), )(
hasErrors: !!state.getIn([ 'sessions', 'errors' ]), connect(
session: state.getIn([ 'sessions', 'current' ]), (state: any, props: any) => {
}; const {
}, { match: {
fetchSlackList, params: { sessionId },
fetchV2, },
clearCurrentSession, } = props;
})(Session)); return {
sessionId,
loading: state.getIn(['sessions', 'loading']),
hasErrors: !!state.getIn(['sessions', 'errors']),
session: state.getIn(['sessions', 'current']),
};
},
{
fetchSlackList,
fetchV2,
clearCurrentSession,
}
)(Session)
);

View file

@ -1,21 +1,28 @@
import React, { useEffect, useState } from 'react'; import withLocationHandlers from 'HOCs/withLocationHandlers';
import { connect } from 'react-redux';
import { Modal, Loader } from 'UI';
import { toggleFullscreen, closeBottomBlock } from 'Duck/components/player';
import { fetchList } from 'Duck/integrations';
import { createWebPlayer } from 'Player'; import { createWebPlayer } from 'Player';
import { makeAutoObservable } from 'mobx'; import { makeAutoObservable } from 'mobx';
import withLocationHandlers from 'HOCs/withLocationHandlers';
import { useStore } from 'App/mstore';
import PlayerBlockHeader from './Player/ReplayPlayer/PlayerBlockHeader';
import ReadNote from '../Session_/Player/Controls/components/ReadNote';
import PlayerContent from './Player/ReplayPlayer/PlayerContent';
import { IPlayerContext, PlayerContext, defaultContextValue } from './playerContext';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { Note } from 'App/services/NotesService'; import React, { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useStore } from 'App/mstore';
import { Note } from 'App/services/NotesService';
import { closeBottomBlock, toggleFullscreen } from 'Duck/components/player';
import { fetchList } from 'Duck/integrations';
import { Loader, Modal } from 'UI';
import ReadNote from '../Session_/Player/Controls/components/ReadNote';
import PlayerBlockHeader from './Player/ReplayPlayer/PlayerBlockHeader';
import PlayerContent from './Player/ReplayPlayer/PlayerContent';
import { IPlayerContext, PlayerContext, defaultContextValue } from './playerContext';
const TABS = { const TABS = {
EVENTS: 'Activity', EVENTS: 'Activity',
CLICKMAP: 'Click Map', CLICKMAP: 'Click Map',
@ -54,13 +61,21 @@ function WebPlayer(props: any) {
useEffect(() => { useEffect(() => {
playerInst = undefined; playerInst = undefined;
if (!session.sessionId || contextValue.player !== undefined) return; if (!session.sessionId || contextValue.player !== undefined) return;
const mobData = sessionStore.prefetchedMobUrls[session.sessionId] as Record<string, any> | undefined;
const usePrefetched = props.prefetched && mobData?.data;
fetchList('issues'); fetchList('issues');
sessionStore.setUserTimezone(session.timezone); sessionStore.setUserTimezone(session.timezone);
const [WebPlayerInst, PlayerStore] = createWebPlayer( const [WebPlayerInst, PlayerStore] = createWebPlayer(
session, session,
(state) => makeAutoObservable(state), (state) => makeAutoObservable(state),
toast toast,
props.prefetched,
); );
if (usePrefetched) {
if (mobData?.data) {
WebPlayerInst.preloadFirstFile(mobData?.data)
}
}
setContextValue({ player: WebPlayerInst, store: PlayerStore }); setContextValue({ player: WebPlayerInst, store: PlayerStore });
playerInst = WebPlayerInst; playerInst = WebPlayerInst;
@ -78,6 +93,12 @@ function WebPlayer(props: any) {
} }
}, [session.sessionId]); }, [session.sessionId]);
useEffect(() => {
if (!props.prefetched && session.domURL.length > 0) {
playerInst?.reinit(session)
}
}, [session.domURL.length, props.prefetched])
const { firstVisualEvent: visualOffset, messagesProcessed, tabStates, ready } = contextValue.store?.get() || {}; const { firstVisualEvent: visualOffset, messagesProcessed, tabStates, ready } = contextValue.store?.get() || {};
const cssLoading = ready && tabStates ? Object.values(tabStates).some( const cssLoading = ready && tabStates ? Object.values(tabStates).some(
({ cssLoading }) => cssLoading ({ cssLoading }) => cssLoading
@ -205,6 +226,7 @@ export default connect(
(state: any) => ({ (state: any) => ({
session: state.getIn(['sessions', 'current']), session: state.getIn(['sessions', 'current']),
insights: state.getIn(['sessions', 'insights']), insights: state.getIn(['sessions', 'insights']),
prefetched: state.getIn(['sessions', 'prefetched']),
visitedEvents: state.getIn(['sessions', 'visitedEvents']), visitedEvents: state.getIn(['sessions', 'visitedEvents']),
jwt: state.getIn(['user', 'jwt']), jwt: state.getIn(['user', 'jwt']),
fullscreen: state.getIn(['components', 'player', 'fullscreen']), fullscreen: state.getIn(['components', 'player', 'fullscreen']),

View file

@ -1,46 +1,74 @@
import React, { useState, useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { Link, Icon } from 'UI'; import { useHistory } from 'react-router';
import { session as sessionRoute, liveSession as liveSessionRoute } from 'App/routes';
import {
liveSession as liveSessionRoute,
session as sessionRoute,
withSiteId,
} from 'App/routes';
import { Icon, Link } from 'UI';
import { connect } from 'react-redux';
const PLAY_ICON_NAMES = { const PLAY_ICON_NAMES = {
notPlayed: 'play-fill', notPlayed: 'play-fill',
played: 'play-circle-light', played: 'play-circle-light',
hovered: 'play-hover', hovered: 'play-hover',
}; };
const getDefaultIconName = (isViewed: any) => (!isViewed ? PLAY_ICON_NAMES.notPlayed : PLAY_ICON_NAMES.played); const getDefaultIconName = (isViewed: any) =>
!isViewed ? PLAY_ICON_NAMES.notPlayed : PLAY_ICON_NAMES.played;
interface Props { interface Props {
isAssist: boolean; isAssist: boolean;
viewed: boolean; viewed: boolean;
sessionId: string; sessionId: string;
onClick?: () => void; onClick?: () => void;
queryParams?: any; queryParams?: any;
newTab?: boolean; newTab?: boolean;
query?: string query?: string;
beforeOpen?: () => void;
siteId?: string;
} }
export default function PlayLink(props: Props) { function PlayLink(props: Props) {
const { isAssist, viewed, sessionId, onClick = null, queryParams } = props; const { isAssist, viewed, sessionId, onClick = null, queryParams } = props;
const defaultIconName = getDefaultIconName(viewed); const history = useHistory();
const defaultIconName = getDefaultIconName(viewed);
const [isHovered, toggleHover] = useState(false); const [isHovered, toggleHover] = useState(false);
const [iconName, setIconName] = useState(defaultIconName); const [iconName, setIconName] = useState(defaultIconName);
useEffect(() => { useEffect(() => {
if (isHovered) setIconName(PLAY_ICON_NAMES.hovered); if (isHovered) setIconName(PLAY_ICON_NAMES.hovered);
else setIconName(getDefaultIconName(viewed)); else setIconName(getDefaultIconName(viewed));
}, [isHovered, viewed]); }, [isHovered, viewed]);
const link = isAssist ? liveSessionRoute(sessionId, queryParams) : sessionRoute(sessionId); const link = isAssist
return ( ? liveSessionRoute(sessionId, queryParams)
<Link : sessionRoute(sessionId);
onClick={onClick ? onClick : () => {}}
to={link + (props.query ? props.query : '')} const handleBeforeOpen = () => {
onMouseEnter={() => toggleHover(true)} if (props.beforeOpen) {
onMouseLeave={() => toggleHover(false)} props.beforeOpen();
target={props.newTab ? "_blank" : undefined} rel={props.newTab ? "noopener noreferrer" : undefined} history.push(withSiteId(link + (props.query ? props.query : ''), props.siteId));
> }
<Icon name={iconName} size={38} color={isAssist ? 'tealx' : 'teal'} /> };
</Link>
); const onLinkClick = props.beforeOpen ? handleBeforeOpen : onClick;
return (
<Link
onClick={onLinkClick}
to={link + (props.query ? props.query : '')}
onMouseEnter={() => toggleHover(true)}
onMouseLeave={() => toggleHover(false)}
target={props.newTab ? '_blank' : undefined}
rel={props.newTab ? 'noopener noreferrer' : undefined}
>
<Icon name={iconName} size={38} color={isAssist ? 'tealx' : 'teal'} />
</Link>
);
} }
export default connect((state: any, props: Props) => ({
siteId: props.siteId || state.getIn([ 'site', 'siteId' ])
}))(PlayLink);

View file

@ -1,26 +1,38 @@
import React, { useMemo } from 'react';
import cn from 'classnames'; import cn from 'classnames';
import { CountryFlag, Avatar, TextEllipsis, Label, Icon, Tooltip, ItemMenu } from 'UI'; import copy from 'copy-to-clipboard';
import { useStore } from 'App/mstore'; import { Duration } from 'luxon';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import React, { useMemo } from 'react';
import { connect } from 'react-redux';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { toast } from 'react-toastify';
import { durationFormatted, formatTimeOrDate } from 'App/date'; import { durationFormatted, formatTimeOrDate } from 'App/date';
import stl from './sessionItem.module.css'; import { presetSession } from 'App/duck/sessions';
import Counter from './Counter'; import { useStore } from 'App/mstore';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import SessionMetaList from './SessionMetaList';
import PlayLink from './PlayLink';
import ErrorBars from './ErrorBars';
import { import {
assist as assistRoute, assist as assistRoute,
isRoute,
liveSession, liveSession,
sessions as sessionsRoute,
session as sessionRoute, session as sessionRoute,
isRoute sessions as sessionsRoute,
} from 'App/routes'; } from 'App/routes';
import { capitalize } from 'App/utils'; import { capitalize } from 'App/utils';
import { Duration } from 'luxon'; import {
import copy from 'copy-to-clipboard'; Avatar,
import { toast } from 'react-toastify'; CountryFlag,
Icon,
ItemMenu,
Label,
TextEllipsis,
Tooltip,
} from 'UI';
import Counter from './Counter';
import ErrorBars from './ErrorBars';
import PlayLink from './PlayLink';
import SessionMetaList from './SessionMetaList';
import stl from './sessionItem.module.css';
const ASSIST_ROUTE = assistRoute(); const ASSIST_ROUTE = assistRoute();
const ASSIST_LIVE_SESSION = liveSession(); const ASSIST_LIVE_SESSION = liveSession();
@ -69,12 +81,20 @@ interface Props {
ignoreAssist?: boolean; ignoreAssist?: boolean;
bookmarked?: boolean; bookmarked?: boolean;
toggleFavorite?: (sessionId: string) => void; toggleFavorite?: (sessionId: string) => void;
query?: string query?: string;
presetSession?: typeof presetSession;
}
const PREFETCH_STATE = {
none: 0,
loading: 1,
fetched: 2,
} }
function SessionItem(props: RouteComponentProps & Props) { function SessionItem(props: RouteComponentProps & Props) {
const { settingsStore } = useStore(); const { settingsStore, sessionStore } = useStore();
const { timezone, shownTimezone } = settingsStore.sessionSettings; const { timezone, shownTimezone } = settingsStore.sessionSettings;
const [prefetchState, setPrefetched] = React.useState(PREFETCH_STATE.none);
const { const {
session, session,
@ -88,6 +108,7 @@ function SessionItem(props: RouteComponentProps & Props) {
ignoreAssist = false, ignoreAssist = false,
bookmarked = false, bookmarked = false,
query, query,
presetSession,
} = props; } = props;
const { const {
@ -110,7 +131,7 @@ function SessionItem(props: RouteComponentProps & Props) {
metadata, metadata,
issueTypes, issueTypes,
active, active,
timezone: userTimezone timezone: userTimezone,
} = session; } = session;
const location = props.location; const location = props.location;
@ -120,11 +141,11 @@ function SessionItem(props: RouteComponentProps & Props) {
const hasUserId = userId || userAnonymousId; const hasUserId = userId || userAnonymousId;
const isSessions = isRoute(SESSIONS_ROUTE, location.pathname); const isSessions = isRoute(SESSIONS_ROUTE, location.pathname);
const isAssist = const isAssist =
!ignoreAssist && (!ignoreAssist &&
(isRoute(ASSIST_ROUTE, location.pathname) || (isRoute(ASSIST_ROUTE, location.pathname) ||
isRoute(ASSIST_LIVE_SESSION, location.pathname) || isRoute(ASSIST_LIVE_SESSION, location.pathname) ||
location.pathname.includes('multiview')) || location.pathname.includes('multiview'))) ||
props.live props.live;
const isLastPlayed = lastPlayedSessionId === sessionId; const isLastPlayed = lastPlayedSessionId === sessionId;
@ -146,16 +167,32 @@ function SessionItem(props: RouteComponentProps & Props) {
}${sessionRoute(sessionId)}`; }${sessionRoute(sessionId)}`;
copy(sessionPath); copy(sessionPath);
toast.success('Session URL copied to clipboard'); toast.success('Session URL copied to clipboard');
} },
}, },
{ {
icon: 'trash', icon: 'trash',
text: 'Remove', text: 'Remove',
onClick: () => (props.toggleFavorite ? props.toggleFavorite(sessionId) : null) onClick: () =>
} props.toggleFavorite ? props.toggleFavorite(sessionId) : null,
},
]; ];
}, []); }, []);
const handleHover = async () => {
if (prefetchState !== PREFETCH_STATE.none || props.live || isAssist) return;
setPrefetched(PREFETCH_STATE.loading);
try {
await sessionStore.getFirstMob(sessionId);
setPrefetched(PREFETCH_STATE.fetched);
} catch (e) {
console.error('Error while prefetching first mob', e);
}
};
const openSession = () => {
if (props.live || isAssist || prefetchState === PREFETCH_STATE.none) return
presetSession?.(session);
};
return ( return (
<Tooltip <Tooltip
delay={0} delay={0}
@ -164,23 +201,31 @@ function SessionItem(props: RouteComponentProps & Props) {
> >
<div <div
className={cn(stl.sessionItem, 'flex flex-col py-2 px-4')} className={cn(stl.sessionItem, 'flex flex-col py-2 px-4')}
id='session-item' id="session-item"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className='flex items-start'> <div className="flex items-start">
<div className={cn('flex items-center w-full')}> <div className={cn('flex items-center w-full')}>
{!compact && ( {!compact && (
<div className='flex items-center pr-2 shrink-0' style={{ width: '40%' }}> <div
className="flex items-center pr-2 shrink-0"
style={{ width: '40%' }}
>
<div> <div>
<Avatar isActive={active} seed={userNumericHash} isAssist={isAssist} /> <Avatar
isActive={active}
seed={userNumericHash}
isAssist={isAssist}
/>
</div> </div>
<div <div className="flex flex-col overflow-hidden color-gray-medium ml-3 justify-between items-center shrink-0">
className='flex flex-col overflow-hidden color-gray-medium ml-3 justify-between items-center shrink-0'>
<div <div
className={cn('text-lg', { className={cn('text-lg', {
'color-teal cursor-pointer': !disableUser && hasUserId && !props.isDisabled, 'color-teal cursor-pointer':
[stl.userName]: !disableUser && hasUserId && !props.isDisabled, !disableUser && hasUserId && !props.isDisabled,
'color-gray-medium': disableUser || !hasUserId [stl.userName]:
!disableUser && hasUserId && !props.isDisabled,
'color-gray-medium': disableUser || !hasUserId,
})} })}
onClick={() => onClick={() =>
!disableUser && !hasUserFilter && hasUserId !disableUser && !hasUserFilter && hasUserId
@ -190,7 +235,7 @@ function SessionItem(props: RouteComponentProps & Props) {
> >
<TextEllipsis <TextEllipsis
text={userDisplayName} text={userDisplayName}
maxWidth='200px' maxWidth="200px"
popupProps={{ inverted: true, size: 'tiny' }} popupProps={{ inverted: true, size: 'tiny' }}
/> />
</div> </div>
@ -199,7 +244,7 @@ function SessionItem(props: RouteComponentProps & Props) {
)} )}
<div <div
style={{ width: compact ? '40%' : '20%' }} style={{ width: compact ? '40%' : '20%' }}
className='px-2 flex flex-col justify-between' className="px-2 flex flex-col justify-between"
> >
<div> <div>
<Tooltip <Tooltip
@ -208,7 +253,9 @@ function SessionItem(props: RouteComponentProps & Props) {
title={ title={
<div className={'flex flex-col gap-1'}> <div className={'flex flex-col gap-1'}>
<span> <span>
Local Time: {formatTimeOrDate(startedAt, timezone, true)} {timezone.label} Local Time:{' '}
{formatTimeOrDate(startedAt, timezone, true)}{' '}
{timezone.label}
</span> </span>
{userTimezone ? ( {userTimezone ? (
<span> <span>
@ -217,7 +264,7 @@ function SessionItem(props: RouteComponentProps & Props) {
startedAt, startedAt,
{ {
label: userTimezone.split('+').join(' +'), label: userTimezone.split('+').join(' +'),
value: userTimezone.split(':')[0] value: userTimezone.split(':')[0],
}, },
true true
)}{' '} )}{' '}
@ -226,36 +273,49 @@ function SessionItem(props: RouteComponentProps & Props) {
) : null} ) : null}
</div> </div>
} }
className='w-fit !block' className="w-fit !block"
> >
<TextEllipsis <TextEllipsis
text={formatTimeOrDate( text={formatTimeOrDate(
startedAt, startedAt,
shownTimezone === 'user' && userTimezone shownTimezone === 'user' && userTimezone
? { ? {
label: userTimezone.split('+').join(' +'), label: userTimezone.split('+').join(' +'),
value: userTimezone.split(':')[0] value: userTimezone.split(':')[0],
} }
: timezone : timezone
)} )}
popupProps={{ inverted: true, size: 'tiny' }} popupProps={{ inverted: true, size: 'tiny' }}
/> />
</Tooltip> </Tooltip>
</div> </div>
<div className='flex items-center color-gray-medium py-1'> <div className="flex items-center color-gray-medium py-1">
{!isAssist && ( {!isAssist && (
<> <>
<div className='color-gray-medium'> <div className="color-gray-medium">
<span className='mr-1'>{eventsCount}</span> <span className="mr-1">{eventsCount}</span>
<span>{eventsCount === 0 || eventsCount > 1 ? 'Events' : 'Event'}</span> <span>
{eventsCount === 0 || eventsCount > 1
? 'Events'
: 'Event'}
</span>
</div> </div>
<Icon name='circle-fill' size={3} className='mx-4' /> <Icon name="circle-fill" size={3} className="mx-4" />
</> </>
)} )}
<div>{live || props.live ? <Counter startTime={startedAt} /> : formattedDuration}</div> <div>
{live || props.live ? (
<Counter startTime={startedAt} />
) : (
formattedDuration
)}
</div>
</div> </div>
</div> </div>
<div style={{ width: '30%' }} className='px-2 flex flex-col justify-between'> <div
style={{ width: '30%' }}
className="px-2 flex flex-col justify-between"
>
<div style={{ height: '21px' }}> <div style={{ height: '21px' }}>
<CountryFlag <CountryFlag
userCity={userCity} userCity={userCity}
@ -276,14 +336,17 @@ function SessionItem(props: RouteComponentProps & Props) {
{userOs && userBrowser ? ( {userOs && userBrowser ? (
<Icon name="circle-fill" size={3} className="mx-4" /> <Icon name="circle-fill" size={3} className="mx-4" />
) : null} ) : null}
<span className={/ios/i.test(userOs) ? '' : 'capitalize'} style={{ maxWidth: '70px' }}> <span
className={/ios/i.test(userOs) ? '' : 'capitalize'}
style={{ maxWidth: '70px' }}
>
<TextEllipsis <TextEllipsis
text={/ios/i.test(userOs) ? 'iOS' : capitalize(userOs)} text={/ios/i.test(userOs) ? 'iOS' : capitalize(userOs)}
popupProps={{ inverted: true, size: 'tiny' }} popupProps={{ inverted: true, size: 'tiny' }}
/> />
</span> </span>
<Icon name='circle-fill' size={3} className='mx-4' /> <Icon name="circle-fill" size={3} className="mx-4" />
<span className='capitalize' style={{ maxWidth: '70px' }}> <span className="capitalize" style={{ maxWidth: '70px' }}>
<TextEllipsis <TextEllipsis
text={capitalize(userDeviceType)} text={capitalize(userDeviceType)}
popupProps={{ inverted: true, size: 'tiny' }} popupProps={{ inverted: true, size: 'tiny' }}
@ -292,35 +355,44 @@ function SessionItem(props: RouteComponentProps & Props) {
</div> </div>
</div> </div>
{isSessions && ( {isSessions && (
<div style={{ width: '10%' }} className='self-center px-2 flex items-center'> <div
style={{ width: '10%' }}
className="self-center px-2 flex items-center"
>
<ErrorBars count={issueTypes.length} /> <ErrorBars count={issueTypes.length} />
</div> </div>
)} )}
</div> </div>
<div className='flex items-center'> <div className="flex items-center">
<div <div
className={cn( className={cn(
stl.playLink, stl.playLink,
props.isDisabled ? 'cursor-not-allowed' : 'cursor-pointer' props.isDisabled ? 'cursor-not-allowed' : 'cursor-pointer'
)} )}
id='play-button' id="play-button"
data-viewed={viewed} data-viewed={viewed}
> >
{live && session.isCallActive && session.agentIds!.length > 0 ? ( {live && session.isCallActive && session.agentIds!.length > 0 ? (
<div className='mr-4'> <div className="mr-4">
<Label className='bg-gray-lightest p-1 px-2 rounded-lg'> <Label className="bg-gray-lightest p-1 px-2 rounded-lg">
<span className='color-gray-medium text-xs' style={{ whiteSpace: 'nowrap' }}> <span
className="color-gray-medium text-xs"
style={{ whiteSpace: 'nowrap' }}
>
CALL IN PROGRESS CALL IN PROGRESS
</span> </span>
</Label> </Label>
</div> </div>
) : null} ) : null}
{isSessions && ( {isSessions && (
<div className='mr-4 flex-shrink-0 w-24'> <div className="mr-4 flex-shrink-0 w-24">
{isLastPlayed && ( {isLastPlayed && (
<Label className='bg-gray-lightest p-1 px-2 rounded-lg'> <Label className="bg-gray-lightest p-1 px-2 rounded-lg">
<span className='color-gray-medium text-xs' style={{ whiteSpace: 'nowrap' }}> <span
className="color-gray-medium text-xs"
style={{ whiteSpace: 'nowrap' }}
>
LAST PLAYED LAST PLAYED
</span> </span>
</Label> </Label>
@ -329,15 +401,15 @@ function SessionItem(props: RouteComponentProps & Props) {
)} )}
{props.isAdd ? ( {props.isAdd ? (
<div <div
className='rounded-full border-tealx p-2 border' className="rounded-full border-tealx p-2 border"
onClick={() => (props.isDisabled ? null : props.onClick())} onClick={() => (props.isDisabled ? null : props.onClick())}
> >
<div className='bg-tealx rounded-full p-2'> <div className="bg-tealx rounded-full p-2">
<Icon name='plus' size={16} color='white' /> <Icon name="plus" size={16} color="white" />
</div> </div>
</div> </div>
) : ( ) : (
<> <div onMouseEnter={handleHover}>
<PlayLink <PlayLink
isAssist={isAssist} isAssist={isAssist}
sessionId={sessionId} sessionId={sessionId}
@ -345,21 +417,24 @@ function SessionItem(props: RouteComponentProps & Props) {
onClick={onClick} onClick={onClick}
queryParams={queryParams} queryParams={queryParams}
query={query} query={query}
beforeOpen={props.live || isAssist ? undefined : openSession}
/> />
{bookmarked && ( {bookmarked && (
<div className='ml-2 cursor-pointer'> <div className="ml-2 cursor-pointer">
<ItemMenu bold items={menuItems} /> <ItemMenu bold items={menuItems} />
</div> </div>
)} )}
</> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
{_metaList.length > 0 && <SessionMetaList className='mt-4' metaList={_metaList} />} {_metaList.length > 0 && (
<SessionMetaList className="mt-4" metaList={_metaList} />
)}
</div> </div>
</Tooltip> </Tooltip>
); );
} }
export default withRouter(observer(SessionItem)); export default withRouter(connect(null, { presetSession })(observer(SessionItem)));

View file

@ -48,6 +48,8 @@ const SET_ACTIVE_TAB = 'sessions/SET_ACTIVE_TAB';
const CLEAR_CURRENT_SESSION = 'sessions/CLEAR_CURRENT_SESSION' const CLEAR_CURRENT_SESSION = 'sessions/CLEAR_CURRENT_SESSION'
const PREFETCH_SESSION = 'sessions/PREFETCH_SESSION'
const range = getDateRangeFromValue(LAST_7_DAYS); const range = getDateRangeFromValue(LAST_7_DAYS);
const defaultDateFilters = { const defaultDateFilters = {
url: '', url: '',
@ -60,6 +62,7 @@ const initObj = {
list: [], list: [],
sessionIds: [], sessionIds: [],
current: new Session(), current: new Session(),
prefetched: false,
eventsAsked: false, eventsAsked: false,
total: 0, total: 0,
keyMap: Map(), keyMap: Map(),
@ -185,6 +188,13 @@ const reducer = (state = initialState, action: IAction) => {
return state return state
.set('current', session) .set('current', session)
.set('prefetched', false)
}
case PREFETCH_SESSION: {
const { data } = action;
return state
.set('current', data)
.set('prefetched', true);
} }
case FETCH_EVENTS.SUCCESS: { case FETCH_EVENTS.SUCCESS: {
const { const {
@ -454,6 +464,13 @@ export const fetchV2 = (sessionId: string) =>
} }
export function presetSession(sessionData) {
return {
type: PREFETCH_SESSION,
data: sessionData
}
}
export function clearCurrentSession() { export function clearCurrentSession() {
return { return {
type: CLEAR_CURRENT_SESSION type: CLEAR_CURRENT_SESSION

View file

@ -10,6 +10,7 @@ import { getDateRangeFromValue } from "App/dateRange";
import { getRE, setSessionFilter, getSessionFilter, compareJsonObjects, cleanSessionFilters } from 'App/utils'; import { getRE, setSessionFilter, getSessionFilter, compareJsonObjects, cleanSessionFilters } from 'App/utils';
import store from 'App/store' import store from 'App/store'
import { Note } from "App/services/NotesService"; import { Note } from "App/services/NotesService";
import { loadFile } from "../player/web/network/loadFiles";
class UserFilter { class UserFilter {
endDate: number = new Date().getTime(); endDate: number = new Date().getTime();
@ -125,6 +126,7 @@ export default class SessionStore {
previousId = '' previousId = ''
nextId = '' nextId = ''
userTimezone = '' userTimezone = ''
prefetchedMobUrls: Record<string, { data: Uint8Array, entryNum: number }> = {}
constructor() { constructor() {
makeAutoObservable(this, { makeAutoObservable(this, {
@ -141,6 +143,28 @@ export default class SessionStore {
this.userFilter = new UserFilter(); this.userFilter = new UserFilter();
} }
async getFirstMob(sessionId: string) {
const { domURL } = await sessionService.getFirstMobUrl(sessionId)
await loadFile(
domURL[0],
(data) => this.setPrefetchedMobUrl(sessionId, data)
)
}
setPrefetchedMobUrl(sessionId: string, fileData: Uint8Array) {
const keys = Object.keys(this.prefetchedMobUrls)
const toLimit = 10 - keys.length
if (toLimit < 0) {
const oldest = keys.sort(
(a, b) => this.prefetchedMobUrls[a].entryNum - this.prefetchedMobUrls[b].entryNum
)[0]
delete this.prefetchedMobUrls[oldest]
}
const nextEntryNum = keys.length > 0
? Math.max(...keys.map(key => this.prefetchedMobUrls[key].entryNum)) + 1 : 0
this.prefetchedMobUrls[sessionId] = { data: fileData, entryNum: nextEntryNum }
}
getSessions(filter: any): Promise<any> { getSessions(filter: any): Promise<any> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
sessionService sessionService

View file

@ -40,7 +40,8 @@ export function createIOSPlayer(
export function createWebPlayer( export function createWebPlayer(
session: SessionFilesInfo, session: SessionFilesInfo,
wrapStore?: (s: IWebPlayerStore) => IWebPlayerStore, wrapStore?: (s: IWebPlayerStore) => IWebPlayerStore,
uiErrorHandler?: { error: (msg: string) => void } uiErrorHandler?: { error: (msg: string) => void },
prefetched?: boolean,
): [IWebPlayer, IWebPlayerStore] { ): [IWebPlayer, IWebPlayerStore] {
let store: WebPlayerStore = new SimpleStore<WebState>({ let store: WebPlayerStore = new SimpleStore<WebState>({
...WebPlayer.INITIAL_STATE, ...WebPlayer.INITIAL_STATE,
@ -49,7 +50,7 @@ export function createWebPlayer(
store = wrapStore(store); store = wrapStore(store);
} }
const player = new WebPlayer(store, session, false, false, uiErrorHandler); const player = new WebPlayer(store, session, false, false, uiErrorHandler, prefetched);
return [player, store]; return [player, store];
} }

View file

@ -32,13 +32,17 @@ export default class MessageLoader {
}; };
constructor( constructor(
private readonly session: SessionFilesInfo, private session: SessionFilesInfo,
private store: Store<State>, private store: Store<State>,
private messageManager: MessageManager | IOSMessageManager, private messageManager: MessageManager | IOSMessageManager,
private isClickmap: boolean, private isClickmap: boolean,
private uiErrorHandler?: { error: (msg: string) => void } private uiErrorHandler?: { error: (msg: string) => void }
) {} ) {}
setSession(session: SessionFilesInfo) {
this.session = session
}
createNewParser( createNewParser(
shouldDecrypt = true, shouldDecrypt = true,
onMessagesDone: (msgs: PlayerMsg[], file?: string) => void, onMessagesDone: (msgs: PlayerMsg[], file?: string) => void,
@ -145,6 +149,18 @@ export default class MessageLoader {
} }
} }
preloaded = false;
async preloadFirstFile(data: Uint8Array) {
this.mobParser = this.createNewParser(true, this.processMessages, 'p:dom');
try {
await this.mobParser(data)
this.preloaded = true;
} catch (e) {
console.error('error parsing msgs', e)
}
}
async loadDomFiles(urls: string[], parser: (b: Uint8Array) => Promise<void>) { async loadDomFiles(urls: string[], parser: (b: Uint8Array) => Promise<void>) {
if (urls.length > 0) { if (urls.length > 0) {
this.store.update({ domLoading: true }); this.store.update({ domLoading: true });
@ -197,21 +213,25 @@ export default class MessageLoader {
} }
} }
mobParser: (b: Uint8Array) => Promise<void>
loadMobs = async () => { loadMobs = async () => {
const loadMethod = const loadMethod =
this.session.domURL && this.session.domURL.length > 0 this.session.domURL && this.session.domURL.length > 0
? { ? {
mobUrls: this.session.domURL, mobUrls: this.session.domURL,
parser: () => parser: () =>
this.createNewParser(true, this.processMessages, 'dom'), this.createNewParser(true, this.processMessages, 'd:dom'),
} }
: { : {
mobUrls: this.session.mobsUrl, mobUrls: this.session.mobsUrl,
parser: () => parser: () =>
this.createNewParser(false, this.processMessages, 'dom'), this.createNewParser(false, this.processMessages, 'm:dom'),
}; };
const parser = loadMethod.parser(); if (!this.mobParser) {
this.mobParser = loadMethod.parser();
}
const parser = this.mobParser
const devtoolsParser = this.createNewParser( const devtoolsParser = this.createNewParser(
true, true,
this.processMessages, this.processMessages,
@ -225,7 +245,7 @@ export default class MessageLoader {
* as a tradeoff we have some copy-paste code * as a tradeoff we have some copy-paste code
* for the devtools file * for the devtools file
* */ * */
await loadFiles([loadMethod.mobUrls[0]], parser); if (!this.preloaded) await loadFiles([loadMethod.mobUrls[0]], parser);
this.messageManager.onFileReadFinally(); this.messageManager.onFileReadFinally();
const restDomFilesPromise = this.loadDomFiles( const restDomFilesPromise = this.loadDomFiles(
[...loadMethod.mobUrls.slice(1)], [...loadMethod.mobUrls.slice(1)],

View file

@ -237,9 +237,9 @@ export default class MessageManager {
}); });
if ( if (
this.waitingForFiles && this.waitingForFiles ||
this.lastMessageTime <= t && (this.lastMessageTime <= t &&
t !== this.session.duration.milliseconds t !== this.session.duration.milliseconds)
) { ) {
this.setMessagesLoading(true); this.setMessagesLoading(true);
} }

View file

@ -20,9 +20,10 @@ export default class WebPlayer extends Player {
liveTimeTravel: false, liveTimeTravel: false,
inspectorMode: false, inspectorMode: false,
mobsFetched: false,
} }
private readonly inspectorController: InspectorController private inspectorController: InspectorController
protected screen: Screen protected screen: Screen
protected readonly messageManager: MessageManager protected readonly messageManager: MessageManager
protected readonly messageLoader: MessageLoader protected readonly messageLoader: MessageLoader
@ -34,7 +35,8 @@ export default class WebPlayer extends Player {
session: SessionFilesInfo, session: SessionFilesInfo,
live: boolean, live: boolean,
isClickMap = false, isClickMap = false,
public readonly uiErrorHandler?: { error: (msg: string) => void } public readonly uiErrorHandler?: { error: (msg: string) => void },
private readonly prefetched?: boolean,
) { ) {
let initialLists = live ? {} : { let initialLists = live ? {} : {
event: session.events || [], event: session.events || [],
@ -62,8 +64,10 @@ export default class WebPlayer extends Player {
this.screen = screen this.screen = screen
this.messageManager = messageManager this.messageManager = messageManager
this.messageLoader = messageLoader this.messageLoader = messageLoader
if (!live) { // hack. TODO: split OfflinePlayer class
if (!live && !prefetched) { // hack. TODO: split OfflinePlayer class
void messageLoader.loadFiles() void messageLoader.loadFiles()
wpState.update({ mobsFetched: true })
} }
this.targetMarker = new TargetMarker(this.screen, wpState) this.targetMarker = new TargetMarker(this.screen, wpState)
@ -84,6 +88,30 @@ export default class WebPlayer extends Player {
window.playerJumpToTime = this.jump.bind(this) window.playerJumpToTime = this.jump.bind(this)
} }
preloadFirstFile(data: Uint8Array) {
void this.messageLoader.preloadFirstFile(data)
}
reinit(session: SessionFilesInfo) {
if (this.wpState.get().mobsFetched) return; // already initialized
this.messageLoader.setSession(session)
void this.messageLoader.loadFiles();
this.targetMarker = new TargetMarker(this.screen, this.wpState)
this.inspectorController = new InspectorController(this.screen, this.wpState)
const endTime = session.duration?.valueOf() || 0
this.wpState.update({
//@ts-ignore
session,
endTime, // : 0,
})
// @ts-ignore
window.playerJumpToTime = this.jump.bind(this)
}
updateLists = (session: any) => { updateLists = (session: any) => {
const lists = { const lists = {
event: session.events || [], event: session.events || [],

View file

@ -46,6 +46,14 @@ export default class SettingsService {
.catch((e) => Promise.reject(e)); .catch((e) => Promise.reject(e));
} }
getFirstMobUrl(sessionId: string): Promise<{ domURL: string[] }> {
return this.client
.get(`/sessions/${sessionId}/first-mob`)
.then((r) => r.json())
.then((j) => j.data || {})
.catch(console.error);
}
getSessionInfo(sessionId: string, isLive?: boolean): Promise<ISession> { getSessionInfo(sessionId: string, isLive?: boolean): Promise<ISession> {
return this.client return this.client
.get(isLive ? `/assist/sessions/${sessionId}` : `/sessions/${sessionId}`) .get(isLive ? `/assist/sessions/${sessionId}` : `/sessions/${sessionId}`)