change(ui): extract Live Player and its components
This commit is contained in:
parent
36be728a54
commit
6334f10888
43 changed files with 2545 additions and 349 deletions
|
|
@ -2,9 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||
import { Button, Tooltip } from 'UI';
|
||||
import { connect } from 'react-redux';
|
||||
import cn from 'classnames';
|
||||
import { toggleChatWindow } from 'Duck/sessions';
|
||||
import ChatWindow from '../../ChatWindow';
|
||||
// state enums
|
||||
import {
|
||||
CallingState,
|
||||
ConnectionStatus,
|
||||
|
|
@ -12,7 +10,7 @@ import {
|
|||
RequestLocalStream,
|
||||
} from 'Player';
|
||||
import type { LocalStream } from 'Player';
|
||||
import { PlayerContext } from 'App/components/Session/playerContext';
|
||||
import { PlayerContext, ILivePlayerContext } from 'App/components/Session/playerContext';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { toast } from 'react-toastify';
|
||||
import { confirm } from 'UI';
|
||||
|
|
@ -30,15 +28,10 @@ function onError(e: any) {
|
|||
|
||||
interface Props {
|
||||
userId: string;
|
||||
calling: CallingState;
|
||||
annotating: boolean;
|
||||
peerConnectionStatus: ConnectionStatus;
|
||||
remoteControlStatus: RemoteControlStatus;
|
||||
hasPermission: boolean;
|
||||
isEnterprise: boolean;
|
||||
isCallActive: boolean;
|
||||
agentIds: string[];
|
||||
livePlay: boolean;
|
||||
userDisplayName: string;
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +43,8 @@ function AssistActions({
|
|||
agentIds,
|
||||
userDisplayName,
|
||||
}: Props) {
|
||||
const { player, store } = React.useContext(PlayerContext)
|
||||
// @ts-ignore ???
|
||||
const { player, store } = React.useContext<ILivePlayerContext>(PlayerContext)
|
||||
|
||||
const {
|
||||
assistManager: {
|
||||
|
|
@ -123,6 +117,7 @@ function AssistActions({
|
|||
|
||||
const addIncomeStream = (stream: MediaStream) => {
|
||||
setIncomeStream((oldState) => {
|
||||
if (oldState === null) return [stream]
|
||||
if (!oldState.find((existingStream) => existingStream.id === stream.id)) {
|
||||
return [...oldState, stream];
|
||||
}
|
||||
|
|
@ -257,8 +252,7 @@ const con = connect(
|
|||
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
|
||||
userDisplayName: state.getIn(['sessions', 'current']).userDisplayName,
|
||||
};
|
||||
},
|
||||
{ toggleChatWindow }
|
||||
}
|
||||
);
|
||||
|
||||
export default con(
|
||||
|
|
|
|||
|
|
@ -1,41 +1,34 @@
|
|||
import React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { toggleFullscreen, closeBottomBlock } from 'Duck/components/player';
|
||||
import withRequest from 'HOCs/withRequest';
|
||||
import withPermissions from 'HOCs/withPermissions';
|
||||
import { PlayerContext, defaultContextValue } from './playerContext';
|
||||
import { PlayerContext, defaultContextValue, ILivePlayerContext } from './playerContext';
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import { createLiveWebPlayer } from 'Player';
|
||||
import PlayerBlockHeader from '../Session_/PlayerBlockHeader';
|
||||
import PlayerBlock from '../Session_/PlayerBlock';
|
||||
import PlayerBlockHeader from './Player/LivePlayer/LivePlayerBlockHeader';
|
||||
import PlayerBlock from './Player/LivePlayer/LivePlayerBlock';
|
||||
import styles from '../Session_/session.module.css';
|
||||
import Session from 'App/mstore/types/session';
|
||||
import withLocationHandlers from 'HOCs/withLocationHandlers';
|
||||
|
||||
interface Props {
|
||||
session: Session;
|
||||
fullscreen: boolean;
|
||||
loadingCredentials: boolean;
|
||||
assistCredendials: RTCIceServer[];
|
||||
assistCredentials: RTCIceServer[];
|
||||
isEnterprise: boolean;
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
customSession?: Session;
|
||||
isMultiview?: boolean;
|
||||
query?: Record<string, (key: string) => any>;
|
||||
toggleFullscreen: (isOn: boolean) => void;
|
||||
closeBottomBlock: () => void;
|
||||
request: () => void;
|
||||
}
|
||||
|
||||
function LivePlayer({
|
||||
session,
|
||||
toggleFullscreen,
|
||||
closeBottomBlock,
|
||||
fullscreen,
|
||||
loadingCredentials,
|
||||
assistCredendials,
|
||||
assistCredentials,
|
||||
request,
|
||||
isEnterprise,
|
||||
userEmail,
|
||||
|
|
@ -44,11 +37,11 @@ function LivePlayer({
|
|||
customSession,
|
||||
query
|
||||
}: Props) {
|
||||
const [contextValue, setContextValue] = useState(defaultContextValue);
|
||||
// @ts-ignore
|
||||
const [contextValue, setContextValue] = useState<ILivePlayerContext>(defaultContextValue);
|
||||
const [fullView, setFullView] = useState(false);
|
||||
const openedFromMultiview = query.get('multi') === 'true'
|
||||
// @ts-ignore burn immutable
|
||||
const usedSession = isMultiview ? customSession : session.toJS();
|
||||
const openedFromMultiview = query?.get('multi') === 'true'
|
||||
const usedSession = isMultiview ? customSession! : session;
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingCredentials || !usedSession.sessionId) return;
|
||||
|
|
@ -59,13 +52,13 @@ function LivePlayer({
|
|||
name: userName,
|
||||
},
|
||||
};
|
||||
const [player, store] = createLiveWebPlayer(sessionWithAgentData, assistCredendials, (state) =>
|
||||
const [player, store] = createLiveWebPlayer(sessionWithAgentData, assistCredentials, (state) =>
|
||||
makeAutoObservable(state)
|
||||
);
|
||||
setContextValue({ player, store });
|
||||
|
||||
return () => player.clean();
|
||||
}, [session.sessionId, assistCredendials]);
|
||||
}, [session.sessionId, assistCredentials]);
|
||||
|
||||
// LAYOUT (TODO: local layout state - useContext or something..)
|
||||
useEffect(() => {
|
||||
|
|
@ -80,10 +73,6 @@ function LivePlayer({
|
|||
if (isEnterprise) {
|
||||
request();
|
||||
}
|
||||
return () => {
|
||||
toggleFullscreen(false);
|
||||
closeBottomBlock();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const TABS = {
|
||||
|
|
@ -102,7 +91,6 @@ function LivePlayer({
|
|||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
tabs={TABS}
|
||||
fullscreen={fullscreen}
|
||||
isMultiview={openedFromMultiview}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -112,7 +100,6 @@ function LivePlayer({
|
|||
height: isMultiview ? '100%' : undefined,
|
||||
width: isMultiview ? '100%' : undefined,
|
||||
}}
|
||||
data-fullscreen={fullscreen}
|
||||
>
|
||||
<PlayerBlock isMultiview={isMultiview} />
|
||||
</div>
|
||||
|
|
@ -123,7 +110,7 @@ function LivePlayer({
|
|||
export default withRequest({
|
||||
initialData: null,
|
||||
endpoint: '/assist/credentials',
|
||||
dataName: 'assistCredendials',
|
||||
dataName: 'assistCredentials',
|
||||
loadingName: 'loadingCredentials',
|
||||
})(
|
||||
withPermissions(
|
||||
|
|
@ -136,13 +123,11 @@ export default withRequest({
|
|||
return {
|
||||
session: state.getIn(['sessions', 'current']),
|
||||
showAssist: state.getIn(['sessions', 'showChatWindow']),
|
||||
fullscreen: state.getIn(['components', 'player', 'fullscreen']),
|
||||
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
|
||||
userEmail: state.getIn(['user', 'account', 'email']),
|
||||
userName: state.getIn(['user', 'account', 'name']),
|
||||
};
|
||||
},
|
||||
{ toggleFullscreen, closeBottomBlock }
|
||||
}
|
||||
)(withLocationHandlers()(LivePlayer))
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
import { Duration } from 'luxon';
|
||||
import { PlayerContext, ILivePlayerContext } from 'App/components/Session/playerContext';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
const AssistDurationCont = () => {
|
||||
// @ts-ignore ??? TODO
|
||||
const { store } = React.useContext<ILivePlayerContext>(PlayerContext)
|
||||
const { assistStart } = store.get()
|
||||
|
||||
const [assistDuration, setAssistDuration] = React.useState('00:00');
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setAssistDuration(Duration.fromMillis(+new Date() - assistStart).toFormat('mm:ss'));
|
||||
}
|
||||
, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [])
|
||||
return (
|
||||
<>
|
||||
Elapsed {assistDuration}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const AssistDuration = observer(AssistDurationCont)
|
||||
|
||||
export default AssistDuration;
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import { Icon } from 'UI';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { multiview, liveSession, withSiteId } from 'App/routes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
interface ITab {
|
||||
onClick?: () => void;
|
||||
classNames?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Tab = (props: ITab) => (
|
||||
<div
|
||||
onClick={props.onClick}
|
||||
className={cn('p-1 rounded flex items-center justify-center cursor-pointer', props.classNames)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const InactiveTab = React.memo((props: Omit<ITab, 'children'>) => (
|
||||
<Tab onClick={props.onClick} classNames={cn("hover:bg-gray-bg bg-gray-light", props.classNames)}>
|
||||
<Icon name="plus" size="22" color="white" />
|
||||
</Tab>
|
||||
));
|
||||
|
||||
const ActiveTab = React.memo((props: Omit<ITab, 'children'>) => (
|
||||
<Tab onClick={props.onClick} classNames="hover:bg-teal bg-borderColor-primary">
|
||||
<Icon name="play-fill-new" size="22" color="white" />
|
||||
</Tab>
|
||||
));
|
||||
|
||||
const CurrentTab = React.memo(() => (
|
||||
<Tab classNames="bg-teal color-white">
|
||||
<span style={{ fontSize: '0.65rem' }}>PLAYING</span>
|
||||
</Tab>
|
||||
));
|
||||
|
||||
function AssistTabs({ session, siteId }: { session: Record<string, any>; siteId: string }) {
|
||||
const history = useHistory();
|
||||
const { assistMultiviewStore } = useStore();
|
||||
|
||||
const placeholder = new Array(4 - assistMultiviewStore.sessions.length).fill(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (assistMultiviewStore.sessions.length === 0) {
|
||||
assistMultiviewStore.setDefault(session);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openGrid = () => {
|
||||
const sessionIdQuery = encodeURIComponent(assistMultiviewStore.sessions.map((s) => s.sessionId).join(','));
|
||||
return history.push(withSiteId(multiview(sessionIdQuery), siteId));
|
||||
};
|
||||
const openLiveSession = (sessionId: string) => {
|
||||
assistMultiviewStore.setActiveSession(sessionId);
|
||||
history.push(withSiteId(liveSession(sessionId), siteId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 w-28 h-full" style={{ gap: '4px' }}>
|
||||
{assistMultiviewStore.sortedSessions.map((session: { key: number, sessionId: string }) => (
|
||||
<React.Fragment key={session.key}>
|
||||
{assistMultiviewStore.isActive(session.sessionId) ? (
|
||||
<CurrentTab />
|
||||
) : (
|
||||
<ActiveTab onClick={() => openLiveSession(session.sessionId)} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{placeholder.map((_, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<InactiveTab onClick={openGrid} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect((state: any) => ({ siteId: state.getIn(['site', 'siteId']) }))(
|
||||
observer(AssistTabs)
|
||||
);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default, InactiveTab } from './AssistSessionsTabs'
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import { connect } from 'react-redux';
|
||||
import LiveTag from 'Shared/LiveTag';
|
||||
import AssistSessionsTabs from './AssistSessionsTabs';
|
||||
|
||||
import {
|
||||
CONSOLE, toggleBottomBlock,
|
||||
} from 'Duck/components/player';
|
||||
import { PlayerContext, ILivePlayerContext } from 'App/components/Session/playerContext';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { fetchSessions } from 'Duck/liveSearch';
|
||||
|
||||
import AssistDuration from './AssistDuration';
|
||||
import Timeline from './Timeline';
|
||||
import ControlButton from 'Components/Session_/Player/Controls/ControlButton';
|
||||
|
||||
import styles from 'Components/Session_/Player/Controls/controls.module.css';
|
||||
|
||||
function Controls(props: any) {
|
||||
// @ts-ignore ?? TODO
|
||||
const { player, store } = React.useContext<ILivePlayerContext>(PlayerContext);
|
||||
|
||||
const { jumpToLive } = player;
|
||||
const {
|
||||
livePlay,
|
||||
logMarkedCountNow: logRedCount,
|
||||
exceptionsList,
|
||||
} = store.get();
|
||||
const showExceptions = exceptionsList.length > 0;
|
||||
const {
|
||||
bottomBlock,
|
||||
toggleBottomBlock,
|
||||
closedLive,
|
||||
skipInterval,
|
||||
session,
|
||||
fetchSessions: fetchAssistSessions,
|
||||
totalAssistSessions,
|
||||
} = props;
|
||||
|
||||
const onKeyDown = (e: any) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
forthTenSeconds();
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
backTenSeconds();
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
document.addEventListener('keydown', onKeyDown.bind(this));
|
||||
if (totalAssistSessions === 0) {
|
||||
fetchAssistSessions();
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown.bind(this));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const forthTenSeconds = () => {
|
||||
// @ts-ignore
|
||||
player.jumpInterval(SKIP_INTERVALS[skipInterval]);
|
||||
};
|
||||
|
||||
const backTenSeconds = () => {
|
||||
// @ts-ignore
|
||||
player.jumpInterval(-SKIP_INTERVALS[skipInterval]);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const toggleBottomTools = (blockName: number) => {
|
||||
toggleBottomBlock(blockName);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.controls}>
|
||||
<Timeline />
|
||||
<div className={cn(styles.buttons, '!px-5 !pt-0')} data-is-live>
|
||||
<div className="flex items-center">
|
||||
{!closedLive && (
|
||||
<div className={styles.buttonsLeft}>
|
||||
<LiveTag isLive={livePlay} onClick={() => (livePlay ? null : jumpToLive())} />
|
||||
<div className="font-semibold px-2">
|
||||
<AssistDuration />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalAssistSessions > 1 ? (
|
||||
<div>
|
||||
<AssistSessionsTabs session={session} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center h-full">
|
||||
<ControlButton
|
||||
onClick={() => toggleBottomTools(CONSOLE)}
|
||||
active={bottomBlock === CONSOLE}
|
||||
label="CONSOLE"
|
||||
noIcon
|
||||
labelClassName="!text-base font-semibold"
|
||||
hasErrors={logRedCount > 0 || showExceptions}
|
||||
containerClassName="mx-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ControlPlayer = observer(Controls);
|
||||
|
||||
export default connect(
|
||||
(state: any) => {
|
||||
return {
|
||||
session: state.getIn(['sessions', 'current']),
|
||||
totalAssistSessions: state.getIn(['liveSearch', 'total']),
|
||||
closedLive:
|
||||
!!state.getIn(['sessions', 'errors']) || !state.getIn(['sessions', 'current']).live,
|
||||
};
|
||||
},
|
||||
{
|
||||
fetchSessions,
|
||||
toggleBottomBlock
|
||||
}
|
||||
)(ControlPlayer);
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import Player from './LivePlayerInst';
|
||||
import SubHeader from './LivePlayerSubHeader';
|
||||
|
||||
import styles from 'Components/Session_/playerBlock.module.css';
|
||||
|
||||
interface IProps {
|
||||
fullView?: boolean;
|
||||
isMultiview?: boolean;
|
||||
}
|
||||
|
||||
function LivePlayerBlock(props: IProps) {
|
||||
const { fullView = false, isMultiview } = props;
|
||||
|
||||
const shouldShowSubHeader = !fullView && !isMultiview
|
||||
|
||||
return (
|
||||
<div className={cn(styles.playerBlock, 'flex flex-col', 'overflow-x-hidden')} style={{ zIndex: undefined, minWidth: isMultiview ? '100%' : undefined }}>
|
||||
{shouldShowSubHeader ? (
|
||||
<SubHeader />
|
||||
) : null}
|
||||
<Player
|
||||
fullView={fullView}
|
||||
isMultiview={isMultiview}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LivePlayerBlock
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import {
|
||||
assist as assistRoute,
|
||||
withSiteId,
|
||||
multiview,
|
||||
} from 'App/routes';
|
||||
import { BackLink, Icon } from 'UI';
|
||||
import cn from 'classnames';
|
||||
import SessionMetaList from 'Shared/SessionItem/SessionMetaList';
|
||||
import UserCard from '../ReplayPlayer/EventsBlock/UserCard';
|
||||
import { PlayerContext } from 'Components/Session/playerContext';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from 'App/mstore'
|
||||
import stl from '../ReplayPlayer/playerBlockHeader.module.css';
|
||||
import AssistActions from 'Components/Assist/components/AssistActions';
|
||||
import AssistTabs from 'Components/Assist/components/AssistTabs';
|
||||
|
||||
const ASSIST_ROUTE = assistRoute();
|
||||
|
||||
// TODO props
|
||||
function LivePlayerBlockHeader(props: any) {
|
||||
const [hideBack, setHideBack] = React.useState(false);
|
||||
const { store } = React.useContext(PlayerContext);
|
||||
const { assistMultiviewStore } = useStore();
|
||||
|
||||
const { width, height } = store.get();
|
||||
|
||||
const {
|
||||
session,
|
||||
metaList,
|
||||
closedLive = false,
|
||||
siteId,
|
||||
location,
|
||||
history,
|
||||
isMultiview,
|
||||
} = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
setHideBack(queryParams.has('iframe') && queryParams.get('iframe') === 'true');
|
||||
}, []);
|
||||
|
||||
const backHandler = () => {
|
||||
history.push(withSiteId(ASSIST_ROUTE, siteId));
|
||||
};
|
||||
|
||||
const { userId, userNumericHash, metadata, isCallActive, agentIds } = session;
|
||||
let _metaList = Object.keys(metadata)
|
||||
.filter((i) => metaList.includes(i))
|
||||
.map((key) => {
|
||||
const value = metadata[key];
|
||||
return { label: key, value };
|
||||
});
|
||||
|
||||
const openGrid = () => {
|
||||
const sessionIdQuery = encodeURIComponent(assistMultiviewStore.sessions.map((s) => s?.sessionId).join(','));
|
||||
return history.push(withSiteId(multiview(sessionIdQuery), siteId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(stl.header, 'flex justify-between')}>
|
||||
<div className="flex w-full items-center">
|
||||
{!hideBack && (
|
||||
<div
|
||||
className="flex items-center h-full cursor-pointer group"
|
||||
onClick={() => (assistMultiviewStore.sessions.length > 1 || isMultiview ? openGrid() : backHandler())}
|
||||
>
|
||||
{assistMultiviewStore.sessions.length > 1 || isMultiview ? (
|
||||
<>
|
||||
<div className="rounded-full border group-hover:border-teal group-hover:text-teal group-hover:fill-teal p-1 mr-2">
|
||||
<Icon name="close" color="inherit" size={13} />
|
||||
</div>
|
||||
<span className="group-hover:text-teal group-hover:fill-teal">
|
||||
Close
|
||||
</span>
|
||||
<div className={stl.divider} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* @ts-ignore TODO */}
|
||||
<BackLink label="Back" className="h-full" />
|
||||
<div className={stl.divider} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<UserCard className="" width={width} height={height} />
|
||||
<AssistTabs userId={userId} userNumericHash={userNumericHash} />
|
||||
|
||||
<div className={cn('ml-auto flex items-center h-full', { hidden: closedLive })}>
|
||||
{_metaList.length > 0 && (
|
||||
<div className="border-l h-full flex items-center px-2">
|
||||
<SessionMetaList className="" metaList={_metaList} maxLength={2} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AssistActions userId={userId} isCallActive={isCallActive} agentIds={agentIds} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PlayerHeaderCont = connect(
|
||||
(state: any) => {
|
||||
const isAssist = window.location.pathname.includes('/assist/');
|
||||
const session = state.getIn(['sessions', 'current']);
|
||||
|
||||
return {
|
||||
isAssist,
|
||||
session,
|
||||
sessionPath: state.getIn(['sessions', 'sessionPath']),
|
||||
siteId: state.getIn(['site', 'siteId']),
|
||||
metaList: state.getIn(['customFields', 'list']).map((i: any) => i.key),
|
||||
closedLive: !!state.getIn(['sessions', 'errors']) || (isAssist && !session.live),
|
||||
};
|
||||
}
|
||||
)(observer(LivePlayerBlockHeader));
|
||||
|
||||
export default withRouter(PlayerHeaderCont);
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
import cn from 'classnames';
|
||||
import LiveControls from './LiveControls';
|
||||
import ConsolePanel from 'Shared/DevTools/ConsolePanel';
|
||||
|
||||
import Overlay from './Overlay';
|
||||
import stl from 'Components/Session_/Player/player.module.css';
|
||||
import { PlayerContext, ILivePlayerContext } from 'App/components/Session/playerContext';
|
||||
import { CONSOLE } from "Duck/components/player";
|
||||
|
||||
interface IProps {
|
||||
closedLive: boolean;
|
||||
fullView: boolean;
|
||||
isMultiview?: boolean;
|
||||
bottomBlock: number;
|
||||
}
|
||||
|
||||
function Player(props: IProps) {
|
||||
const {
|
||||
closedLive,
|
||||
fullView,
|
||||
isMultiview,
|
||||
bottomBlock,
|
||||
} = props;
|
||||
// @ts-ignore TODO
|
||||
const playerContext = React.useContext<ILivePlayerContext>(PlayerContext);
|
||||
const screenWrapper = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!props.closedLive || isMultiview) {
|
||||
const parentElement = findDOMNode(screenWrapper.current) as HTMLDivElement | null; //TODO: good architecture
|
||||
if (parentElement) {
|
||||
playerContext.player.attach(parentElement);
|
||||
playerContext.player.play();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
playerContext.player.scale();
|
||||
}, [playerContext.player]);
|
||||
|
||||
if (!playerContext.player) return null;
|
||||
|
||||
const maxWidth = '100vw';
|
||||
return (
|
||||
<div
|
||||
className={cn(stl.playerBody, 'flex flex-1 flex-col relative')}
|
||||
>
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<Overlay closedLive={closedLive} />
|
||||
<div className={cn(stl.screenWrapper)} ref={screenWrapper} />
|
||||
</div>
|
||||
{bottomBlock === CONSOLE ? (
|
||||
<div style={{ maxWidth, width: '100%' }}>
|
||||
<ConsolePanel />
|
||||
</div>
|
||||
) : null}
|
||||
{!fullView && !isMultiview ? (
|
||||
<LiveControls
|
||||
jump={playerContext.player.jump}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: any) => {
|
||||
const isAssist = window.location.pathname.includes('/assist/');
|
||||
return {
|
||||
sessionId: state.getIn(['sessions', 'current']).sessionId,
|
||||
bottomBlock: state.getIn(['components', 'player', 'bottomBlock']),
|
||||
closedLive:
|
||||
!!state.getIn(['sessions', 'errors']) ||
|
||||
(isAssist && !state.getIn(['sessions', 'current']).live),
|
||||
};
|
||||
}
|
||||
)(Player);
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import React from 'react';
|
||||
import { Icon, Tooltip } from 'UI';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { PlayerContext } from 'App/components/Session/playerContext';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
function SubHeader() {
|
||||
const { store } = React.useContext(PlayerContext)
|
||||
const {
|
||||
location: currentLocation,
|
||||
} = store.get()
|
||||
const [isCopied, setCopied] = React.useState(false);
|
||||
|
||||
const location =
|
||||
currentLocation !== undefined ? currentLocation.length > 60
|
||||
? `${currentLocation.slice(0, 60)}...`
|
||||
: currentLocation : undefined;
|
||||
|
||||
return (
|
||||
<div className="w-full px-4 py-2 flex items-center border-b min-h-3">
|
||||
{location && (
|
||||
<div
|
||||
className="flex items-center cursor-pointer color-gray-medium text-sm p-1 hover:bg-gray-light-shade rounded-md"
|
||||
onClick={() => {
|
||||
copy(currentLocation || '');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 5000);
|
||||
}}
|
||||
>
|
||||
<Icon size="20" name="event/link" className="mr-1" />
|
||||
<Tooltip title={isCopied ? 'URL Copied to clipboard' : 'Click to copy'}>
|
||||
{location}
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(SubHeader);
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
SessionRecordingStatus,
|
||||
getStatusText,
|
||||
CallingState,
|
||||
ConnectionStatus,
|
||||
RemoteControlStatus,
|
||||
} from 'Player';
|
||||
|
||||
import LiveStatusText from './LiveStatusText';
|
||||
import Loader from 'Components/Session_/Player/Overlay/Loader';
|
||||
import RequestingWindow, { WindowType } from 'App/components/Assist/RequestingWindow';
|
||||
import { PlayerContext, ILivePlayerContext } from 'App/components/Session/playerContext';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
interface Props {
|
||||
closedLive?: boolean,
|
||||
}
|
||||
|
||||
function Overlay({
|
||||
closedLive,
|
||||
}: Props) {
|
||||
// @ts-ignore ?? TODO
|
||||
const { store } = React.useContext<ILivePlayerContext>(PlayerContext)
|
||||
|
||||
const {
|
||||
messagesLoading,
|
||||
cssLoading,
|
||||
peerConnectionStatus,
|
||||
livePlay,
|
||||
calling,
|
||||
remoteControl,
|
||||
recordingState,
|
||||
} = store.get()
|
||||
const loading = messagesLoading || cssLoading
|
||||
const liveStatusText = getStatusText(peerConnectionStatus)
|
||||
const connectionStatus = peerConnectionStatus
|
||||
|
||||
const showLiveStatusText = livePlay && liveStatusText && !loading;
|
||||
|
||||
const showRequestWindow =
|
||||
(calling === CallingState.Connecting ||
|
||||
remoteControl === RemoteControlStatus.Requesting ||
|
||||
recordingState === SessionRecordingStatus.Requesting);
|
||||
|
||||
const getRequestWindowType = () => {
|
||||
if (calling === CallingState.Connecting) {
|
||||
return WindowType.Call
|
||||
}
|
||||
if (remoteControl === RemoteControlStatus.Requesting) {
|
||||
return WindowType.Control
|
||||
}
|
||||
if (recordingState === SessionRecordingStatus.Requesting) {
|
||||
return WindowType.Record
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* @ts-ignore wtf */}
|
||||
{showRequestWindow ? <RequestingWindow getWindowType={getRequestWindowType} /> : null}
|
||||
{showLiveStatusText && (
|
||||
<LiveStatusText
|
||||
connectionStatus={closedLive ? ConnectionStatus.Closed : connectionStatus}
|
||||
/>
|
||||
)}
|
||||
{loading ? <Loader /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Overlay);
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import React from 'react';
|
||||
import ovStl from 'Components/Session_/Player/Overlay/overlay.module.css';
|
||||
import { ConnectionStatus } from 'Player';
|
||||
import { Loader } from 'UI';
|
||||
|
||||
interface Props {
|
||||
connectionStatus: ConnectionStatus;
|
||||
}
|
||||
|
||||
export default function LiveStatusText({ connectionStatus }: Props) {
|
||||
const renderView = () => {
|
||||
switch (connectionStatus) {
|
||||
case ConnectionStatus.Closed:
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="text-lg -mt-8">Session not found</div>
|
||||
<div className="text-sm">The remote session doesn’t exist anymore. <br/> The user may have closed the tab/browser while you were trying to establish a connection.</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case ConnectionStatus.Connecting:
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<Loader loading={true} />
|
||||
<div className="text-lg -mt-8">Connecting...</div>
|
||||
<div className="text-sm">Establishing a connection with the remote session.</div>
|
||||
</div>
|
||||
)
|
||||
case ConnectionStatus.WaitingMessages:
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<Loader loading={true} />
|
||||
<div className="text-lg -mt-8">Waiting for the session to become active...</div>
|
||||
<div className="text-sm">If it's taking too much time, it could mean the user is simply inactive.</div>
|
||||
</div>
|
||||
)
|
||||
case ConnectionStatus.Connected:
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-lg -mt-8">Connected</div>
|
||||
</div>
|
||||
)
|
||||
case ConnectionStatus.Inactive:
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<Loader loading={true} />
|
||||
<div className="text-lg -mt-8">Waiting for the session to become active...</div>
|
||||
<div className="text-sm">If it's taking too much time, it could mean the user is simply inactive.</div>
|
||||
</div>
|
||||
)
|
||||
case ConnectionStatus.Disconnected:
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-lg -mt-8">Disconnected</div>
|
||||
<div className="text-sm">The connection was lost with the remote session. The user may have simply closed the tab/browser.</div>
|
||||
</div>
|
||||
)
|
||||
case ConnectionStatus.Error:
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-lg -mt-8">Error</div>
|
||||
<div className="text-sm">Something wrong just happened. Try refreshing the page.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
return <div className={ovStl.overlay}>
|
||||
{ renderView()}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './LiveOverlay'
|
||||
166
frontend/app/components/Session/Player/LivePlayer/Timeline.tsx
Normal file
166
frontend/app/components/Session/Player/LivePlayer/Timeline.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import React, { useMemo, useContext, useState, useRef } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import TimeTracker from 'Components/Session_/Player/Controls/TimeTracker';
|
||||
import stl from 'Components/Session_/Player/Controls/timeline.module.css';
|
||||
import { setTimelinePointer, setTimelineHoverTime } from 'Duck/sessions';
|
||||
import DraggableCircle from 'Components/Session_/Player/Controls/components/DraggableCircle';
|
||||
import CustomDragLayer, { OnDragCallback } from 'Components/Session_/Player/Controls/components/CustomDragLayer';
|
||||
import { debounce } from 'App/utils';
|
||||
import TooltipContainer from 'Components/Session_/Player/Controls/components/TooltipContainer';
|
||||
import { PlayerContext, ILivePlayerContext } from 'App/components/Session/playerContext';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Duration } from 'luxon';
|
||||
|
||||
interface IProps {
|
||||
setTimelineHoverTime: (t: number) => void
|
||||
startedAt: number
|
||||
tooltipVisible: boolean
|
||||
}
|
||||
|
||||
function Timeline(props: IProps) {
|
||||
// @ts-ignore
|
||||
const { player, store } = useContext<ILivePlayerContext>(PlayerContext)
|
||||
const [wasPlaying, setWasPlaying] = useState(false)
|
||||
const {
|
||||
playing,
|
||||
time,
|
||||
ready,
|
||||
endTime,
|
||||
liveTimeTravel,
|
||||
} = store.get()
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const scale = 100 / endTime;
|
||||
|
||||
const debouncedJump = useMemo(() => debounce(player.jump, 500), [])
|
||||
const debouncedTooltipChange = useMemo(() => debounce(props.setTimelineHoverTime, 50), [])
|
||||
|
||||
const onDragEnd = () => {
|
||||
if (!liveTimeTravel) return;
|
||||
|
||||
if (wasPlaying) {
|
||||
player.togglePlay();
|
||||
}
|
||||
};
|
||||
|
||||
const onDrag: OnDragCallback = (offset: { x: number }) => {
|
||||
if ((!liveTimeTravel) || !progressRef.current) return;
|
||||
|
||||
const p = (offset.x) / progressRef.current.offsetWidth;
|
||||
const time = Math.max(Math.round(p * endTime), 0);
|
||||
debouncedJump(time);
|
||||
hideTimeTooltip();
|
||||
if (playing) {
|
||||
setWasPlaying(true)
|
||||
player.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const getLiveTime = (e: React.MouseEvent) => {
|
||||
const duration = new Date().getTime() - props.startedAt;
|
||||
// @ts-ignore type mismatch from react?
|
||||
const p = e.nativeEvent.offsetX / e.target.offsetWidth;
|
||||
const time = Math.max(Math.round(p * duration), 0);
|
||||
|
||||
return [time, duration];
|
||||
};
|
||||
|
||||
const showTimeTooltip = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target !== progressRef.current && e.target !== timelineRef.current) {
|
||||
return props.tooltipVisible && hideTimeTooltip();
|
||||
}
|
||||
|
||||
const [time, duration] = getLiveTime(e);
|
||||
const timeLineTooltip = {
|
||||
time: Duration.fromMillis(duration - time).toFormat(`-mm:ss`),
|
||||
offset: e.nativeEvent.offsetX,
|
||||
isVisible: true,
|
||||
};
|
||||
|
||||
debouncedTooltipChange(timeLineTooltip);
|
||||
}
|
||||
|
||||
const hideTimeTooltip = () => {
|
||||
const timeLineTooltip = { isVisible: false };
|
||||
debouncedTooltipChange(timeLineTooltip);
|
||||
};
|
||||
|
||||
const seekProgress = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const time = getTime(e);
|
||||
player.jump(time);
|
||||
hideTimeTooltip();
|
||||
};
|
||||
|
||||
const loadAndSeek = async (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.persist();
|
||||
await player.toggleTimetravel();
|
||||
|
||||
setTimeout(() => {
|
||||
seekProgress(e);
|
||||
});
|
||||
};
|
||||
|
||||
const jumpToTime = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!liveTimeTravel) {
|
||||
void loadAndSeek(e);
|
||||
} else {
|
||||
seekProgress(e);
|
||||
}
|
||||
};
|
||||
|
||||
const getTime = (e: React.MouseEvent<HTMLDivElement>, customEndTime?: number) => {
|
||||
// @ts-ignore type mismatch from react?
|
||||
const p = e.nativeEvent.offsetX / e.target.offsetWidth;
|
||||
const targetTime = customEndTime || endTime;
|
||||
|
||||
return Math.max(Math.round(p * targetTime), 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center absolute w-full"
|
||||
style={{
|
||||
top: '-4px',
|
||||
zIndex: 100,
|
||||
maxWidth: 'calc(100% - 1rem)',
|
||||
left: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={stl.progress}
|
||||
onClick={ready ? jumpToTime : undefined }
|
||||
ref={progressRef}
|
||||
role="button"
|
||||
onMouseMoveCapture={showTimeTooltip}
|
||||
onMouseEnter={showTimeTooltip}
|
||||
onMouseLeave={hideTimeTooltip}
|
||||
>
|
||||
<TooltipContainer live />
|
||||
<DraggableCircle
|
||||
left={time * scale}
|
||||
onDrop={onDragEnd}
|
||||
live
|
||||
/>
|
||||
<CustomDragLayer
|
||||
onDrag={onDrag}
|
||||
minX={0}
|
||||
maxX={progressRef.current ? progressRef.current.offsetWidth : 0}
|
||||
/>
|
||||
<TimeTracker scale={scale} live left={time * scale} />
|
||||
|
||||
|
||||
<div className={stl.timeline} ref={timelineRef} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: any) => ({
|
||||
startedAt: state.getIn(['sessions', 'current']).startedAt || 0,
|
||||
tooltipVisible: state.getIn(['sessions', 'timeLineTooltip', 'isVisible']),
|
||||
}),
|
||||
{ setTimelinePointer, setTimelineHoverTime }
|
||||
)(observer(Timeline))
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import React from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import cn from 'classnames';
|
||||
import { Icon, TextEllipsis } from 'UI';
|
||||
import { TYPES } from 'Types/session/event';
|
||||
import { prorata } from 'App/utils';
|
||||
import withOverlay from 'Components/hocs/withOverlay';
|
||||
import LoadInfo from './LoadInfo';
|
||||
import cls from './event.module.css';
|
||||
import { numberWithCommas } from 'App/utils';
|
||||
|
||||
@withOverlay()
|
||||
export default class Event extends React.PureComponent {
|
||||
state = {
|
||||
menuOpen: false,
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.wrapper.addEventListener('contextmenu', this.onContextMenu);
|
||||
}
|
||||
|
||||
onContextMenu = (e) => {
|
||||
e.preventDefault();
|
||||
this.setState({ menuOpen: true });
|
||||
}
|
||||
onMouseLeave = () => this.setState({ menuOpen: false })
|
||||
|
||||
copyHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
//const ctrlOrCommandPressed = e.ctrlKey || e.metaKey;
|
||||
//if (ctrlOrCommandPressed && e.keyCode === 67) {
|
||||
const { event } = this.props;
|
||||
copy(event.getIn([ 'target', 'path' ]) || event.url || '');
|
||||
this.setState({ menuOpen: false });
|
||||
}
|
||||
|
||||
toggleInfo = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.toggleInfo();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
renderBody = () => {
|
||||
const { event } = this.props;
|
||||
let title = event.type;
|
||||
let body;
|
||||
switch (event.type) {
|
||||
case TYPES.LOCATION:
|
||||
title = 'Visited';
|
||||
body = event.url;
|
||||
break;
|
||||
case TYPES.CLICK:
|
||||
title = 'Clicked';
|
||||
body = event.label;
|
||||
break;
|
||||
case TYPES.INPUT:
|
||||
title = 'Input';
|
||||
body = event.value;
|
||||
break;
|
||||
case TYPES.CLICKRAGE:
|
||||
title = `${ event.count } Clicks`;
|
||||
body = event.label;
|
||||
break;
|
||||
case TYPES.IOS_VIEW:
|
||||
title = 'View';
|
||||
body = event.name;
|
||||
break;
|
||||
}
|
||||
const isLocation = event.type === TYPES.LOCATION;
|
||||
const isClickrage = event.type === TYPES.CLICKRAGE;
|
||||
|
||||
return (
|
||||
<div className={ cn(cls.main, 'flex flex-col w-full') } >
|
||||
<div className="flex items-center w-full">
|
||||
{ event.type && <Icon name={`event/${event.type.toLowerCase()}`} size="16" color={isClickrage? 'red' : 'gray-dark' } /> }
|
||||
<div className="ml-3 w-full">
|
||||
<div className="flex w-full items-first justify-between">
|
||||
<div className="flex items-center w-full" style={{ minWidth: '0'}}>
|
||||
<span className={cls.title}>{ title }</span>
|
||||
{/* { body && !isLocation && <div className={ cls.data }>{ body }</div> } */}
|
||||
{ body && !isLocation &&
|
||||
<TextEllipsis maxWidth="60%" className="w-full ml-2 text-sm color-gray-medium" text={body} />
|
||||
}
|
||||
</div>
|
||||
{ isLocation && event.speedIndex != null &&
|
||||
<div className="color-gray-medium flex font-medium items-center leading-none justify-end">
|
||||
<div className="font-size-10 pr-2">{"Speed Index"}</div>
|
||||
<div>{ numberWithCommas(event.speedIndex || 0) }</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{ event.target && event.target.label &&
|
||||
<div className={ cls.badge } >{ event.target.label }</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{ isLocation &&
|
||||
<div className="mt-1">
|
||||
<span className="text-sm font-normal color-gray-medium">{ body }</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
event,
|
||||
selected,
|
||||
isCurrent,
|
||||
onClick,
|
||||
showSelection,
|
||||
onCheckboxClick,
|
||||
showLoadInfo,
|
||||
toggleLoadInfo,
|
||||
isRed,
|
||||
extended,
|
||||
highlight = false,
|
||||
presentInSearch = false,
|
||||
isLastInGroup,
|
||||
whiteBg,
|
||||
} = this.props;
|
||||
const { menuOpen } = this.state;
|
||||
return (
|
||||
<div
|
||||
ref={ ref => { this.wrapper = ref } }
|
||||
onMouseLeave={ this.onMouseLeave }
|
||||
data-openreplay-label="Event"
|
||||
data-type={event.type}
|
||||
className={ cn(cls.event, {
|
||||
[ cls.menuClosed ]: !menuOpen,
|
||||
[ cls.highlighted ]: showSelection ? selected : isCurrent,
|
||||
[ cls.selected ]: selected,
|
||||
[ cls.showSelection ]: showSelection,
|
||||
[ cls.red ]: isRed,
|
||||
[ cls.clickType ]: event.type === TYPES.CLICK,
|
||||
[ cls.inputType ]: event.type === TYPES.INPUT,
|
||||
[ cls.clickrageType ]: event.type === TYPES.CLICKRAGE,
|
||||
[ cls.highlight ] : presentInSearch,
|
||||
[ cls.lastInGroup ]: whiteBg,
|
||||
}) }
|
||||
onClick={ onClick }
|
||||
>
|
||||
{ menuOpen &&
|
||||
<button onClick={ this.copyHandler } className={ cls.contextMenu }>
|
||||
{ event.target ? 'Copy CSS' : 'Copy URL' }
|
||||
</button>
|
||||
}
|
||||
<div className={ cls.topBlock }>
|
||||
<div className={ cls.firstLine }>
|
||||
{ this.renderBody() }
|
||||
</div>
|
||||
{/* { event.type === TYPES.LOCATION &&
|
||||
<div className="text-sm font-normal color-gray-medium">{event.url}</div>
|
||||
} */}
|
||||
</div>
|
||||
{ event.type === TYPES.LOCATION && (event.fcpTime || event.visuallyComplete || event.timeToInteractive) &&
|
||||
<LoadInfo
|
||||
showInfo={ showLoadInfo }
|
||||
onClick={ toggleLoadInfo }
|
||||
event={ event }
|
||||
prorata={ prorata({
|
||||
parts: 100,
|
||||
elements: { a: event.fcpTime, b: event.visuallyComplete, c: event.timeToInteractive },
|
||||
startDivisorFn: elements => elements / 1.2,
|
||||
// eslint-disable-next-line no-mixed-operators
|
||||
divisorFn: (elements, parts) => elements / (2 * parts + 1),
|
||||
}) }
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import { connect } from 'react-redux'
|
||||
import { TextEllipsis } from 'UI';
|
||||
import withToggle from 'HOCs/withToggle';
|
||||
import { TYPES } from 'Types/session/event';
|
||||
import Event from './Event'
|
||||
import stl from './eventGroupWrapper.module.css';
|
||||
import NoteEvent from './NoteEvent';
|
||||
import { setEditNoteTooltip } from 'Duck/sessions';;
|
||||
|
||||
// TODO: incapsulate toggler in LocationEvent
|
||||
@withToggle('showLoadInfo', 'toggleLoadInfo')
|
||||
@connect(
|
||||
(state) => ({
|
||||
members: state.getIn(['members', 'list']),
|
||||
currentUserId: state.getIn(['user', 'account', 'id']),
|
||||
}),
|
||||
{ setEditNoteTooltip }
|
||||
)
|
||||
class EventGroupWrapper extends React.Component {
|
||||
toggleLoadInfo = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.toggleLoadInfo();
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
prevProps.showLoadInfo !== this.props.showLoadInfo ||
|
||||
prevProps.query !== this.props.query ||
|
||||
prevProps.event.timestamp !== this.props.event.timestamp ||
|
||||
prevProps.isNote !== this.props.isNote
|
||||
) {
|
||||
this.props.mesureHeight();
|
||||
}
|
||||
}
|
||||
componentDidMount() {
|
||||
this.props.toggleLoadInfo(this.props.isFirst);
|
||||
this.props.mesureHeight();
|
||||
}
|
||||
|
||||
onEventClick = (e) => this.props.onEventClick(e, this.props.event);
|
||||
|
||||
onCheckboxClick = (e) => this.props.onCheckboxClick(e, this.props.event);
|
||||
|
||||
render() {
|
||||
const {
|
||||
event,
|
||||
isLastEvent,
|
||||
isLastInGroup,
|
||||
isSelected,
|
||||
isCurrent,
|
||||
isEditing,
|
||||
showSelection,
|
||||
showLoadInfo,
|
||||
isFirst,
|
||||
presentInSearch,
|
||||
isNote,
|
||||
filterOutNote,
|
||||
} = this.props;
|
||||
const isLocation = event.type === TYPES.LOCATION;
|
||||
|
||||
const whiteBg =
|
||||
(isLastInGroup && event.type !== TYPES.LOCATION) ||
|
||||
(!isLastEvent && event.type !== TYPES.LOCATION);
|
||||
const safeRef = String(event.referrer || '');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
stl.container,
|
||||
'!py-1',
|
||||
{
|
||||
[stl.last]: isLastInGroup,
|
||||
[stl.first]: event.type === TYPES.LOCATION,
|
||||
[stl.dashAfter]: isLastInGroup && !isLastEvent,
|
||||
},
|
||||
isLastInGroup && '!pb-2',
|
||||
event.type === TYPES.LOCATION && '!pt-2 !pb-2'
|
||||
)}
|
||||
>
|
||||
{isFirst && isLocation && event.referrer && (
|
||||
<div className={stl.referrer}>
|
||||
<TextEllipsis>
|
||||
Referrer: <span className={stl.url}>{safeRef}</span>
|
||||
</TextEllipsis>
|
||||
</div>
|
||||
)}
|
||||
{isNote ? (
|
||||
<NoteEvent
|
||||
userEmail={this.props.members.find((m) => m.id === event.userId)?.email || event.userId}
|
||||
note={event}
|
||||
filterOutNote={filterOutNote}
|
||||
onEdit={this.props.setEditNoteTooltip}
|
||||
noEdit={this.props.currentUserId !== event.userId}
|
||||
/>
|
||||
) : isLocation ? (
|
||||
<Event
|
||||
extended={isFirst}
|
||||
key={event.key}
|
||||
event={event}
|
||||
onClick={this.onEventClick}
|
||||
selected={isSelected}
|
||||
showLoadInfo={showLoadInfo}
|
||||
toggleLoadInfo={this.toggleLoadInfo}
|
||||
isCurrent={isCurrent}
|
||||
presentInSearch={presentInSearch}
|
||||
isLastInGroup={isLastInGroup}
|
||||
whiteBg={whiteBg}
|
||||
/>
|
||||
) : (
|
||||
<Event
|
||||
key={event.key}
|
||||
event={event}
|
||||
onClick={this.onEventClick}
|
||||
onCheckboxClick={this.onCheckboxClick}
|
||||
selected={isSelected}
|
||||
isCurrent={isCurrent}
|
||||
showSelection={showSelection}
|
||||
overlayed={isEditing}
|
||||
presentInSearch={presentInSearch}
|
||||
isLastInGroup={isLastInGroup}
|
||||
whiteBg={whiteBg}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default EventGroupWrapper
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react'
|
||||
import { Input, Icon } from 'UI'
|
||||
import { PlayerContext } from 'App/components/Session/playerContext';
|
||||
|
||||
function EventSearch(props) {
|
||||
const { player } = React.useContext(PlayerContext)
|
||||
|
||||
const { onChange, value, header, setActiveTab } = props;
|
||||
|
||||
const toggleEvents = () => player.toggleEvents()
|
||||
|
||||
return (
|
||||
<div className="flex items-center w-full relative">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className='flex flex-center justify-between'>
|
||||
<span>{header}</span>
|
||||
<div
|
||||
onClick={() => { setActiveTab(''); toggleEvents(); }}
|
||||
className=" flex items-center justify-center bg-white cursor-pointer"
|
||||
>
|
||||
<Icon name="close" size="18" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-2">
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Filter by Event Type, URL or Keyword"
|
||||
className="inset-0 w-full"
|
||||
name="query"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
wrapperClassName="w-full"
|
||||
style={{ height: '32px' }}
|
||||
autoComplete="off chromebugfix"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EventSearch
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './EventSearch'
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import cn from 'classnames';
|
||||
import { Icon } from 'UI';
|
||||
import { List, AutoSizer, CellMeasurer } from "react-virtualized";
|
||||
import { TYPES } from 'Types/session/event';
|
||||
import { setEventFilter, filterOutNote } from 'Duck/sessions';
|
||||
import EventGroupWrapper from './EventGroupWrapper';
|
||||
import styles from './eventsBlock.module.css';
|
||||
import EventSearch from './EventSearch/EventSearch';
|
||||
import { PlayerContext } from 'App/components/Session/playerContext';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { RootStore } from 'App/duck'
|
||||
import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache'
|
||||
import { InjectedEvent } from 'Types/session/event'
|
||||
import Session from 'Types/session'
|
||||
|
||||
interface IProps {
|
||||
setEventFilter: (filter: { query: string }) => void
|
||||
filteredEvents: InjectedEvent[]
|
||||
setActiveTab: (tab?: string) => void
|
||||
query: string
|
||||
session: Session
|
||||
filterOutNote: (id: string) => void
|
||||
eventsIndex: number[]
|
||||
}
|
||||
|
||||
function EventsBlock(props: IProps) {
|
||||
const [mouseOver, setMouseOver] = React.useState(true)
|
||||
const scroller = React.useRef<List>(null)
|
||||
const cache = useCellMeasurerCache(undefined, {
|
||||
fixedWidth: true,
|
||||
defaultHeight: 300
|
||||
});
|
||||
|
||||
const { store, player } = React.useContext(PlayerContext)
|
||||
|
||||
const { eventListNow, playing } = store.get()
|
||||
|
||||
const { session: { events, notesWithEvents }, filteredEvents,
|
||||
eventsIndex,
|
||||
filterOutNote,
|
||||
query,
|
||||
setActiveTab,
|
||||
} = props
|
||||
const currentTimeEventIndex = eventListNow.length > 0 ? eventListNow.length - 1 : 0
|
||||
const usedEvents = filteredEvents || notesWithEvents
|
||||
|
||||
const write = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||
props.setEventFilter({ query: value })
|
||||
|
||||
setTimeout(() => {
|
||||
if (!scroller.current) return;
|
||||
|
||||
scroller.current.scrollToRow(0);
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
props.setEventFilter({ query: '' })
|
||||
if (scroller.current) {
|
||||
scroller.current.forceUpdateGrid();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!scroller.current) return;
|
||||
|
||||
scroller.current.scrollToRow(0);
|
||||
}, 100)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
clearSearch()
|
||||
}
|
||||
}, [])
|
||||
React.useEffect(() => {
|
||||
if (scroller.current) {
|
||||
scroller.current.forceUpdateGrid();
|
||||
if (!mouseOver) {
|
||||
scroller.current.scrollToRow(currentTimeEventIndex);
|
||||
}
|
||||
}
|
||||
}, [currentTimeEventIndex])
|
||||
|
||||
const onEventClick = (_: React.MouseEvent, event: { time: number }) => player.jump(event.time)
|
||||
const onMouseOver = () => setMouseOver(true)
|
||||
const onMouseLeave = () => setMouseOver(false)
|
||||
|
||||
const renderGroup = ({ index, key, style, parent }: { index: number; key: string; style: React.CSSProperties; parent: any }) => {
|
||||
const isLastEvent = index === usedEvents.length - 1;
|
||||
const isLastInGroup = isLastEvent || usedEvents[index + 1]?.type === TYPES.LOCATION;
|
||||
const event = usedEvents[index];
|
||||
const isNote = 'noteId' in event
|
||||
const isCurrent = index === currentTimeEventIndex;
|
||||
|
||||
const heightBug = index === 0 && event?.type === TYPES.LOCATION && 'referrer' in event ? { top: 2 } : {}
|
||||
return (
|
||||
<CellMeasurer
|
||||
key={key}
|
||||
cache={cache}
|
||||
parent={parent}
|
||||
rowIndex={index}
|
||||
>
|
||||
{({measure, registerChild}) => (
|
||||
<div style={{ ...style, ...heightBug }} ref={registerChild}>
|
||||
<EventGroupWrapper
|
||||
query={query}
|
||||
presentInSearch={eventsIndex.includes(index)}
|
||||
isFirst={index==0}
|
||||
mesureHeight={measure}
|
||||
onEventClick={ onEventClick }
|
||||
event={ event }
|
||||
isLastEvent={ isLastEvent }
|
||||
isLastInGroup={ isLastInGroup }
|
||||
isCurrent={ isCurrent }
|
||||
showSelection={ !playing }
|
||||
isNote={isNote}
|
||||
filterOutNote={filterOutNote}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CellMeasurer>
|
||||
);
|
||||
}
|
||||
|
||||
const isEmptySearch = query && (usedEvents.length === 0 || !usedEvents)
|
||||
return (
|
||||
<>
|
||||
<div className={ cn(styles.header, 'p-4') }>
|
||||
<div className={ cn(styles.hAndProgress, 'mt-3') }>
|
||||
<EventSearch
|
||||
onChange={write}
|
||||
setActiveTab={setActiveTab}
|
||||
value={query}
|
||||
header={
|
||||
<div className="text-xl">User Steps <span className="color-gray-medium">{ events.length }</span></div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={ cn("flex-1 px-4 pb-4", styles.eventsList) }
|
||||
id="eventList"
|
||||
data-openreplay-masked
|
||||
onMouseOver={ onMouseOver }
|
||||
onMouseLeave={ onMouseLeave }
|
||||
>
|
||||
{isEmptySearch && (
|
||||
<div className='flex items-center'>
|
||||
<Icon name="binoculars" size={18} />
|
||||
<span className='ml-2'>No Matching Results</span>
|
||||
</div>
|
||||
)}
|
||||
<AutoSizer disableWidth>
|
||||
{({ height }) => (
|
||||
<List
|
||||
ref={scroller}
|
||||
className={ styles.eventsList }
|
||||
height={height + 10}
|
||||
width={248}
|
||||
overscanRowCount={6}
|
||||
itemSize={230}
|
||||
rowCount={usedEvents.length}
|
||||
deferredMeasurementCache={cache}
|
||||
rowHeight={cache.rowHeight}
|
||||
rowRenderer={renderGroup}
|
||||
scrollToAlignment="start"
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect((state: RootStore) => ({
|
||||
session: state.getIn([ 'sessions', 'current' ]),
|
||||
filteredEvents: state.getIn([ 'sessions', 'filteredEvents' ]),
|
||||
query: state.getIn(['sessions', 'eventsQuery']),
|
||||
eventsIndex: state.getIn([ 'sessions', 'eventsIndex' ]),
|
||||
}), {
|
||||
setEventFilter,
|
||||
filterOutNote
|
||||
})(observer(EventsBlock))
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import React from 'react';
|
||||
import styles from './loadInfo.module.css';
|
||||
import { numberWithCommas } from 'App/utils'
|
||||
|
||||
const LoadInfo = ({ showInfo = false, onClick, event: { fcpTime, visuallyComplete, timeToInteractive }, prorata: { a, b, c } }) => (
|
||||
<div>
|
||||
<div className={ styles.bar } onClick={ onClick }>
|
||||
{ typeof fcpTime === 'number' && <div style={ { width: `${ a }%` } } /> }
|
||||
{ typeof visuallyComplete === 'number' && <div style={ { width: `${ b }%` } } /> }
|
||||
{ typeof timeToInteractive === 'number' && <div style={ { width: `${ c }%` } } /> }
|
||||
</div>
|
||||
<div className={ styles.bottomBlock } data-hidden={ !showInfo }>
|
||||
{ typeof fcpTime === 'number' &&
|
||||
<div className={ styles.wrapper }>
|
||||
<div className={ styles.lines } />
|
||||
<div className={ styles.label } >{ 'Time to Render' }</div>
|
||||
<div className={ styles.value }>{ `${ numberWithCommas(fcpTime || 0) }ms` }</div>
|
||||
</div>
|
||||
}
|
||||
{ typeof visuallyComplete === 'number' &&
|
||||
<div className={ styles.wrapper }>
|
||||
<div className={ styles.lines } />
|
||||
<div className={ styles.label } >{ 'Visually Complete' }</div>
|
||||
<div className={ styles.value }>{ `${ numberWithCommas(visuallyComplete || 0) }ms` }</div>
|
||||
</div>
|
||||
}
|
||||
{ typeof timeToInteractive === 'number' &&
|
||||
<div className={ styles.wrapper }>
|
||||
<div className={ styles.lines } />
|
||||
<div className={ styles.label } >{ 'Time To Interactive' }</div>
|
||||
<div className={ styles.value }>{ `${ numberWithCommas(timeToInteractive || 0) }ms` }</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
LoadInfo.displayName = 'LoadInfo';
|
||||
|
||||
export default LoadInfo;
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import MetadataItem from './MetadataItem';
|
||||
|
||||
export default connect(state => ({
|
||||
metadata: state.getIn([ 'sessions', 'current' ]).metadata,
|
||||
}))(function Metadata ({ metadata }) {
|
||||
|
||||
const metaLenth = Object.keys(metadata).length;
|
||||
|
||||
if (metaLenth === 0) {
|
||||
return (
|
||||
(<span className="text-sm color-gray-medium">Check <a href="https://docs.openreplay.com/installation/metadata" target="_blank" className="link">how to use Metadata</a> if you haven’t yet done so.</span>)
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{ Object.keys(metadata).map((key) => {
|
||||
// const key = Object.keys(i)[0]
|
||||
const value = metadata[key]
|
||||
return <MetadataItem item={ { value, key } } key={ key } />
|
||||
}) }
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import React from 'react';
|
||||
import { List } from 'immutable';
|
||||
import cn from 'classnames';
|
||||
import { withRequest, withToggle } from 'HOCs';
|
||||
import { Button, Icon, SlideModal, TextEllipsis } from 'UI';
|
||||
import stl from './metadataItem.module.css';
|
||||
import SessionList from './SessionList';
|
||||
|
||||
@withToggle()
|
||||
@withRequest({
|
||||
initialData: List(),
|
||||
endpoint: '/metadata/session_search',
|
||||
dataWrapper: data => Object.values(data),
|
||||
dataName: 'similarSessions',
|
||||
})
|
||||
export default class extends React.PureComponent {
|
||||
state = {
|
||||
requested: false,
|
||||
}
|
||||
switchOpen = () => {
|
||||
const {
|
||||
item: {
|
||||
key, value
|
||||
},
|
||||
request,
|
||||
switchOpen,
|
||||
} = this.props;
|
||||
|
||||
const { requested } = this.state;
|
||||
if (!requested) {
|
||||
this.setState({ requested: true });
|
||||
request({ key, value });
|
||||
}
|
||||
switchOpen();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
item,
|
||||
similarSessions,
|
||||
open,
|
||||
loading,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SlideModal
|
||||
title={ <div className={ stl.searchResultsHeader }>{ `All Sessions Matching - ` } <span>{ item.key + ' - ' + item.value }</span> </div> }
|
||||
isDisplayed={ open }
|
||||
content={ open && <SessionList similarSessions={ similarSessions } loading={ loading } /> }
|
||||
onClose={ open ? this.switchOpen : () => null }
|
||||
/>
|
||||
<div className={ cn("flex justify-between items-center p-3 capitalize", stl.field) } >
|
||||
<div>
|
||||
<div className={ stl.key }>{ item.key }</div>
|
||||
<TextEllipsis
|
||||
maxWidth="210px"
|
||||
popupProps={ { disabled: item.value && item.value.length < 30 } }
|
||||
>
|
||||
{ item.value }
|
||||
</TextEllipsis>
|
||||
</div>
|
||||
<Button
|
||||
onClick={ this.switchOpen }
|
||||
variant="text"
|
||||
className={ stl.searchButton }
|
||||
id="metadata-item"
|
||||
>
|
||||
<Icon name="search" size="16" color="teal" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { BrowserIcon, OsIcon, Icon, CountryFlag, Link } from 'UI';
|
||||
import { deviceTypeIcon } from 'App/iconNames';
|
||||
import { session as sessionRoute } from 'App/routes';
|
||||
import { formatTimeOrDate } from 'App/date';
|
||||
|
||||
|
||||
const SessionLine = ({ session: {
|
||||
userBrowser,
|
||||
userOs,
|
||||
userCountry,
|
||||
siteId,
|
||||
sessionId,
|
||||
viewed,
|
||||
userDeviceType,
|
||||
startedAt
|
||||
} }) => (
|
||||
<div className="flex justify-between items-center" style={{ padding: '5px 20px' }}>
|
||||
<div className="color-gray-medium font-size-10" >
|
||||
<CountryFlag country={ userCountry } className="mr-4" />
|
||||
{ formatTimeOrDate(startedAt) }
|
||||
</div>
|
||||
<div className="flex">
|
||||
<BrowserIcon browser={ userBrowser } className="mr-4" />
|
||||
<OsIcon os={ userOs } size="20" className="mr-4" />
|
||||
<Icon name={ deviceTypeIcon(userDeviceType) } size="20" className="mr-4" />
|
||||
</div>
|
||||
<Link to={ sessionRoute(sessionId) } siteId={ siteId } >
|
||||
<Icon
|
||||
name={ viewed ? 'play-fill' : 'play-circle-light' }
|
||||
size="20"
|
||||
color="teal"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
SessionLine.displayName = "SessionLine";
|
||||
|
||||
export default SessionLine;
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { NoContent, Icon, Loader } from 'UI';
|
||||
import Session from 'Types/session';
|
||||
import SessionItem from 'Shared/SessionItem';
|
||||
import stl from './sessionList.module.css';
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
|
||||
@connect((state) => ({
|
||||
currentSessionId: state.getIn(['sessions', 'current']).sessionId,
|
||||
}))
|
||||
class SessionList extends React.PureComponent {
|
||||
render() {
|
||||
const { similarSessions, loading, currentSessionId } = this.props;
|
||||
|
||||
const similarSessionWithoutCurrent = similarSessions
|
||||
.map(({ sessions, ...rest }) => {
|
||||
return {
|
||||
...rest,
|
||||
sessions: sessions.map(s => new Session(s)).filter(({ sessionId }) => sessionId !== currentSessionId),
|
||||
};
|
||||
})
|
||||
.filter((site) => site.sessions.length > 0);
|
||||
|
||||
return (
|
||||
<Loader loading={loading}>
|
||||
<NoContent
|
||||
show={!loading && (similarSessionWithoutCurrent.length === 0 || similarSessionWithoutCurrent.size === 0)}
|
||||
title={
|
||||
<div className="flex items-center justify-center flex-col">
|
||||
<AnimatedSVG name={ICONS.NO_SESSIONS} size={170} />
|
||||
<div className="mt-2" />
|
||||
<div className="text-center text-gray-600">No sessions found.</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={stl.sessionList}>
|
||||
{similarSessionWithoutCurrent.map((site) => (
|
||||
<div className={stl.siteWrapper} key={site.host}>
|
||||
<div className={stl.siteHeader}>
|
||||
<Icon name="window" size="14" color="gray-medium" marginRight="10" />
|
||||
<span>{site.name}</span>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded border">
|
||||
{site.sessions.map((session) => (
|
||||
<div className="border-b last:border-none">
|
||||
<SessionItem key={session.sessionId} session={session} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</NoContent>
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SessionList;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './Metadata';
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
|
||||
@import 'mixins.css';
|
||||
|
||||
/* .wrapper {
|
||||
position: relative;
|
||||
} */
|
||||
|
||||
.modal {
|
||||
/* width: 288px; */
|
||||
/* position: absolute; */
|
||||
/* top: 50px; */
|
||||
/* right: 0; */
|
||||
/* background-color: white; */
|
||||
/* border-radius: 3px; */
|
||||
/* z-index: 99; */
|
||||
/* padding: 10px; */
|
||||
/* min-height: 90px; */
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
/* @mixin shadow; */
|
||||
/* border: solid thin $gray-light; */
|
||||
|
||||
/* & .tooltipArrow {
|
||||
width: 50px;
|
||||
height: 25px;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
transform: translateX(-50%);
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
transform: translateX(-50%) translateY(50%) rotate(45deg);
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
box-shadow: 2px 2px 6px 0px rgba(0,0,0,0.6);
|
||||
}
|
||||
} */
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
/* padding: 10px 20px; */
|
||||
border-bottom: solid thin $gray-light;
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
|
||||
|
||||
.field {
|
||||
&:not(:last-child) {
|
||||
border-bottom: solid thin $gray-light-shade;
|
||||
}
|
||||
/* padding: 10px 20px; */
|
||||
}
|
||||
|
||||
.key {
|
||||
color: $gray-medium;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.searchResultsHeader {
|
||||
& span {
|
||||
padding: 4px 8px;
|
||||
font-size: 18px;
|
||||
background-color: $gray-lightest;
|
||||
border: solid thin $gray-light;
|
||||
margin-left: 10px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
border-radius: 3px;
|
||||
height: 30px !important;
|
||||
width: 30px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
padding: 0 !important;
|
||||
justify-content: center !important;
|
||||
&:hover {
|
||||
background-color: $gray-lightest !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
|
||||
|
||||
.sessionList {
|
||||
padding: 0 20px;
|
||||
background-color: #f6f6f6;
|
||||
min-height: calc(100vh - 59px);
|
||||
}
|
||||
|
||||
.siteWrapper {
|
||||
padding-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.siteHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
background-color: white;
|
||||
border-top: solid thin $gray-lightest;
|
||||
margin: -15px;
|
||||
margin-top: -10px;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import React from 'react';
|
||||
import { Icon } from 'UI';
|
||||
import { tagProps, Note } from 'App/services/NotesService';
|
||||
import { formatTimeOrDate } from 'App/date';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { ItemMenu } from 'UI';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { toast } from 'react-toastify';
|
||||
import { session } from 'App/routes';
|
||||
import { confirm } from 'UI';
|
||||
import { TeamBadge } from 'Shared/SessionListContainer/components/Notes';
|
||||
|
||||
interface Props {
|
||||
note: Note;
|
||||
noEdit: boolean;
|
||||
userEmail: string;
|
||||
filterOutNote: (id: number) => void;
|
||||
onEdit: (noteTooltipObj: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
function NoteEvent(props: Props) {
|
||||
const { settingsStore, notesStore } = useStore();
|
||||
const { timezone } = settingsStore.sessionSettings;
|
||||
|
||||
const onEdit = () => {
|
||||
props.onEdit({
|
||||
isVisible: true,
|
||||
isEdit: true,
|
||||
time: props.note.timestamp,
|
||||
note: {
|
||||
timestamp: props.note.timestamp,
|
||||
tag: props.note.tag,
|
||||
isPublic: props.note.isPublic,
|
||||
message: props.note.message,
|
||||
sessionId: props.note.sessionId,
|
||||
noteId: props.note.noteId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onCopy = () => {
|
||||
copy(
|
||||
`${window.location.origin}/${window.location.pathname.split('/')[1]}${session(
|
||||
props.note.sessionId
|
||||
)}${props.note.timestamp > 0 ? `?jumpto=${props.note.timestamp}¬e=${props.note.noteId}` : `?note=${props.note.noteId}`}`
|
||||
);
|
||||
toast.success('Note URL copied to clipboard');
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
if (
|
||||
await confirm({
|
||||
header: 'Confirm',
|
||||
confirmButton: 'Yes, delete',
|
||||
confirmation: `Are you sure you want to delete this note?`,
|
||||
})
|
||||
) {
|
||||
notesStore.deleteNote(props.note.noteId).then((r) => {
|
||||
props.filterOutNote(props.note.noteId);
|
||||
toast.success('Note deleted');
|
||||
});
|
||||
}
|
||||
};
|
||||
const menuItems = [
|
||||
{ icon: 'pencil', text: 'Edit', onClick: onEdit, disabled: props.noEdit },
|
||||
{ icon: 'link-45deg', text: 'Copy URL', onClick: onCopy },
|
||||
{ icon: 'trash', text: 'Delete', onClick: onDelete },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
className="flex items-start flex-col p-2 border rounded"
|
||||
style={{ background: '#FFFEF5' }}
|
||||
>
|
||||
<div className="flex items-center w-full relative">
|
||||
<div className="p-3 bg-gray-light rounded-full">
|
||||
<Icon name="quotes" color="main" />
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<div
|
||||
className="text-base"
|
||||
style={{
|
||||
maxWidth: 150,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{props.userEmail}, {props.userEmail}
|
||||
</div>
|
||||
<div className="text-disabled-text text-sm">
|
||||
{formatTimeOrDate(props.note.createdAt as unknown as number, timezone)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="cursor-pointer absolute" style={{ right: -5 }}>
|
||||
<ItemMenu bold items={menuItems} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-base capitalize-first my-3 overflow-y-scroll overflow-x-hidden"
|
||||
style={{ maxHeight: 200, maxWidth: 220 }}
|
||||
>
|
||||
{props.note.message}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap w-full">
|
||||
{props.note.tag ? (
|
||||
<div
|
||||
key={props.note.tag}
|
||||
style={{
|
||||
// @ts-ignore
|
||||
background: tagProps[props.note.tag],
|
||||
userSelect: 'none',
|
||||
padding: '1px 6px',
|
||||
}}
|
||||
className="rounded-full text-white text-xs select-none w-fit"
|
||||
>
|
||||
{props.note.tag}
|
||||
</div>
|
||||
) : null}
|
||||
{!props.note.isPublic ? null : <TeamBadge />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(NoteEvent);
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { List } from 'immutable';
|
||||
import { countries } from 'App/constants';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { browserIcon, osIcon, deviceTypeIcon } from 'App/iconNames';
|
||||
import { formatTimeOrDate } from 'App/date';
|
||||
import { Avatar, TextEllipsis, CountryFlag, Icon, Tooltip, Popover } from 'UI';
|
||||
import cn from 'classnames';
|
||||
import { withRequest } from 'HOCs';
|
||||
import SessionInfoItem from 'Components/Session_/SessionInfoItem';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import UserSessionsModal from 'Shared/UserSessionsModal';
|
||||
|
||||
function UserCard({ className, request, session, width, height, similarSessions, loading }) {
|
||||
const { settingsStore } = useStore();
|
||||
const { timezone } = settingsStore.sessionSettings;
|
||||
|
||||
const {
|
||||
userBrowser,
|
||||
userDevice,
|
||||
userCountry,
|
||||
userBrowserVersion,
|
||||
userOs,
|
||||
userOsVersion,
|
||||
startedAt,
|
||||
userId,
|
||||
userAnonymousId,
|
||||
userNumericHash,
|
||||
userDisplayName,
|
||||
userDeviceType,
|
||||
revId,
|
||||
} = session;
|
||||
|
||||
const hasUserDetails = !!userId || !!userAnonymousId;
|
||||
|
||||
const getDimension = (width, height) => {
|
||||
return width && height ? (
|
||||
<div className="flex items-center">
|
||||
{width || 'x'} <Icon name="close" size="12" className="mx-1" /> {height || 'x'}
|
||||
</div>
|
||||
) : (
|
||||
<span className="">Resolution N/A</span>
|
||||
);
|
||||
};
|
||||
|
||||
const avatarbgSize = '38px';
|
||||
return (
|
||||
<div className={cn('bg-white flex items-center w-full', className)}>
|
||||
<div className="flex items-center">
|
||||
<Avatar iconSize="23" width={avatarbgSize} height={avatarbgSize} seed={userNumericHash} />
|
||||
<div className="ml-3 overflow-hidden leading-tight">
|
||||
<TextEllipsis
|
||||
noHint
|
||||
className={cn('font-medium', { 'color-teal cursor-pointer': hasUserDetails })}
|
||||
// onClick={hasUserDetails ? showSimilarSessions : undefined}
|
||||
>
|
||||
<UserName name={userDisplayName} userId={userId} hash={userNumericHash} />
|
||||
</TextEllipsis>
|
||||
|
||||
<div className="text-sm color-gray-medium flex items-center">
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
<Tooltip
|
||||
title={`${formatTimeOrDate(startedAt, timezone, true)} ${timezone.label}`}
|
||||
className="w-fit !block"
|
||||
>
|
||||
{formatTimeOrDate(startedAt, timezone)}
|
||||
</Tooltip>
|
||||
|
||||
</span>
|
||||
<span className="mx-1 font-bold text-xl">·</span>
|
||||
<span>{countries[userCountry]}</span>
|
||||
<span className="mx-1 font-bold text-xl">·</span>
|
||||
<span className="capitalize">
|
||||
{userBrowser}, {userOs}, {userDevice}
|
||||
</span>
|
||||
<span className="mx-1 font-bold text-xl">·</span>
|
||||
<Popover
|
||||
render={() => (
|
||||
<div className="text-left bg-white">
|
||||
<SessionInfoItem
|
||||
comp={<CountryFlag country={userCountry} />}
|
||||
label={countries[userCountry]}
|
||||
value={<span style={{ whiteSpace: 'nowrap' }}>{formatTimeOrDate(startedAt)}</span>}
|
||||
/>
|
||||
<SessionInfoItem icon={browserIcon(userBrowser)} label={userBrowser} value={`v${userBrowserVersion}`} />
|
||||
<SessionInfoItem icon={osIcon(userOs)} label={userOs} value={userOsVersion} />
|
||||
<SessionInfoItem
|
||||
icon={deviceTypeIcon(userDeviceType)}
|
||||
label={userDeviceType}
|
||||
value={getDimension(width, height)}
|
||||
isLast={!revId}
|
||||
/>
|
||||
{revId && <SessionInfoItem icon="info" label="Rev ID:" value={revId} isLast />}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<span className="link">More</span>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const component = React.memo(connect((state) => ({ session: state.getIn(['sessions', 'current']) }))(UserCard));
|
||||
|
||||
export default withRequest({
|
||||
initialData: List(),
|
||||
endpoint: '/metadata/session_search',
|
||||
dataWrapper: (data) => Object.values(data),
|
||||
dataName: 'similarSessions',
|
||||
})(component);
|
||||
|
||||
// inner component
|
||||
function UserName({ name, userId, hash }) {
|
||||
const { showModal } = useModal();
|
||||
const onClick = () => {
|
||||
showModal(<UserSessionsModal userId={userId} hash={hash} name={name} />, { right: true });
|
||||
};
|
||||
return <div onClick={userId ? onClick : () => {}}>{name}</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './UserCard';
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
.contextMenu {
|
||||
position: absolute;
|
||||
top: 27px;
|
||||
right: 15px;
|
||||
padding: 2px 3px;
|
||||
background: $white;
|
||||
border: 1px solid $gray-light;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: $gray-medium;
|
||||
font-size: 12px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.event {
|
||||
position: relative;
|
||||
background: #f6f6f6;
|
||||
border-radius: 3px;
|
||||
user-select: none;
|
||||
/* box-shadow: 0px 1px 3px 0 $gray-light; */
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
&:hover {
|
||||
background-color: $active-blue;
|
||||
border: 1px solid $active-blue-border;
|
||||
}
|
||||
|
||||
& .title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
& .topBlock {
|
||||
min-height: 30px;
|
||||
position: relative;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
& .checkbox {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 8px;
|
||||
bottom: 0;
|
||||
/* margin: auto; */
|
||||
display: none;
|
||||
/* align-items: center; */
|
||||
}
|
||||
|
||||
&.menuClosed:hover {
|
||||
& .edit {
|
||||
opacity: 1;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
&.menuClosed.showSelection {
|
||||
&:hover, &.selected {
|
||||
background-color: #EFFCFB;
|
||||
|
||||
& .checkbox {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& .icon {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.highlighted {
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0px 2px 10px 0 $gray-light;
|
||||
border: 1px solid $active-blue-border;
|
||||
/* background-color: red; */
|
||||
}
|
||||
|
||||
&.red {
|
||||
border-color: $red;
|
||||
}
|
||||
}
|
||||
|
||||
.firstLine {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
|
||||
.type {
|
||||
color: $gray-dark;
|
||||
font-size: 12px;
|
||||
text-transform: capitalize;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.data {
|
||||
margin-left: 5px;
|
||||
color: $gray-medium;
|
||||
font-size: 12px;
|
||||
max-width: 100%;
|
||||
/* overflow: hidden; */
|
||||
/* text-overflow: ellipsis; */
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
/* margin-left: 28px; */
|
||||
max-width: 170px;
|
||||
word-wrap: break-word;
|
||||
line-height: normal;
|
||||
color: #999;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
& i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.clickType, .inputType {
|
||||
/* border: 1px solid $gray-light; */
|
||||
background-color: $gray-lightest;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickrageType {
|
||||
background-color: #FFF3F3;
|
||||
border: 1px solid #CC0000;
|
||||
box-shadow:
|
||||
/* The top layer shadow */
|
||||
/* 0 1px 1px rgba(0,0,0,0.15), */
|
||||
/* The second layer */
|
||||
2px 2px 1px 1px white,
|
||||
/* The second layer shadow */
|
||||
2px 2px 0px 1px rgba(0,0,0,0.4);
|
||||
/* Padding for demo purposes */
|
||||
/* padding: 12px; */
|
||||
}
|
||||
|
||||
.highlight {
|
||||
border: solid thin red;
|
||||
}
|
||||
|
||||
.lastInGroup {
|
||||
background: white;
|
||||
box-shadow: 0px 1px 1px 0px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
.container {
|
||||
padding: 0px 7px; /*0.35rem 0.5rem */
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
|
||||
.first {
|
||||
padding-top: 7px;
|
||||
border-top-left-radius: 3px;
|
||||
border-top-right-radius: 3px;
|
||||
}
|
||||
|
||||
.last {
|
||||
padding-bottom: 7px;
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
|
||||
.dashAfter {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.referrer {
|
||||
font-size: 14px;
|
||||
color: $gray-dark;
|
||||
font-weight: 500 !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
& .url {
|
||||
margin-left: 5px;
|
||||
font-weight: 300;
|
||||
color: $gray-medium;
|
||||
max-width: 70%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
.eventsBlock {
|
||||
width: 270px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.header {
|
||||
& .hAndProgress {
|
||||
display:flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
/* margin-bottom: 5px; */
|
||||
/* height: 40px; */
|
||||
& .progress {
|
||||
flex: 1;
|
||||
margin: 0 0 0 15px;
|
||||
& :global(.bar) {
|
||||
background: #ffcc99;
|
||||
}
|
||||
& :global(.progress) {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& h5 {
|
||||
margin: 0; /* get rid of semantic, please*/
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.eventsList {
|
||||
/* box-shadow: inset 0px 2px 4px rgba(0, 0, 0, 0.1); */
|
||||
/* border-top: solid thin $gray-light-shade; */
|
||||
&::-webkit-scrollbar {
|
||||
width: 2px;
|
||||
background: transparent !important;
|
||||
background: rgba(0,0,0,0);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: transparent !important;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent !important;
|
||||
}
|
||||
&:hover {
|
||||
&::-webkit-scrollbar {
|
||||
width: 2px;
|
||||
background: rgba(0,0,0,0.1)
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: rgba(0,0,0,0.1)
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sessionDetails {
|
||||
display: flex;
|
||||
font-size: 10px;
|
||||
color: $gray-medium;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './EventsBlock';
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
|
||||
|
||||
$green-light: #A0D6AE;
|
||||
$green-middle: #859D9A;
|
||||
$green-dark: #3A625E;
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
/* margin: 0 -11px;
|
||||
margin-bottom: -9px; */
|
||||
|
||||
& div {
|
||||
height: 5px;
|
||||
}
|
||||
& div:nth-child(1) {
|
||||
background-color: #C5E6E7;
|
||||
}
|
||||
& div:nth-child(2) {
|
||||
background-color: #8BCCCF;
|
||||
}
|
||||
& div:nth-child(3) {
|
||||
background-color :rgba(62, 170, 175, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.bottomBlock {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 5px 12px 34px;
|
||||
font-size: 13px;
|
||||
/* font-weight: 500; */
|
||||
|
||||
& .lines {
|
||||
border-bottom: 1px solid $gray-light;
|
||||
border-left: 2px solid;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
top: -21px;
|
||||
left: 14px;
|
||||
width: 15px;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
border-radius: 5px;
|
||||
border: 5px solid;
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: -6px;
|
||||
z-index: 1; /* in context */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper:nth-child(1) {
|
||||
/* overflow: hidden; */
|
||||
& .lines {
|
||||
border-left-color: #C5E6E7;
|
||||
&:before {
|
||||
border-color: #C5E6E7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper:nth-child(2) {
|
||||
& .lines {
|
||||
border-left-color: #8BCCCF;
|
||||
&:before {
|
||||
border-color: #8BCCCF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper:nth-child(3) {
|
||||
& .lines {
|
||||
border-left-color: rgba(62, 170, 175, 1);
|
||||
&:before {
|
||||
border-color: rgba(62, 170, 175, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 500;
|
||||
color: $gray-medium;
|
||||
}
|
||||
|
||||
.download {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import {
|
||||
sessions as sessionsRoute,
|
||||
liveSession as liveSessionRoute,
|
||||
withSiteId,
|
||||
} from 'App/routes';
|
||||
import { BackLink, Link } from 'UI';
|
||||
import { toggleFavorite, setSessionPath } from 'Duck/sessions';
|
||||
import cn from 'classnames';
|
||||
import SessionMetaList from 'Shared/SessionItem/SessionMetaList';
|
||||
import UserCard from './EventsBlock/UserCard';
|
||||
import Tabs from 'Components/Session/Tabs';
|
||||
import { PlayerContext } from 'App/components/Session/playerContext';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import stl from './playerBlockHeader.module.css';
|
||||
|
||||
const SESSIONS_ROUTE = sessionsRoute();
|
||||
|
||||
// TODO props
|
||||
function PlayerBlockHeader(props: any) {
|
||||
const [hideBack, setHideBack] = React.useState(false);
|
||||
const { player, store } = React.useContext(PlayerContext);
|
||||
|
||||
const { width, height, showEvents } = store.get();
|
||||
|
||||
const {
|
||||
session,
|
||||
fullscreen,
|
||||
metaList,
|
||||
closedLive = false,
|
||||
siteId,
|
||||
setActiveTab,
|
||||
activeTab,
|
||||
location,
|
||||
history,
|
||||
sessionPath,
|
||||
} = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
setHideBack(queryParams.has('iframe') && queryParams.get('iframe') === 'true');
|
||||
}, []);
|
||||
|
||||
const backHandler = () => {
|
||||
if (
|
||||
sessionPath.pathname === history.location.pathname ||
|
||||
sessionPath.pathname.includes('/session/')
|
||||
) {
|
||||
history.push(withSiteId(SESSIONS_ROUTE, siteId));
|
||||
} else {
|
||||
history.push(
|
||||
sessionPath ? sessionPath.pathname + sessionPath.search : withSiteId(SESSIONS_ROUTE, siteId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const { sessionId, live, metadata } = session;
|
||||
let _metaList = Object.keys(metadata)
|
||||
.filter((i) => metaList.includes(i))
|
||||
.map((key) => {
|
||||
const value = metadata[key];
|
||||
return { label: key, value };
|
||||
});
|
||||
|
||||
const TABS = [props.tabs.EVENTS, props.tabs.CLICKMAP].map((tab) => ({
|
||||
text: tab,
|
||||
key: tab,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={cn(stl.header, 'flex justify-between', { hidden: fullscreen })}>
|
||||
<div className="flex w-full items-center">
|
||||
{!hideBack && (
|
||||
<div
|
||||
className="flex items-center h-full cursor-pointer group"
|
||||
onClick={backHandler}
|
||||
>
|
||||
{/* @ts-ignore TODO */}
|
||||
<BackLink label="Back" className="h-full" />
|
||||
<div className={stl.divider} />
|
||||
</div>
|
||||
)}
|
||||
<UserCard className="" width={width} height={height} />
|
||||
|
||||
<div className={cn('ml-auto flex items-center h-full', { hidden: closedLive })}>
|
||||
{live && (
|
||||
<>
|
||||
<div className={cn(stl.liveSwitchButton, 'pr-4')}>
|
||||
<Link to={withSiteId(liveSessionRoute(sessionId), siteId)}>
|
||||
This Session is Now Continuing Live
|
||||
</Link>
|
||||
</div>
|
||||
{_metaList.length > 0 && <div className={stl.divider} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{_metaList.length > 0 && (
|
||||
<div className="border-l h-full flex items-center px-2">
|
||||
<SessionMetaList className="" metaList={_metaList} maxLength={2} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative border-l" style={{ minWidth: '270px' }}>
|
||||
<Tabs
|
||||
tabs={TABS}
|
||||
active={activeTab}
|
||||
onClick={(tab) => {
|
||||
if (activeTab === tab) {
|
||||
setActiveTab('');
|
||||
player.toggleEvents();
|
||||
} else {
|
||||
setActiveTab(tab);
|
||||
!showEvents && player.toggleEvents();
|
||||
}
|
||||
}}
|
||||
border={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PlayerHeaderCont = connect(
|
||||
(state: any) => {
|
||||
const session = state.getIn(['sessions', 'current']);
|
||||
|
||||
return {
|
||||
session,
|
||||
sessionPath: state.getIn(['sessions', 'sessionPath']),
|
||||
local: state.getIn(['sessions', 'timezone']),
|
||||
funnelRef: state.getIn(['funnels', 'navRef']),
|
||||
siteId: state.getIn(['site', 'siteId']),
|
||||
metaList: state.getIn(['customFields', 'list']).map((i: any) => i.key),
|
||||
};
|
||||
},
|
||||
{
|
||||
toggleFavorite,
|
||||
setSessionPath,
|
||||
}
|
||||
)(observer(PlayerBlockHeader));
|
||||
|
||||
export default withRouter(PlayerHeaderCont);
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
.header {
|
||||
height: 50px;
|
||||
border-bottom: solid thin $gray-light;
|
||||
padding-left: 15px;
|
||||
padding-right: 0;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 49px;
|
||||
margin: 0 10px;
|
||||
background-color: $gray-light;
|
||||
}
|
||||
|
||||
.liveSwitchButton {
|
||||
cursor: pointer;
|
||||
color: $green;
|
||||
text-decoration: underline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
@ -25,7 +25,6 @@ function WebPlayer(props: any) {
|
|||
session,
|
||||
toggleFullscreen,
|
||||
closeBottomBlock,
|
||||
live,
|
||||
fullscreen,
|
||||
fetchList,
|
||||
customSession,
|
||||
|
|
@ -37,7 +36,8 @@ function WebPlayer(props: any) {
|
|||
const { notesStore } = useStore();
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [showNoteModal, setShowNote] = useState(false);
|
||||
const [noteItem, setNoteItem] = useState<Note>();
|
||||
const [noteItem, setNoteItem] = useState<Note | undefined>(undefined);
|
||||
// @ts-ignore
|
||||
const [contextValue, setContextValue] = useState<IPlayerContext>(defaultContextValue);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -55,11 +55,10 @@ function WebPlayer(props: any) {
|
|||
|
||||
if (!isClickmap) {
|
||||
notesStore.fetchSessionNotes(session.sessionId).then((r) => {
|
||||
const noteId = props.query.get('note');
|
||||
const note = notesStore.getNoteById(parseInt(noteId, 10), r)
|
||||
const note = props.query.get('note');
|
||||
if (note) {
|
||||
WebPlayerInst.pause();
|
||||
setNoteItem(note);
|
||||
setNoteItem(notesStore.getNoteById(parseInt(note, 10), r));
|
||||
setShowNote(true);
|
||||
}
|
||||
});
|
||||
|
|
@ -124,7 +123,6 @@ function WebPlayer(props: any) {
|
|||
<PlayerContent
|
||||
activeTab={activeTab}
|
||||
fullscreen={fullscreen}
|
||||
live={live}
|
||||
setActiveTab={setActiveTab}
|
||||
session={session}
|
||||
isClickmap={isClickmap}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,18 @@ import {
|
|||
} from 'Player'
|
||||
|
||||
export interface IPlayerContext {
|
||||
player: IWebPlayer | IWebLivePlayer
|
||||
store: IWebPlayerStore | IWebLivePlayerStore,
|
||||
player: IWebPlayer
|
||||
store: IWebPlayerStore,
|
||||
}
|
||||
|
||||
export interface ILivePlayerContext {
|
||||
player: IWebLivePlayer
|
||||
store: IWebLivePlayerStore
|
||||
}
|
||||
|
||||
type ContextType =
|
||||
| IPlayerContext
|
||||
| ILivePlayerContext
|
||||
export const defaultContextValue = { player: undefined, store: undefined}
|
||||
// @ts-ignore
|
||||
export const PlayerContext = createContext<IPlayerContext>(defaultContextValue);
|
||||
export const PlayerContext = createContext<ContextType>(defaultContextValue);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { TeamBadge } from 'Shared/SessionListContainer/components/Notes';
|
|||
|
||||
interface Props {
|
||||
userEmail: string;
|
||||
note: Note;
|
||||
note?: Note;
|
||||
notFound?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ function ReadNote(props: Props) {
|
|||
const { settingsStore } = useStore();
|
||||
const { timezone } = settingsStore.sessionSettings;
|
||||
|
||||
if (props.notFound) {
|
||||
if (props.notFound || props.note === undefined) {
|
||||
return (
|
||||
<div style={{ position: 'absolute', top: '45%', left: 'calc(50% - 200px)' }}>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,298 +0,0 @@
|
|||
import { storiesOf } from '@storybook/react';
|
||||
import { List } from 'immutable';
|
||||
import EventGroup from './EventsBlock/Event';
|
||||
|
||||
const groups = [
|
||||
{
|
||||
"page": {
|
||||
"key": "Location_257",
|
||||
"time": 2751,
|
||||
"type": "LOCATION",
|
||||
"url": "/login",
|
||||
"pageLoad": false,
|
||||
"fcpTime": 6787,
|
||||
"loadTime": 7872,
|
||||
"domTime": 5821,
|
||||
"referrer": "Search Engine"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"sessionId": 2406625057772570,
|
||||
"messageId": 76446,
|
||||
"timestamp": 1586722257371,
|
||||
"label": "Device Memory: 8.19GB",
|
||||
"type": "CLICKRAGE",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "Click_256",
|
||||
"time": 13398,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_262",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Input_256",
|
||||
"time": 13438,
|
||||
"type": "INPUT",
|
||||
"target": {
|
||||
"key": "record_263",
|
||||
"path": "",
|
||||
"label": null
|
||||
},
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"page": {
|
||||
"key": "Location_258",
|
||||
"time": 15841,
|
||||
"type": "LOCATION",
|
||||
"url": "/1/sessions",
|
||||
"pageLoad": false,
|
||||
"fcpTime": null,
|
||||
"loadTime": null,
|
||||
"domTime": null,
|
||||
"referrer": ""
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"key": "Click_257",
|
||||
"time": 24408,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_264",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"page": {
|
||||
"key": "Location_259",
|
||||
"time": 25019,
|
||||
"type": "LOCATION",
|
||||
"url": "/1/session/2303531983744788",
|
||||
"pageLoad": false,
|
||||
"fcpTime": null,
|
||||
"loadTime": null,
|
||||
"domTime": null,
|
||||
"referrer": ""
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"key": "Click_258",
|
||||
"time": 31134,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_265",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_259",
|
||||
"time": 32022,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_266",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_260",
|
||||
"time": 35951,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_267",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_261",
|
||||
"time": 164029,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_268",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_262",
|
||||
"time": 169739,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_269",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_263",
|
||||
"time": 170524,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_270",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_264",
|
||||
"time": 172580,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_271",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_265",
|
||||
"time": 173102,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_272",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_266",
|
||||
"time": 173698,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_273",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_267",
|
||||
"time": 173867,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_274",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_268",
|
||||
"time": 174599,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_275",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_269",
|
||||
"time": 175148,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_276",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_270",
|
||||
"time": 175779,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_277",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_271",
|
||||
"time": 176658,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_278",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_272",
|
||||
"time": 177267,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_279",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_273",
|
||||
"time": 187025,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_280",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_274",
|
||||
"time": 189787,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_281",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Click_275",
|
||||
"time": 191326,
|
||||
"type": "CLICK",
|
||||
"targetContent": "",
|
||||
"target": {
|
||||
"key": "record_282",
|
||||
"path": "",
|
||||
"label": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// storiesOf('Player', module)
|
||||
// .add('Event Group', () => (
|
||||
// <EventGroup
|
||||
// group={groups[0]}
|
||||
// selectedEvents={[]}
|
||||
// />
|
||||
// ))
|
||||
|
|
@ -401,7 +401,7 @@ export default class MessageManager {
|
|||
case MType.Fetch:
|
||||
case MType.NetworkRequest:
|
||||
// @ts-ignore burn immutable
|
||||
this.lists.lists.fetch.insert(Resource({
|
||||
this.lists.lists.fetch.insert(new Resource({
|
||||
method: msg.method,
|
||||
url: msg.url,
|
||||
request: msg.request,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue