Highlight UI (#2951)

* ui: start highlight ui

* ui: tag items

* ui: connecting highlights to notes api...

* Highlight feature refinements (#2948)

* ui: move clips player to foss, connect notes api to hl

* ui: tune note/hl editing, prevent zoom slider body from jumping around

* ui: safe check for tag

* ui: fix thumbnail gen

* ui: fix thumbnail gen

* ui: make player modal wider, add shadow

* ui: custom warn barge for clips

* ui: swap icon for note event wrapper

* ui: rm other, fix cancel

* ui: moving around creation modal

* ui: bg tint

* ui: rm disabled for text btn

* ui: fix ownership sorting

* ui: close player on bg click

* ui: fix query, fix min distance for default range

* ui: move hl list header out of list comp

* ui: spot list header segmented size

* Various improvements in highlights (#2955)

* ui: update hl in hlPanel comp

* ui: rm debug

* ui: fix icons file

---------

Co-authored-by: Sudheer Salavadi <connect.uxmaster@gmail.com>
This commit is contained in:
Delirium 2025-01-24 09:59:54 +01:00 committed by GitHub
parent 622d0a7dfa
commit 2cd96b0df0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 2536 additions and 265 deletions

View file

@ -36,6 +36,7 @@ const components: any = {
SpotsListPure: lazy(() => import('Components/Spots/SpotsList')),
SpotPure: lazy(() => import('Components/Spots/SpotPlayer')),
ScopeSetup: lazy(() => import('Components/ScopeForm')),
HighlightsPure: lazy(() => import('Components/Highlights/HighlightsList')),
};
const enhancedComponents: any = {
@ -58,6 +59,7 @@ const enhancedComponents: any = {
SpotsList: withSiteIdUpdater(components.SpotsListPure),
Spot: components.SpotPure,
ScopeSetup: components.ScopeSetup,
Highlights: components.HighlightsPure,
};
const withSiteId = routes.withSiteId;
@ -105,6 +107,8 @@ const SPOTS_LIST_PATH = routes.spotsList();
const SPOT_PATH = routes.spot();
const SCOPE_SETUP = routes.scopeSetup();
const HIGHLIGHTS_PATH = routes.highlights();
function PrivateRoutes() {
const { projectsStore, userStore, integrationsStore } = useStore();
const onboarding = userStore.onboarding;
@ -236,6 +240,12 @@ function PrivateRoutes() {
path={withSiteId(RECORDINGS_PATH, siteIdList)}
component={enhancedComponents.Assist}
/>
<Route
exact
strict
path={withSiteId(HIGHLIGHTS_PATH, siteIdList)}
component={enhancedComponents.Highlights}
/>
<Route
exact
strict

View file

@ -0,0 +1,62 @@
import React from 'react';
import { Checkbox, Modal, Input } from 'antd';
function EditHlModal({
open,
onSave,
onCancel,
text,
visible,
}: {
open: boolean;
onSave: (noteText: string, visible: boolean) => any;
onCancel: () => any;
text: string;
visible: boolean;
}) {
const [noteText, setNoteText] = React.useState(text);
const [checkboxVisible, setVisible] = React.useState(visible);
React.useEffect(() => {
setNoteText(text);
setVisible(visible);
}, [text, visible])
const onEdit = (val: string) => {
if (val.length > 200) {
return;
}
setNoteText(val);
};
if (!open) return null;
return (
<Modal
title={'Edit Highlight'}
open={open}
okText={'Save'}
width={350}
centered
onOk={() => onSave(noteText, visible)}
onCancel={() => onCancel()}
>
<div className={'flex flex-col gap-2'}>
<Input.TextArea
placeholder={'Highlight note'}
onChange={(e) => onEdit(e.target.value)}
maxLength={200}
value={noteText}
/>
<div>{noteText.length}/200 Characters remaining</div>
<Checkbox
checked={checkboxVisible}
onChange={(e) => setVisible(e.target.checked)}
>
Team can see and edit this Highlight.
</Checkbox>
</div>
</Modal>
);
}
export default EditHlModal;

View file

@ -0,0 +1,114 @@
import React from 'react';
import { tagProps } from "App/services/NotesService";
import { GridItem } from 'App/components/Spots/SpotsList/SpotListItem';
import { confirm } from "UI";
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
import { Tag } from "antd";
import copy from "copy-to-clipboard";
import { Eye, Link } from "lucide-react";
import { toast } from "react-toastify";
import { resentOrDate } from 'App/date'
function HighlightClip({
note = 'Highlight note',
tag = 'ISSUE',
user = 'user@openreplay.com',
createdAt = '12/12/2025',
hId = 1234,
thumbnail = undefined,
openEdit = () => undefined,
onItemClick = () => undefined,
onDelete = () => undefined,
}: {
note: string;
tag: string;
user: string;
createdAt: string;
hId: number;
thumbnail?: string;
openEdit: (id: any) => any;
onItemClick: (id: any) => any;
onDelete: (id: any) => any;
}) {
const copyToClipboard = () => {
const currUrl = window.location.href;
const hUrl = `${currUrl}?highlight=${hId}`;
copy(hUrl);
};
const menuItems = [
{
key: 'copy',
icon: <Link size={14} strokeWidth={1} />,
label: 'Copy Link',
},
{
key: 'edit',
icon: <EditOutlined />,
label: 'Edit',
},
{
key: 'visibility',
icon: <Eye strokeWidth={1} size={14} />,
label: 'Visibility',
},
{
key: 'delete',
icon: <DeleteOutlined />,
label: 'Delete',
},
];
const onMenuClick = async ({ key }: any) => {
switch (key) {
case 'edit':
return openEdit();
case 'copy':
copyToClipboard();
toast.success('Highlight link copied to clipboard');
return
case 'delete':
const res = await confirm({
header: 'Are you sure delete this Highlight?',
confirmation:
'Deleting a Highlight will only remove this instance and its associated note. It will not affect the original session.',
confirmButton: 'Yes, Delete',
});
if (res) {
onDelete();
}
return;
case 'visibility':
return openEdit();
default:
break;
}
};
return (
<GridItem
title={note}
onItemClick={onItemClick}
thumbnail={thumbnail}
setLoading={() => null}
loading={false}
copyToClipboard={copyToClipboard}
user={user}
createdAt={resentOrDate(createdAt, true)}
menuItems={menuItems}
onMenuClick={onMenuClick}
modifier={
tag ? <div className="left-0 bottom-8 flex relative gap-2 justify-end pe-2 pb-2 ">
<Tag
color={tagProps[tag]}
className="border-0 rounded-lg hover:inherit gap-2 w-14 text-center capitalize"
bordered={false}
>
{tag.toLowerCase()}
</Tag>
</div> : null
}
/>
);
}
export default HighlightClip;

View file

@ -0,0 +1,79 @@
import React from 'react';
import cn from 'classnames';
import ClipsPlayer from '../Session/ClipsPlayer';
import { useStore } from 'App/mstore';
import { Loader } from 'UI';
import { observer } from 'mobx-react-lite';
interface Clip {
sessionId: string | undefined;
range: [number, number];
message: string;
}
function HighlightPlayer({
hlId,
onClose,
}: {
hlId: string;
onClose: () => void;
}) {
const { notesStore } = useStore();
const [clip, setClip] = React.useState<Clip>({
sessionId: undefined,
range: [],
message: '',
});
React.useEffect(() => {
if (hlId) {
notesStore.fetchNoteById(hlId).then((hl) => {
if (!hl) {
onClose();
} else {
setClip({
range: [hl.startAt ?? 0, hl.endAt ?? 99999],
sessionId: hl.sessionId,
message: hl.message,
});
}
});
}
}, [hlId]);
const onBgClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
}
return (
<div
className={
'w-screen h-screen fixed top-0 left-0 flex items-center justify-center'
}
style={{ zIndex: 100, background: 'rgba(0,0,0, 0.15)' }}
onClick={onBgClick}
>
<div
className={cn(
'rounded-lg overflow-hidden',
'rounded shadow boarder bg-white'
)}
style={{ width: 960 }}
>
<Loader loading={notesStore.loading}>
<ClipsPlayer
isHighlight
onClose={onClose}
clip={clip}
currentIndex={0}
isCurrent={true}
autoplay={false}
/>
</Loader>
</div>
</div>
);
}
export default observer(HighlightPlayer);

View file

@ -0,0 +1,192 @@
import React from 'react';
import { observer } from 'mobx-react-lite';
import { iTag } from 'App/services/NotesService';
import { useStore } from 'App/mstore';
import { numberWithCommas } from 'App/utils';
import { Pagination, NoContent, Loader } from 'UI';
import cn from 'classnames';
import { withSiteId, highlights } from "App/routes";
import HighlightClip from './HighlightClip';
import { useQuery } from '@tanstack/react-query';
import HighlightPlayer from "./HighlightPlayer";
import { useLocation, useHistory } from 'react-router-dom';
import { toast } from 'react-toastify';
import EditHlModal from "./EditHlModal";
import HighlightsListHeader from './HighlightsListHeader'
function HighlightsList() {
const { notesStore, projectsStore } = useStore();
const hist = useHistory();
const [editModalOpen, setEditModalOpen] = React.useState(false);
const [editHl, setEditHl] = React.useState<Record<string, any>>({
message: '',
isPublic: false,
});
const { search } = useLocation();
const highlight = new URLSearchParams(search).get('highlight');
const query = notesStore.query;
const limit = notesStore.pageSize;
const listLength = notesStore.notes.length;
const activeTags = notesStore.activeTags;
const page = notesStore.page;
const ownOnly = notesStore.ownOnly
const {
data = { notes: [], total: 0 },
isPending,
refetch,
} = useQuery({
queryKey: ['notes', page, query, activeTags],
queryFn: () => notesStore.fetchNotes(),
retry: 3,
});
const { total, notes } = data;
const onSearch = (value: string) => {
notesStore.setQuery(value);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
notesStore.setQuery(e.target.value);
};
const toggleTag = (tag?: iTag) => {
notesStore.toggleTag(tag)
};
const onPageChange = (page: number) => {
notesStore.changePage(page);
};
const onDelete = async (id: number) => {
await notesStore.deleteNote(id);
refetch();
toast.success('Highlight deleted successfully');
};
const onItemClick = (id: string) => {
hist.replace(`?highlight=${id}`);
}
const onClose = () => {
hist.replace(withSiteId(highlights(), projectsStore.active?.id));
}
const onEdit = (id: string) => {
const hl = notesStore.getNoteById(id);
if (!hl) {
return toast.error('Highlight not found in the list');
}
setEditHl(hl);
setEditModalOpen(true)
}
const onSave = async (noteText: string, visible: boolean) => {
if (!editHl) {
return;
}
const newNote = {
...editHl,
message: noteText,
isPublic: visible,
}
try {
await notesStore.updateNote(editHl.noteId, newNote);
toast.success('Highlight updated successfully');
} catch (e) {
console.error(e);
toast.error('Error updating highlight');
}
setEditModalOpen(false);
}
const toggleShared = (val: boolean) => {
notesStore.toggleShared(val);
refetch();
}
const isEmpty = !isPending && total === 0;
return (
<div
className={'relative w-full mx-auto bg-white rounded-lg'}
style={{ maxWidth: 1360 }}
>
{highlight && <HighlightPlayer onClose={onClose} hlId={highlight} />}
<HighlightsListHeader
activeTags={activeTags}
ownOnly={ownOnly}
onSearch={onSearch}
handleInputChange={handleInputChange}
toggleTag={toggleTag}
toggleShared={toggleShared}
query={query}
/>
<div
className={cn(
'py-2 px-4 border-gray-lighter',
isEmpty
? 'h-96 flex items-center justify-center'
: ' grid grid-cols-3 gap-6'
)}
>
<Loader loading={isPending}>
<NoContent
show={isEmpty}
subtext={
<div className={'w-full text-center'}>
Highlight and note observations during session replays and share
them with your team.
</div>
}
>
{notes.map((note) => (
<HighlightClip
note={note.message}
tag={note.tag}
user={note.userName}
createdAt={note.createdAt}
hId={note.noteId}
thumbnail={note.thumbnail}
openEdit={() => onEdit(note.noteId)}
onDelete={() => onDelete(note.noteId)}
onItemClick={() => onItemClick(note.noteId)}
/>
))}
</NoContent>
</Loader>
<EditHlModal
open={editModalOpen}
onCancel={() => setEditModalOpen(false)}
text={editHl?.message}
visible={editHl?.isPublic}
onSave={onSave}
/>
</div>
<div
className={cn(
'flex items-center justify-between px-4 py-3 shadow-sm w-full bg-white rounded-lg mt-2',
isEmpty ? 'hidden' : 'visible'
)}
>
<div>
Showing <span className="font-medium">{(page - 1) * limit + 1}</span>
{' to '}
<span className="font-medium">{(page - 1) * limit + listLength}</span>
{' of '}
<span className="font-medium">{numberWithCommas(total)}</span>
{' highlights'}.
</div>
<Pagination
page={page}
total={total}
onPageChange={onPageChange}
limit={limit}
debounceRequest={250}
/>
</div>
</div>
);
}
export default observer(HighlightsList);

View file

@ -0,0 +1,96 @@
import React from 'react'
import { iTag, TAGS } from "App/services/NotesService";
import { SortDropdown } from "Components/shared/SessionsTabOverview/components/SessionSort/SessionSort";
import { Input, Segmented } from "antd";
function HighlightsListHeader({
activeTags,
ownOnly,
toggleShared,
toggleTag,
query,
onSearch,
handleInputChange
}: {
activeTags: iTag[];
ownOnly: boolean;
toggleShared: (value: boolean) => void;
toggleTag: (value?: iTag) => void;
query: string;
onSearch: (value: string) => void;
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<div className={'flex p-2 px-4 w-full border-b gap-4 items-center'}>
<h1 className={'text-2xl capitalize mr-2'}>Highlights</h1>
<Segmented
size="small"
options={[
{
value: 'ALL',
label: (
<div
className={
activeTags.includes('ALL') || activeTags.length === 0
? 'text-main'
: ''
}
>
All
</div>
),
},
...TAGS.map((tag: iTag) => ({
value: tag,
label: (
<div
className={
activeTags.includes(tag)
? 'text-main capitalize'
: 'capitalize'
}
>
{tag.toLowerCase()}
</div>
),
})),
]}
onChange={(value: iTag) =>
toggleTag(value === 'ALL' ? undefined : value)
}
/>
<div className={'ml-auto'}>
<SortDropdown
sortOptions={[
{
key: 'own',
label: 'Personal',
},
{
key: 'team',
label: 'Team',
},
]}
onSort={({ key }) => {
toggleShared(key === 'own');
}}
current={ownOnly ? 'Personal' : 'Team'}
/>
</div>
<div className="w-56">
<Input.Search
value={query}
allowClear
name="spot-search"
placeholder="Filter by title"
onChange={handleInputChange}
onSearch={onSearch}
className="rounded-lg"
size="small"
/>
</div>
</div>
)
}
export default HighlightsListHeader

View file

@ -0,0 +1,159 @@
import { createClipPlayer } from 'Player';
import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';
import React, { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useStore } from 'App/mstore';
import { Loader } from 'UI';
import {
IPlayerContext,
PlayerContext,
defaultContextValue,
} from './playerContext';
import ClipPlayerHeader from 'Components/Session/Player/ClipPlayer/ClipPlayerHeader';
import ClipPlayerContent from 'Components/Session/Player/ClipPlayer/ClipPlayerContent';
import Session from 'Types/session';
import { sessionService } from '@/services';
let playerInst: IPlayerContext['player'] | undefined;
interface Props {
clip: any;
currentIndex: number;
isCurrent: boolean;
autoplay: boolean;
onClose?: () => void;
isHighlight?: boolean;
}
function WebPlayer(props: Props) {
const { clip, currentIndex, isCurrent, onClose, isHighlight } = props;
const { sessionStore } = useStore();
const prefetched = sessionStore.prefetched;
const [windowActive, setWindowActive] = useState(!document.hidden);
const [contextValue, setContextValue] =
// @ts-ignore
useState<IPlayerContext>(defaultContextValue);
const openedAt = React.useRef<number>();
const [session, setSession] = useState<Session | undefined>(undefined);
useEffect(() => {
if (!clip.sessionId) return;
const fetchSession = async () => {
if (clip.sessionId != null && clip?.sessionId !== '') {
try {
// const data = await sessionStore.fetchSessionData(props.sessionId);
const data = await sessionService.getSessionInfo(clip.sessionId);
setSession(new Session(data));
} catch (error) {
console.error('Error fetching session data:', error);
}
} else {
console.error('No sessionID in route.');
}
};
void fetchSession();
}, [clip]);
React.useEffect(() => {
openedAt.current = Date.now();
if (windowActive) {
const handleActivation = () => {
if (!document.hidden) {
setWindowActive(true);
document.removeEventListener('visibilitychange', handleActivation);
}
};
document.addEventListener('visibilitychange', handleActivation);
}
}, []);
useEffect(() => {
playerInst = undefined;
if (!clip.sessionId || contextValue.player !== undefined || !session)
return;
// @ts-ignore
sessionStore.setUserTimezone(session?.timezone);
const [WebPlayerInst, PlayerStore] = createClipPlayer(
session,
(state) => makeAutoObservable(state),
toast,
clip.range
);
setContextValue({ player: WebPlayerInst, store: PlayerStore });
playerInst = WebPlayerInst;
// playerInst.pause();
}, [session]);
const domFiles = session?.domURL?.length ?? 0;
useEffect(() => {
if (!prefetched && domFiles > 0) {
playerInst?.reinit(session!);
playerInst?.pause();
}
}, [session, domFiles, prefetched]);
const {
firstVisualEvent: visualOffset,
messagesProcessed,
tabStates,
ready,
playing,
} = contextValue.store?.get() || {};
const cssLoading =
ready && tabStates
? Object.values(tabStates).some(({ cssLoading }) => cssLoading)
: true;
useEffect(() => {
if (ready) {
if (!isCurrent) {
contextValue.player?.pause();
}
}
}, [ready]);
useEffect(() => {
contextValue.player?.jump(clip.range[0]);
setTimeout(() => {
contextValue.player?.play();
}, 500);
}, [currentIndex]);
if (!session || !session?.sessionId)
return (
<Loader
size={75}
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translateX(-50%)',
height: 75,
}}
/>
);
return (
<PlayerContext.Provider value={contextValue}>
{contextValue.player ? (
<>
<ClipPlayerHeader isHighlight={isHighlight} onClose={onClose} range={clip.range} session={session!} />
<ClipPlayerContent message={clip.message} isHighlight={isHighlight} autoplay={props.autoplay} range={clip.range} session={session!} />
</>
) : (
<Loader />
)}
</PlayerContext.Provider>
);
}
export default observer(WebPlayer);

View file

@ -0,0 +1,61 @@
import React, {useEffect, useState} from 'react';
import cn from 'classnames';
import {observer} from 'mobx-react-lite';
import {Button} from 'UI';
import stl from './AutoplayTimer.module.css';
import clsOv from './overlay.module.css';
import AutoplayToggle from 'Shared/AutoplayToggle';
import {useStore} from 'App/mstore';
function AutoplayTimer({history}: any) {
let timer: NodeJS.Timer;
const [cancelled, setCancelled] = useState(false);
const [counter, setCounter] = useState(5);
const {clipStore} = useStore();
useEffect(() => {
if (counter > 0) {
timer = setTimeout(() => {
setCounter(counter - 1);
}, 1000);
}
if (counter === 0) {
clipStore.next();
}
return () => clearTimeout(timer);
}, [counter]);
const cancel = () => {
clearTimeout(timer);
setCancelled(true);
};
if (cancelled) return null;
return (
<div className={cn(clsOv.overlay, stl.overlayBg, "z-10")}>
<div className="border p-5 shadow-lg bg-white rounded">
<div className="mb-5">
Autoplaying next clip in <span className="font-medium">{counter}</span> seconds
</div>
<div className="flex items-center justify-between">
<div className="mr-10">
<AutoplayToggle/>
</div>
<div className="flex items-center">
<Button variant="text-primary" onClick={cancel}>
Cancel
</Button>
<div className="px-2"/>
<Button variant="outline" onClick={() => clipStore.next()}>Play Now</Button>
</div>
</div>
</div>
</div>
);
}
export default observer(AutoplayTimer);

View file

@ -0,0 +1,3 @@
.overlayBg {
background-color: rgba(255, 255, 255, 0.8);
}

View file

@ -0,0 +1,32 @@
import React, {useContext} from 'react';
import {Switch, Tooltip} from ".store/antd-virtual-7db13b4af6/package";
import {CaretRightOutlined, PauseOutlined} from ".store/@ant-design-icons-virtual-de151eefe5/package";
import {useStore} from "@/mstore";
import {observer} from "mobx-react-lite";
import {IPlayerContext, PlayerContext} from "Components/Session/playerContext";
function AutoplayToggle() {
const {clipStore} = useStore();
const playerContext = React.useContext<IPlayerContext>(PlayerContext);
// const { player, store } = playerContext;
const { autoplay } = playerContext.store.get();
const handleToggle = () => {
console.log('Toggle Autoplay');
clipStore.toggleAutoplay();
}
return (
<Tooltip title="Toggle Autoplay" placement="bottom">
<Switch
className="custom-switch"
onChange={handleToggle}
checked={clipStore.autoplay}
checkedChildren={<CaretRightOutlined className="switch-icon"/>}
unCheckedChildren={<PauseOutlined className="switch-icon"/>}
/>
</Tooltip>
);
}
export default observer(AutoplayToggle);

View file

@ -0,0 +1,83 @@
import React, {useRef} from 'react';
import {App, Button, ButtonProps} from "antd";
import {useStore} from "@/mstore";
import {observer} from "mobx-react-lite";
import {Tour, TourProps} from ".store/antd-virtual-7db13b4af6/package";
import {DislikeFilled, DislikeOutlined, LikeFilled, LikeOutlined} from "@ant-design/icons";
interface Props {
clip?: any
}
function ClipFeedback(props: Props) {
const {clipStore} = useStore();
const currentClip = clipStore.currentClip;
const ref1 = useRef(null);
const {message} = App.useApp();
const steps: TourProps['steps'] = [
{
title: 'Upload File',
description: 'Put your files here.',
cover: (
<div>
<Button>Upload</Button>
</div>
),
target: () => ref1.current,
},
];
const interestStatus = currentClip?.interested;
const disabled = interestStatus != null
const isInterestedProps: ButtonProps = interestStatus === true ? {
color: "primary",
variant: "outlined",
icon: <LikeFilled/>,
} : {
icon: <LikeOutlined/>,
onClick: () => submitFeedback(true)
};
const isNotInterestedProps: ButtonProps = interestStatus === false ? {
color: "primary",
variant: "outlined",
icon: <DislikeFilled/>,
} : {
icon: <DislikeOutlined/>,
onClick: () => submitFeedback(false)
};
// if (disabled) {
// isInterestedProps.disabled = true;
// isNotInterestedProps.disabled = true;
// } else {
// isInterestedProps.disabled = false;
// isNotInterestedProps.disabled = false;
// }
const submitFeedback = async (isInterested: boolean) => {
await clipStore.sendFeedback(isInterested).then(() => {
message.success('Your feedback has been submitted');
}).catch(() => {
message.error('There was an error submitting your feedback');
});
};
return (
<div className="absolute right-0 bottom-0 z-10 flex flex-col gap-4 mr-4" style={{marginBottom: '1rem'}}>
{clipStore.tour && <Tour open={clipStore.tour} steps={steps} onClose={() => clipStore.toggleTour()}/>}
<Button
ref={ref1}
shape="circle"
{...isInterestedProps}
/>
<Button
shape="circle"
{...isNotInterestedProps}
/>
</div>
);
}
export default observer(ClipFeedback);

View file

@ -0,0 +1,93 @@
import React, { useEffect } from 'react';
import cn from 'classnames';
import stl from 'Components/Session_/Player/player.module.css';
import {
IPlayerContext,
PlayerContext,
} from 'Components/Session/playerContext';
import ClipPlayerControls from 'Components/Session/Player/ClipPlayer/ClipPlayerControls';
import { findDOMNode } from 'react-dom';
import Session from 'Types/session';
import styles from 'Components/Session_/playerBlock.module.css';
import ClipPlayerOverlay from 'Components/Session/Player/ClipPlayer/ClipPlayerOverlay';
import { observer } from 'mobx-react-lite';
import { Icon } from 'UI';
interface Props {
session: Session;
range: [number, number];
autoplay: boolean;
isHighlight?: boolean;
message?: string;
}
function ClipPlayerContent(props: Props) {
const playerContext = React.useContext<IPlayerContext>(PlayerContext);
const screenWrapper = React.useRef<HTMLDivElement>(null);
const { time } = playerContext.store.get();
const { range } = props;
React.useEffect(() => {
if (!playerContext.player) return;
const parentElement = findDOMNode(
screenWrapper.current
) as HTMLDivElement | null;
if (parentElement && playerContext.player) {
playerContext.player?.attach(parentElement);
playerContext.player?.play();
}
}, [playerContext.player]);
React.useEffect(() => {
playerContext.player.scale();
}, [playerContext.player]);
useEffect(() => {
if (time < range[0]) {
playerContext.player?.jump(range[0]);
}
if (time > range[1]) {
playerContext.store.update({ completed: true });
playerContext.player?.pause();
}
}, [time]);
if (!playerContext.player) return null;
return (
<div
className={cn(styles.playerBlock, 'flex flex-col', 'overflow-x-hidden')}
>
<div className={cn(stl.playerBody, 'flex-1 flex flex-col relative')}>
<div className={cn(stl.playerBody, 'flex flex-1 flex-col relative')}>
<div className="relative flex-1 overflow-hidden group">
<ClipPlayerOverlay autoplay={props.autoplay} />
<div
className={cn(stl.screenWrapper, stl.checkers)}
ref={screenWrapper}
data-openreplay-obscured
style={{ height: '500px' }}
/>
</div>
</div>
{props.isHighlight && props.message ? (
<div className={'shadow-inner p-3 bg-yellow flex gap-2 w-full items-center'}>
<Icon name="chat-square-quote" color="inherit" size={18} />
<div className={'leading-none font-medium'}>
{props.message}
</div>
</div>
) : null}
<ClipPlayerControls
session={props.session}
range={props.range}
/>
</div>
</div>
);
}
export default observer(ClipPlayerContent);

View file

@ -0,0 +1,55 @@
import React from 'react';
import Timeline from 'Components/Session/Player/ClipPlayer/Timeline';
import { PlayButton, PlayingState } from '@/player-ui';
import { PlayerContext } from 'Components/Session/playerContext';
import { observer } from '.store/mobx-react-lite-virtual-356dc1206a/package';
import { Button } from 'antd';
import { useHistory } from 'react-router-dom';
import { CirclePlay } from 'lucide-react';
import { withSiteId } from '@/routes';
import * as routes from '@/routes';
import { useStore } from '@/mstore';
import Session from 'Types/session';
function ClipPlayerControls({
session,
range,
}: {
session: Session;
range: [number, number];
}) {
const { projectsStore } = useStore();
const { player, store } = React.useContext(PlayerContext);
const history = useHistory();
const siteId = projectsStore.siteId;
const { playing, completed } = store.get();
const state = completed
? PlayingState.Completed
: playing
? PlayingState.Playing
: PlayingState.Paused;
const togglePlay = () => {
player.togglePlay();
};
const showFullSession = () => {
const path = withSiteId(routes.session(session.sessionId), siteId);
history.push(path + '?jumpto=' + Math.round(range[0]));
};
return (
<div className="relative flex items-center gap-4 p-3 border-t">
<PlayButton state={state} togglePlay={togglePlay} iconSize={30} />
<Timeline range={range} />
<Button size="small" type='primary' onClick={showFullSession}>
Play Full Session
<CirclePlay size={16}/>
</Button>
</div>
);
}
export default observer(ClipPlayerControls);

View file

@ -0,0 +1,57 @@
import React from 'react';
import { Button, Tooltip } from '.store/antd-virtual-7db13b4af6/package';
import Session from 'Types/session';
import UserCard from 'Components/Session/Player/ClipPlayer/UserCard';
import QueueControls from 'Components/Session/Player/ClipPlayer/QueueControls';
import { App, Space } from 'antd';
import copy from 'copy-to-clipboard';
import { withSiteId } from '@/routes';
import * as routes from '@/routes';
import { useStore } from '@/mstore';
import { LinkIcon, X } from 'lucide-react';
import { PartialSessionBadge } from "Components/Session_/WarnBadge";
interface Props {
session: Session;
range: [number, number];
onClose?: () => void;
isHighlight?: boolean;
}
function ClipPlayerHeader(props: Props) {
const { projectsStore } = useStore();
const { session, range, onClose, isHighlight } = props;
const siteId = projectsStore.siteId;
const { message } = App.useApp();
const copyHandler = () => {
const path = withSiteId(routes.session(session.sessionId), siteId + '');
copy(window.location.origin + path + '?jumpto=' + Math.round(range[0]));
void message.success('Session link copied to clipboard');
};
return (
<div className="bg-white p-3 flex justify-between items-center border-b relative">
{isHighlight ? <PartialSessionBadge /> : null}
<UserCard session={props.session} />
<Space>
<Tooltip title="Copy link to clipboard" placement="bottom">
<Button
onClick={copyHandler}
size={'small'}
className="flex items-center justify-center"
>
<LinkIcon size={14} />
</Button>
</Tooltip>
{isHighlight ? (
<Button icon={<X size={14} strokeWidth={1} />} size={'small'} onClick={onClose} />
) : <QueueControls />}
</Space>
</div>
);
}
export default ClipPlayerHeader;

View file

@ -0,0 +1,35 @@
import React from 'react';
import {observer} from 'mobx-react-lite';
import {PlayerContext} from "Components/Session/playerContext";
import {Loader} from "UI";
import PlayIconLayer from "Components/Session_/Player/Overlay/PlayIconLayer";
import ClipFeedback from "Components/Session/Player/ClipPlayer/ClipFeedback";
import AutoplayTimer from "Components/Session/Player/ClipPlayer/AutoPlayTimer";
interface Props {
autoplay: boolean;
}
function Overlay({ autoplay }: Props) {
const {player, store} = React.useContext(PlayerContext);
const togglePlay = () => player.togglePlay();
const {
messagesLoading,
playing,
completed,
} = store.get();
return (
<>
{messagesLoading ? <Loader/> : null}
{/*<div className="hidden group-hover:block">*/}
{/* <ClipFeedback/>*/}
{/*</div>*/}
<PlayIconLayer playing={playing} togglePlay={togglePlay}/>
{completed && autoplay && <AutoplayTimer/>}
</>
);
}
export default observer(Overlay);

View file

@ -0,0 +1,79 @@
import React, {useEffect} from 'react';
import cn from 'classnames';
import {LeftOutlined, RightOutlined} from '@ant-design/icons';
import {Button, Popover} from 'antd';
import {useStore} from 'App/mstore';
import {observer} from 'mobx-react-lite';
import AutoplayToggle from "Shared/AutoplayToggle";
// import AutoplayToggle from "Components/Session/Player/ClipPlayer/AutoplayToggle";
interface Props {
}
function QueueControls(props: Props) {
const {clipStore, projectsStore, sessionStore, searchStore} = useStore();
const previousId = clipStore.prevId;
const nextId = clipStore.nextId;
const nextHandler = () => {
clipStore.next();
};
const prevHandler = () => {
clipStore.prev();
};
return (
<div className="flex items-center gap-1">
<div
onClick={prevHandler}
className={cn('p-1 group rounded-full', {
'pointer-events-none opacity-50': !previousId,
'cursor-pointer': !!previousId,
})}
>
<Popover
placement="bottom"
content={
<div className="whitespace-nowrap">Play Previous Session</div>
}
open={previousId ? undefined : false}
mouseEnterDelay={1}
>
<Button
size={'small'}
shape={'circle'}
disabled={!previousId}
className={'flex items-center justify-center'}
>
<LeftOutlined/>
</Button>
</Popover>
</div>
<AutoplayToggle/>
<div
onClick={nextHandler}
className={cn('p-1 group ml-1 rounded-full')}
>
<Popover
placement="bottom"
content={<div className="whitespace-nowrap">Play Next Session</div>}
open={nextId ? undefined : false}
mouseEnterDelay={1}
>
<Button
size={'small'}
shape={'circle'}
// disabled={!nextId}
className={'flex items-center justify-center'}
>
<RightOutlined/>
</Button>
</Popover>
</div>
</div>
);
}
export default observer(QueueControls);

View file

@ -0,0 +1,24 @@
import React from 'react';
import {PlayerContext} from 'App/components/Session/playerContext';
import {observer} from 'mobx-react-lite';
import {ProgressBar} from 'App/player-ui'
const TimeTracker = ({scale, live = false, left}) => {
const {store} = React.useContext(PlayerContext)
const {time, range} = store.get()
const adjustedTime = time - range[0]
return (
<ProgressBar
scale={scale}
live={live}
left={left}
time={adjustedTime}
/>
);
}
TimeTracker.displayName = 'TimeTracker';
export default observer(TimeTracker);

View file

@ -0,0 +1,163 @@
import React, {useEffect, useMemo, useContext, useState, useRef} from 'react';
import stl from '../../../Session_/Player/Controls/timeline.module.css';
import {debounce} from 'App/utils';
import {PlayerContext} from 'App/components/Session/playerContext';
import {observer} from 'mobx-react-lite';
import {useStore} from 'App/mstore';
import {DateTime, Duration} from 'luxon';
import CustomDragLayer, {OnDragCallback} from "Components/Session_/Player/Controls/components/CustomDragLayer";
import TooltipContainer from "Components/Session_/Player/Controls/components/TooltipContainer";
import TimelineTracker from "Components/Session/Player/ClipPlayer/TimelineTracker";
function Timeline({ range }: any) {
const {player, store} = useContext(PlayerContext);
const [wasPlaying, setWasPlaying] = useState(false);
const [maxWidth, setMaxWidth] = useState(0);
const {settingsStore, sessionStore} = useStore();
const startedAt = sessionStore.current.startedAt ?? 0;
const tooltipVisible = sessionStore.timeLineTooltip.isVisible;
const setTimelineHoverTime = sessionStore.setTimelineTooltip;
const timezone = sessionStore.current.timezone;
const issues = sessionStore.current.issues ?? [];
const { playing, skipToIssue, ready, endTime, devtoolsLoading, domLoading } = store.get();
const progressRef = useRef<HTMLDivElement>(null);
const timelineRef = useRef<HTMLDivElement>(null);
// const scale = 100 / endTime;
const trimmedEndTime = range[1] - range[0];
const scale = 100 / trimmedEndTime;
useEffect(() => {
const firstIssue = issues[0];
if (firstIssue && skipToIssue) {
player.jump(firstIssue.time);
}
if (progressRef.current) {
setMaxWidth(progressRef.current.clientWidth);
}
}, []);
const debouncedJump = useMemo(() => debounce(player.jump, 500), []);
const debouncedTooltipChange = useMemo(() => debounce(setTimelineHoverTime, 50), []);
const onDragEnd = () => {
if (wasPlaying) {
player.togglePlay();
}
};
const onDrag: OnDragCallback = (offset) => {
// @ts-ignore react mismatch
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 showTimeTooltip = (e: React.MouseEvent<HTMLDivElement>) => {
if (
e.target !== progressRef.current &&
e.target !== timelineRef.current &&
// @ts-ignore black magic
!progressRef.current.contains(e.target)
) {
return tooltipVisible && hideTimeTooltip();
}
const time = getTime(e);
if (!time) return;
const tz = settingsStore.sessionSettings.timezone.value;
const timeStr = DateTime.fromMillis(startedAt + time)
.setZone(tz)
.toFormat(`hh:mm:ss a`);
const userTimeStr = timezone
? DateTime.fromMillis(startedAt + time)
.setZone(timezone)
.toFormat(`hh:mm:ss a`)
: undefined;
const timeLineTooltip = {
time: Duration.fromMillis(time).toFormat(`mm:ss`),
localTime: timeStr,
userTime: userTimeStr,
offset: e.nativeEvent.pageX,
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 jumpToTime = (e: React.MouseEvent<HTMLDivElement>) => {
if ((e.target as HTMLDivElement).id === 'click-ignore') {
return;
}
seekProgress(e);
};
const getTime = (e: React.MouseEvent<HTMLDivElement>) => {
// @ts-ignore react mismatch
const p = e.nativeEvent.offsetX / e.target.offsetWidth;
const time = Math.round(p * trimmedEndTime) + range[0]; // Map to the defined range
return Math.max(range[0], Math.min(time, range[1])); // Clamp to range boundaries
};
// const getTime = (e: React.MouseEvent<HTMLDivElement>, customEndTime?: number) => {
// // @ts-ignore react mismatch
// const p = e.nativeEvent.offsetX / e.target.offsetWidth;
// const targetTime = customEndTime || endTime; // + rangeStart
// return Math.max(Math.round(p * targetTime), 0);
// };
return (
<div
className="flex items-center w-full"
style={{
// top: '-4px',
// zIndex: 100,
// maxWidth: 'calc(100% - 5rem)',
// left: '3.5rem',
}}
>
<div
className={stl.progress}
onClick={ready ? jumpToTime : undefined}
ref={progressRef}
role="button"
onMouseMoveCapture={showTimeTooltip}
onMouseEnter={showTimeTooltip}
onMouseLeave={hideTimeTooltip}
>
<TooltipContainer/>
<TimelineTracker scale={scale} onDragEnd={onDragEnd}/>
<CustomDragLayer
onDrag={onDrag}
minX={0}
maxX={maxWidth}
/>
<div className={stl.timeline} ref={timelineRef}>
{devtoolsLoading || domLoading || !ready ? <div className={stl.stripes}/> : null}
</div>
</div>
</div>
);
}
export default observer(Timeline);

View file

@ -0,0 +1,20 @@
import React, {useContext} from 'react';
import {observer} from 'mobx-react-lite';
import DraggableCircle from 'Components/Session_/Player/Controls/components/DraggableCircle';
import {PlayerContext} from 'Components/Session/playerContext';
import TimeTracker from "Components/Session/Player/ClipPlayer/TimeTracker";
function TimelineTracker({scale, onDragEnd}: { scale: number, onDragEnd: () => void }) {
const {store} = useContext(PlayerContext);
const {time, range} = store.get();
const adjustedTime = time - range[0];
return (
<>
<DraggableCircle left={adjustedTime * scale} onDrop={onDragEnd}/>
<TimeTracker scale={scale} left={(adjustedTime - range[0]) * scale}/>
</>
);
}
export default observer(TimelineTracker);

View file

@ -0,0 +1,73 @@
import React from 'react';
import {countries} from 'App/constants';
import {useStore} from 'App/mstore';
import {formatTimeOrDate} from 'App/date';
import {Avatar, TextEllipsis, Tooltip} from 'UI';
import cn from 'classnames';
import {capitalize} from 'App/utils';
import {observer} from 'mobx-react-lite';
import Session from 'Types/session';
interface Props {
session: Session;
className?: string;
width?: number;
height?: number;
}
const UserCard: React.FC<Props> = ({session, className, width, height}) => {
const {settingsStore} = useStore();
const {timezone} = settingsStore.sessionSettings;
const {
userBrowser,
userDevice,
userCountry,
userCity,
userOs,
startedAt,
userNumericHash,
userDisplayName,
} = session;
const avatarBgSize = '38px';
return (
<div className={cn('bg-white flex items-center w-full', className)} style={{width, height}}>
<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="font-medium">
{userDisplayName}
</TextEllipsis>
<div className="text-sm text-gray-500 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">&#183;</span>
{userCity && <span className="mr-1">{userCity},</span>}
<span>{countries[userCountry]}</span>
<span className="mx-1 font-bold text-xl">&#183;</span>
<span>
{userBrowser && `${capitalize(userBrowser)}, `}
{`${/ios/i.test(userOs) ? 'iOS' : capitalize(userOs)}, `}
{capitalize(userDevice)}
</span>
</div>
</div>
</div>
</div>
);
};
export default observer(UserCard);

View file

@ -0,0 +1,12 @@
.overlay {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
text-shadow:1px 0 0 white,0 1px 0 white,-1px 0 0 white,0 -1px 0 white;
}

View file

@ -32,7 +32,7 @@ function PlayerBlock(props: IProps) {
className={cn(styles.playerBlock, 'flex flex-col', 'overflow-x-hidden')}
>
{shouldShowSubHeader ? (
<SubHeader sessionId={sessionId} jiraConfig={jiraConfig} />
<SubHeader setActiveTab={setActiveTab} sessionId={sessionId} jiraConfig={jiraConfig} />
) : null}
<Player
setActiveTab={setActiveTab}

View file

@ -118,13 +118,27 @@ function Player(props: IProps) {
return (
<div
className={cn(stl.playerBody, 'flex-1 flex flex-col relative', fullscreen && 'pb-2')}
className={cn(
stl.playerBody,
'flex-1 flex flex-col relative',
fullscreen && 'pb-2'
)}
data-bottom-block={bottomBlockIsActive}
>
{fullscreen && <EscapeButton onClose={fullscreenOff} />}
<div className={cn('relative flex-1', 'overflow-hidden')}>
<div
className={cn('relative flex-1', 'overflow-hidden')}
id={'player-container'}
>
{activeTab === 'HIGHLIGHT' ? <div style={{ background: 'rgba(0,0,0, 0.3)' }} className={'w-full h-full z-50 absolute top-0 left-0'} /> : undefined}
<Overlay nextId={nextId} />
<div className={cn(stl.screenWrapper, isInspMode ? stl.solidBg : stl.checkers)} ref={screenWrapper} />
<div
className={cn(
stl.screenWrapper,
isInspMode ? stl.solidBg : stl.checkers
)}
ref={screenWrapper}
/>
</div>
{!fullscreen && !!bottomBlock && (
<div
@ -142,10 +156,14 @@ function Player(props: IProps) {
/>
{bottomBlock === OVERVIEW && <OverviewPanel />}
{bottomBlock === CONSOLE && <ConsolePanel />}
{bottomBlock === NETWORK && <WebNetworkPanel panelHeight={panelHeight} />}
{bottomBlock === NETWORK && (
<WebNetworkPanel panelHeight={panelHeight} />
)}
{bottomBlock === STACKEVENTS && <WebStackEventPanel />}
{bottomBlock === STORAGE && <Storage />}
{bottomBlock === PROFILER && <ProfilerPanel panelHeight={panelHeight} />}
{bottomBlock === PROFILER && (
<ProfilerPanel panelHeight={panelHeight} />
)}
{bottomBlock === PERFORMANCE && <ConnectedPerformance />}
{bottomBlock === GRAPHQL && <GraphQL panelHeight={panelHeight} />}
{bottomBlock === EXCEPTIONS && <Exceptions />}
@ -154,7 +172,9 @@ function Player(props: IProps) {
)}
{!fullView ? (
<Controls
setActiveTab={(tab: string) => activeTab === tab ? props.setActiveTab('') : props.setActiveTab(tab)}
setActiveTab={(tab: string) =>
activeTab === tab ? props.setActiveTab('') : props.setActiveTab(tab)
}
speedDown={playerContext.player.speedDown}
speedUp={playerContext.player.speedUp}
jump={playerContext.player.jump}

View file

@ -1,7 +1,6 @@
import SummaryBlock from 'Components/Session/Player/ReplayPlayer/SummaryBlock';
import React from 'react';
import Session from 'Types/session/session';
import EventsBlock from '../Session_/EventsBlock';
import HighlightPanel from "../Session_/Highlight/HighlightPanel";
import PageInsightsPanel from '../Session_/PageInsightsPanel/PageInsightsPanel';
import TagWatch from 'Components/Session/Player/TagWatch';
@ -34,6 +33,12 @@ function RightBlock({
<TagWatch />
</div>
);
case 'HIGHLIGHT':
return (
<div className={cn('bg-white border-l', stl.panel)}>
<HighlightPanel onClose={() => setActiveTab('')} />
</div>
)
default:
return null;
}

View file

@ -47,6 +47,7 @@ function EventGroupWrapper(props) {
if (isNote) {
return (
<NoteEvent
setActiveTab={props.setActiveTab}
note={event}
filterOutNote={filterOutNote}
noEdit={currentUserId !== event.userId}

View file

@ -187,6 +187,7 @@ function EventsBlock(props: IProps) {
isTabChange={isTabChange}
isPrev={isPrev}
filterOutNote={filterOutNote}
setActiveTab={setActiveTab}
/>
);
};

View file

@ -1,7 +1,4 @@
import { useModal } from 'Components/Modal';
import CreateNote from 'Components/Session_/Player/Controls/components/CreateNote';
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';
@ -13,34 +10,22 @@ import { session } from 'App/routes';
import { confirm } from 'UI';
import { TeamBadge } from 'Shared/SessionsTabOverview/components/Notes';
import { Tag } from 'antd'
import { MessageSquareDot } from 'lucide-react'
interface Props {
note: Note;
noEdit: boolean;
filterOutNote: (id: number) => void;
setActiveTab: (tab: string) => void;
}
function NoteEvent(props: Props) {
const { settingsStore, notesStore } = useStore();
const { timezone } = settingsStore.sessionSettings;
const { showModal, hideModal } = useModal();
const onEdit = () => {
showModal(
<CreateNote
hideModal={hideModal}
isEdit
time={props.note.timestamp}
editNote={{
timestamp: props.note.timestamp,
tag: props.note.tag,
isPublic: props.note.isPublic,
message: props.note.message,
noteId: props.note.noteId.toString(),
}}
/>,
{ right: true, width: 380 }
);
notesStore.setEditNote(props.note);
props.setActiveTab('HIGHLIGHT')
};
const onCopy = () => {
@ -78,9 +63,7 @@ function NoteEvent(props: Props) {
return (
<div className="flex items-start flex-col p-2 border rounded ps-4" 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>
<MessageSquareDot size={16} strokeWidth={1} />
<div className="ml-2">
<div
className="text-base"

View file

@ -0,0 +1,20 @@
import React from 'react'
import { Button, Tooltip } from 'antd';
import { MessageSquareQuote } from 'lucide-react'
import { Icon } from 'UI';
function HighlightButton({ onClick }: { onClick: () => void }) {
const openPanel = () => {
onClick();
}
return (
<Tooltip title={'Highlight a moment'} placement={'bottom'}>
<Button onClick={openPanel} size={'small'}>
<Icon name="chat-square-quote" color="inherit" size={15} />
</Button>
</Tooltip>
)
}
export default HighlightButton

View file

@ -0,0 +1,266 @@
import React from 'react';
import { Button, Checkbox, Input, Tag } from 'antd';
import { X } from 'lucide-react';
import { TAGS, iTag, tagProps } from 'App/services/NotesService';
import { useStore } from 'App/mstore';
import { Icon } from 'UI';
import { PlayerContext } from 'Components/Session/playerContext';
import { observer } from 'mobx-react-lite';
import { shortDurationFromMs } from 'App/date';
import { toast } from 'react-toastify';
function maskDuration(input: string): string {
const digits = input.replace(/\D/g, '');
const limitedDigits = digits.slice(0, 4);
if (limitedDigits.length <= 2) {
return limitedDigits;
}
return `${limitedDigits.slice(0, 2)}:${limitedDigits.slice(2)}`;
}
const duration = new RegExp(/(\d{2}):(\d{2})/);
function HighlightPanel({ onClose }: { onClose: () => void }) {
const { uiPlayerStore, notesStore, sessionStore } = useStore();
const editNote = notesStore.editNote;
const [message, setMessage] = React.useState(editNote?.message ?? '');
const [isPublic, setIsPublic] = React.useState(editNote?.isPublic ?? false);
const { store, player } = React.useContext(PlayerContext);
const currentTime = store.get().time;
const startTsStr = shortDurationFromMs(
editNote?.startAt ?? uiPlayerStore.highlightSelection.startTs
);
const endTsStr = shortDurationFromMs(
editNote?.endAt ?? uiPlayerStore.highlightSelection.endTs
);
const [startTs, setStartTs] = React.useState(startTsStr);
const [endTs, setEndTs] = React.useState(endTsStr);
const [tag, setTag] = React.useState(editNote?.tag ?? TAGS[0]);
const onStartChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newState = maskDuration(e.target.value)
setStartTs(newState);
if (duration.test(newState)) {
const [_, minutes, seconds] = duration.exec(newState) ?? [];
const newTime = (parseInt(minutes) * 60 + parseInt(seconds))*1000;
const sessLength = store.get().endTime;
uiPlayerStore.toggleHighlightSelection({
enabled: true,
range: [Math.min(newTime, sessLength), uiPlayerStore.highlightSelection.endTs],
})
}
};
const onEndChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newState = maskDuration(e.target.value)
setEndTs(newState);
if (duration.test(newState)) {
const [_, minutes, seconds] = duration.exec(newState) ?? [];
const newTime = (parseInt(minutes) * 60 + parseInt(seconds))*1000;
const sessLength = store.get().endTime;
uiPlayerStore.toggleHighlightSelection({
enabled: true,
range: [uiPlayerStore.highlightSelection.startTs, Math.min(newTime, sessLength)],
})
}
};
const playing = store.get().playing;
React.useEffect(() => {
player.pause();
const time = store.get().time;
const endTime = store.get().endTime;
const distance = Math.max(endTime / 40, 2500);
uiPlayerStore.toggleHighlightSelection({
enabled: true,
range: [Math.max(time - distance, 0), Math.min(time + distance, endTime)],
});
return () => {
uiPlayerStore.toggleHighlightSelection({
enabled: false,
});
notesStore.setEditNote(null)
};
}, []);
React.useEffect(() => {
const startStr = shortDurationFromMs(
uiPlayerStore.highlightSelection.startTs
);
const endStr = shortDurationFromMs(uiPlayerStore.highlightSelection.endTs);
setStartTs(startStr);
setEndTs(endStr);
}, [
uiPlayerStore.highlightSelection.startTs,
uiPlayerStore.highlightSelection.endTs,
]);
React.useEffect(() => {
player.pause();
}, [playing]);
const addTag = (newTag: iTag) => {
setTag(newTag);
};
const tagActive = (checkedTag: iTag) => {
return tag === checkedTag;
};
const onSave = async () => {
try {
notesStore.setSaving(true)
const playerContainer = document.querySelector('iframe')?.contentWindow?.document.body;
let thumbnail;
if (playerContainer) {
thumbnail = await elementToImage(playerContainer);
}
const note = {
message,
tag: tag,
isPublic,
timestamp: parseInt(currentTime, 10),
startAt: parseInt(uiPlayerStore.highlightSelection.startTs, 10),
endAt: parseInt(uiPlayerStore.highlightSelection.endTs, 10),
thumbnail,
}
if (editNoteId) {
await notesStore.updateNote(editNoteId, note);
toast.success('Highlight updated');
} else {
const sessionId = sessionStore.current.sessionId;
await notesStore.addNote(sessionId, note);
toast.success('Highlight saved. Find it in Home > Highlights');
}
onClose();
} catch (e) {
toast.error('Failed to save highlight');
} finally {
notesStore.setSaving(false);
}
}
return (
<div
className={'w-full p-4 flex flex-col gap-4'}
style={{ width: 270 }}
onClick={(e) => e.stopPropagation()}
>
<div className={'flex items-center gap-2'}>
<Icon name="chat-square-quote" color="inherit" size={16} />
<h3 className={'text-xl font-semibold'}>
{editNote ? 'Edit ' : ''}Highlight
</h3>
<div className={'cursor-pointer ml-auto'} onClick={onClose}>
<X size={18} strokeWidth={2} />
</div>
</div>
<div className="text-sm text-neutral-500">
Save key moments from sessions. Access them anytime on the Highlights
page to share with your team.
</div>
<div>
<Input.TextArea
onChange={(e) => setMessage(e.target.value)}
placeholder={'Enter Comments'}
maxLength={200}
rows={6}
value={message}
className="rounded-lg"
autoFocus
/>
<div className={'text-disabled-text text-sm'}>
{message.length}/200 characters remaining
</div>
</div>
<div className={'flex items-center gap-2'}>
<div>
<div className={'font-semibold'}>From</div>
<Input
value={startTs}
onChange={onStartChange}
className="rounded-lg"
/>
</div>
<div>
<div className={'font-semibold'}>To</div>
<Input value={endTs} onChange={onEndChange} className="rounded-lg" />
</div>
</div>
<div className={'flex items-center gap-2 flex-wrap'}>
{TAGS.map((tag) => (
<Tag
onClick={() => addTag(tag)}
key={tag}
className="cursor-pointer rounded-lg hover:bg-indigo-50 mr-0"
color={tagProps[tag]}
bordered={false}
>
<div className={'flex items-center gap-1 text-sm'}>
{tagActive(tag) ? (
<Icon name="check-circle-fill" color="inherit" size={13} />
) : null}
{tag}
</div>
</Tag>
))}
</div>
<div>
<Checkbox
onChange={(e) => setIsPublic(e.target.checked)}
value={isPublic}
className="ms-2"
>
Visible to team members
</Checkbox>
</div>
<div className={'flex items-center gap-2'}>
<Button
onClick={onSave}
type={'primary'}
loading={notesStore.isSaving}
className="font-medium"
>
<Icon name="chat-square-quote" color="inherit" size={14} /> {editNote ? 'Update ' : 'Save '}
Highlight
</Button>
<Button onClick={onClose} type="text" className="font-medium">
Cancel
</Button>
</div>
</div>
);
}
window.__debugElementToImage = (el) => elementToImage(el).then(img => {
const a = document.createElement('a');
a.href = img;
a.download = 'highlight.png';
a.click();
});
function elementToImage(el) {
return import('html2canvas').then(({ default: html2canvas }) => {
return html2canvas(
el,
{
scale: 1,
allowTaint: true,
useCORS: false,
logging: true,
foreignObjectRendering: false,
height: 900,
width: 1200,
x: 0,
y: 0,
}
).then((canvas) => {
return canvas.toDataURL('img/png');
}).catch(e => {
console.log(e);
return undefined
});
})
}
export default observer(HighlightPanel);

View file

@ -1,13 +1,14 @@
import React from 'react';
import stl from './issuesModal.module.css';
import IssueForm from './IssueForm';
import cn from 'classnames'
const IssuesModal = ({
sessionId,
closeHandler,
}) => {
return (
<div className={ stl.wrapper }>
<div className={cn(stl.wrapper, 'h-screen')}>
<h3 className="text-xl font-semibold">
<span>Create Issue</span>
</h3>

View file

@ -1,4 +1,3 @@
import DraggableMarkers from 'Components/Session_/Player/Controls/components/ZoomDragLayer';
import React, { useEffect, useMemo, useContext, useState, useRef } from 'react';
import stl from './timeline.module.css';
import CustomDragLayer, { OnDragCallback } from './components/CustomDragLayer';
@ -12,6 +11,7 @@ import { WebEventsList, MobEventsList } from './EventsList';
import NotesList from './NotesList';
import SkipIntervalsList from './SkipIntervalsList';
import TimelineTracker from 'Components/Session_/Player/Controls/TimelineTracker';
import { ZoomDragLayer, HighlightDragLayer } from 'Components/Session_/Player/Controls/components/ZoomDragLayer';
function Timeline({ isMobile }: { isMobile: boolean }) {
const { player, store } = useContext(PlayerContext);
@ -24,6 +24,7 @@ function Timeline({ isMobile }: { isMobile: boolean }) {
const timezone = sessionStore.current.timezone;
const issues = sessionStore.current.issues ?? [];
const timelineZoomEnabled = uiPlayerStore.timelineZoom.enabled;
const highlightEnabled = uiPlayerStore.highlightSelection.enabled;
const { playing, skipToIssue, ready, endTime, devtoolsLoading, domLoading } = store.get();
const progressRef = useRef<HTMLDivElement>(null);
@ -132,7 +133,8 @@ function Timeline({ isMobile }: { isMobile: boolean }) {
left: '0.5rem',
}}
>
{timelineZoomEnabled ? <DraggableMarkers scale={scale} /> : null}
{timelineZoomEnabled ? <ZoomDragLayer scale={scale} /> : null}
{highlightEnabled ? <HighlightDragLayer scale={scale} /> : null}
<div
className={stl.progress}
onClick={ready ? jumpToTime : undefined}
@ -143,15 +145,13 @@ function Timeline({ isMobile }: { isMobile: boolean }) {
onMouseLeave={hideTimeTooltip}
>
<TooltipContainer />
<TimelineTracker scale={scale} onDragEnd={onDragEnd} />
<CustomDragLayer
onDrag={onDrag}
minX={0}
maxX={maxWidth}
/>
{highlightEnabled ? null : <TimelineTracker scale={scale} onDragEnd={onDragEnd} />}
<CustomDragLayer onDrag={onDrag} minX={0} maxX={maxWidth} />
<div className={stl.timeline} ref={timelineRef}>
{devtoolsLoading || domLoading || !ready ? <div className={stl.stripes} /> : null}
{devtoolsLoading || domLoading || !ready ? (
<div className={stl.stripes} />
) : null}
</div>
{isMobile ? <MobEventsList /> : <WebEventsList />}

View file

@ -1,4 +1,4 @@
import { Tag } from 'antd';
import { Tag, Input } from 'antd';
import { Duration } from 'luxon';
import React from 'react';
import { toast } from 'react-toastify';
@ -12,6 +12,7 @@ import {
tagProps,
} from 'App/services/NotesService';
import { Button, Checkbox, Icon } from 'UI';
import { shortDurationFromMs } from 'App/date';
import Select from 'Shared/Select';
@ -208,7 +209,7 @@ function CreateNote({
<div className="">
<div className={'font-semibold'}>Note</div>
<textarea
<Input.TextArea
ref={inputRef}
name="message"
id="message"
@ -221,6 +222,16 @@ function CreateNote({
}}
className="text-area"
/>
<div className={'flex w-full items-center gap-1 my-2'}>
{editNote && editNote?.startAt ? (
<>
<span>From: </span>
<Input disabled readOnly value={shortDurationFromMs(editNote?.startAt)} />
<span>To: </span>
<Input disabled readOnly value={shortDurationFromMs(editNote?.endAt)} />
</>
) : null}
</div>
<div className="flex items-center gap-1" style={{ lineHeight: '15px' }}>
{TAGS.map((tag) => (

View file

@ -1,10 +1,16 @@
import React from 'react';
import { Icon } from 'UI';
import {Keyboard} from 'lucide-react'
import { Keyboard } from 'lucide-react';
import { Button, Tooltip } from 'antd';
import { useModal } from "../../../../Modal";
import { useModal } from 'Components/Modal';
const Key = ({ label }: { label: string }) => <div style={{ minWidth: 52 }} className="whitespace-nowrap font-normal bg-indigo-50 rounded-lg px-2 py-1 text-figmaColors-text-primary text-center font-mono">{label}</div>;
const Key = ({ label }: { label: string }) => (
<div
style={{ minWidth: 52 }}
className="whitespace-nowrap font-normal bg-indigo-50 rounded-lg px-2 py-1 text-figmaColors-text-primary text-center font-mono"
>
{label}
</div>
);
function Cell({ shortcut, text }: any) {
return (
<div className="flex items-center gap-2 justify-start rounded">
@ -14,25 +20,24 @@ function Cell({ shortcut, text }: any) {
);
}
export const LaunchConsoleShortcut = () => <Key label={'⇧ + C'} />;
export const LaunchNetworkShortcut = () => <Key label={'⇧ + N'} />;
export const LaunchPerformanceShortcut = () => <Key label={'⇧ + P'} />;
export const LaunchStateShortcut = () => <Key label={'⇧ + R'} />;
export const LaunchEventsShortcut = () => <Key label={'⇧ + E'} />;
export const PlaySessionInFullscreenShortcut = () => <Key label={'⇧ + F'} />;
export const PlayPauseSessionShortcut = () => <Key label={'Space'} />;
export const LaunchXRaShortcut = () => <Key label={'⇧ + X'} />;
export const LaunchUserActionsShortcut = () => <Key label={'⇧ + A'} />;
export const LaunchMoreUserInfoShortcut = () => <Key label={'⇧ + I'} />;
export const LaunchOptionsMenuShortcut = () => <Key label={'⇧ + M'} />;
export const PlayNextSessionShortcut = () => <Key label={'⇧ + >'} />;
export const PlayPreviousSessionShortcut = () => <Key label={'⇧ + <'} />;
export const SkipForwardShortcut = () => <Key label={'→'} />;
export const SkipBackwardShortcut = () => <Key label={'←'} />;
export const PlaybackSpeedShortcut = () => <Key label={'↑ / ↓'} />;
export const LaunchConsoleShortcut = () => <Key label={"⇧ + C"} />
export const LaunchNetworkShortcut = () => <Key label={"⇧ + N"} />
export const LaunchPerformanceShortcut = () => <Key label={"⇧ + P"} />
export const LaunchStateShortcut = () => <Key label={"⇧ + R"} />
export const LaunchEventsShortcut = () => <Key label={"⇧ + E"} />
export const PlaySessionInFullscreenShortcut = () => <Key label={"⇧ + F"} />
export const PlayPauseSessionShortcut = () => <Key label={"Space"} />
export const LaunchXRaShortcut = () => <Key label={"⇧ + X"} />
export const LaunchUserActionsShortcut = () => <Key label={"⇧ + A"} />
export const LaunchMoreUserInfoShortcut = () => <Key label={"⇧ + I"} />
export const LaunchOptionsMenuShortcut = () => <Key label={"⇧ + M"} />
export const PlayNextSessionShortcut = () => <Key label={"⇧ + >"} />
export const PlayPreviousSessionShortcut = () => <Key label={"⇧ + <"} />
export const SkipForwardShortcut = () => <Key label={"→"} />
export const SkipBackwardShortcut = () => <Key label={"←"} />
export const PlaybackSpeedShortcut = () => <Key label={"↑ / ↓"} />
function ShortcutGrid() {
export function ShortcutGrid() {
return (
<div className={'p-4 overflow-y-auto h-screen'}>
<div className={'mb-4 font-semibold text-xl'}>Keyboard Shortcuts</div>
@ -63,16 +68,16 @@ function ShortcutGrid() {
function KeyboardHelp() {
const { showModal } = useModal();
return (
<Tooltip placement='bottom' title='Keyboard Shortcuts'>
<Button
size={'small'}
className={'flex items-center justify-center'}
onClick={() => {
showModal(<ShortcutGrid />, { right: true, width: 320 })
}}
>
<Keyboard size={18}/>
</Button>
<Tooltip placement="bottom" title="Keyboard Shortcuts">
<Button
size={'small'}
className={'flex items-center justify-center'}
onClick={() => {
showModal(<ShortcutGrid />, { right: true, width: 320 });
}}
>
<Keyboard size={18} />
</Button>
</Tooltip>
);
}

View file

@ -9,7 +9,7 @@ function TimeTooltip() {
const { time = 0, offset = 0, isVisible, localTime, userTime } = timeLineTooltip;
return (
<div
className={stl.timeTooltip}
className={`${stl.timeTooltip} p-2 rounded-lg min-w-40 max-w-64`}
style={{
top: 0,
left: `calc(${offset}px - 0.5rem)`,

View file

@ -1,27 +1,119 @@
import React, { useCallback, useState } from 'react';
import { observer } from 'mobx-react-lite';
import { useStore } from "App/mstore";
import { useStore } from 'App/mstore';
import { getTimelinePosition } from 'Components/Session_/Player/Controls/getTimelinePosition';
import { PlayerContext } from 'App/components/Session/playerContext';
import { shortDurationFromMs } from 'App/date';
import { throttle } from 'App/utils';
interface Props {
scale: number;
}
const DraggableMarkers = ({
scale,
}: Props) => {
export const HighlightDragLayer = observer(({ scale }: Props) => {
const { uiPlayerStore } = useStore();
const { player, store } = React.useContext(PlayerContext);
const sessEnd = store.get().endTime;
const toggleHighlight = uiPlayerStore.toggleHighlightSelection;
const timelineHighlightStartTs = uiPlayerStore.highlightSelection.startTs;
const timelineHighlightEndTs = uiPlayerStore.highlightSelection.endTs;
const lastStartTs = React.useRef(timelineHighlightStartTs);
const lastEndTs = React.useRef(timelineHighlightEndTs);
const [throttledJump] = React.useMemo(
() => throttle(player.jump, 25),
[player]
);
React.useEffect(() => {
if (timelineHighlightStartTs !== lastStartTs.current) {
player.pause();
throttledJump(timelineHighlightStartTs, true);
lastStartTs.current = timelineHighlightStartTs;
return;
}
if (timelineHighlightEndTs !== lastEndTs.current) {
player.pause();
throttledJump(timelineHighlightEndTs, true);
lastEndTs.current = timelineHighlightEndTs;
return;
}
}, [timelineHighlightStartTs, timelineHighlightEndTs]);
const onDrag = (start: number, end: number) => {
toggleHighlight({
enabled: true,
range: [start, end],
});
};
return (
<DraggableMarkers
scale={scale}
onDragEnd={onDrag}
defaultStartPos={timelineHighlightStartTs}
defaultEndPos={timelineHighlightEndTs}
sessEnd={sessEnd}
/>
);
});
export const ZoomDragLayer = observer(({ scale }: Props) => {
const { uiPlayerStore } = useStore();
const toggleZoom = uiPlayerStore.toggleZoom;
const timelineZoomStartTs = uiPlayerStore.timelineZoom.startTs;
const timelineZoomEndTs = uiPlayerStore.timelineZoom.endTs;
const onDrag = (start: number, end: number) => {
toggleZoom({
enabled: true,
range: [start, end],
});
};
return (
<DraggableMarkers
scale={scale}
onDragEnd={onDrag}
defaultStartPos={timelineZoomStartTs}
defaultEndPos={timelineZoomEndTs}
/>
);
});
function DraggableMarkers({
scale,
onDragEnd,
defaultStartPos,
defaultEndPos,
sessEnd,
}: {
scale: Props['scale'];
onDragEnd: (start: number, end: number) => void;
defaultStartPos: number;
defaultEndPos: number;
sessEnd?: number;
}) {
const [startPos, setStartPos] = useState(
getTimelinePosition(timelineZoomStartTs, scale)
getTimelinePosition(defaultStartPos, scale)
);
const [endPos, setEndPos] = useState(
getTimelinePosition(timelineZoomEndTs, scale)
getTimelinePosition(defaultEndPos, scale)
);
const [dragging, setDragging] = useState<string | null>(null);
React.useEffect(() => {
if (dragging) {
return;
}
setStartPos(getTimelinePosition(defaultStartPos, scale));
setEndPos(getTimelinePosition(defaultEndPos, scale));
}, [
defaultEndPos,
defaultStartPos,
scale,
dragging
])
const convertToPercentage = useCallback(
(clientX: number, element: HTMLElement) => {
const rect = element.getBoundingClientRect();
@ -50,19 +142,13 @@ const DraggableMarkers = ({
if (endPos - newPos <= minDistance) {
setEndPos(newPos + minDistance);
}
toggleZoom({
enabled: true,
range: [newPos / scale, endPos / scale],
});
onDragEnd(newPos / scale, endPos / scale);
} else if (dragging === 'end') {
setEndPos(newPos);
if (newPos - startPos <= minDistance) {
setStartPos(newPos - minDistance);
}
toggleZoom({
enabled: true,
range: [startPos / scale, newPos / scale],
});
onDragEnd(startPos / scale, newPos / scale);
} else if (dragging === 'body') {
const offset = (endPos - startPos) / 2;
let newStartPos = newPos - offset;
@ -76,31 +162,38 @@ const DraggableMarkers = ({
}
setStartPos(newStartPos);
setEndPos(newEndPos);
toggleZoom({
enabled: true,
range: [newStartPos / scale, newEndPos / scale],
});
setTimeout(() => {
onDragEnd(newStartPos / scale, newEndPos / scale);
}, 1)
}
}
},
[dragging, startPos, endPos, scale, toggleZoom]
[dragging, startPos, endPos, scale, onDragEnd]
);
const endDrag = useCallback(() => {
setDragging(null);
}, []);
const barSize = 104;
const centering = -41;
const topPadding = 41;
const uiSize = 16;
const startRangeStr = shortDurationFromMs(Math.max(defaultStartPos, 0));
const endRangeStr = shortDurationFromMs(
Math.min(defaultEndPos, sessEnd ?? defaultEndPos)
);
return (
<div
onMouseMove={onDrag}
onMouseLeave={endDrag}
onMouseUp={endDrag}
style={{
position: 'absolute',
width: '100%',
height: '24px',
height: barSize,
left: 0,
top: '-4px',
top: centering,
zIndex: 100,
}}
>
@ -110,86 +203,114 @@ const DraggableMarkers = ({
style={{
position: 'absolute',
left: `${startPos}%`,
height: '100%',
background: '#FCC100',
height: uiSize,
top: topPadding,
background: dragging && dragging !== 'start' ? '#c2970a' : '#FCC100',
cursor: 'ew-resize',
borderTopLeftRadius: 3,
borderBottomLeftRadius: 3,
zIndex: 100,
display: 'flex',
alignItems: 'center',
paddingRight: 3,
paddingLeft: 6,
width: 18,
paddingRight: 1,
paddingLeft: 3,
width: 10,
opacity: dragging && dragging !== 'start' ? 0.8 : 1,
}}
>
{dragging === 'start' ? (
<div
className={
'absolute bg-[#FCC100] text-black rounded-xl px-2 py-1 -top-10 select-none left-1/2 -translate-x-1/2'
}
>
{startRangeStr}
</div>
) : null}
<div
className={'bg-black rounded-xl'}
className={'bg-black/20 rounded-xl'}
style={{
zIndex: 101,
height: 18,
width: 2,
marginRight: 3,
height: 16,
width: 1,
marginRight: 2,
overflow: 'hidden',
}}
/>
<div
className={'bg-black rounded-xl'}
style={{ zIndex: 101, height: 18, width: 2, overflow: 'hidden' }}
className={'bg-black/20 rounded-xl'}
style={{ zIndex: 101, height: 16, width: 1, overflow: 'hidden' }}
/>
</div>
<div
className="slider-body"
onMouseDown={startDrag('body')}
style={{
position: 'absolute',
left: `calc(${startPos}% + 18px)`,
width: `calc(${endPos - startPos}% - 18px)`,
height: '100%',
background: 'rgba(252, 193, 0, 0.10)',
borderTop: '2px solid #FCC100',
borderBottom: '2px solid #FCC100',
cursor: 'grab',
zIndex: 100,
}}
/>
className="slider-body"
onMouseDown={startDrag('body')}
style={{
position: 'absolute',
left: `calc(${startPos}% + 10px)`,
width: `calc(${endPos - startPos}% - 10px)`,
height: uiSize,
top: topPadding,
background: `repeating-linear-gradient(
-45deg,
rgba(252, 193, 0, 0.3),
rgba(252, 193, 0, 0.3) 4px,
transparent 4px,
transparent 8px
)`,
borderTop: `1px solid ${dragging ? '#c2970a' : '#FCC100'}`,
borderBottom: `1px solid ${dragging ? '#c2970a' : '#FCC100'}`,
cursor: 'grab',
zIndex: 100,
opacity: dragging ? 0.8 : 1,
}}
/>
<div
className="marker end"
onMouseDown={startDrag('end')}
style={{
position: 'absolute',
left: `${endPos}%`,
height: '100%',
background: '#FCC100',
height: uiSize,
top: topPadding,
background: dragging && dragging !== 'end' ? '#c2970a' : '#FCC100',
cursor: 'ew-resize',
borderTopRightRadius: 3,
borderBottomRightRadius: 3,
zIndex: 100,
display: 'flex',
alignItems: 'center',
paddingLeft: 3,
paddingRight: 6,
width: 18,
paddingLeft: 1,
paddingRight: 1,
width: 10,
opacity: dragging && dragging !== 'end' ? 0.8 : 1,
}}
>
{dragging === 'end' ? (
<div
className={
'absolute bg-[#FCC100] text-black rounded-xl px-2 p-1 -top-10 select-none left-1/2 -translate-x-1/2'
}
>
{endRangeStr}
</div>
) : null}
<div
className={'bg-black rounded-xl'}
className={'bg-black/20 rounded-xl'}
style={{
zIndex: 101,
height: 18,
width: 2,
marginRight: 3,
height: 16,
width: 1,
marginRight: 2,
marginLeft: 2,
overflow: 'hidden',
}}
/>
<div
className={'bg-black rounded-xl'}
style={{ zIndex: 101, height: 18, width: 2, overflow: 'hidden' }}
className={'bg-black/20 rounded-xl'}
style={{ zIndex: 101, height: 16, width: 1, overflow: 'hidden' }}
/>
</div>
</div>
);
};
export default observer(DraggableMarkers);
}

View file

@ -1,29 +1,40 @@
import { ShareAltOutlined } from '@ant-design/icons';
import { Button as AntButton, Switch, Tooltip } from 'antd';
import { Button as AntButton, Switch, Tooltip, Dropdown } from 'antd';
import cn from 'classnames';
import { Link2 } from 'lucide-react';
import { useModal } from "Components/Modal";
import IssuesModal from "Components/Session_/Issues/IssuesModal";
import { Link2, Keyboard } from 'lucide-react';
import { observer } from 'mobx-react-lite';
import React, { useMemo } from 'react';
import { MoreOutlined } from '@ant-design/icons'
import { Icon } from 'UI';
import { PlayerContext } from 'App/components/Session/playerContext';
import { IFRAME } from 'App/constants/storageKeys';
import { useStore } from 'App/mstore';
import { checkParam, truncateStringToFit } from 'App/utils';
import SessionTabs from 'Components/Session/Player/SharedComponents/SessionTabs';
import KeyboardHelp from 'Components/Session_/Player/Controls/components/KeyboardHelp';
import { ShortcutGrid } from 'Components/Session_/Player/Controls/components/KeyboardHelp';
import WarnBadge from 'Components/Session_/WarnBadge';
import Bookmark from 'Shared/Bookmark';
import { toast } from "react-toastify";
import HighlightButton from './Highlight/HighlightButton';
import SharePopup from '../shared/SharePopup/SharePopup';
import Issues from './Issues/Issues';
import QueueControls from './QueueControls';
import NotePopup from './components/NotePopup';
import { Bookmark as BookmarkIcn, BookmarkCheck, Vault } from "lucide-react";
const disableDevtools = 'or_devtools_uxt_toggle';
function SubHeader(props) {
const { uxtestingStore, integrationsStore, sessionStore, projectsStore } = useStore();
const {
uxtestingStore,
integrationsStore,
sessionStore,
projectsStore,
userStore,
issueReportingStore,
} = useStore();
const favorite = sessionStore.current.favorite;
const isEnterprise = userStore.isEnterprise;
const currentSession = sessionStore.current
const projectId = projectsStore.siteId;
const integrations = integrationsStore.issues.list;
@ -31,6 +42,9 @@ function SubHeader(props) {
const { location: currentLocation = 'loading...' } = store.get();
const hasIframe = localStorage.getItem(IFRAME) === 'true';
const [hideTools, setHideTools] = React.useState(false);
const [isFavorite, setIsFavorite] = React.useState(favorite);
const { showModal, hideModal } = useModal();
React.useEffect(() => {
const hideDevtools = checkParam('hideTools');
if (hideDevtools) {
@ -46,6 +60,27 @@ function SubHeader(props) {
return integrations.some((i) => i.token);
}, [integrations]);
const issuesIntegrationList = integrationsStore.issues.list;
const handleOpenIssueModal = () => {
issueReportingStore.init();
if (!issueReportingStore.projectsFetched) {
issueReportingStore.fetchProjects().then((projects) => {
if (projects && projects[0]) {
void issueReportingStore.fetchMeta(projects[0].id);
}
});
}
showModal(
<IssuesModal
provider={reportingProvider}
sessionId={currentSession.sessionId}
closeHandler={hideModal}
/>
)
};
const reportingProvider = issuesIntegrationList[0]?.provider || '';
const locationTruncated = truncateStringToFit(
currentLocation,
window.innerWidth - 200
@ -55,6 +90,32 @@ function SubHeader(props) {
localStorage.setItem(disableDevtools, enabled ? '0' : '1');
uxtestingStore.setHideDevtools(!enabled);
};
const showKbHelp = () => {
showModal(<ShortcutGrid />, { right: true, width: 320 });
}
const vaultIcon = isEnterprise ? (
<Vault size={16} strokeWidth={1} />
) : isFavorite ? (
<BookmarkCheck size={16} strokeWidth={1} />
) : (
<BookmarkIcn size={16} strokeWidth={1} />
);
const toggleFavorite = () => {
const onToggleFavorite = sessionStore.toggleFavorite;
const ADDED_MESSAGE = isEnterprise
? 'Session added to vault'
: 'Session added to your bookmarks';
const REMOVED_MESSAGE = isEnterprise
? 'Session removed from vault'
: 'Session removed from your bookmarks';
onToggleFavorite(currentSession.sessionId).then(() => {
toast.success(isFavorite ? REMOVED_MESSAGE : ADDED_MESSAGE);
setIsFavorite(!isFavorite);
});
}
return (
<>
@ -84,10 +145,6 @@ function SubHeader(props) {
)}
style={{ width: 'max-content' }}
>
<KeyboardHelp />
<Bookmark sessionId={currentSession.sessionId} />
<NotePopup />
{enabledIntegration && <Issues sessionId={currentSession.sessionId} />}
<SharePopup
showCopyLink={true}
trigger={
@ -103,6 +160,44 @@ function SubHeader(props) {
</div>
}
/>
<HighlightButton onClick={() => props.setActiveTab('HIGHLIGHT')} />
<Dropdown
menu={{
items: [
{
key: '2',
label: <div className={'flex items-center gap-2'}>
{vaultIcon}
<span>{isEnterprise ? 'Vault' : 'Bookmark'}</span>
</div>,
onClick: toggleFavorite
},
{
key: '4',
label: <div className={'flex items-center gap-2'}>
<Icon name={`integrations/${reportingProvider || 'github'}`} />
<span>Issues</span>
</div>,
disabled: !enabledIntegration,
onClick: handleOpenIssueModal,
},
{
key: '1',
label: <div className={'flex items-center gap-2'}>
<Keyboard size={16} strokeWidth={1} />
<span>Keyboard Shortcuts</span>
</div>,
onClick: showKbHelp
}
]
}}
>
<AntButton size={'small'}>
<MoreOutlined />
</AntButton>
</Dropdown>
{uxtestingStore.isUxt() ? (
<Switch

View file

@ -1,4 +1,5 @@
import React from 'react';
import { Alert } from 'antd';
import { Icon } from 'UI';
const localhostWarn = (project: string) => project + '_localhost_warn';
@ -137,4 +138,21 @@ const WarnBadge = React.memo(
}
);
export function PartialSessionBadge() {
return (
<div
className="flex flex-col gap-2"
style={{
zIndex: 999,
position: 'absolute',
left: '61%',
bottom: '1.3rem',
}}
>
<Alert message="You are viewing a portion of full session" type="info" className='border-0 rounded-lg py-0.5' showIcon/>
</div>
)
}
export default WarnBadge;

View file

@ -14,6 +14,7 @@ import { Link2 } from 'lucide-react';
import React, { useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { toast } from 'react-toastify';
import { TextEllipsis } from "UI";
import { Spot } from 'App/mstore/types/spot';
import { spot as spotUrl, withSiteId } from 'App/routes';
@ -128,11 +129,7 @@ function SpotListItem({
};
return (
<div
className={`bg-white rounded-lg overflow-hidden shadow-sm border ${
isSelected ? 'border-teal/30' : 'border-transparent'
} transition flex flex-col items-start hover:border-teal`}
>
<>
{isEdit ? (
<EditItemModal
onSave={onSave}
@ -140,6 +137,85 @@ function SpotListItem({
itemName={spot.title}
/>
) : null}
<GridItem
modifier={
<div className="absolute left-0 bottom-8 flex relative gap-2 justify-end pe-2 pb-2 ">
<Tooltip title={tooltipText} className='capitalize'>
<div
className={
'bg-black/70 text-white p-1 px-2 text-xs rounded-lg transition-transform transform translate-y-14 group-hover:translate-y-0 '
}
onClick={copyToClipboard}
style={{ cursor: 'pointer' }}
>
<Link2 size={16} strokeWidth={1} />
</div>
</Tooltip>
<div
className={
'bg-black/70 text-white p-1 px-2 text-xs rounded-lg flex items-center cursor-normal'
}
>
{spot.duration}
</div>
</div>
}
onSave={onSave}
setIsEdit={setIsEdit}
isEdit={isEdit}
title={spot.title}
onItemClick={onSpotClick}
thumbnail={spot.thumbnail}
setLoading={setLoading}
loading={loading}
isSelected={isSelected}
tooltipText={tooltipText}
copyToClipboard={copyToClipboard}
duration={spot.duration}
onSelect={onSelect}
user={spot.user}
createdAt={spot.createdAt}
menuItems={menuItems}
onMenuClick={onMenuClick}
/>
</>
);
}
export function GridItem({
title,
onItemClick,
thumbnail,
setLoading,
loading,
isSelected,
onSelect,
user,
createdAt,
menuItems,
onMenuClick,
modifier,
}: {
title: string;
onItemClick: () => void;
thumbnail: string;
setLoading: (loading: boolean) => void;
loading?: boolean;
isSelected?: boolean;
copyToClipboard: () => void;
onSelect?: (selected: boolean) => void;
user: string;
createdAt: string;
menuItems: any[];
onMenuClick: (key: any) => void;
modifier: React.ReactNode;
}) {
return (
<div
className={`bg-white rounded-lg overflow-hidden shadow-sm border ${
isSelected ? 'border-teal/30' : 'border-transparent'
} transition flex flex-col items-start hover:border-teal`}
>
<div
className="relative group overflow-hidden"
style={{
@ -157,11 +233,11 @@ function SpotListItem({
)}
<div
className="block w-full h-full cursor-pointer transition hover:bg-teal/70 relative"
onClick={onSpotClick}
onClick={onItemClick}
>
<img
src={spot.thumbnail}
alt={spot.title}
src={thumbnail}
alt={title}
className={'w-full h-full object-cover opacity-80'}
onLoad={() => setLoading(false)}
onError={() => setLoading(false)}
@ -175,49 +251,36 @@ function SpotListItem({
</div>
</div>
<div className="absolute left-0 bottom-8 flex relative gap-2 justify-end pe-2 pb-2 ">
<Tooltip title={tooltipText}>
<div
className={
'bg-black/70 text-white p-1 px-2 text-xs rounded-lg transition-transform transform translate-y-14 group-hover:translate-y-0 '
}
onClick={copyToClipboard}
style={{ cursor: 'pointer' }}
>
<Link2 size={16} strokeWidth={1} />
</div>
</Tooltip>
<div
className={
'bg-black/70 text-white p-1 px-2 text-xs rounded-lg flex items-center cursor-normal'
}
>
{spot.duration}
</div>
</div>
{modifier}
</div>
<div className={'px-4 py-4 w-full border-t'}>
<div className={'w-full border-t'}>
<div className={'flex items-center gap-2'}>
<Checkbox
checked={isSelected}
onChange={({ target: { checked } }) => onSelect(checked)}
className={`flex cursor-pointer w-full hover:text-teal ${isSelected ? 'text-teal' : ''}`}
>
<span className="w-full text-nowrap text-ellipsis overflow-hidden max-w-80 mb-0 block">
{spot.title}
</span>
</Checkbox>
{onSelect ? (
<div className='px-3 pt-2'>
<Checkbox
checked={isSelected}
onChange={({ target: { checked } }) => onSelect(checked)}
className={`flex cursor-pointer w-full hover:text-teal ${
isSelected ? 'text-teal' : ''
}`}
>
<TextEllipsis text={title} className='w-full'/>
</Checkbox>
</div>
) : (
<div className='bg-yellow/50 mx-2 mt-2 px-2 w-full rounded '><TextEllipsis text={title} className='capitalize' /></div>
)}
</div>
<div className={'flex items-center gap-1 leading-4 text-xs opacity-50'}>
<div className={'flex items-center gap-1 leading-4 text-xs opacity-50 p-3'}>
<div>
<UserOutlined />
</div>
<div>{spot.user}</div>
<div className="ms-4">
<TextEllipsis text={user} className='capitalize' />
<div className="ml-auto">
<ClockCircleOutlined />
</div>
<div>{spot.createdAt}</div>
<div className={'ml-auto'}>
<div>{createdAt}</div>
<div>
<Dropdown
menu={{ items: menuItems, onClick: onMenuClick }}
trigger={['click']}

View file

@ -77,6 +77,7 @@ const SpotsListHeader = observer(
value={spotStore.filter === 'all' ? 'All Spots' : 'My Spots'}
onChange={handleSegmentChange}
className="mr-4 lg:hidden xl:flex"
size={'small'}
/>
<div className="w-56">
@ -88,6 +89,7 @@ const SpotsListHeader = observer(
onChange={handleInputChange}
onSearch={onSearch}
className="rounded-lg"
size="small"
/>
</div>
</div>

View file

@ -1,6 +1,5 @@
import React from 'react';
import { Link, confirm } from 'UI';
import PlayLink from 'Shared/SessionItem/PlayLink';
import { tagProps, Note } from 'App/services/NotesService';
import { formatTimeOrDate } from 'App/date';
import { useStore } from 'App/mstore';

View file

@ -449,6 +449,7 @@ export { default as Social_trello } from './social_trello';
export { default as Sparkles } from './sparkles';
export { default as Speedometer2 } from './speedometer2';
export { default as Spinner } from './spinner';
export { default as Square_mouse_pointer } from './square_mouse_pointer';
export { default as Star } from './star';
export { default as Step_forward } from './step_forward';
export { default as Stickies } from './stickies';

View file

@ -0,0 +1,19 @@
/* Auto-generated, do not edit */
import React from 'react';
interface Props {
size?: number | string;
width?: number | string;
height?: number | string;
fill?: string;
}
function Square_mouse_pointer(props: Props) {
const { size = 14, width = size, height = size, fill = '' } = props;
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" width={ `${ width }px` } height={ `${ height }px` } ><path d="M12.034 12.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.943l-3.444 1.068a1 1 0 0 0-.66.66l-1.067 3.443a.5.5 0 0 1-.943.033z"/><path d="M21 11V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6"/></svg>
);
}
export default Square_mouse_pointer;

File diff suppressed because one or more lines are too long

View file

@ -12,7 +12,8 @@ export function getDateFromString(date: string, format = 'yyyy-MM-dd HH:mm:ss:SS
/**
* Formats a given duration.
*
* @param {Duration | number} inputDuration - The duration to format. Can be a Duration object or a number representing milliseconds.
* @param {Duration | number} inputDuration - The duration to format. Can be a Duration object or a number representing
* milliseconds.
* @returns {string} - Formatted duration string.
*
* @example
@ -163,7 +164,7 @@ export const checkForRecent = (date: DateTime, format: string): string => {
// Formatted
return date.toFormat(format);
};
export const resentOrDate = (ts) => {
export const resentOrDate = (ts, short?: boolean) => {
const date = DateTime.fromMillis(ts);
const d = new Date();
// Today
@ -171,7 +172,7 @@ export const resentOrDate = (ts) => {
// Yesterday
if (date.hasSame(d.setDate(d.getDate() - 1), 'day')) return 'Yesterday at ' + date.toFormat('hh:mm a');
return date.toFormat('LLL dd, yyyy, hh:mm a');
return date.toFormat(`LLL dd, yyyy${short ? '' : ', hh:mm a'}`);
}
export const checkRecentTime = (date, format) => {

View file

@ -150,7 +150,8 @@ function SideMenu(props: Props) {
[PREFERENCES_MENU.TEAM]: () => client(CLIENT_TABS.MANAGE_USERS),
[PREFERENCES_MENU.NOTIFICATIONS]: () => client(CLIENT_TABS.NOTIFICATIONS),
[PREFERENCES_MENU.BILLING]: () => client(CLIENT_TABS.BILLING),
[PREFERENCES_MENU.MODULES]: () => client(CLIENT_TABS.MODULES)
[PREFERENCES_MENU.MODULES]: () => client(CLIENT_TABS.MODULES),
[MENU.HIGHLIGHTS]: () => withSiteId(routes.highlights(''), siteId),
};
const handleClick = (item: any) => {

View file

@ -40,6 +40,7 @@ export const enum MENU {
VAULT = 'vault',
BOOKMARKS = 'bookmarks',
NOTES = 'notes',
HIGHLIGHTS = 'highlights',
LIVE_SESSIONS = 'live-sessions',
DASHBOARDS = 'dashboards',
CARDS = 'cards',
@ -63,7 +64,8 @@ export const categories: Category[] = [
{ label: 'Recommendations', key: MENU.RECOMMENDATIONS, icon: 'magic', hidden: true },
{ label: 'Vault', key: MENU.VAULT, icon: 'safe', hidden: true },
{ label: 'Bookmarks', key: MENU.BOOKMARKS, icon: 'bookmark' },
{ label: 'Notes', key: MENU.NOTES, icon: 'stickies' }
//{ label: 'Notes', key: MENU.NOTES, icon: 'stickies' },
{ label: 'Highlights', key: MENU.HIGHLIGHTS, icon: 'chat-square-quote' }
]
},
{

View file

@ -9,17 +9,32 @@ export default class NotesStore {
sessionNotes: Note[] = []
loading: boolean
page = 1
pageSize = 10
pageSize = 9
activeTags: iTag[] = []
sort = 'createdAt'
order: 'DESC' | 'ASC' = 'DESC'
ownOnly = false
total = 0
isSaving = false;
query = ''
editNote: Note | null = null
constructor() {
makeAutoObservable(this)
}
setEditNote = (note: Note | null) => {
this.editNote = note
}
setQuery = (query: string) => {
this.query = query
}
setSaving = (saving: boolean) => {
this.isSaving = saving
}
setLoading(loading: boolean) {
this.loading = loading
}
@ -40,7 +55,8 @@ export default class NotesStore {
order: this.order,
tags: this.activeTags,
mineOnly: this.ownOnly,
sharedOnly: false
sharedOnly: false,
search: this.query,
}
this.setLoading(true)
@ -48,7 +64,18 @@ export default class NotesStore {
const { notes, count } = await notesService.fetchNotes(filter);
this.setNotes(notes);
this.setTotal(count)
return notes;
return { notes, total: count };
} catch (e) {
console.error(e)
} finally {
this.setLoading(false)
}
}
fetchNoteById = async (noteId: string)=> {
this.setLoading(true)
try {
return await notesService.fetchNoteById(noteId);
} catch (e) {
console.error(e)
} finally {
@ -115,7 +142,7 @@ export default class NotesStore {
}
}
getNoteById(noteId: number, notes?: Note[]) {
getNoteById(noteId: any, notes?: Note[]) {
const notesSource = notes ? notes : this.notes
return notesSource.find(note => note.noteId === noteId)
@ -128,24 +155,19 @@ export default class NotesStore {
toggleTag(tag?: iTag) {
if (!tag) {
this.activeTags = []
this.fetchNotes()
} else {
this.activeTags = [tag]
this.fetchNotes()
}
}
toggleShared(ownOnly: boolean) {
this.ownOnly = ownOnly
this.fetchNotes()
}
toggleSort(sort: string) {
const sortOrder = sort.split('-')[1]
// @ts-ignore
this.order = sortOrder
this.fetchNotes()
}
async sendSlackNotification(noteId: string, webhook: string) {

View file

@ -394,21 +394,23 @@ export default class SessionStore {
const wasInFavorite =
this.favoriteList.findIndex(({ sessionId }) => sessionId === id) > -1;
if (session) {
session.favorite = !wasInFavorite;
this.list[sessionIdx] = session;
}
if (current.sessionId === id) {
this.current.favorite = !wasInFavorite;
}
runInAction(() => {
if (session) {
session.favorite = !wasInFavorite;
this.list[sessionIdx] = session;
}
if (current.sessionId === id) {
this.current.favorite = !wasInFavorite;
}
if (wasInFavorite) {
this.favoriteList = this.favoriteList.filter(
({ sessionId }) => sessionId !== id
);
} else {
this.favoriteList.push(session);
}
if (wasInFavorite) {
this.favoriteList = this.favoriteList.filter(
({ sessionId }) => sessionId !== id
);
} else {
this.favoriteList.push(session);
}
})
} else {
console.error(r);
}

View file

@ -22,7 +22,7 @@ export class Spot {
this.comments = data.comments ?? [];
this.thumbnail = data.previewURL
this.title = data.name;
this.createdAt = resentOrDate(new Date(data.createdAt).getTime());
this.createdAt = resentOrDate(new Date(data.createdAt).getTime(), true);
this.user = data.userEmail;
this.duration = shortDurationFromMs(data.duration);
this.spotId = data.id

View file

@ -65,6 +65,11 @@ export default class UiPlayerStore {
startTs: 0,
endTs: 0,
}
highlightSelection = {
enabled: false,
startTs: 0,
endTs: 0,
}
zoomTab: 'overview' | 'journey' | 'issues' | 'errors' = 'overview'
dataSource: 'all' | 'current' = 'all'
@ -113,6 +118,12 @@ export default class UiPlayerStore {
this.timelineZoom.endTs = payload.range?.[1] ?? 0;
}
toggleHighlightSelection = (payload: ToggleZoomPayload) => {
this.highlightSelection.enabled = payload.enabled;
this.highlightSelection.startTs = payload.range?.[0] ?? 0;
this.highlightSelection.endTs = payload.range?.[1] ?? 0;
}
setZoomTab = (tab: 'overview' | 'journey' | 'issues' | 'errors') => {
this.zoomTab = tab;
}

View file

@ -88,3 +88,23 @@ export function createLiveWebPlayer(
const player = new WebLivePlayer(store, session, config, agentId, projectId, uiErrorHandler)
return [player, store]
}
export function createClipPlayer(
session: SessionFilesInfo,
wrapStore?: (s: IWebPlayerStore) => IWebPlayerStore,
uiErrorHandler?: { error: (msg: string) => void },
range?: [number, number]
): [IWebPlayer, IWebPlayerStore] {
let store: WebPlayerStore = new SimpleStore<WebState>({
...WebPlayer.INITIAL_STATE,
});
if (wrapStore) {
store = wrapStore(store);
}
const player = new WebPlayer(store, session, false, false, uiErrorHandler);
if (range && range[0] !== range[1]) {
player.toggleRange(range[0], range[1]);
}
return [player, store];
}

View file

@ -195,11 +195,13 @@ export default class Animator {
}
// jump by index?
jump = (time: number) => {
jump = (time: number, silent?: boolean) => {
if (this.store.get().playing && this.store.get().ready) {
cancelAnimationFrame(this.animationFrameRequestId)
this.setTime(time)
this.startAnimation()
if (!silent) {
this.startAnimation()
}
this.store.update({ livePlay: time === this.store.get().endTime })
} else {
this.setTime(time)

View file

@ -32,6 +32,7 @@ export default class Player extends Animator {
autoplay: initialAutoplay,
skip: initialSkip,
speed: initialSpeed,
range: [0, 0] as [number, number],
} as const
constructor(private pState: Store<State & AnimatorGetState>, private manager: IMessageManager) {
@ -105,8 +106,11 @@ export default class Player extends Animator {
const { speed } = this.pState.get()
this.updateSpeed(Math.max(1, speed / 2))
}
/* === === */
// toggle range (start, end)
toggleRange(start: number, end: number) {
this.pState.update({ range: [start, end] })
}
clean() {
this.pause()

View file

@ -195,6 +195,11 @@ export default class WebPlayer extends Player {
this.screen.cursor.showTag(name)
}
// toggle range -> from super
toggleRange = (start: number, end: number) => {
super.toggleRange(start, end)
}
changeTab = (tab: string) => {
const playing = this.wpState.get().playing
this.pause()

View file

@ -146,6 +146,8 @@ export const spotsList = (): string => '/spots';
export const spot = (id = ':spotId', hash?: string | number): string => hashed(`/view-spot/${id}`, hash);
export const scopeSetup = (): string => '/scope-setup';
export const highlights = (): string => '/highlights';
const REQUIRED_SITE_ID_ROUTES = [
liveSession(''),
session(''),
@ -188,6 +190,8 @@ const REQUIRED_SITE_ID_ROUTES = [
usabilityTestingCreate(),
usabilityTestingEdit(''),
usabilityTestingView(''),
highlights(),
];
const routeNeedsSiteId = (path: string): boolean => REQUIRED_SITE_ID_ROUTES.some(r => path.startsWith(r));
const siteIdToUrl = (siteId = ':siteId'): string => {

View file

@ -3,9 +3,8 @@ import APIClient from 'App/api_client';
export const tagProps = {
'ISSUE': 'red',
'QUERY': 'geekblue',
'TASK': 'purple',
'OTHER': '',
'DESIGN': 'geekblue',
'NOTE': 'purple',
}
export type iTag = keyof typeof tagProps | "ALL"
@ -14,11 +13,12 @@ export const TAGS = Object.keys(tagProps) as unknown as (keyof typeof tagProps)[
export interface WriteNote {
message: string
tag: iTag
tag: string
isPublic: boolean
timestamp: number
noteId?: string
author?: string
timestamp?: number
startAt: number
endAt: number
thumbnail: string
}
export interface Note {
@ -33,6 +33,9 @@ export interface Note {
timestamp: number
userId: number
userName: string
startAt: number
endAt: number
thumbnail: string
}
export interface NotesFilter {
@ -43,6 +46,7 @@ export interface NotesFilter {
tags: iTag[]
sharedOnly: boolean
mineOnly: boolean
search: string
}
export default class NotesService {
@ -62,6 +66,12 @@ export default class NotesService {
})
}
fetchNoteById(noteId: string): Promise<Note> {
return this.client.get(`/notes/${noteId}`).then(r => {
return r.json().then(r => r.data)
})
}
getNotesBySessionId(sessionID: string): Promise<Note[]> {
return this.client.get(`/sessions/${sessionID}/notes`)
.then(r => {

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-mouse-pointer"><path d="M12.034 12.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.943l-3.444 1.068a1 1 0 0 0-.66.66l-1.067 3.443a.5.5 0 0 1-.943.033z"/><path d="M21 11V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6"/></svg>

After

Width:  |  Height:  |  Size: 421 B

View file

@ -401,37 +401,41 @@ export function simpleThrottle(func: (...args: any[]) => void, limit: number): (
};
}
export function throttle(func, wait, options) {
var context, args, result;
var timeout = null;
var previous = 0;
if (!options) options = {};
var later = function () {
previous = options.leading === false ? 0 : Date.now();
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function () {
var now = Date.now();
if (!previous && options.leading === false) previous = now;
var remaining = wait - (now - previous);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
timeout = setTimeout(later, remaining);
}
return result;
};
}
export const throttle = <R, A extends any[]>(
fn: (...args: A) => R,
delay: number
): [(...args: A) => R | undefined, () => void, () => void] => {
let wait = false;
let timeout: undefined | number;
let cancelled = false;
function resetWait() {
wait = false;
}
return [
(...args: A) => {
if (cancelled) return undefined;
if (wait) return undefined;
const val = fn(...args);
wait = true;
timeout = window.setTimeout(resetWait, delay);
return val;
},
() => {
cancelled = true;
clearTimeout(timeout);
},
() => {
clearTimeout(timeout);
resetWait();
},
];
};
export function deleteCookie(name: string, path: string, domain: string) {
document.cookie =

View file

@ -167,3 +167,48 @@ export function tryFilterUrl(url: string) {
return url;
}
}
export function mergeRequests(
webTrackedRequests: SpotNetworkRequest[],
proxyNetworkRequests: SpotNetworkRequest[],
): SpotNetworkRequest[] {
const map = new Map<string, SpotNetworkRequest>();
const webReqClone = webTrackedRequests.map((r) => ({ ...r }));
const makeKey = (r: SpotNetworkRequest) =>
`${r.statusCode}::${r.method}::${r.url}::${Math.round(r.timestamp).toString().slice(0, -2) + "00"}`;
for (const proxyReq of proxyNetworkRequests) {
map.set(makeKey(proxyReq), proxyReq);
}
const merged: SpotNetworkRequest[] = [];
for (const webReq of webReqClone) {
if (webReq.url.includes("ingest/v1/web/i")) {
continue;
}
const key = makeKey(webReq);
const found = map.get(key);
if (found) {
if (
found.responseBody &&
found.responseBody.length > 0 &&
found.responseBody !== "{}"
) {
webReq.responseBody = found.responseBody;
webReq.responseBodySize = found.responseBodySize;
if (webReq.encodedBodySize < found.encodedBodySize) {
webReq.encodedBodySize = found.encodedBodySize;
}
}
merged.push(webReq);
map.delete(key);
} else {
webReq.responseBody = JSON.stringify({
message: "Spot was unable to track this request's data",
});
merged.push(webReq);
}
}
return merged;
}