[WIP] Mobile replayer (#1452)

* fix(ui): fix up mobile recordings display

* fix(ui): some messages

* fix(ui): some messages

* fix(player): fix msg generation for ios messages

* feat(player): add generic mmanager interface for ios player impl

* feat(player): mobile player and message manager; touch manager; videoplayer

* feat(player): add iphone shells, add log panel,

* feat(player): detect ios sessions and inject correct player

* feat(player): move screen mapper to utils

* feat(player): events panel for mobile, map shell sizes to device type data,

* feat(player): added network tab to mobile player; unify network message (ios and web)

* feat(player): resize canvas up to phone screen size, fix capitalize util

* feat(player): some general changes to support mobile events and network entries

* feat(player): remove swipes from timeline

* feat(player): more stuff for list walker

* fix(ui): performance tab, mobile project typings and form

* fix(ui):some ui touches for ios replayer shell

* fix(ui): more fixes for ui, new onboarding screen for mobile projects

* feat(ui): mobile overview panel (xray)

* feat(ui): fixes for phone shell and tap events

* fix(tracker): change phone shells and sizes

* fix(tracker): fix border on replay screen

* feat(ui): use crashes from db to show in session

* feat(ui): use event name for xray

* feat(ui): some overall ui fixes

* feat(ui): IOS -> iOS

* feat(ui): change tags to ant d

* fix(ui): fast fix

* fix(ui): fix for capitalizer

* fix(ui): fix for browser display

* fix(ui): fix for note popup

* fix(ui): change exceptions display

* fix(ui): add click rage to ios xray

* fix(ui): some icons and resizing

* fix(ui): fix ios context menu overlay, fix console logs creation for ios

* feat(ui): added icons

* feat(ui): performance warnings

* feat(ui): performance warnings

* feat(ui): different styles

* feat(ui): rm debug true

* feat(ui): fix warnings display

* feat(ui): some styles for animation

* feat(ui): add some animations to warnings

* feat(ui): move perf warnings to performance graph

* feat(ui): hide/show warns dynamically

* feat(ui): new mobile touch animation

* fix(tracker): update msg for ios

* fix(ui): taprage fixes

* fix(ui): regenerate icons and messages

* fix(ui): fix msgs

* fix(backend): fix events gen

* fix(backend): fix userid msg
This commit is contained in:
Delirium 2023-10-27 12:12:09 +02:00 committed by GitHub
parent 3214e58ff5
commit 35461acaf3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
109 changed files with 4567 additions and 1082 deletions

View file

@ -63,7 +63,7 @@ func main() {
messages.MsgUnbindNodes,
// Mobile messages
messages.MsgIOSSessionStart, messages.MsgIOSSessionEnd, messages.MsgIOSUserID, messages.MsgIOSUserAnonymousID,
messages.MsgIOSMetadata, messages.MsgIOSCustomEvent, messages.MsgIOSNetworkCall,
messages.MsgIOSMetadata, messages.MsgIOSEvent, messages.MsgIOSNetworkCall,
messages.MsgIOSClickEvent, messages.MsgIOSSwipeEvent, messages.MsgIOSInputEvent,
messages.MsgIOSCrash, messages.MsgIOSIssueEvent,
}

View file

@ -72,21 +72,21 @@ func (s *saverImpl) handleMobileMessage(msg Message) error {
case *IOSSessionEnd:
return s.pg.InsertIOSSessionEnd(m.SessionID(), m)
case *IOSUserID:
if err = s.sessions.UpdateUserID(session.SessionID, m.Value); err != nil {
if err = s.sessions.UpdateUserID(session.SessionID, m.ID); err != nil {
return err
}
s.pg.InsertAutocompleteValue(session.SessionID, session.ProjectID, "USERID_IOS", m.Value)
s.pg.InsertAutocompleteValue(session.SessionID, session.ProjectID, "USERID_IOS", m.ID)
return nil
case *IOSUserAnonymousID:
if err = s.sessions.UpdateAnonymousID(session.SessionID, m.Value); err != nil {
if err = s.sessions.UpdateAnonymousID(session.SessionID, m.ID); err != nil {
return err
}
s.pg.InsertAutocompleteValue(session.SessionID, session.ProjectID, "USERANONYMOUSID_IOS", m.Value)
s.pg.InsertAutocompleteValue(session.SessionID, session.ProjectID, "USERANONYMOUSID_IOS", m.ID)
return nil
case *IOSMetadata:
return s.sessions.UpdateMetadata(m.SessionID(), m.Key, m.Value)
case *IOSCustomEvent:
return s.pg.InsertIOSCustomEvent(session, m)
case *IOSEvent:
return s.pg.InsertIOSEvent(session, m)
case *IOSClickEvent:
if err := s.pg.InsertIOSClickEvent(session, m); err != nil {
return err

View file

@ -18,7 +18,7 @@ func (conn *Conn) InsertIOSSessionEnd(sessionID uint64, e *messages.IOSSessionEn
return nil
}
func (conn *Conn) InsertIOSCustomEvent(session *sessions.Session, e *messages.IOSCustomEvent) error {
func (conn *Conn) InsertIOSEvent(session *sessions.Session, e *messages.IOSEvent) error {
if err := conn.InsertCustomEvent(session.SessionID, e.Timestamp, truncSqIdx(e.Index), e.Name, e.Payload); err != nil {
return err
}

View file

@ -2,7 +2,7 @@
package messages
func IsReplayerType(id int) bool {
return 1 != id && 3 != id && 17 != id && 23 != id && 24 != id && 25 != id && 26 != id && 27 != id && 28 != id && 29 != id && 30 != id && 31 != id && 32 != id && 42 != id && 56 != id && 62 != id && 63 != id && 64 != id && 66 != id && 78 != id && 80 != id && 81 != id && 82 != id && 112 != id && 115 != id && 125 != id && 126 != id && 127 != id && 90 != id && 91 != id && 92 != id && 94 != id && 95 != id && 97 != id && 98 != id && 104 != id && 107 != id && 110 != id && 111 != id
return 1 != id && 3 != id && 17 != id && 23 != id && 24 != id && 25 != id && 26 != id && 27 != id && 28 != id && 29 != id && 30 != id && 31 != id && 32 != id && 42 != id && 56 != id && 62 != id && 63 != id && 64 != id && 66 != id && 78 != id && 80 != id && 81 != id && 82 != id && 112 != id && 115 != id && 125 != id && 126 != id && 127 != id && 90 != id && 91 != id && 92 != id && 94 != id && 95 != id && 97 != id && 98 != id && 107 != id && 110 != id
}
func IsIOSType(id int) bool {
@ -10,5 +10,5 @@ func IsIOSType(id int) bool {
}
func IsDOMType(id int) bool {
return 0 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 37 == id || 38 == id || 49 == id || 50 == id || 51 == id || 54 == id || 55 == id || 57 == id || 58 == id || 59 == id || 60 == id || 61 == id || 67 == id || 69 == id || 70 == id || 71 == id || 72 == id || 73 == id || 74 == id || 75 == id || 76 == id || 77 == id || 113 == id || 114 == id || 117 == id || 118 == id || 93 == id || 96 == id || 100 == id || 101 == id || 102 == id || 103 == id || 105 == id || 106 == id
return 0 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 37 == id || 38 == id || 49 == id || 50 == id || 51 == id || 54 == id || 55 == id || 57 == id || 58 == id || 59 == id || 60 == id || 61 == id || 67 == id || 69 == id || 70 == id || 71 == id || 72 == id || 73 == id || 74 == id || 75 == id || 76 == id || 77 == id || 113 == id || 114 == id || 117 == id || 118 == id || 93 == id || 96 == id || 100 == id || 101 == id || 102 == id || 103 == id || 104 == id || 105 == id || 106 == id || 111 == id
}

View file

@ -13,7 +13,7 @@ func GetTimestamp(message Message) uint64 {
case *IOSMetadata:
return msg.Timestamp
case *IOSCustomEvent:
case *IOSEvent:
return msg.Timestamp
case *IOSUserID:

View file

@ -90,7 +90,7 @@ const (
MsgIOSSessionStart = 90
MsgIOSSessionEnd = 91
MsgIOSMetadata = 92
MsgIOSCustomEvent = 93
MsgIOSEvent = 93
MsgIOSUserID = 94
MsgIOSUserAnonymousID = 95
MsgIOSScreenChanges = 96
@ -2411,7 +2411,7 @@ func (msg *IOSMetadata) TypeID() int {
return 92
}
type IOSCustomEvent struct {
type IOSEvent struct {
message
Timestamp uint64
Length uint64
@ -2419,7 +2419,7 @@ type IOSCustomEvent struct {
Payload string
}
func (msg *IOSCustomEvent) Encode() []byte {
func (msg *IOSEvent) Encode() []byte {
buf := make([]byte, 41+len(msg.Name)+len(msg.Payload))
buf[0] = 93
p := 1
@ -2430,11 +2430,11 @@ func (msg *IOSCustomEvent) Encode() []byte {
return buf[:p]
}
func (msg *IOSCustomEvent) Decode() Message {
func (msg *IOSEvent) Decode() Message {
return msg
}
func (msg *IOSCustomEvent) TypeID() int {
func (msg *IOSEvent) TypeID() int {
return 93
}
@ -2442,16 +2442,16 @@ type IOSUserID struct {
message
Timestamp uint64
Length uint64
Value string
ID string
}
func (msg *IOSUserID) Encode() []byte {
buf := make([]byte, 31+len(msg.Value))
buf := make([]byte, 31+len(msg.ID))
buf[0] = 94
p := 1
p = WriteUint(msg.Timestamp, buf, p)
p = WriteUint(msg.Length, buf, p)
p = WriteString(msg.Value, buf, p)
p = WriteString(msg.ID, buf, p)
return buf[:p]
}
@ -2467,16 +2467,16 @@ type IOSUserAnonymousID struct {
message
Timestamp uint64
Length uint64
Value string
ID string
}
func (msg *IOSUserAnonymousID) Encode() []byte {
buf := make([]byte, 31+len(msg.Value))
buf := make([]byte, 31+len(msg.ID))
buf[0] = 95
p := 1
p = WriteUint(msg.Timestamp, buf, p)
p = WriteUint(msg.Length, buf, p)
p = WriteString(msg.Value, buf, p)
p = WriteString(msg.ID, buf, p)
return buf[:p]
}

View file

@ -1479,9 +1479,9 @@ func DecodeIOSMetadata(reader BytesReader) (Message, error) {
return msg, err
}
func DecodeIOSCustomEvent(reader BytesReader) (Message, error) {
func DecodeIOSEvent(reader BytesReader) (Message, error) {
var err error = nil
msg := &IOSCustomEvent{}
msg := &IOSEvent{}
if msg.Timestamp, err = reader.ReadUint(); err != nil {
return nil, err
}
@ -1506,7 +1506,7 @@ func DecodeIOSUserID(reader BytesReader) (Message, error) {
if msg.Length, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.Value, err = reader.ReadString(); err != nil {
if msg.ID, err = reader.ReadString(); err != nil {
return nil, err
}
return msg, err
@ -1521,7 +1521,7 @@ func DecodeIOSUserAnonymousID(reader BytesReader) (Message, error) {
if msg.Length, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.Value, err = reader.ReadString(); err != nil {
if msg.ID, err = reader.ReadString(); err != nil {
return nil, err
}
return msg, err
@ -2006,7 +2006,7 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) {
case 92:
return DecodeIOSMetadata(reader)
case 93:
return DecodeIOSCustomEvent(reader)
return DecodeIOSEvent(reader)
case 94:
return DecodeIOSUserID(reader)
case 95:

View file

@ -705,13 +705,13 @@ def handle_message(message: Message) -> Optional[DetailedEvent]:
if isinstance(message, IOSUserID):
n.iosuserid_timestamp = message.timestamp
n.iosuserid_length = message.length
n.iosuserid_value = message.value
n.iosuserid_id = message.id
return n
if isinstance(message, IOSUserAnonymousID):
n.iosuseranonymousid_timestamp = message.timestamp
n.iosuseranonymousid_length = message.length
n.iosuseranonymousid_value = message.value
n.iosuseranonymousid_id = message.id
return n
if isinstance(message, IOSScreenEnter):
@ -779,11 +779,11 @@ def handle_message(message: Message) -> Optional[DetailedEvent]:
n.iosissueevent_payload = message.payload
return n
if isinstance(message, IOSCustomEvent):
n.ioscustomevent_timestamp = message.timestamp
n.ioscustomevent_length = message.length
n.ioscustomevent_name = message.name
n.ioscustomevent_payload = message.payload
if isinstance(message, IOSEvent):
n.iosevent_timestamp = message.timestamp
n.iosevent_length = message.length
n.iosevent_name = message.name
n.iosevent_payload = message.payload
return n
if isinstance(message, IOSInternalError):

View file

@ -850,7 +850,7 @@ class IOSMetadata(Message):
self.value = value
class IOSCustomEvent(Message):
class IOSEvent(Message):
__id__ = 93
def __init__(self, timestamp, length, name, payload):
@ -863,19 +863,19 @@ class IOSCustomEvent(Message):
class IOSUserID(Message):
__id__ = 94
def __init__(self, timestamp, length, value):
def __init__(self, timestamp, length, id):
self.timestamp = timestamp
self.length = length
self.value = value
self.id = id
class IOSUserAnonymousID(Message):
__id__ = 95
def __init__(self, timestamp, length, value):
def __init__(self, timestamp, length, id):
self.timestamp = timestamp
self.length = length
self.value = value
self.id = id
class IOSScreenChanges(Message):

View file

@ -1258,7 +1258,7 @@ cdef class IOSMetadata(PyMessage):
self.value = value
cdef class IOSCustomEvent(PyMessage):
cdef class IOSEvent(PyMessage):
cdef public int __id__
cdef public unsigned long timestamp
cdef public unsigned long length
@ -1277,26 +1277,26 @@ cdef class IOSUserID(PyMessage):
cdef public int __id__
cdef public unsigned long timestamp
cdef public unsigned long length
cdef public str value
cdef public str id
def __init__(self, unsigned long timestamp, unsigned long length, str value):
def __init__(self, unsigned long timestamp, unsigned long length, str id):
self.__id__ = 94
self.timestamp = timestamp
self.length = length
self.value = value
self.id = id
cdef class IOSUserAnonymousID(PyMessage):
cdef public int __id__
cdef public unsigned long timestamp
cdef public unsigned long length
cdef public str value
cdef public str id
def __init__(self, unsigned long timestamp, unsigned long length, str value):
def __init__(self, unsigned long timestamp, unsigned long length, str id):
self.__id__ = 95
self.timestamp = timestamp
self.length = length
self.value = value
self.id = id
cdef class IOSScreenChanges(PyMessage):

View file

@ -762,7 +762,7 @@ class MessageCodec(Codec):
)
if message_id == 93:
return IOSCustomEvent(
return IOSEvent(
timestamp=self.read_uint(reader),
length=self.read_uint(reader),
name=self.read_string(reader),
@ -773,14 +773,14 @@ class MessageCodec(Codec):
return IOSUserID(
timestamp=self.read_uint(reader),
length=self.read_uint(reader),
value=self.read_string(reader)
id=self.read_string(reader)
)
if message_id == 95:
return IOSUserAnonymousID(
timestamp=self.read_uint(reader),
length=self.read_uint(reader),
value=self.read_string(reader)
id=self.read_string(reader)
)
if message_id == 96:

File diff suppressed because it is too large Load diff

View file

@ -646,7 +646,7 @@ class MessageCodec(Codec):
)
if message_id == 93:
return IOSCustomEvent(
return IOSEvent(
timestamp=self.read_uint(reader),
length=self.read_uint(reader),
name=self.read_string(reader),
@ -657,14 +657,14 @@ class MessageCodec(Codec):
return IOSUserID(
timestamp=self.read_uint(reader),
length=self.read_uint(reader),
value=self.read_string(reader)
id=self.read_string(reader)
)
if message_id == 95:
return IOSUserAnonymousID(
timestamp=self.read_uint(reader),
length=self.read_uint(reader),
value=self.read_string(reader)
id=self.read_string(reader)
)
if message_id == 96:

View file

@ -739,7 +739,7 @@ class IOSMetadata(Message):
self.value = value
class IOSCustomEvent(Message):
class IOSEvent(Message):
__id__ = 93
def __init__(self, timestamp, length, name: str, payload: str):
@ -752,19 +752,19 @@ class IOSCustomEvent(Message):
class IOSUserID(Message):
__id__ = 94
def __init__(self, timestamp, length, value: str):
def __init__(self, timestamp, length, id: str):
self.timestamp = timestamp
self.length = length
self.value = value
self.id = id
class IOSUserAnonymousID(Message):
__id__ = 95
def __init__(self, timestamp, length, value: str):
def __init__(self, timestamp, length, id: str):
self.timestamp = timestamp
self.length = length
self.value = value
self.id = id
class IOSScreenChanges(Message):

View file

@ -113,7 +113,7 @@ function Integrations(props: Props) {
<div className='mb-4' />
{filteredIntegrations.map((cat: any) => (
<div className='grid grid-cols-3 mt-4 gap-3 auto-cols-max'>
<div className={cn('grid grid-cols-3 gap-3 auto-cols-max', cat.integrations.length > 0 ? 'p-2' : '')}>
{cat.integrations.map((integration: any) => (
<IntegrationItem
integrated={integratedList.includes(integration.slug)}

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect, ChangeEvent, FormEvent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { Form, Input, Button, Icon } from 'UI';
import { Form, Input, Button, Icon, SegmentSelection } from 'UI';
import { save, edit, update, fetchList, remove } from 'Duck/site';
import { pushNewSite } from 'Duck/user';
import { setSiteId } from 'Duck/site';
@ -115,6 +115,20 @@ const NewSiteForm = ({
className={styles.input}
/>
</Form.Field>
<Form.Field>
<label>Project Type</label>
<SegmentSelection
name={"platform"}
value={{ name: site.platform, value: site.platform }}
list={[
{ name: 'Web', value: 'web' },
{ name: 'iOS', value: 'ios' },
]}
onSelect={(_, { value }) => {
edit({ platform: value });
}}
/>
</Form.Field>
<div className="mt-6 flex justify-between">
<Button
variant="primary"

View file

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { Drawer } from 'antd';
import { Drawer, Tag } from 'antd';
import cn from 'classnames';
import {
Loader,
@ -8,7 +8,9 @@ import {
TextLink,
NoContent,
Pagination,
PageTitle, Divider
PageTitle,
Divider,
Icon,
} from 'UI';
import {
init,
@ -31,6 +33,7 @@ import CaptureRate from 'Shared/SessionSettings/components/CaptureRate';
type Project = {
id: number;
name: string;
platform: 'web' | 'mobile';
host: string;
projectKey: string;
sampleRate: number;
@ -91,39 +94,32 @@ const Sites = ({
const ProjectItem = ({ project }: { project: Project }) => (
<div
key={project.id}
className='grid grid-cols-12 gap-2 w-full group hover:bg-active-blue items-center px-5 py-3'
className="grid grid-cols-12 gap-2 w-full group hover:bg-active-blue items-center px-5 py-3"
>
<div className='col-span-4'>
<div className='flex items-center'>
<div className='relative flex items-center justify-center w-10 h-10'>
<div
className='absolute left-0 right-0 top-0 bottom-0 mx-auto w-10 h-10 rounded-full opacity-30 bg-tealx' />
<div className='text-lg uppercase color-tealx'>
{getInitials(project.name)}
</div>
<div className="col-span-4">
<div className="flex items-center">
<div className="relative flex items-center justify-center w-10 h-10 rounded-full bg-tealx-light">
<Icon color={'tealx'} size={18} name={project.platform === 'web' ? 'browser/browser' : 'mobile'} />
</div>
<span className="ml-2">{project.host}</span>
<div className={'ml-4 flex items-center gap-2'}>
{project.platform === 'web' ? null : <Tag color="error">iOS BETA</Tag>}
</div>
<span className='ml-2'>{project.host}</span>
</div>
</div>
<div className='col-span-3'>
<ProjectKey
value={project.projectKey}
tooltip='Project key copied to clipboard'
/>
<div className="col-span-3">
<ProjectKey value={project.projectKey} tooltip="Project key copied to clipboard" />
</div>
<div className='col-span-2'>
<Button
variant='text-primary'
onClick={() => captureRateClickHandler(project)}
>
<div className="col-span-2">
<Button variant="text-primary" onClick={() => captureRateClickHandler(project)}>
{project.sampleRate}%
</Button>
</div>
<div className='col-span-3 justify-self-end flex items-center'>
<div className='mr-4'>
<div className="col-span-3 justify-self-end flex items-center">
<div className="mr-4">
<InstallButton site={project} />
</div>
<div className='invisible group-hover:visible'>
<div className="invisible group-hover:visible">
<EditButton isAdmin={isAdmin} onClick={() => init(project)} />
</div>
</div>

View file

@ -79,7 +79,7 @@ export default class SiteDropdown extends React.PureComponent {
<div className="border-b border-dashed my-1" />
{sites.map((site) => (
<li key={site.id} onClick={() => this.switchSite(site.id)}>
<Icon name="folder2" size="16" />
<Icon name={site.platform === 'web' ? 'browser/browser' : 'mobile'} size="16" />
<span className="ml-3">{site.host}</span>
</li>
))}

View file

@ -1,3 +1,4 @@
import { Segmented } from 'antd';
import React from 'react';
import CircleNumber from '../CircleNumber';
import MetadataList from '../MetadataList/MetadataList';
@ -10,6 +11,32 @@ import withPageTitle from 'App/components/hocs/withPageTitle';
interface Props extends WithOnboardingProps {}
function IdentifyUsersTab(props: Props) {
const platforms = [
{
label: (
<div className={'font-semibold flex gap-2 items-center'}>
<Icon name="browser/browser" size={16} /> Web
</div>
),
value: 'web',
} as const,
{
label: (
<div className={'font-semibold flex gap-2 items-center'}>
<Icon name="mobile" size={16} /> Mobile
</div>
),
value: 'mobile',
} as const,
];
const [platform, setPlatform] = React.useState(platforms[0]);
const { site } = props;
React.useEffect(() => {
if (site.platform)
setPlatform(platforms.find(({ value }) => value === site.platform) || platforms[0]);
}, [site]);
return (
<>
<h1 className="flex items-center px-4 py-3 border-b justify-between">
@ -18,12 +45,25 @@ function IdentifyUsersTab(props: Props) {
<div className="ml-3">Identify Users</div>
</div>
<a href="https://docs.openreplay.com/en/v1.10.0/installation/identify-user/" target="_blank">
<a
href="https://docs.openreplay.com/en/v1.10.0/installation/identify-user/"
target="_blank"
>
<Button variant="text-primary" icon="question-circle" className="ml-2">
See Documentation
</Button>
</a>
</h1>
<div className="p-4 flex gap-2 items-center">
<span className="font-medium">Your platform</span>
<Segmented
options={platforms}
value={platform.value}
onChange={(value) =>
setPlatform(platforms.find(({ value: v }) => v === value) || platforms[0])
}
/>
</div>
<div className="grid grid-cols-6 gap-4 w-full p-4">
<div className="col-span-4">
<div>
@ -34,12 +74,17 @@ function IdentifyUsersTab(props: Props) {
</div>
</div>
<div className="flex items-center my-2">
<Icon name="info-circle" color="gray-darkest" />
<span className="ml-2">OpenReplay keeps the last communicated user ID.</span>
</div>
<HighlightCode className="js" text={`tracker.setUserID('john@doe.com');`} />
{platform.value === 'web' ? (
<HighlightCode className="js" text={`tracker.setUserID('john@doe.com');`} />
) : (
<HighlightCode className="swift" text={`ORTracker.shared.setUserID('john@doe.com');`} />
)}
{platform.value === 'web' ? (
<div className="flex items-center my-2">
<Icon name="info-circle" color="gray-darkest" />
<span className="ml-2">OpenReplay keeps the last communicated user ID.</span>
</div>
) : null}
</div>
<div className="col-span-2">
<DocCard
@ -78,7 +123,9 @@ function IdentifyUsersTab(props: Props) {
Use the <span className="highlight-blue">setMetadata</span> method in your code to
inject custom user data in the form of a key/value pair (string).
</div>
<HighlightCode className="js" text={`tracker.setMetadata('plan', 'premium');`} />
{platform.value === 'web' ? (
<HighlightCode className="js" text={`tracker.setMetadata('plan', 'premium');`} />
) : <HighlightCode className="swift" text={`ORTracker.shared.setMetadata('plan', 'premium');`} />}
</div>
</div>
</div>
@ -109,4 +156,4 @@ function IdentifyUsersTab(props: Props) {
);
}
export default withOnboarding(withPageTitle("Identify Users - OpenReplay")(IdentifyUsersTab));
export default withOnboarding(withPageTitle('Identify Users - OpenReplay')(IdentifyUsersTab));

View file

@ -1,16 +1,27 @@
import React from 'react';
import OnboardingTabs from '../OnboardingTabs';
import MobileOnboardingTabs from '../OnboardingTabs/OnboardingMobileTabs'
import ProjectFormButton from '../ProjectFormButton';
import { Button, Icon } from 'UI';
import {Button, Icon } from 'UI';
import withOnboarding from '../withOnboarding';
import { WithOnboardingProps } from '../withOnboarding';
import { OB_TABS } from 'App/routes';
import withPageTitle from 'App/components/hocs/withPageTitle';
import { Segmented } from "antd";
interface Props extends WithOnboardingProps {}
function InstallOpenReplayTab(props: Props) {
const platforms = [
{ label: <div className={"font-semibold flex gap-2 items-center"}><Icon name="browser/browser" size={16} /> Web</div>, value: 'web' } as const,
{ label: <div className={"font-semibold flex gap-2 items-center"}><Icon name="mobile" size={16} /> Mobile</div>, value: 'mobile' } as const,
]
const [platform, setPlatform] = React.useState(platforms[0]);
const { site } = props;
React.useEffect(() => {
if (site.platform) setPlatform(platforms.find(({ value }) => value === site.platform) || platforms[0])
}, [site])
return (
<>
<h1 className="flex items-center px-4 py-3 border-b justify-between">
@ -27,12 +38,18 @@ function InstallOpenReplayTab(props: Props) {
</Button>
</a>
</h1>
<div className="p-4 flex gap-2 items-center">
<span className="font-medium">Your platform</span>
<Segmented
options={platforms}
value={platform.value}
onChange={(value) => setPlatform(platforms.find(({ value: v }) => v === value) || platforms[0])}
/>
</div>
<div className="p-4">
<div className="mb-6 text-lg font-medium">
Setup OpenReplay through NPM package <span className="text-sm">(recommended)</span> or
script.
</div>
<OnboardingTabs site={site} />
{
platform.value === 'web' ? <Snippet site={site} /> : <MobileOnboardingTabs site={site} />
}
</div>
<div className="border-t px-4 py-3 flex justify-end">
<Button
@ -48,4 +65,16 @@ function InstallOpenReplayTab(props: Props) {
);
}
function Snippet({ site }: { site: Record<string, any>}) {
return (
<>
<div className="mb-6 text-lg font-medium">
Setup OpenReplay through NPM package <span className="text-sm">(recommended)</span> or
script.
</div>
<OnboardingTabs site={site} />
</>
)
}
export default withOnboarding(withPageTitle("Project Setup - OpenReplay")(InstallOpenReplayTab));

View file

@ -104,6 +104,4 @@ function InstallDocs({ site }) {
);
}
export default connect((state) => ({
site: state.getIn(['site', 'instance']),
}))(InstallDocs);
export default InstallDocs;

View file

@ -0,0 +1,159 @@
import React from 'react';
import stl from './installDocs.module.css';
import cn from 'classnames';
import Highlight from 'react-highlight';
import CircleNumber from '../../CircleNumber';
import { CopyButton } from 'UI';
const installationCommand = 'add command after publishing!';
const usageCode = `// AppDelegate.swift
import ORTracker
//...
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
ORTracker.shared.serverURL = "https://your.instance.com/ingest"
ORTracker.shared.start(projectKey: "PROJECT_KEY", options: .defaults)
// ...
return true
}
// ...`;
const configuration = `let crashs: Bool
let analytics: Bool
let performances: Bool
let logs: Bool
let screen: Bool
let wifiOnly: Bool`;
const touches = `// SceneDelegate.Swift
import ORTracker
// ...
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = ContentView()
.environmentObject(TodoStore())
if let windowScene = scene as? UIWindowScene {
let window = TouchTrackingWindow(windowScene: windowScene) // <<<< here
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
// ...`
const sensitive = `import ORTracker
// swiftUI
Text("Very important sensitive text")
.sensitive()
// UIKit
ORTracker.shared.addIgnoredView(view)`
const inputs = `// swiftUI
TextField("Input", text: $text)
.observeInput(text: $text, label: "tracker input #1", masked: Bool)
// UIKit will use placeholder as label and sender.isSecureTextEntry to mask the input
Analytics.shared.addObservedInput(inputEl)`
function MobileInstallDocs({ site }: any) {
const _usageCode = usageCode.replace('PROJECT_KEY', site.projectKey);
return (
<div>
<div className="mb-4">
<div className="font-semibold mb-2 flex items-center">
<CircleNumber text="1" />
Install the Swift Package
</div>
<div className={cn(stl.snippetWrapper, 'ml-10')}>
<div className="absolute mt-1 mr-2 right-0">
<CopyButton content={installationCommand} />
</div>
<Highlight className="cli">{installationCommand}</Highlight>
</div>
</div>
<div className="font-semibold mb-2 flex items-center">
<CircleNumber text="2" />
Add to your app
</div>
<div className="flex ml-10 mt-4">
<div className="w-full">
<div className={cn(stl.snippetWrapper)}>
<div className="absolute mt-1 mr-2 right-0">
<CopyButton content={_usageCode} />
</div>
<Highlight className="swift">{_usageCode}</Highlight>
</div>
</div>
</div>
<div className="font-semibold mb-2 mt-4 flex items-center">
<CircleNumber text="3" />
Configuration
</div>
<div className="flex ml-10 mt-4">
<div className="w-full">
<div className={cn(stl.snippetWrapper)}>
<Highlight className="swift">{configuration}</Highlight>
<div className={"mt-2"}>By default, all options equals <code className={'p-1 text-red rounded bg-gray-lightest'}>true</code></div>
</div>
</div>
</div>
<div className="font-semibold mb-2 mt-4 flex items-center">
<CircleNumber text="4" />
Set up touch events listener
</div>
<div className="flex ml-10 mt-4">
<div className="w-full">
<div className={cn(stl.snippetWrapper)}>
<div className="absolute mt-1 mr-2 right-0">
<CopyButton content={touches} />
</div>
<Highlight className="swift">{touches}</Highlight>
</div>
</div>
</div>
<div className="font-semibold mb-2 mt-4 flex items-center">
<CircleNumber text="5" />
Hide sensitive views
</div>
<div className="flex ml-10 mt-4">
<div className="w-full">
<div className={cn(stl.snippetWrapper)}>
<div className="absolute mt-1 mr-2 right-0">
<CopyButton content={sensitive} />
</div>
<Highlight className="swift">{sensitive}</Highlight>
</div>
</div>
</div>
<div className="font-semibold mb-2 mt-4 flex items-center">
<CircleNumber text="6" />
Track inputs
</div>
<div className="flex ml-10 mt-4">
<div className="w-full">
<div className={cn(stl.snippetWrapper)}>
<div className="absolute mt-1 mr-2 right-0">
<CopyButton content={inputs} />
</div>
<Highlight className="swift">{inputs}</Highlight>
</div>
</div>
</div>
</div>
);
}
export default MobileInstallDocs

View file

@ -0,0 +1,64 @@
import React, { useState } from 'react';
import { Tabs, CopyButton } from 'UI';
import MobileInstallDocs from './InstallDocs/MobileInstallDocs';
import DocCard from 'Shared/DocCard/DocCard';
import { useModal } from 'App/components/Modal';
import UserForm from 'App/components/Client/Users/components/UserForm/UserForm';
const iOS = 'iOS';
const TABS = [
{ key: iOS, text: iOS },
];
interface Props {
site: Record<string, any>;
}
const MobileTrackingCodeModal = (props: Props) => {
const { site } = props;
const [activeTab, setActiveTab] = useState(iOS);
const { showModal } = useModal();
const showUserModal = () => {
showModal(<UserForm />, { right: true });
};
const renderActiveTab = () => {
switch (activeTab) {
case iOS:
return (
<div className="grid grid-cols-6 gap-4">
<div className="col-span-4">
<MobileInstallDocs site={site} />
</div>
<div className="col-span-2">
<DocCard title="Need help from team member?">
<a className="link" onClick={showUserModal}>
Invite and Collaborate
</a>
</DocCard>
<DocCard title="Project Key">
<div className={'p-2 rounded bg-white flex justify-between items-center'}>
{site.projectKey}
<CopyButton content={site.projectKey} />
</div>
</DocCard>
</div>
</div>
);
default:
return null;
}
};
return (
<>
<Tabs tabs={TABS} active={activeTab} onClick={setActiveTab} />
<div className="p-5 py-8">{renderActiveTab()}</div>
</>
);
};
export default MobileTrackingCodeModal;

View file

@ -31,7 +31,7 @@ const TrackingCodeModal = (props: Props) => {
return (
<div className="grid grid-cols-6 gap-4">
<div className="col-span-4">
<ProjectCodeSnippet />
<ProjectCodeSnippet site={site} />
</div>
<div className="col-span-2">
@ -43,7 +43,7 @@ const TrackingCodeModal = (props: Props) => {
<DocCard title="Project Key">
<div className="rounded bg-white px-2 py-1 flex items-center justify-between">
<span>{site.projectKey}</span>
<CopyButton content={''} className="capitalize" />
<CopyButton content={site.projectKey} className="capitalize" />
</div>
</DocCard>
<DocCard title="Other ways to install">
@ -63,7 +63,7 @@ const TrackingCodeModal = (props: Props) => {
return (
<div className="grid grid-cols-6 gap-4">
<div className="col-span-4">
<InstallDocs />
<InstallDocs site={site} />
</div>
<div className="col-span-2">
@ -72,6 +72,13 @@ const TrackingCodeModal = (props: Props) => {
Invite and Collaborate
</a>
</DocCard>
<DocCard title="Project Key">
<div className={'p-2 rounded bg-white flex justify-between items-center'}>
{site.projectKey}
<CopyButton content={site.projectKey} />
</div>
</DocCard>
</div>
</div>
);

View file

@ -9,6 +9,7 @@ import { CountryFlag, IconButton, BackLink } from 'UI';
import { toggleFavorite } from 'Duck/sessions';
import { fetchList as fetchListIntegration } from 'Duck/integrations/actions';
import SharePopup from 'Shared/SharePopup/SharePopup';
import { capitalize } from "App/utils";
import Section from './Header/Section';
import Resolution from './Header/Resolution';
@ -18,10 +19,6 @@ import cls from './header.module.css';
const SESSIONS_ROUTE = sessionsRoute();
function capitalise(str) {
return str[0].toUpperCase() + str.slice(1);
}
function Header({
player,

View file

@ -0,0 +1,159 @@
import React, { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { Modal, Loader } from 'UI';
import { toggleFullscreen, closeBottomBlock } from 'Duck/components/player';
import { fetchList } from 'Duck/integrations';
import { createIOSPlayer } from 'Player';
import { makeAutoObservable } from 'mobx';
import withLocationHandlers from 'HOCs/withLocationHandlers';
import { useStore } from 'App/mstore';
import MobilePlayerHeader from 'Components/Session/Player/MobilePlayer/MobilePlayerHeader';
import ReadNote from '../Session_/Player/Controls/components/ReadNote';
import PlayerContent from './Player/MobilePlayer/PlayerContent';
import { IOSPlayerContext, defaultContextValue, MobilePlayerContext } from './playerContext';
import { observer } from 'mobx-react-lite';
import { Note } from 'App/services/NotesService';
import { useParams } from 'react-router-dom';
import { toast } from 'react-toastify';
import PlayerErrorBoundary from 'Components/Session/Player/PlayerErrorBoundary';
const TABS = {
EVENTS: 'User Events',
};
let playerInst: IOSPlayerContext['player'] | undefined;
function MobilePlayer(props: any) {
const { session, toggleFullscreen, closeBottomBlock, fullscreen, fetchList } = props;
const { notesStore, sessionStore } = useStore();
const [activeTab, setActiveTab] = useState('');
const [noteItem, setNoteItem] = useState<Note | undefined>(undefined);
// @ts-ignore
const [contextValue, setContextValue] = useState<IOSPlayerContext>(defaultContextValue);
const params: { sessionId: string } = useParams();
useEffect(() => {
playerInst = undefined;
if (!session.sessionId || contextValue.player !== undefined) return;
fetchList('issues');
sessionStore.setUserTimezone(session.timezone);
const [IOSPlayerInst, PlayerStore] = createIOSPlayer(
session,
(state) => makeAutoObservable(state),
toast
);
setContextValue({ player: IOSPlayerInst, store: PlayerStore });
playerInst = IOSPlayerInst;
notesStore.fetchSessionNotes(session.sessionId).then((r) => {
const note = props.query.get('note');
if (note) {
setNoteItem(notesStore.getNoteById(parseInt(note, 10), r));
IOSPlayerInst.pause();
}
});
}, [session.sessionId]);
const { messagesProcessed } = contextValue.store?.get() || {};
React.useEffect(() => {
if ((messagesProcessed && session.events.length > 0) || session.errors.length > 0) {
contextValue.player?.updateLists?.(session);
}
}, [session.events, session.errors, contextValue.player, messagesProcessed]);
React.useEffect(() => {
if (noteItem !== undefined) {
contextValue.player.pause();
}
if (activeTab === '' && !noteItem !== undefined && messagesProcessed && contextValue.player) {
const jumpToTime = props.query.get('jumpto');
if (jumpToTime) {
contextValue.player.jump(parseInt(jumpToTime));
}
contextValue.player.play();
}
}, [activeTab, noteItem, messagesProcessed]);
useEffect(
() => () => {
console.debug('cleaning up player after', params.sessionId);
toggleFullscreen(false);
closeBottomBlock();
playerInst?.clean();
// @ts-ignore
setContextValue(defaultContextValue);
},
[params.sessionId]
);
const onNoteClose = () => {
setNoteItem(undefined);
contextValue.player.play();
};
if (!session.sessionId)
return (
<Loader
size={75}
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translateX(-50%)',
height: 75,
}}
/>
);
return (
<MobilePlayerContext.Provider value={contextValue}>
<MobilePlayerHeader
// @ts-ignore TODO?
activeTab={activeTab}
setActiveTab={setActiveTab}
tabs={TABS}
fullscreen={fullscreen}
/>
<PlayerErrorBoundary>
{contextValue.player ? (
<PlayerContent
activeTab={activeTab}
fullscreen={fullscreen}
setActiveTab={setActiveTab}
session={session}
/>
) : (
<Loader
style={{ position: 'fixed', top: '0%', left: '50%', transform: 'translateX(-50%)' }}
/>
)}
<Modal open={noteItem !== undefined} onClose={onNoteClose}>
{noteItem !== undefined ? (
<ReadNote note={noteItem} onClose={onNoteClose} notFound={!noteItem} />
) : null}
</Modal>
</PlayerErrorBoundary>
</MobilePlayerContext.Provider>
);
}
export default connect(
(state: any) => ({
session: state.getIn(['sessions', 'current']),
visitedEvents: state.getIn(['sessions', 'visitedEvents']),
jwt: state.getIn(['user', 'jwt']),
fullscreen: state.getIn(['components', 'player', 'fullscreen']),
showEvents: state.get('showEvents'),
members: state.getIn(['members', 'list']),
}),
{
toggleFullscreen,
closeBottomBlock,
fetchList,
}
)(withLocationHandlers()(observer(MobilePlayer)));

View file

@ -0,0 +1,231 @@
import React from 'react';
import cn from 'classnames';
import { connect } from 'react-redux';
import { PlayButton, PlayingState, FullScreenButton } from 'App/player-ui';
import { Tooltip } from 'UI';
import {
fullscreenOff,
fullscreenOn,
OVERVIEW,
toggleBottomBlock,
changeSkipInterval,
CONSOLE, STACKEVENTS, NETWORK, PERFORMANCE, EXCEPTIONS,
} from 'Duck/components/player';
import { MobilePlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite';
import { fetchSessions } from 'Duck/liveSearch';
import Timeline from 'Components/Session_/Player/Controls/Timeline';
import ControlButton from 'Components/Session_/Player/Controls/ControlButton';
import PlayerControls from 'Components/Session_/Player/Controls/components/PlayerControls';
import styles from 'Components/Session_/Player/Controls/controls.module.css';
import XRayButton from 'Shared/XRayButton';
import CreateNote from 'Components/Session_/Player/Controls/components/CreateNote';
export const SKIP_INTERVALS = {
2: 2e3,
5: 5e3,
10: 1e4,
15: 15e3,
20: 2e4,
30: 3e4,
60: 6e4,
};
function Controls(props: any) {
const { player, store } = React.useContext(MobilePlayerContext);
const { playing, completed, skip, speed, messagesLoading } = store.get();
const { bottomBlock, toggleBottomBlock, fullscreen, changeSkipInterval, skipInterval, session } =
props;
const disabled = messagesLoading;
const sessionTz = session?.timezone;
const onKeyDown = (e: any) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
if (e.key === 'Esc' || e.key === 'Escape') {
props.fullscreenOff();
}
if (e.key === 'ArrowRight') {
forthTenSeconds();
}
if (e.key === 'ArrowLeft') {
backTenSeconds();
}
if (e.key === 'ArrowDown') {
player.speedDown();
}
if (e.key === 'ArrowUp') {
player.speedUp();
}
};
React.useEffect(() => {
document.addEventListener('keydown', onKeyDown.bind(this));
return () => {
document.removeEventListener('keydown', onKeyDown.bind(this));
};
}, []);
const forthTenSeconds = () => {
// @ts-ignore
player.jumpInterval(SKIP_INTERVALS[skipInterval]);
};
const backTenSeconds = () => {
// @ts-ignore
player.jumpInterval(-SKIP_INTERVALS[skipInterval]);
};
const toggleBottomTools = (blockName: number) => {
toggleBottomBlock(blockName);
};
const state = completed
? PlayingState.Completed
: playing
? PlayingState.Playing
: PlayingState.Paused;
return (
<div className={styles.controls}>
<Timeline isMobile />
<CreateNote />
{!fullscreen && (
<div className={cn(styles.buttons, '!px-2')}>
<div className="flex items-center">
<PlayerControls
skip={skip}
sessionTz={sessionTz}
speed={speed}
disabled={disabled}
backTenSeconds={backTenSeconds}
forthTenSeconds={forthTenSeconds}
toggleSpeed={(speedIndex) => player.toggleSpeed(speedIndex)}
toggleSkip={() => player.toggleSkip()}
playButton={<PlayButton state={state} togglePlay={player.togglePlay} iconSize={36} />}
skipIntervals={SKIP_INTERVALS}
setSkipInterval={changeSkipInterval}
currentInterval={skipInterval}
startedAt={session.startedAt}
/>
<div className={cn('mx-2')} />
<XRayButton
isActive={bottomBlock === OVERVIEW}
onClick={() => toggleBottomTools(OVERVIEW)}
/>
</div>
<div className="flex items-center h-full">
<DevtoolsButtons toggleBottomTools={toggleBottomTools} bottomBlock={bottomBlock} />
<Tooltip title="Fullscreen" delay={0} placement="top-start" className="mx-4">
<FullScreenButton
size={16}
onClick={props.fullscreenOn}
customClasses={'rounded hover:bg-gray-light-shade color-gray-medium'}
/>
</Tooltip>
</div>
</div>
)}
</div>
);
}
interface DevtoolsButtonsProps {
toggleBottomTools: (blockName: number) => void;
bottomBlock: number;
}
function DevtoolsButtons({ toggleBottomTools, bottomBlock }: DevtoolsButtonsProps) {
const { store } = React.useContext(MobilePlayerContext);
const { exceptionsList, logMarkedCountNow, messagesLoading, stackMarkedCountNow, resourceMarkedCountNow } = store.get();
const showExceptions = exceptionsList.length > 0;
return (
<>
<ControlButton
disabled={messagesLoading}
onClick={() => toggleBottomTools(CONSOLE)}
active={bottomBlock === CONSOLE}
label="LOGS"
noIcon
labelClassName="!text-base font-semibold"
hasErrors={logMarkedCountNow > 0 || showExceptions}
containerClassName="mx-2"
/>
<ControlButton
disabled={messagesLoading}
onClick={() => toggleBottomTools(NETWORK)}
active={bottomBlock === NETWORK}
label="NETWORK"
hasErrors={resourceMarkedCountNow > 0}
noIcon
labelClassName="!text-base font-semibold"
containerClassName="mx-2"
/>
{showExceptions ?
<ControlButton
disabled={messagesLoading}
onClick={() => toggleBottomTools(EXCEPTIONS)}
active={bottomBlock === EXCEPTIONS}
hasErrors={showExceptions}
label="EXCEPTIONS"
noIcon
labelClassName="!text-base font-semibold"
containerClassName="mx-2"
/>
: null}
<ControlButton
disabled={messagesLoading}
onClick={() => toggleBottomTools(STACKEVENTS)}
active={bottomBlock === STACKEVENTS}
label="EVENTS"
noIcon
labelClassName="!text-base font-semibold"
containerClassName="mx-2"
/>
<ControlButton
disabled={messagesLoading}
onClick={() => toggleBottomTools(PERFORMANCE)}
active={bottomBlock === PERFORMANCE}
label="PERFORMANCE"
noIcon
labelClassName="!text-base font-semibold"
containerClassName="mx-2"
/>
</>
);
}
const ControlPlayer = observer(Controls);
export default connect(
(state: any) => {
const permissions = state.getIn(['user', 'account', 'permissions']) || [];
const isEnterprise = state.getIn(['user', 'account', 'edition']) === 'ee';
return {
disabledRedux: isEnterprise && !permissions.includes('DEV_TOOLS'),
fullscreen: state.getIn(['components', 'player', 'fullscreen']),
bottomBlock: state.getIn(['components', 'player', 'bottomBlock']),
showStorageRedux: !state.getIn(['components', 'player', 'hiddenHints', 'storage']),
showStackRedux: !state.getIn(['components', 'player', 'hiddenHints', 'stack']),
session: state.getIn(['sessions', 'current']),
totalAssistSessions: state.getIn(['liveSearch', 'total']),
skipInterval: state.getIn(['components', 'player', 'skipInterval']),
};
},
{
fullscreenOn,
fullscreenOff,
toggleBottomBlock,
fetchSessions,
changeSkipInterval,
}
)(ControlPlayer);

View file

@ -0,0 +1,118 @@
import {
CONSOLE,
NETWORK,
PERFORMANCE,
STACKEVENTS,
STORAGE,
toggleBottomBlock,
} from 'Duck/components/player';
import React from 'react';
import AutoplayTimer from 'Components/Session_/Player/Overlay/AutoplayTimer';
import PlayIconLayer from 'Components/Session_/Player/Overlay/PlayIconLayer';
import Loader from 'Components/Session_/Player/Overlay/Loader';
import { PlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite';
import { Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import { connect } from 'react-redux';
import { setCreateNoteTooltip } from 'Duck/sessions';
import { Icon } from 'UI';
interface Props {
nextId?: string;
closedLive?: boolean;
isClickmap?: boolean;
toggleBottomBlock: (block: number) => void;
setCreateNoteTooltip: (args: any) => void;
}
enum ItemKey {
Console = '1',
Network = '2',
Performance = '3',
Events = '4',
State = '5',
AddNote = '6',
}
const menuItems: MenuProps['items'] = [
{
key: ItemKey.Console,
label: 'Console',
icon: <Icon name={'terminal'} size={14} />,
},
{
key: ItemKey.Network,
label: 'Network',
icon: <Icon name={'arrow-down-up'} size={14} />,
},
{
key: ItemKey.Performance,
label: 'Performance',
icon: <Icon name={'speedometer2'} size={14} />,
},
{
key: ItemKey.Events,
label: 'Events',
icon: <Icon name={'filetype-js'} size={14} />,
},
{ type: 'divider' },
{
key: ItemKey.AddNote,
label: 'Add Note',
icon: <Icon name={'quotes'} size={14} />,
},
];
function Overlay({ nextId, isClickmap, toggleBottomBlock, setCreateNoteTooltip }: Props) {
const { player, store } = React.useContext(PlayerContext);
const togglePlay = () => player.togglePlay();
const { playing, messagesLoading, completed, autoplay } = store.get();
const loading = messagesLoading
const showAutoplayTimer = completed && autoplay && nextId;
const showPlayIconLayer =
!isClickmap && !loading && !showAutoplayTimer;
const onClick = ({ key }: { key: string }) => {
switch (key) {
case ItemKey.Console:
toggleBottomBlock(CONSOLE);
break;
case ItemKey.Network:
toggleBottomBlock(NETWORK);
break;
case ItemKey.Performance:
toggleBottomBlock(PERFORMANCE);
break;
case ItemKey.Events:
toggleBottomBlock(STACKEVENTS);
break;
case ItemKey.State:
toggleBottomBlock(STORAGE);
break;
case ItemKey.AddNote:
setCreateNoteTooltip({ time: store.get().time, isVisible: true });
break;
default:
return;
}
};
return (
<>
{showAutoplayTimer && <AutoplayTimer />}
{loading ? <Loader /> : null}
<Dropdown menu={{ items: menuItems, onClick }} trigger={['contextMenu']}>
<div>
{showPlayIconLayer && <PlayIconLayer playing={playing} togglePlay={togglePlay} />}
</div>
</Dropdown>
</>
);
}
export default connect(null, {
toggleBottomBlock,
setCreateNoteTooltip,
})(observer(Overlay));

View file

@ -0,0 +1,140 @@
import React from 'react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import {
sessions as sessionsRoute,
liveSession as liveSessionRoute,
withSiteId,
} from 'App/routes';
import { BackLink, Link } from 'UI';
import { toggleFavorite, setSessionPath } from 'Duck/sessions';
import cn from 'classnames';
import SessionMetaList from 'Shared/SessionItem/SessionMetaList';
import UserCard from '../ReplayPlayer/EventsBlock/UserCard';
import Tabs from 'Components/Session/Tabs';
import { PlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite';
import stl from '../ReplayPlayer/playerBlockHeader.module.css';
import { fetchListActive as fetchMetadata } from 'Duck/customField';
import { IFRAME } from 'App/constants/storageKeys';
const SESSIONS_ROUTE = sessionsRoute();
// TODO props
function PlayerBlockHeader(props: any) {
const [hideBack, setHideBack] = React.useState(false);
const { player, store } = React.useContext(PlayerContext);
const playerState = store?.get?.() || { width: 0, height: 0, showEvents: false }
const { width = 0, height = 0, showEvents = false } = playerState
const {
session,
fullscreen,
metaList,
siteId,
setActiveTab,
activeTab,
history,
sessionPath,
fetchMetadata,
} = props;
React.useEffect(() => {
const iframe = localStorage.getItem(IFRAME) || false;
setHideBack(!!iframe && iframe === 'true');
if (metaList.size === 0) fetchMetadata();
}, []);
const backHandler = () => {
if (
sessionPath.pathname === history.location.pathname ||
sessionPath.pathname.includes('/session/')
) {
history.push(withSiteId(SESSIONS_ROUTE, siteId));
} else {
history.push(
sessionPath ? sessionPath.pathname + sessionPath.search : withSiteId(SESSIONS_ROUTE, siteId)
);
}
};
const { metadata } = session;
let _metaList = Object.keys(metadata || {})
.filter((i) => metaList.includes(i))
.map((key) => {
const value = metadata[key];
return { label: key, value };
});
const TABS = [props.tabs.EVENTS].map((tab) => ({
text: tab,
key: tab,
}));
return (
<div className={cn(stl.header, 'flex justify-between', { hidden: fullscreen })}>
<div className="flex w-full items-center">
{!hideBack && (
<div
className="flex items-center h-full cursor-pointer group"
onClick={backHandler}
>
{/* @ts-ignore TODO */}
<BackLink label="Back" className="h-full ml-2" />
<div className={stl.divider} />
</div>
)}
<UserCard className="" width={width} height={height} />
<div className={cn('ml-auto flex items-center h-full')}>
{_metaList.length > 0 && (
<div className="border-l h-full flex items-center px-2">
<SessionMetaList className="" metaList={_metaList} maxLength={2} />
</div>
)}
</div>
</div>
<div className="relative border-l" style={{ minWidth: '270px' }}>
<Tabs
tabs={TABS}
active={activeTab}
onClick={(tab) => {
if (activeTab === tab) {
setActiveTab('');
player.toggleEvents();
} else {
setActiveTab(tab);
!showEvents && player.toggleEvents();
}
}}
border={false}
/>
</div>
</div>
);
}
const PlayerHeaderCont = connect(
(state: any) => {
const session = state.getIn(['sessions', 'current']);
return {
session,
sessionPath: state.getIn(['sessions', 'sessionPath']),
local: state.getIn(['sessions', 'timezone']),
funnelRef: state.getIn(['funnels', 'navRef']),
siteId: state.getIn(['site', 'siteId']),
metaList: state.getIn(['customFields', 'list']).map((i: any) => i.key),
};
},
{
toggleFavorite,
setSessionPath,
fetchMetadata,
}
)(observer(PlayerBlockHeader));
export default withRouter(PlayerHeaderCont);

View file

@ -0,0 +1,81 @@
import React, { useMemo } from 'react';
import { Button } from 'UI';
import QueueControls from 'Components/Session_/QueueControls';
import Bookmark from 'Shared/Bookmark';
import SharePopup from 'Components/shared/SharePopup/SharePopup';
import Issues from 'Components/Session_/Issues/Issues';
import NotePopup from 'Components/Session_/components/NotePopup';
import ItemMenu from 'Components/Session_/components/HeaderMenu';
import { observer } from 'mobx-react-lite';
import AutoplayToggle from 'Shared/AutoplayToggle';
import { connect } from 'react-redux';
import { Tag } from 'antd'
function SubHeader(props: any) {
const enabledIntegration = useMemo(() => {
const { integrations } = props;
if (!integrations || !integrations.size) {
return false;
}
return integrations.some((i: Record<string, any>) => i.token);
}, [props.integrations]);
const viewportWidth = window.innerWidth;
const baseMenuItems = [
{
key: 1,
component: <AutoplayToggle />,
},
{
key: 2,
component: <Bookmark noMargin sessionId={props.sessionId} />,
},
]
const menuItems = viewportWidth > 1400 ? baseMenuItems : baseMenuItems.concat({
key: 3,
component: <NotePopup />,
})
return (
<>
<div className="w-full px-4 flex items-center border-b relative">
<Tag color="error">iOS BETA</Tag>
<div
className="ml-auto text-sm flex items-center color-gray-medium gap-2"
style={{ width: 'max-content' }}
>
{viewportWidth > 1400 ? <NotePopup /> : null}
{enabledIntegration && <Issues sessionId={props.sessionId} />}
<SharePopup
entity="sessions"
id={props.sessionId}
showCopyLink={true}
trigger={
<div className="relative">
<Button icon="share-alt" variant="text" className="relative">
Share
</Button>
</div>
}
/>
<ItemMenu
items={menuItems}
/>
<div>
{/* @ts-ignore */}
<QueueControls />
</div>
</div>
</div>
</>
);
}
export default connect((state: any) => ({
siteId: state.getIn(['site', 'siteId']),
integrations: state.getIn(['issues', 'list']),
modules: state.getIn(['user', 'account', 'modules']) || [],
}))(observer(SubHeader));

View file

@ -0,0 +1,110 @@
import {IosPerformanceEvent} from "Player/web/messages";
import React from 'react';
import { MobilePlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite';
import { Icon } from 'UI';
import { mapIphoneModel } from 'Player/mobile/utils';
import cn from 'classnames';
import { connect } from 'react-redux';
import { NONE } from 'Duck/components/player';
type warningsType =
| 'thermalState'
| 'memoryWarning'
| 'lowDiskSpace'
| 'isLowPowerModeEnabled'
| 'batteryLevel';
const elements = {
thermalState: {
title: 'Overheating',
icon: 'thermometer-sun',
},
memoryWarning: {
title: 'High Memory Usage',
icon: 'memory-ios',
},
lowDiskSpace: {
title: 'Low Disk Space',
icon: 'low-disc-space',
},
isLowPowerModeEnabled: {
title: 'Low Power Mode',
icon: 'battery-charging',
},
batteryLevel: {
title: 'Low Battery',
icon: 'battery',
},
} as const;
function PerfWarnings({ userDevice, bottomBlock }: { userDevice: string; bottomBlock: number }) {
const { store } = React.useContext(MobilePlayerContext);
const { scale, performanceListNow, performanceList } = store.get()
const allElements = Object.keys(elements) as warningsType[];
const list = React.useMemo(() => allElements
.filter(el => performanceList.findIndex((pw: IosPerformanceEvent & { techName: warningsType }) => pw.techName === el) !== -1)
, [performanceList.length])
const contStyles = {
left: '50%',
display: 'flex',
marginLeft: `${(mapIphoneModel(userDevice).styles.shell.width / 2 + 10) * scale}px`,
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'center',
gap: '6px',
position: 'absolute',
width: '200px',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 0,
} as const;
const activeWarnings = React.useMemo(() => {
const warnings: warningsType[] = []
performanceListNow.forEach((warn: IosPerformanceEvent & { techName: warningsType }) => {
switch (warn.techName) {
case 'thermalState':
if (warn.value > 1) warnings.push(warn.techName) // 2 = serious 3 = overheating
break;
case 'memoryWarning':
warnings.push(warn.techName)
break;
case 'lowDiskSpace':
warnings.push(warn.techName)
break;
case 'isLowPowerModeEnabled':
if (warn.value === 1) warnings.push(warn.techName)
break;
case 'batteryLevel':
if (warn.value < 25) warnings.push(warn.techName)
break;
}
})
return warnings
}, [performanceListNow.length]);
if (bottomBlock !== NONE) return null;
return (
<div style={contStyles}>
{list.map((w) => (
<div
className={cn(
'transition-all flex items-center gap-1 bg-white border rounded px-2 py-1',
activeWarnings.findIndex((a) => a === w) !== -1 ? 'opacity-100' : 'opacity-0'
)}
>
<Icon name={elements[w].icon} size={16} />
<span>{elements[w].title}</span>
</div>
))}
</div>
);
}
export default connect((state: any) => ({
bottomBlock: state.getIn(['components', 'player', 'bottomBlock']),
}))(observer(PerfWarnings));

View file

@ -0,0 +1,49 @@
import React from 'react';
import cn from 'classnames';
import { connect } from 'react-redux';
import Player from './PlayerInst';
import MobilePlayerSubheader from './MobilePlayerSubheader';
import styles from 'Components/Session_/playerBlock.module.css';
interface IProps {
fullscreen: boolean;
sessionId: string;
disabled: boolean;
activeTab: string;
jiraConfig: Record<string, any>
fullView?: boolean
}
function PlayerBlock(props: IProps) {
const {
fullscreen,
sessionId,
disabled,
activeTab,
jiraConfig,
fullView = false,
} = props;
const shouldShowSubHeader = !fullscreen && !fullView
return (
<div
className={cn(styles.playerBlock, 'flex flex-col', 'overflow-x-hidden')}
>
{shouldShowSubHeader ? (
<MobilePlayerSubheader sessionId={sessionId} disabled={disabled} jiraConfig={jiraConfig} />
) : null}
<Player
activeTab={activeTab}
fullView={fullView}
/>
</div>
);
}
export default connect((state: any) => ({
fullscreen: state.getIn(['components', 'player', 'fullscreen']),
sessionId: state.getIn(['sessions', 'current']).sessionId,
disabled: state.getIn(['components', 'targetDefiner', 'inspectorMode']),
jiraConfig: state.getIn(['issues', 'list'])[0],
}))(PlayerBlock)

View file

@ -0,0 +1,84 @@
import React from 'react';
import { observer } from 'mobx-react-lite';
import cn from 'classnames';
import styles from 'Components/Session_/session.module.css';
import { countDaysFrom } from 'App/date';
import RightBlock from 'Components/Session/RightBlock';
import { PlayerContext } from 'Components/Session/playerContext';
import Session from 'Types/session'
import PlayerBlock from './PlayerBlock';
const TABS = {
EVENTS: 'User Events',
HEATMAPS: 'Click Map',
};
interface IProps {
fullscreen: boolean;
activeTab: string;
setActiveTab: (tab: string) => void;
session: Session
}
function PlayerContent({ session, fullscreen, activeTab, setActiveTab }: IProps) {
const { store } = React.useContext(PlayerContext)
const {
error,
} = store.get()
const hasError = !!error
const sessionDays = countDaysFrom(session.startedAt);
return (
<div className="relative">
{hasError ? (
<div
className="inset-0 flex items-center justify-center absolute"
style={{
height: 'calc(100vh - 50px)',
zIndex: '999',
}}
>
<div className="flex flex-col items-center">
<div className="text-lg -mt-8">
{sessionDays > 2 ? 'Session not found.' : 'This session is still being processed.'}
</div>
<div className="text-sm">
{sessionDays > 2
? 'Please check your data retention policy.'
: 'Please check it again in a few minutes.'}
</div>
</div>
</div>
) : (
<div className={cn('flex', { 'pointer-events-none': hasError })}>
<div
className="w-full"
style={activeTab && !fullscreen ? { maxWidth: 'calc(100% - 270px)' } : undefined}
>
<div className={cn(styles.session, 'relative')} data-fullscreen={fullscreen}>
<PlayerBlock activeTab={activeTab} />
</div>
</div>
{activeTab !== '' && (
<RightMenu
activeTab={activeTab}
setActiveTab={setActiveTab}
fullscreen={fullscreen}
tabs={TABS}
/>
)}
</div>
)}
</div>
);
}
function RightMenu({ tabs, activeTab, setActiveTab, fullscreen }: any) {
return (
!fullscreen ? <RightBlock tabs={tabs} setActiveTab={setActiveTab} activeTab={activeTab} /> : null
);
}
export default observer(PlayerContent);

View file

@ -0,0 +1,128 @@
import React from 'react';
import { connect } from 'react-redux';
import { findDOMNode } from 'react-dom';
import cn from 'classnames';
import { EscapeButton } from 'UI';
import {
NONE,
CONSOLE,
NETWORK,
STACKEVENTS,
PERFORMANCE,
EXCEPTIONS,
OVERVIEW,
fullscreenOff,
} from 'Duck/components/player';
import { MobileNetworkPanel } from 'Shared/DevTools/NetworkPanel';
import { MobilePerformance } from 'Components/Session_/Performance';
import { MobileExceptions } from 'Components/Session_/Exceptions/Exceptions';
import MobileControls from './MobileControls';
import Overlay from './MobileOverlay'
import stl from 'Components/Session_/Player/player.module.css';
import { updateLastPlayedSession } from 'Duck/sessions';
import { MobileOverviewPanel } from 'Components/Session_/OverviewPanel';
import MobileConsolePanel from 'Shared/DevTools/ConsolePanel/MobileConsolePanel';
import { MobilePlayerContext } from 'App/components/Session/playerContext';
import { MobileStackEventPanel } from 'Shared/DevTools/StackEventPanel';
import ReplayWindow from "Components/Session/Player/MobilePlayer/ReplayWindow";
import PerfWarnings from "Components/Session/Player/MobilePlayer/PerfWarnings";
interface IProps {
fullView: boolean;
isMultiview?: boolean;
bottomBlock: number;
fullscreen: boolean;
fullscreenOff: () => any;
nextId: string;
sessionId: string;
activeTab: string;
updateLastPlayedSession: (id: string) => void
videoURL: string;
userDevice: string;
}
function Player(props: IProps) {
const {
fullscreen,
fullscreenOff,
nextId,
bottomBlock,
activeTab,
fullView,
videoURL,
userDevice,
} = props;
const playerContext = React.useContext(MobilePlayerContext);
const isReady = playerContext.store.get().ready
const screenWrapper = React.useRef<HTMLDivElement>(null);
const bottomBlockIsActive = !fullscreen && bottomBlock !== NONE;
const [isAttached, setAttached] = React.useState(false);
React.useEffect(() => {
props.updateLastPlayedSession(props.sessionId);
const parentElement = findDOMNode(screenWrapper.current) as HTMLDivElement | null; //TODO: good architecture
if (parentElement && !isAttached) {
playerContext.player.attach(parentElement);
setAttached(true)
}
}, [isReady]);
React.useEffect(() => {
playerContext.player.scale();
}, [props.bottomBlock, props.fullscreen, playerContext.player, activeTab, fullView]);
React.useEffect(() => {
playerContext.player.addFullscreenBoundary(props.fullscreen || fullView);
}, [props.fullscreen, fullView])
if (!playerContext.player) return null;
const maxWidth = activeTab ? 'calc(100vw - 270px)' : '100vw';
return (
<div
className={cn(stl.playerBody, 'flex-1 flex flex-col relative', fullscreen && 'pb-2')}
data-bottom-block={bottomBlockIsActive}
>
{fullscreen && <EscapeButton onClose={fullscreenOff} />}
<div className={"relative flex-1"}>
<Overlay nextId={nextId} />
<div className={cn(stl.mobileScreenWrapper)} ref={screenWrapper}>
<ReplayWindow videoURL={videoURL} userDevice={userDevice} />
<PerfWarnings userDevice={userDevice} />
</div>
</div>
{!fullscreen && !!bottomBlock && (
<div style={{ maxWidth, width: '100%' }}>
{bottomBlock === OVERVIEW && <MobileOverviewPanel />}
{bottomBlock === CONSOLE && <MobileConsolePanel isLive={false} />}
{bottomBlock === STACKEVENTS && <MobileStackEventPanel />}
{bottomBlock === NETWORK && <MobileNetworkPanel />}
{bottomBlock === PERFORMANCE && <MobilePerformance />}
{bottomBlock === EXCEPTIONS && <MobileExceptions />}
</div>
)}
{!fullView ? (
<MobileControls
speedDown={playerContext.player.speedDown}
speedUp={playerContext.player.speedUp}
jump={playerContext.player.jump}
/>
) : null}
</div>
);
}
export default connect(
(state: any) => ({
fullscreen: state.getIn(['components', 'player', 'fullscreen']),
nextId: state.getIn(['sessions', 'nextId']),
sessionId: state.getIn(['sessions', 'current']).sessionId,
userDevice: state.getIn(['sessions', 'current']).userDevice,
videoURL: state.getIn(['sessions', 'current']).videoURL,
bottomBlock: state.getIn(['components', 'player', 'bottomBlock']),
}),
{
fullscreenOff,
updateLastPlayedSession,
}
)(Player);

View file

@ -0,0 +1,122 @@
import React from 'react'
import { MobilePlayerContext, IOSPlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite';
import { mapIphoneModel } from "Player/mobile/utils";
interface Props {
videoURL: string;
userDevice: string;
}
const appleIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" fill="white" viewBox="0 0 16 16">
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.386 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z"/>
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.386 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z"/>
</svg>`
function ReplayWindow({ videoURL, userDevice }: Props) {
const playerContext = React.useContext<IOSPlayerContext>(MobilePlayerContext);
const videoRef = React.useRef<HTMLVideoElement>();
const time = playerContext.store.get().time
React.useEffect(() => {
if (videoRef.current) {
const timeSecs = time / 1000
if (videoRef.current.duration >= timeSecs) {
videoRef.current.currentTime = timeSecs
}
}
}, [time])
React.useEffect(() => {
if (playerContext.player.screen.document && videoURL) {
playerContext.player.pause()
const { svg, styles } = mapIphoneModel(userDevice)
const host = document.createElement('div')
const videoEl = document.createElement('video')
const sourceEl = document.createElement('source')
const shell = document.createElement('div')
const icon = document.createElement('div')
const videoContainer = document.createElement('div')
videoContainer.style.borderRadius = '10px'
videoContainer.style.overflow = 'hidden'
videoContainer.style.margin = styles.margin
videoContainer.style.display = 'none'
videoContainer.style.width = styles.screen.width + 'px'
videoContainer.style.height = styles.screen.height + 'px'
videoContainer.appendChild(videoEl)
shell.innerHTML = svg
videoEl.width = styles.screen.width
videoEl.height = styles.screen.height
videoEl.style.backgroundColor = '#333'
Object.assign(icon.style, {
backgroundColor: '#333',
borderRadius: '10px',
width: styles.screen.width + 'px',
height: styles.screen.height + 'px',
margin: styles.margin,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
})
const spacer = document.createElement('div')
spacer.style.width = '60px'
spacer.style.height = '60px'
const loadingBar = document.createElement('div')
Object.assign(loadingBar.style, {
width: styles.screen.width/2 + 'px',
height: '6px',
borderRadius: '3px',
backgroundColor: 'white',
})
icon.innerHTML = appleIcon
icon.appendChild(spacer)
icon.appendChild(loadingBar)
shell.style.position = 'absolute'
shell.style.top = '0'
sourceEl.setAttribute('src', videoURL)
sourceEl.setAttribute('type', 'video/mp4')
host.appendChild(videoContainer)
host.appendChild(shell)
host.appendChild(icon)
videoEl.appendChild(sourceEl)
videoEl.addEventListener("loadeddata", () => {
videoContainer.style.display = 'block'
icon.style.display = 'none'
host.removeChild(icon)
console.log('loaded')
playerContext.player.play()
})
videoRef.current = videoEl
playerContext.player.injectPlayer(host)
playerContext.player.customScale(styles.shell.width, styles.shell.height)
playerContext.player.updateDimensions({
width: styles.screen.width,
height: styles.screen.height,
})
playerContext.player.updateOverlayStyle({
margin: styles.margin,
width: styles.screen.width + 'px',
height: styles.screen.height + 'px',
})
}
}, [videoURL, playerContext.player.screen.document])
return (
<div />
)
}
export default observer(ReplayWindow);

View file

@ -0,0 +1,40 @@
import React, { ErrorInfo } from 'react'
import { Button } from 'UI'
class PlayerErrorBoundary extends React.Component<any> {
state = { hasError: false, error: '' };
constructor(props: any) {
super(props);
}
componentDidCatch(error: Error, info: ErrorInfo) {
this.setState({
hasError: true,
error: error + info.componentStack
})
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return (
<div className={"flex flex-col p-4 gap-4"}>
<h4>Something went wrong during player rendering.</h4>
<p>{this.state.error}</p>
<Button
onClick={() => window.location.reload()}
icon={"spinner"}
variant={"primary"}
style={{ width: 'fit-content' }}
>
Reload
</Button>
</div>
);
}
return this.props.children;
}
}
export default PlayerErrorBoundary

View file

@ -12,6 +12,7 @@ import SessionInfoItem from 'Components/Session_/SessionInfoItem';
import { useModal } from 'App/components/Modal';
import UserSessionsModal from 'Shared/UserSessionsModal';
import { IFRAME } from 'App/constants/storageKeys';
import { capitalize } from "App/utils";
function UserCard({ className, request, session, width, height, similarSessions, loading }) {
const { settingsStore } = useStore();
@ -48,70 +49,81 @@ function UserCard({ className, request, session, width, height, similarSessions,
};
const avatarbgSize = '38px';
const safeOs = userOs === 'IOS' ? 'iOS' : userOs;
return (
<div className={cn('bg-white flex items-center w-full', className)}>
<div className="flex items-center">
<Avatar iconSize="23" width={avatarbgSize} height={avatarbgSize} seed={userNumericHash} />
<div className="ml-3 overflow-hidden leading-tight">
<TextEllipsis
noHint
className={cn('font-medium', { 'color-teal cursor-pointer': hasUserDetails })}
// onClick={hasUserDetails ? showSimilarSessions : undefined}
>
<UserName name={userDisplayName} userId={userId} hash={userNumericHash} />
</TextEllipsis>
<div className={cn('bg-white flex items-center w-full', className)}>
<div className="flex items-center">
<Avatar iconSize="23" width={avatarbgSize} height={avatarbgSize} seed={userNumericHash} />
<div className="ml-3 overflow-hidden leading-tight">
<TextEllipsis
noHint
className={cn('font-medium', { 'color-teal cursor-pointer': hasUserDetails })}
// onClick={hasUserDetails ? showSimilarSessions : undefined}
>
<UserName name={userDisplayName} userId={userId} hash={userNumericHash} />
</TextEllipsis>
<div className="text-sm color-gray-medium flex items-center">
<div className="text-sm color-gray-medium flex items-center">
<span style={{ whiteSpace: 'nowrap' }}>
<Tooltip
title={`${formatTimeOrDate(startedAt, timezone, true)} ${timezone.label}`}
className="w-fit !block"
>
{formatTimeOrDate(startedAt, timezone)}
</Tooltip>
</span>
<span className="mx-1 font-bold text-xl">&#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>
<span className="mx-1 font-bold text-xl">&#183;</span>
<Popover
render={() => (
<div className="text-left bg-white rounded">
<SessionInfoItem
comp={<CountryFlag country={userCountry} height={11} />}
label={countries[userCountry]}
value={
<span style={{ whiteSpace: 'nowrap' }}>
<Tooltip
title={`${formatTimeOrDate(startedAt, timezone, true)} ${timezone.label}`}
className="w-fit !block"
>
{formatTimeOrDate(startedAt, timezone)}
</Tooltip>
{
<>
{userCity && <span className="mr-1">{userCity},</span>}
{userState && <span className="mr-1">{userState}</span>}
</>
}
</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 className="capitalize">
{userBrowser}, {userOs}, {userDevice}
</span>
<span className="mx-1 font-bold text-xl">&#183;</span>
<Popover
render={() => (
<div className="text-left bg-white rounded">
<SessionInfoItem
comp={<CountryFlag country={userCountry} height={11} />}
label={countries[userCountry]}
value={<span style={{ whiteSpace: 'nowrap' }}>{
<>
{userCity && <span className="mr-1">{userCity},</span>}
{userState && <span className="mr-1">{userState}</span>}
</>
}</span>}
/>
<SessionInfoItem icon={browserIcon(userBrowser)} label={userBrowser} value={`v${userBrowserVersion}`} />
<SessionInfoItem icon={osIcon(userOs)} label={userOs} value={userOsVersion} />
<SessionInfoItem
icon={deviceTypeIcon(userDeviceType)}
label={userDeviceType}
value={getDimension(width, height)}
isLast={!revId}
/>
{revId && <SessionInfoItem icon="info" label="Rev ID:" value={revId} isLast />}
</div>
)}
>
<span className="link">More</span>
</Popover>
</div>
</div>
}
/>
{userBrowser &&
<SessionInfoItem
icon={browserIcon(userBrowser)}
label={userBrowser}
value={`v${userBrowserVersion}`}
/>
}
<SessionInfoItem icon={osIcon(userOs)} label={safeOs} value={userOsVersion} />
<SessionInfoItem
icon={deviceTypeIcon(userDeviceType)}
label={userDeviceType}
value={getDimension(width, height)}
isLast={!revId}
/>
{revId && <SessionInfoItem icon="info" label="Rev ID:" value={revId} isLast />}
</div>
)}
>
<span className="link">More</span>
</Popover>
</div>
</div>
</div>
</div>
);
}

View file

@ -2,6 +2,7 @@ import React from 'react';
import { connect } from 'react-redux';
import { findDOMNode } from 'react-dom';
import cn from 'classnames';
import { WebStackEventPanel } from 'Shared/DevTools/StackEventPanel/StackEventPanel';
import { EscapeButton } from 'UI';
import {
NONE,
@ -17,22 +18,20 @@ import {
OVERVIEW,
fullscreenOff,
} from 'Duck/components/player';
import NetworkPanel from 'Shared/DevTools/NetworkPanel';
import { WebNetworkPanel } from 'Shared/DevTools/NetworkPanel';
import Storage from 'Components/Session_/Storage';
import { ConnectedPerformance } from 'Components/Session_/Performance';
import GraphQL from 'Components/Session_/GraphQL';
import Exceptions from 'Components/Session_/Exceptions/Exceptions';
import { Exceptions } from 'Components/Session_/Exceptions/Exceptions';
import Inspector from 'Components/Session_/Inspector';
import Controls from 'Components/Session_/Player/Controls';
import Overlay from 'Components/Session_/Player/Overlay';
import stl from 'Components/Session_/Player/player.module.css';
import { updateLastPlayedSession } from 'Duck/sessions';
import OverviewPanel from 'Components/Session_/OverviewPanel';
import { OverviewPanel } from 'Components/Session_/OverviewPanel';
import ConsolePanel from 'Shared/DevTools/ConsolePanel';
import ProfilerPanel from 'Shared/DevTools/ProfilerPanel';
import { PlayerContext } from 'App/components/Session/playerContext';
import StackEventPanel from 'Shared/DevTools/StackEventPanel';
interface IProps {
fullView: boolean;
@ -43,20 +42,13 @@ interface IProps {
nextId: string;
sessionId: string;
activeTab: string;
updateLastPlayedSession: (id: string) => void
updateLastPlayedSession: (id: string) => void;
}
function Player(props: IProps) {
const {
fullscreen,
fullscreenOff,
nextId,
bottomBlock,
activeTab,
fullView,
} = props;
const { fullscreen, fullscreenOff, nextId, bottomBlock, activeTab, fullView } = props;
const playerContext = React.useContext(PlayerContext);
const isReady = playerContext.store.get().ready
const isReady = playerContext.store.get().ready;
const screenWrapper = React.useRef<HTMLDivElement>(null);
const bottomBlockIsActive = !fullscreen && bottomBlock !== NONE;
const [isAttached, setAttached] = React.useState(false);
@ -66,7 +58,7 @@ function Player(props: IProps) {
const parentElement = findDOMNode(screenWrapper.current) as HTMLDivElement | null; //TODO: good architecture
if (parentElement && !isAttached) {
playerContext.player.attach(parentElement);
setAttached(true)
setAttached(true);
}
}, [isReady]);
@ -83,7 +75,7 @@ function Player(props: IProps) {
data-bottom-block={bottomBlockIsActive}
>
{fullscreen && <EscapeButton onClose={fullscreenOff} />}
<div className={cn("relative flex-1",'overflow-hidden')}>
<div className={cn('relative flex-1', 'overflow-hidden')}>
<Overlay nextId={nextId} />
<div className={cn(stl.screenWrapper)} ref={screenWrapper} />
</div>
@ -91,8 +83,8 @@ function Player(props: IProps) {
<div style={{ maxWidth, width: '100%' }}>
{bottomBlock === OVERVIEW && <OverviewPanel />}
{bottomBlock === CONSOLE && <ConsolePanel />}
{bottomBlock === NETWORK && <NetworkPanel />}
{bottomBlock === STACKEVENTS && <StackEventPanel />}
{bottomBlock === NETWORK && <WebNetworkPanel />}
{bottomBlock === STACKEVENTS && <WebStackEventPanel />}
{bottomBlock === STORAGE && <Storage />}
{bottomBlock === PROFILER && <ProfilerPanel />}
{bottomBlock === PERFORMANCE && <ConnectedPerformance />}

View file

@ -11,15 +11,27 @@ import WebPlayer from './WebPlayer';
import { useStore } from 'App/mstore';
import { clearLogs } from 'App/dev/console';
import MobilePlayer from "Components/Session/MobilePlayer";
const SESSIONS_ROUTE = sessionsRoute();
interface Props {
sessionId: string;
loading: boolean;
hasErrors: boolean;
fetchV2: (sessionId: string) => void;
clearCurrentSession: () => void;
session: Record<string, any>;
}
function Session({
sessionId,
loading,
hasErrors,
fetchV2,
clearCurrentSession,
}) {
session,
}: Props) {
usePageTitle("OpenReplay Session Player");
const [ initializing, setInitializing ] = useState(true)
const { sessionStore } = useStore();
@ -40,6 +52,7 @@ function Session({
sessionStore.resetUserFilter();
} ,[])
const player = session.platform === 'ios' ? <MobilePlayer /> : <WebPlayer />
return (
<NoContent
show={ hasErrors }
@ -52,13 +65,13 @@ function Session({
}
>
<Loader className="flex-1" loading={ loading || initializing }>
<WebPlayer />
{player}
</Loader>
</NoContent>
);
}
export default withPermissions(['SESSION_REPLAY'], '', true)(connect((state, props) => {
export default withPermissions(['SESSION_REPLAY'], '', true)(connect((state: any, props: any) => {
const { match: { params: { sessionId } } } = props;
return {
sessionId,

View file

@ -12,9 +12,9 @@ import ReadNote from '../Session_/Player/Controls/components/ReadNote';
import PlayerContent from './Player/ReplayPlayer/PlayerContent';
import { IPlayerContext, PlayerContext, defaultContextValue } from './playerContext';
import { observer } from 'mobx-react-lite';
import { Note } from "App/services/NotesService";
import { useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { Note } from 'App/services/NotesService';
import { useParams } from 'react-router-dom';
import { toast } from 'react-toastify';
const TABS = {
EVENTS: 'User Events',
@ -24,30 +24,24 @@ const TABS = {
let playerInst: IPlayerContext['player'] | undefined;
function WebPlayer(props: any) {
const {
session,
toggleFullscreen,
closeBottomBlock,
fullscreen,
fetchList,
} = props;
const { session, toggleFullscreen, closeBottomBlock, fullscreen, fetchList } = props;
const { notesStore, sessionStore } = useStore();
const [activeTab, setActiveTab] = useState('');
const [noteItem, setNoteItem] = useState<Note | undefined>(undefined);
const [visuallyAdjusted, setAdjusted] = useState(false);
// @ts-ignore
const [contextValue, setContextValue] = useState<IPlayerContext>(defaultContextValue);
const params: { sessionId: string } = useParams()
const params: { sessionId: string } = useParams();
useEffect(() => {
playerInst = undefined
playerInst = undefined;
if (!session.sessionId || contextValue.player !== undefined) return;
fetchList('issues');
sessionStore.setUserTimezone(session.timezone)
sessionStore.setUserTimezone(session.timezone);
const [WebPlayerInst, PlayerStore] = createWebPlayer(
session,
(state) => makeAutoObservable(state),
toast,
toast
);
setContextValue({ player: WebPlayerInst, store: PlayerStore });
playerInst = WebPlayerInst;
@ -58,55 +52,54 @@ function WebPlayer(props: any) {
setNoteItem(notesStore.getNoteById(parseInt(note, 10), r));
WebPlayerInst.pause();
}
})
});
const freeze = props.query.get('freeze')
const freeze = props.query.get('freeze');
if (freeze) {
void WebPlayerInst.freeze()
void WebPlayerInst.freeze();
}
}, [session.sessionId]);
const { firstVisualEvent: visualOffset, messagesProcessed } = contextValue.store?.get() || {};
React.useEffect(() => {
if (messagesProcessed && session.events.length > 0 || session.errors.length > 0) {
contextValue.player?.updateLists?.(session)
if ((messagesProcessed && session.events.length > 0) || session.errors.length > 0) {
contextValue.player?.updateLists?.(session);
}
}, [session.events, session.errors, contextValue.player, messagesProcessed])
}, [session.events, session.errors, contextValue.player, messagesProcessed]);
React.useEffect(() => {
if (noteItem !== undefined) {
contextValue.player.pause()
contextValue.player.pause();
}
if (activeTab === '' && !noteItem !== undefined && messagesProcessed && contextValue.player) {
const jumpToTime = props.query.get('jumpto');
const shouldAdjustOffset = visualOffset !== 0 && !visuallyAdjusted
const shouldAdjustOffset = visualOffset !== 0 && !visuallyAdjusted;
if (jumpToTime || shouldAdjustOffset) {
if (jumpToTime > visualOffset) {
contextValue.player.jump(parseInt(jumpToTime));
} else {
contextValue.player.jump(visualOffset)
setAdjusted(true)
contextValue.player.jump(visualOffset);
setAdjusted(true);
}
}
contextValue.player.play()
contextValue.player.play();
}
}, [activeTab, noteItem, visualOffset, messagesProcessed])
}, [activeTab, noteItem, visualOffset, messagesProcessed]);
React.useEffect(() => {
if (activeTab === 'Click Map') {
contextValue.player?.pause()
contextValue.player?.pause();
}
}, [activeTab])
}, [activeTab]);
// LAYOUT (TODO: local layout state - useContext or something..)
useEffect(
() => () => {
console.debug('cleaning up player after', params.sessionId)
console.debug('cleaning up player after', params.sessionId);
toggleFullscreen(false);
closeBottomBlock();
playerInst?.clean();
@ -117,11 +110,23 @@ function WebPlayer(props: any) {
);
const onNoteClose = () => {
setNoteItem(undefined)
setNoteItem(undefined);
contextValue.player.play();
};
if (!session.sessionId) return <Loader size={75} style={{ position: 'fixed', top: '50%', left: '50%', transform: 'translateX(-50%)', height: 75 }} />;
if (!session.sessionId)
return (
<Loader
size={75}
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translateX(-50%)',
height: 75,
}}
/>
);
return (
<PlayerContext.Provider value={contextValue}>
@ -133,19 +138,21 @@ function WebPlayer(props: any) {
fullscreen={fullscreen}
/>
{/* @ts-ignore */}
{contextValue.player ? <PlayerContent
activeTab={activeTab}
fullscreen={fullscreen}
setActiveTab={setActiveTab}
session={session}
/> : <Loader style={{ position: 'fixed', top: '0%', left: '50%', transform: 'translateX(-50%)' }} />}
{contextValue.player ? (
<PlayerContent
activeTab={activeTab}
fullscreen={fullscreen}
setActiveTab={setActiveTab}
session={session}
/>
) : (
<Loader
style={{ position: 'fixed', top: '0%', left: '50%', transform: 'translateX(-50%)' }}
/>
)}
<Modal open={noteItem !== undefined} onClose={onNoteClose}>
{noteItem !== undefined ? (
<ReadNote
note={noteItem}
onClose={onNoteClose}
notFound={!noteItem}
/>
<ReadNote note={noteItem} onClose={onNoteClose} notFound={!noteItem} />
) : null}
</Modal>
</PlayerContext.Provider>

View file

@ -1,11 +1,18 @@
import { createContext } from 'react';
import { createContext, Context } from 'react';
import {
IWebPlayer,
IIosPlayer,
IIOSPlayerStore,
IWebPlayerStore,
IWebLivePlayer,
IWebLivePlayerStore,
} from 'Player'
export interface IOSPlayerContext {
player: IIosPlayer
store: IIOSPlayerStore
}
export interface IPlayerContext {
player: IWebPlayer
store: IWebPlayerStore,
@ -16,9 +23,15 @@ export interface ILivePlayerContext {
store: IWebLivePlayerStore
}
type ContextType =
type WebContextType =
| IPlayerContext
| ILivePlayerContext
export const defaultContextValue = { player: undefined, store: undefined}
// @ts-ignore
export const PlayerContext = createContext<ContextType>(defaultContextValue);
type MobileContextType = IOSPlayerContext
export const defaultContextValue = { player: undefined, store: undefined }
const ContextProvider = createContext<Partial<WebContextType | MobileContextType>>(defaultContextValue);
export const PlayerContext = ContextProvider as Context<WebContextType>
export const MobilePlayerContext = ContextProvider as Context<MobileContextType>

View file

@ -23,7 +23,7 @@ type Props = {
};
const isFrustrationEvent = (evt: any): boolean => {
if (evt.type === 'mouse_thrashing' || evt.type === TYPES.CLICKRAGE) {
if (evt.type === 'mouse_thrashing' || evt.type === TYPES.CLICKRAGE || evt.type === TYPES.TAPRAGE) {
return true;
}
if (evt.type === TYPES.CLICK || evt.type === TYPES.INPUT) {
@ -73,12 +73,22 @@ const Event: React.FC<Props> = ({
case TYPES.LOCATION:
title = 'Visited';
body = event.url;
icon = 'location';
icon = 'event/location';
break;
case TYPES.SWIPE:
title = 'Swipe';
body = event.direction;
icon = `chevron-${event.direction}`
break;
case TYPES.TOUCH:
title = 'Tapped';
body = event.label;
icon = 'event/click';
break;
case TYPES.CLICK:
title = 'Clicked';
body = event.label;
icon = isFrustration ? 'click_hesitation' : 'click';
icon = isFrustration ? 'event/click_hesitation' : 'event/click';
isFrustration
? Object.assign(tooltip, {
disabled: false,
@ -89,7 +99,7 @@ const Event: React.FC<Props> = ({
case TYPES.INPUT:
title = 'Input';
body = event.value;
icon = isFrustration ? 'input_hesitation' : 'input';
icon = isFrustration ? 'event/input_hesitation' : 'event/input';
isFrustration
? Object.assign(tooltip, {
disabled: false,
@ -98,18 +108,19 @@ const Event: React.FC<Props> = ({
: null;
break;
case TYPES.CLICKRAGE:
title = `${event.count} Clicks`;
case TYPES.TAPRAGE:
title = event.count ? `${event.count} Clicks` : 'Click Rage';
body = event.label;
icon = 'clickrage';
icon = 'event/clickrage';
break;
case TYPES.IOS_VIEW:
title = 'View';
body = event.name;
icon = 'ios_view';
icon = 'event/ios_view';
break;
case 'mouse_thrashing':
title = 'Mouse Thrashing';
icon = 'mouse_thrashing';
icon = 'event/mouse_thrashing';
break;
}
@ -123,7 +134,7 @@ const Event: React.FC<Props> = ({
>
<div className={cn(cls.main, 'flex flex-col w-full')}>
<div className={cn('flex items-center w-full', { 'px-4': isLocation })}>
{event.type && <Icon name={`event/${icon}`} size='16' color={'gray-dark'} />}
{event.type && <Icon name={icon} size='16' color={'gray-dark'} />}
<div className='ml-3 w-full'>
<div className='flex w-full items-first justify-between'>
<div className='flex items-center w-full' style={{ minWidth: '0' }}>
@ -160,6 +171,7 @@ const Event: React.FC<Props> = ({
const isFrustration = isFrustrationEvent(event);
const mobileTypes = [TYPES.TOUCH, TYPES.SWIPE, TYPES.TAPRAGE]
return (
<div
ref={wrapperRef}
@ -172,12 +184,13 @@ const Event: React.FC<Props> = ({
[cls.selected]: selected,
[cls.showSelection]: showSelection,
[cls.red]: isRed,
[cls.clickType]: event.type === TYPES.CLICK,
[cls.clickType]: event.type === TYPES.CLICK || event.type === TYPES.SWIPE,
[cls.inputType]: event.type === TYPES.INPUT,
[cls.frustration]: isFrustration,
[cls.highlight]: presentInSearch,
[cls.lastInGroup]: whiteBg,
['pl-4 pr-6 ml-4 py-2 border-l']: event.type !== TYPES.LOCATION
['pl-4 pr-6 ml-4 py-2 border-l']: event.type !== TYPES.LOCATION,
['border-0 border-l-0 ml-0']: mobileTypes.includes(event.type),
})}
onClick={onClick}
onContextMenu={onContextMenu}

View file

@ -38,7 +38,7 @@ function EventsBlock(props: IProps) {
const { store, player } = React.useContext(PlayerContext);
const { playing, tabStates, tabChangeEvents } = store.get();
const { playing, tabStates, tabChangeEvents = [] } = store.get();
const {
filteredEvents,
@ -53,23 +53,30 @@ function EventsBlock(props: IProps) {
const filteredLength = filteredEvents?.length || 0;
const notesWithEvtsLength = notesWithEvents?.length || 0;
const notesLength = notes.length;
const eventListNow = Object.values(tabStates)[0]?.eventListNow || [];
const eventListNow: any[] = []
if (tabStates !== undefined) {
eventListNow.concat(Object.values(tabStates)[0]?.eventListNow || [])
} else {
eventListNow.concat(store.get().eventListNow)
}
const currentTimeEventIndex = eventListNow.length > 0 ? eventListNow.length - 1 : 0;
const usedEvents = React.useMemo(() => {
tabChangeEvents.forEach(ev => {
const urlsList = tabStates[ev.tabId].urlsList;
let found = false;
let i = urlsList.length - 1;
while (!found && i >= 0) {
const item = urlsList[i]
if (item.url && item.time <= ev.time) {
found = true;
ev.activeUrl = item.url.replace(/.*\/\/[^\/]*/, '');
if (tabStates !== undefined) {
tabChangeEvents.forEach(ev => {
const urlsList = tabStates[ev.tabId].urlsList;
let found = false;
let i = urlsList.length - 1;
while (!found && i >= 0) {
const item = urlsList[i]
if (item.url && item.time <= ev.time) {
found = true;
ev.activeUrl = item.url.replace(/.*\/\/[^\/]*/, '');
}
i--;
}
i--;
}
})
})
}
const eventsWithMobxNotes = [...notesWithEvents, ...notes].sort(sortEvents);
return mergeEventLists(filteredLength > 0 ? filteredEvents : eventsWithMobxNotes, tabChangeEvents);
}, [filteredLength, notesWithEvtsLength, notesLength])
@ -133,7 +140,7 @@ function EventsBlock(props: IProps) {
const isLastInGroup = isLastEvent || usedEvents[index + 1]?.type === TYPES.LOCATION;
const event = usedEvents[index];
const isNote = 'noteId' in event;
const isTabChange = event.type === 'TABCHANGE';
const isTabChange = 'type' in event && event.type === 'TABCHANGE';
const isCurrent = index === currentTimeEventIndex;
return (

View file

@ -14,7 +14,7 @@ import {
import { error as errorRoute } from 'App/routes';
import Autoscroll from '../Autoscroll';
import BottomBlock from '../BottomBlock';
import { PlayerContext } from 'App/components/Session/playerContext';
import { MobilePlayerContext, PlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite';
interface IProps {
@ -23,7 +23,52 @@ interface IProps {
errorStack: Record<string, any>;
}
function Exceptions({ errorStack, sourcemapUploaded, loading }: IProps) {
function MobileExceptionsCont() {
const { player, store } = React.useContext(MobilePlayerContext);
const { exceptionsList: exceptions = [] } = store.get();
const [filter, setFilter] = React.useState('');
const onFilterChange = ({ target: { value } }: any) => setFilter(value);
const filterRE = getRE(filter, 'i');
const filtered = exceptions.filter((e: any) => filterRE.test(e.name) || filterRE.test(e.message));
return (
<>
<BottomBlock>
<BottomBlock.Header>
<div className="flex items-center">
<span className="font-semibold color-gray-medium mr-4">Exceptions</span>
</div>
<div className={'flex items-center justify-between'}>
<Input
className="input-small"
placeholder="Filter by name or message"
icon="search"
name="filter"
onChange={onFilterChange}
height={28}
/>
</div>
</BottomBlock.Header>
<BottomBlock.Content>
<NoContent size="small" show={filtered.length === 0} title="No recordings found">
<Autoscroll>
{filtered.map((e: any, index) => (
<React.Fragment key={e.key}>
<ErrorItem onJump={() => player.jump(e.time)} error={e} />
</React.Fragment>
))}
</Autoscroll>
</NoContent>
</BottomBlock.Content>
</BottomBlock>
</>
);
}
function ExceptionsCont({ errorStack, sourcemapUploaded, loading }: IProps) {
const { player, store } = React.useContext(PlayerContext);
const { tabStates, currentTab } = store.get();
const { logListNow: logs = [], exceptionsList: exceptions = [] } = tabStates[currentTab]
@ -119,8 +164,10 @@ function Exceptions({ errorStack, sourcemapUploaded, loading }: IProps) {
);
}
export default connect((state: any) => ({
export const Exceptions = connect((state: any) => ({
errorStack: state.getIn(['sessions', 'errorStack']),
sourcemapUploaded: state.getIn(['sessions', 'sourcemapUploaded']),
loading: state.getIn(['sessions', 'fetchErrorStackList', 'loading']),
}))(observer(Exceptions));
}))(observer(ExceptionsCont));
export const MobileExceptions = observer(MobileExceptionsCont)

View file

@ -11,9 +11,72 @@ import cn from 'classnames';
import OverviewPanelContainer from './components/OverviewPanelContainer';
import { NoContent, Icon } from 'UI';
import { observer } from 'mobx-react-lite';
import { PlayerContext } from 'App/components/Session/playerContext';
import {MobilePlayerContext, PlayerContext} from 'App/components/Session/playerContext';
function OverviewPanel({ issuesList }: { issuesList: Record<string, any>[] }) {
function MobileOverviewPanelCont({ issuesList }: { issuesList: Record<string, any>[] }) {
const { store, player } = React.useContext(MobilePlayerContext)
const [dataLoaded, setDataLoaded] = React.useState(false);
const [selectedFeatures, setSelectedFeatures] = React.useState([
'PERFORMANCE',
'FRUSTRATIONS',
'ERRORS',
'NETWORK',
]);
const {
endTime,
eventList: eventsList,
frustrationsList,
exceptionsList,
fetchList,
performanceChartData,
performanceList,
} = store.get()
const fetchPresented = fetchList.length > 0;
const resources = {
NETWORK: fetchList.filter((r: any) => r.status >= 400 || r.isRed || r.isYellow),
ERRORS: exceptionsList,
EVENTS: eventsList,
PERFORMANCE: performanceChartData,
FRUSTRATIONS: frustrationsList,
};
useEffect(() => {
if (dataLoaded) {
return;
}
if (
exceptionsList.length > 0 ||
eventsList.length > 0 ||
issuesList.length > 0 ||
performanceChartData.length > 0 ||
frustrationsList.length > 0
) {
setDataLoaded(true);
}
}, [issuesList, exceptionsList, eventsList, performanceChartData, frustrationsList]);
React.useEffect(() => {
player.scale()
}, [selectedFeatures])
return (
<PanelComponent
resources={resources}
endTime={endTime}
selectedFeatures={selectedFeatures}
fetchPresented={fetchPresented}
setSelectedFeatures={setSelectedFeatures}
isMobile
performanceList={performanceList}
/>
)
}
function WebOverviewPanelCont({ issuesList }: { issuesList: Record<string, any>[] }) {
const { store } = React.useContext(PlayerContext);
const [dataLoaded, setDataLoaded] = React.useState(false);
const [selectedFeatures, setSelectedFeatures] = React.useState([
@ -28,7 +91,6 @@ function OverviewPanel({ issuesList }: { issuesList: Record<string, any>[] }) {
currentTab,
tabStates,
} = store.get();
const states = Object.values(tabStates)
const stackEventList = tabStates[currentTab]?.stackList || []
const eventsList = tabStates[currentTab]?.eventList || []
@ -73,69 +135,101 @@ function OverviewPanel({ issuesList }: { issuesList: Record<string, any>[] }) {
}
}, [resourceList, issuesList, exceptionsList, eventsList, stackEventList, performanceChartData, currentTab]);
return (
<React.Fragment>
<BottomBlock style={{ height: '100%' }}>
<BottomBlock.Header>
<span className="font-semibold color-gray-medium mr-4">X-RAY</span>
<div className="flex items-center h-20">
<FeatureSelection list={selectedFeatures} updateList={setSelectedFeatures} />
</div>
</BottomBlock.Header>
<BottomBlock.Content>
<OverviewPanelContainer endTime={endTime}>
<TimelineScale endTime={endTime} />
<div
// style={{ width: '100%', height: '187px', overflow: 'hidden' }}
style={{ width: 'calc(100% - 1rem)', margin: '0 auto' }}
className="transition relative"
>
<NoContent
show={selectedFeatures.length === 0}
style={{ height: '60px', minHeight: 'unset', padding: 0 }}
title={
<div className="flex items-center">
<Icon name="info-circle" className="mr-2" size="18" />
Select a debug option to visualize on timeline.
</div>
}
>
<VerticalPointerLine />
{selectedFeatures.map((feature: any, index: number) => (
<div
key={feature}
className={cn('border-b last:border-none', { 'bg-white': index % 2 })}
>
<EventRow
isGraph={feature === 'PERFORMANCE'}
title={feature}
list={resources[feature]}
renderElement={(pointer: any) => (
<TimelinePointer
pointer={pointer}
type={feature}
fetchPresented={fetchPresented}
/>
)}
endTime={endTime}
message={HELP_MESSAGE[feature]}
/>
</div>
))}
</NoContent>
</div>
</OverviewPanelContainer>
</BottomBlock.Content>
</BottomBlock>
</React.Fragment>
);
return <PanelComponent resources={resources} endTime={endTime} selectedFeatures={selectedFeatures} fetchPresented={fetchPresented} setSelectedFeatures={setSelectedFeatures} />
}
export default connect(
function PanelComponent({ selectedFeatures, endTime, resources, fetchPresented, setSelectedFeatures, isMobile, performanceList }: any) {
return (
<React.Fragment>
<BottomBlock style={{ height: '100%' }}>
<BottomBlock.Header>
<span className="font-semibold color-gray-medium mr-4">X-RAY</span>
<div className="flex items-center h-20">
<FeatureSelection list={selectedFeatures} updateList={setSelectedFeatures} />
</div>
</BottomBlock.Header>
<BottomBlock.Content>
<OverviewPanelContainer endTime={endTime}>
<TimelineScale endTime={endTime} />
<div
// style={{ width: '100%', height: '187px', overflow: 'hidden' }}
style={{ width: 'calc(100% - 1rem)', margin: '0 auto' }}
className="transition relative"
>
<NoContent
show={selectedFeatures.length === 0}
style={{ height: '60px', minHeight: 'unset', padding: 0 }}
title={
<div className="flex items-center">
<Icon name="info-circle" className="mr-2" size="18" />
Select a debug option to visualize on timeline.
</div>
}
>
<VerticalPointerLine />
{selectedFeatures.map((feature: any, index: number) => (
<div
key={feature}
className={cn('border-b last:border-none relative', { 'bg-white': index % 2 })}
>
<EventRow
isGraph={feature === 'PERFORMANCE'}
title={feature}
list={resources[feature]}
renderElement={(pointer: any) => (
<TimelinePointer
pointer={pointer}
type={feature}
fetchPresented={fetchPresented}
/>
)}
endTime={endTime}
message={HELP_MESSAGE[feature]}
/>
{isMobile && feature === 'PERFORMANCE' ? (
<div className={"absolute top-0 left-0 py-2 flex items-center py-4 w-full"}>
<EventRow
isGraph={false}
title={''}
list={performanceList}
renderElement={(pointer: any) => (
<div className="rounded bg-white p-1 border">
<TimelinePointer
pointer={pointer}
type={"FRUSTRATIONS"}
fetchPresented={fetchPresented}
/>
</div>
)}
endTime={endTime}
/>
</div>
) : null}
</div>
))}
</NoContent>
</div>
</OverviewPanelContainer>
</BottomBlock.Content>
</BottomBlock>
</React.Fragment>
)
}
export const OverviewPanel = connect(
(state: any) => ({
issuesList: state.getIn(['sessions', 'current']).issues,
}),
{
toggleBottomBlock,
}
)(observer(OverviewPanel));
)(observer(WebOverviewPanelCont));
export const MobileOverviewPanel = connect(
(state: any) => ({
issuesList: state.getIn(['sessions', 'current']).issues,
}),
{
toggleBottomBlock,
}
)(observer(MobileOverviewPanelCont));

View file

@ -8,9 +8,9 @@ const FRUSTRATIONS = 'FRUSTRATIONS';
const PERFORMANCE = 'PERFORMANCE';
export const HELP_MESSAGE: any = {
NETWORK: 'Network requests made in this session',
NETWORK: 'Network requests with issues in this session',
EVENTS: 'Visualizes the events that takes place in the DOM',
ERRORS: 'Visualizes native JS errors like Type, URI, Syntax etc.',
ERRORS: 'Visualizes native errors like Type, URI, Syntax etc.',
PERFORMANCE: 'Summary of this sessions memory, and CPU consumption on the timeline',
FRUSTRATIONS: 'Indicates user frustrations in the session',
};

View file

@ -76,8 +76,9 @@ const TimelinePointer = React.memo((props: Props) => {
const elData = { name: '', icon: ''}
if (item.type === TYPES.CLICK) Object.assign(elData, { name: `User hesitated to click for ${Math.round(item.hesitation/1000)}s`, icon: 'click-hesitation' })
if (item.type === TYPES.INPUT) Object.assign(elData, { name: `User hesitated to enter a value for ${Math.round(item.hesitation/1000)}s`, icon: 'input-hesitation' })
if (item.type === TYPES.CLICKRAGE) Object.assign(elData, { name: 'Click Rage', icon: 'click-rage' })
if (item.type === TYPES.CLICKRAGE || item.type === TYPES.TAPRAGE) Object.assign(elData, { name: 'Click Rage', icon: 'click-rage' })
if (item.type === issueTypes.MOUSE_THRASHING) Object.assign(elData, { name: 'Mouse Thrashing', icon: 'cursor-trash' })
if (item.type === 'ios_perf_event') Object.assign(elData, { name: item.name, icon: item.icon })
return (
<Tooltip
@ -99,7 +100,7 @@ const TimelinePointer = React.memo((props: Props) => {
<Tooltip
title={
<div className="">
<b>{'Stack Event'}</b>
<b>{item.name || 'Stack Event'}</b>
</div>
}
>

View file

@ -1 +1 @@
export { default } from './OverviewPanel';
export { OverviewPanel, MobileOverviewPanel } from './OverviewPanel';

View file

@ -1,6 +1,8 @@
import {Timed} from "Player";
import {PerformanceChartPoint} from "Player/mobile/managers/IOSPerformanceTrackManager";
import React from 'react';
import { connect } from 'react-redux';
import { PlayerContext } from 'App/components/Session/playerContext';
import {MobilePlayerContext, PlayerContext} from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite';
import {
AreaChart,
@ -96,6 +98,27 @@ const CPUTooltip = ({ active, payload }) => {
);
};
const MobileCpuTooltip = ({ active, payload }) => {
if (!payload) return null;
if (!active || payload.length < 1) {
return null;
}
if (payload[0].value === null) {
return (
<div className={stl.tooltipWrapper} style={{ color: HIDDEN_SCREEN_COLOR }}>
{'App is in the background.'}
</div>
);
}
return (
<div className={stl.tooltipWrapper}>
<span className="font-medium">{`${CPU}: `}</span>
{payload[0].value}
{'%'}
</div>
);
}
const HeapTooltip = ({ active, payload }) => {
if (!payload) return null;
if (!active || payload.length < 2) return null;
@ -113,6 +136,19 @@ const HeapTooltip = ({ active, payload }) => {
);
};
const MobileMemoryTooltip = ({ active, payload }) => {
if (!payload) return null;
if (!active || payload.length < 1 || payload[1].value === null) return null;
return (
<div className={stl.tooltipWrapper}>
<p>
<span className="font-medium">Used Memory: </span>
{formatBytes(payload[1].value)}
</p>
</div>
);
}
const NodesCountTooltip = ({ active, payload }) => {
if (!payload) return null;
if (!active || !payload || payload.length === 0) return null;
@ -178,6 +214,202 @@ function addFpsMetadata(data) {
});
}
function generateMobileChart(data: PerformanceChartPoint[], biggestMemSpike: number) {
return data.map(p => ({
...p,
isBackground: p.isBackground ? 50 : 0,
isMemBackground: p.isBackground ? biggestMemSpike : 0
}))
}
export const MobilePerformance = connect((state: any) => ({
userDeviceMemorySize: state.getIn(['sessions', 'current']).userDeviceMemorySize || 0,
}))(observer(({ userDeviceMemorySize }: { userDeviceMemorySize: number }) => {
const { player, store } = React.useContext(MobilePlayerContext);
const [_timeTicks, setTicks] = React.useState<number[]>([])
const [_data, setData] = React.useState<any[]>([])
const {
performanceChartTime = 0,
performanceChartData = [],
} = store.get();
React.useEffect(() => {
// setTicks(generateTicks(performanceChartData));
setTicks(performanceChartData.map(p => p.time));
const biggestMemSpike = performanceChartData.reduce((acc, p) => {
if (p.memory && p.memory > acc) return p.memory;
return acc;
}, 0);
setData(generateMobileChart(performanceChartData, biggestMemSpike));
}, [])
const onDotClick = ({ index: pointer }: { index: number }) => {
const point = _data[pointer];
if (!!point) {
player.jump(point.time);
}
};
const onChartClick = (e: any) => {
if (e === null) return;
const { activeTooltipIndex } = e;
const point = _data[activeTooltipIndex];
if (!!point) {
player.jump(point.time);
}
};
const availableCount = 2
const height = `${100 / availableCount}%`;
return (
<BottomBlock>
<BottomBlock.Header>
<div className="flex items-center w-full">
<div className="font-semibold color-gray-medium mr-auto">Performance</div>
<InfoLine>
<InfoLine.Point
label="Device Memory Size"
value={formatBytes(userDeviceMemorySize * 1024)}
display={true}
/>
</InfoLine>
</div>
</BottomBlock.Header>
<BottomBlock.Content>
<ResponsiveContainer height={height}>
<AreaChart
onClick={onChartClick}
data={_data}
syncId="s"
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0,
}}
>
<defs>
<Gradient id="cpuGradient" color={CPU_COLOR} />
</defs>
{/* <CartesianGrid strokeDasharray="3 3" vertical={ false } stroke="#EEEEEE" /> */}
<XAxis
dataKey="time"
type="number"
mirror
orientation="top"
tickLine={false}
tickFormatter={() => ''}
domain={[0, 'dataMax']}
ticks={_timeTicks}
>
<Label value="CPU" position="insideTopRight" className="fill-gray-darkest" />
</XAxis>
<YAxis axisLine={false} tick={false} mirror domain={[0, 120]} orientation="right" />
<Area
dataKey="cpu"
type="monotone"
stroke={CPU_STROKE_COLOR}
fill="url(#cpuGradient)"
dot={false}
activeDot={{
onClick: onDotClick,
style: { cursor: 'pointer' },
}}
isAnimationActive={false}
/>
<Area
dataKey="isBackground"
type="stepBefore"
stroke="none"
fill={HIDDEN_SCREEN_COLOR}
activeDot={false}
isAnimationActive={false}
/>
<ReferenceLine x={performanceChartTime} stroke={CURSOR_COLOR} />
<Tooltip content={MobileCpuTooltip} filterNull={false} />
</AreaChart>
</ResponsiveContainer>
<ResponsiveContainer height={height}>
<ComposedChart
onClick={onChartClick}
data={_data}
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0,
}}
syncId="s"
>
<defs>
<Gradient id="usedHeapGradient" color={USED_HEAP_COLOR} />
</defs>
<XAxis
dataKey="time"
type="number"
mirror
orientation="top"
tickLine={false}
tickFormatter={() => ''} // tick={false} + _timeTicks to cartesian array
domain={[0, 'dataMax']}
ticks={_timeTicks}
>
<Label value="Memory" position="insideTopRight" className="fill-gray-darkest" />
</XAxis>
<YAxis
axisLine={false}
tickFormatter={formatBytes}
mirror
// Hack to keep only end tick
minTickGap={Number.MAX_SAFE_INTEGER}
domain={[0, (max: number) => max * 1.2]}
/>
{/*<Line*/}
{/* type="monotone"*/}
{/* dataKey="totalHeap"*/}
{/* stroke={TOTAL_HEAP_STROKE_COLOR}*/}
{/* dot={false}*/}
{/* activeDot={{*/}
{/* onClick: onDotClick,*/}
{/* style: { cursor: 'pointer' },*/}
{/* }}*/}
{/* isAnimationActive={false}*/}
{/*/>*/}
<Area
dataKey="isMemBackground"
type="stepBefore"
stroke="none"
fill={HIDDEN_SCREEN_COLOR}
activeDot={false}
isAnimationActive={false}
/>
<Area
dataKey="memory"
type="monotone"
fill="url(#usedHeapGradient)"
stroke={USED_HEAP_STROKE_COLOR}
dot={false}
activeDot={{
onClick: onDotClick,
style: { cursor: 'pointer' },
}}
isAnimationActive={false}
/>
<ReferenceLine x={performanceChartTime} stroke={CURSOR_COLOR} />
<Tooltip content={MobileMemoryTooltip} filterNull={false} />
</ComposedChart>
</ResponsiveContainer>
</BottomBlock.Content>
</BottomBlock>
);
}));
function Performance({
userDeviceHeapSize,
}: {
@ -226,6 +458,7 @@ function Performance({
const availableCount = [fps, cpu, heap, nodes].reduce((c, av) => (av ? c + 1 : c), 0);
const height = availableCount === 0 ? '0' : `${100 / availableCount}%`;
console.log(_data)
return (
<BottomBlock>
<BottomBlock.Header>

View file

@ -1,6 +1,6 @@
import React, { useContext } from 'react';
import stl from './timeline.module.css';
import { PlayerContext } from 'Components/Session/playerContext';
import { PlayerContext, MobilePlayerContext } from 'Components/Session/playerContext';
import { observer } from 'mobx-react-lite';
import { getTimelinePosition } from './getTimelinePosition'
@ -25,4 +25,24 @@ function EventsList({ scale }: { scale: number }) {
);
}
export default observer(EventsList);
function MobileEventsList({ scale }: { scale: number }) {
const { store } = useContext(MobilePlayerContext);
const { eventList } = store.get();
const events = eventList.filter(e => e.type !== 'SWIPE')
return (
<>
{events.map((e) => (
<div
/*@ts-ignore TODO */
key={e.key}
className={stl.event}
style={{ left: `${getTimelinePosition(e.time, scale)}%` }}
/>
))}
</>
);
}
export const WebEventsList = observer(EventsList);
export const MobEventsList = observer(MobileEventsList);

View file

@ -12,7 +12,7 @@ import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import { DateTime, Duration } from 'luxon';
import Issue from "Types/session/issue";
import EventsList from './EventsList';
import { WebEventsList, MobEventsList } from './EventsList';
import NotesList from './NotesList';
import SkipIntervalsList from './SkipIntervalsList'
import TimelineTracker from "Components/Session_/Player/Controls/TimelineTracker";
@ -23,6 +23,7 @@ interface IProps {
startedAt: number
tooltipVisible: boolean
timezone?: string
isMobile?: boolean
}
function Timeline(props: IProps) {
@ -125,52 +126,51 @@ function Timeline(props: IProps) {
return (
<div
className="flex items-center absolute w-full"
style={{
top: '-4px',
zIndex: 100,
maxWidth: 'calc(100% - 1rem)',
left: '0.5rem',
}}
className="flex items-center absolute w-full"
style={{
top: '-4px',
zIndex: 100,
maxWidth: 'calc(100% - 1rem)',
left: '0.5rem',
}}
>
<div
className={stl.progress}
onClick={ready ? jumpToTime : undefined}
ref={progressRef}
role="button"
onMouseMoveCapture={showTimeTooltip}
onMouseEnter={showTimeTooltip}
onMouseLeave={hideTimeTooltip}
>
<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={progressRef.current ? progressRef.current.offsetWidth : 0}
/>
<TooltipContainer />
<TimelineTracker scale={scale} onDragEnd={onDragEnd} />
<CustomDragLayer
onDrag={onDrag}
minX={0}
maxX={progressRef.current ? progressRef.current.offsetWidth : 0}
/>
<div className={stl.timeline} ref={timelineRef}>
{devtoolsLoading || domLoading || !ready ? <div className={stl.stripes} /> : null}
</div>
<EventsList scale={scale} />
<NotesList scale={scale} />
<SkipIntervalsList scale={scale} />
{/* TODO: refactor and make any sense out of this */}
{/* {issues.map((i: Issue) => (*/}
{/* <div*/}
{/* key={i.key}*/}
{/* className={stl.redEvent}*/}
{/* style={{ left: `${getTimelinePosition(i.time, scale)}%` }}*/}
{/* />*/}
{/*))}*/}
<div className={stl.timeline} ref={timelineRef}>
{devtoolsLoading || domLoading || !ready ? <div className={stl.stripes} /> : null}
</div>
{props.isMobile ? <MobEventsList scale={scale} /> : <WebEventsList scale={scale} />}
<NotesList scale={scale} />
<SkipIntervalsList scale={scale} />
{/* TODO: refactor and make any sense out of this */}
{/* {issues.map((i: Issue) => (*/}
{/* <div*/}
{/* key={i.key}*/}
{/* className={stl.redEvent}*/}
{/* style={{ left: `${getTimelinePosition(i.time, scale)}%` }}*/}
{/* />*/}
{/*))}*/}
</div>
)
</div>
);
}
export default connect(

View file

@ -19,6 +19,13 @@
50% / 10px 10px;
}
.mobileScreenWrapper {
width: 100%;
position: relative;
height: 100%;
background: #F6F6F6;
}
.disconnected {
font-size: 40px;
font-weight: 200;

View file

@ -12,13 +12,17 @@ interface Props {
export default function SessionInfoItem(props: Props) {
const { label, icon, value, comp, isLast = false } = props
return (
<div className={cn("flex items-center w-full py-2 color-gray-dark", {'border-b' : !isLast})}>
<div className="px-2 capitalize" style={{ width: '30px' }}>
{ icon && <Icon name={icon} size="16" /> }
{ comp && comp }
</div>
<div className="px-2 capitalize" style={{ minWidth: '160px' }}>{label}</div>
<div className="color-gray-medium px-2" style={{ minWidth: '160px' }}>{value}</div>
<div className={cn('flex items-center w-full py-2 color-gray-dark', { 'border-b': !isLast })}>
<div className="px-2 capitalize" style={{ width: '30px' }}>
{icon && <Icon name={icon} size="16" />}
{comp && comp}
</div>
)
<div className={cn('px-2', /ios/i.test(label) ? '' : 'capitalize')} style={{ minWidth: '160px' }}>
{label}
</div>
<div className="color-gray-medium px-2" style={{ minWidth: '160px' }}>
{value}
</div>
</div>
);
}

View file

@ -31,7 +31,7 @@ function SubHeader(props) {
}
return integrations.some((i) => i.token);
});
}, [props.integrations]);
const { showModal, hideModal } = useModal();

View file

@ -13,7 +13,6 @@ import { useModal } from 'App/components/Modal';
import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter'
import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache'
import { toJS } from 'mobx'
const ALL = 'ALL';
const INFO = 'INFO';
@ -60,7 +59,7 @@ const getIconProps = (level: any) => {
const INDEX_KEY = 'console';
function ConsolePanel({ isLive }: { isLive: boolean }) {
function ConsolePanel({ isLive }: { isLive?: boolean }) {
const {
sessionStore: { devTools },
} = useStore()

View file

@ -0,0 +1,225 @@
import React, { useEffect, useRef, useState } from 'react';
import { LogLevel, ILog } from 'Player';
import BottomBlock from '../BottomBlock';
import { Tabs, Input, Icon, NoContent } from 'UI';
import cn from 'classnames';
import ConsoleRow from '../ConsoleRow';
import { IOSPlayerContext, MobilePlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite';
import { List, CellMeasurer, AutoSizer } from 'react-virtualized';
import { useStore } from 'App/mstore';
import ErrorDetailsModal from 'App/components/Dashboard/components/Errors/ErrorDetailsModal';
import { useModal } from 'App/components/Modal';
import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter';
import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache';
const ALL = 'ALL';
const INFO = 'INFO';
const WARNINGS = 'WARNINGS';
const ERRORS = 'ERRORS';
const LEVEL_TAB = {
[LogLevel.INFO]: INFO,
[LogLevel.LOG]: INFO,
[LogLevel.WARN]: WARNINGS,
[LogLevel.ERROR]: ERRORS,
[LogLevel.EXCEPTION]: ERRORS,
} as const;
const TABS = [ALL, ERRORS, WARNINGS, INFO].map((tab) => ({ text: tab, key: tab }));
function renderWithNL(s: string | null = '') {
if (typeof s !== 'string') return '';
return s.split('\n').map((line, i) => (
<div key={i + line.slice(0, 6)} className={cn({ 'ml-20': i !== 0 })}>
{line}
</div>
));
}
const getIconProps = (level: any) => {
switch (level) {
case LogLevel.INFO:
case LogLevel.LOG:
return {
name: 'console/info',
color: 'blue2',
};
case LogLevel.WARN:
return {
name: 'console/warning',
color: 'red2',
};
case LogLevel.ERROR:
return {
name: 'console/error',
color: 'red',
};
}
return null;
};
const INDEX_KEY = 'console';
function MobileConsolePanel() {
const {
sessionStore: { devTools },
} = useStore();
const filter = devTools[INDEX_KEY].filter;
const activeTab = devTools[INDEX_KEY].activeTab;
// Why do we need to keep index in the store? if we could get read of it it would simplify the code
const activeIndex = devTools[INDEX_KEY].index;
const [isDetailsModalActive, setIsDetailsModalActive] = useState(false);
const { showModal } = useModal();
const { player, store } = React.useContext<IOSPlayerContext>(MobilePlayerContext);
const jump = (t: number) => player.jump(t);
const {
logList,
// exceptionsList,
logListNow,
exceptionsListNow,
} = store.get();
const list = logList as ILog[];
// useMemo(() => logList.concat(exceptionsList).sort((a, b) => a.time - b.time),
// [ logList.length, exceptionsList.length ],
// ) as ILog[]
let filteredList = useRegExListFilterMemo(list, (l) => l.value, filter);
filteredList = useTabListFilterMemo(filteredList, (l) => LEVEL_TAB[l.level], ALL, activeTab);
React.useEffect(() => {
setTimeout(() => {
cache.clearAll();
_list.current?.recomputeRowHeights();
}, 0);
}, [activeTab, filter]);
const onTabClick = (activeTab: any) => devTools.update(INDEX_KEY, { activeTab });
const onFilterChange = ({ target: { value } }: any) =>
devTools.update(INDEX_KEY, { filter: value });
// AutoScroll
const [timeoutStartAutoscroll, stopAutoscroll] = useAutoscroll(
filteredList,
getLastItemTime(logListNow, exceptionsListNow),
activeIndex,
(index) => devTools.update(INDEX_KEY, { index })
);
const onMouseEnter = stopAutoscroll;
const onMouseLeave = () => {
if (isDetailsModalActive) {
return;
}
timeoutStartAutoscroll();
};
const _list = useRef<List>(null); // TODO: fix react-virtualized types & encapsulate scrollToRow logic
useEffect(() => {
if (_list.current) {
// @ts-ignore
_list.current.scrollToRow(activeIndex);
}
}, [activeIndex]);
const cache = useCellMeasurerCache();
const showDetails = (log: any) => {
setIsDetailsModalActive(true);
showModal(<ErrorDetailsModal errorId={log.errorId} />, {
right: true,
width: 1200,
onClose: () => {
setIsDetailsModalActive(false);
timeoutStartAutoscroll();
},
});
devTools.update(INDEX_KEY, { index: filteredList.indexOf(log) });
stopAutoscroll();
};
const _rowRenderer = ({ index, key, parent, style }: any) => {
const item = filteredList[index];
return (
// @ts-ignore
<CellMeasurer cache={cache} columnIndex={0} key={key} rowIndex={index} parent={parent}>
{({ measure, registerChild }) => (
<div ref={registerChild} style={style}>
<ConsoleRow
log={item}
jump={jump}
iconProps={getIconProps(item.level)}
renderWithNL={renderWithNL}
onClick={() => showDetails(item)}
recalcHeight={measure}
/>
</div>
)}
</CellMeasurer>
);
};
return (
<BottomBlock
style={{ height: '300px' }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* @ts-ignore */}
<BottomBlock.Header>
<div className="flex items-center">
<span className="font-semibold color-gray-medium mr-4">Console</span>
<Tabs tabs={TABS} active={activeTab} onClick={onTabClick} border={false} />
</div>
<Input
className="input-small h-8"
placeholder="Filter by keyword"
icon="search"
name="filter"
height={28}
onChange={onFilterChange}
value={filter}
/>
{/* @ts-ignore */}
</BottomBlock.Header>
{/* @ts-ignore */}
<BottomBlock.Content className="overflow-y-auto">
<NoContent
title={
<div className="capitalize flex items-center mt-16">
<Icon name="info-circle" className="mr-2" size="18" />
No Data
</div>
}
size="small"
show={filteredList.length === 0}
>
{/* @ts-ignore */}
<AutoSizer>
{({ height, width }: any) => (
// @ts-ignore
<List
ref={_list}
deferredMeasurementCache={cache}
overscanRowCount={5}
estimatedRowSize={36}
rowCount={Math.ceil(filteredList.length || 1)}
rowHeight={cache.rowHeight}
rowRenderer={_rowRenderer}
width={width}
height={height}
// scrollToIndex={activeIndex}
scrollToAlignment="center"
/>
)}
</AutoSizer>
</NoContent>
{/* @ts-ignore */}
</BottomBlock.Content>
</BottomBlock>
);
}
export default observer(MobileConsolePanel);

View file

@ -29,6 +29,7 @@ function ConsoleRow(props: Props) {
const toggleExpand = () => {
setExpanded(!expanded);
};
return (
<div
style={style}
@ -52,14 +53,14 @@ function ConsoleRow(props: Props) {
{canExpand && (
<Icon name={expanded ? 'caret-down-fill' : 'caret-right-fill'} className="mr-2" />
)}
<span>{renderWithNL(lines.pop())}</span>
<span style={{ fontFamily: 'Menlo, Monaco, Consolas' }}>{renderWithNL(lines.pop())}</span>
</div>
{log.errorId && <TextEllipsis className="ml-2 overflow-hidden" text={log.message }></TextEllipsis>}
{log.errorId && <TextEllipsis className="ml-2 overflow-hidden" text={log.message}></TextEllipsis>}
</div>
{canExpand &&
expanded &&
lines.map((l: string, i: number) => (
<div key={l.slice(0, 4) + i} className="ml-4 mb-1">
<div key={l.slice(0, 4) + i} className="ml-4 mb-1" style={{ fontFamily: 'Menlo, Monaco, Consolas' }}>
{l}
</div>
))}

View file

@ -1,22 +1,23 @@
import WebPlayer from 'Player/web/WebPlayer';
import MobilePlayer from 'Player/mobile/IOSPlayer';
import React, { useMemo, useState } from 'react';
import { observer } from 'mobx-react-lite';
import { Duration } from 'luxon';
import { Tooltip, Tabs, Input, NoContent, Icon, Toggler } from 'UI';
import { ResourceType } from 'Player';
import { ResourceType, Timed } from 'Player';
import { formatBytes } from 'App/utils';
import { formatMs } from 'App/date';
import { useModal } from 'App/components/Modal';
import FetchDetailsModal from 'Shared/FetchDetailsModal';
import { PlayerContext } from 'App/components/Session/playerContext';
import { MobilePlayerContext, PlayerContext } from 'App/components/Session/playerContext';
import { useStore } from 'App/mstore';
import { connect } from 'react-redux'
import { connect } from 'react-redux';
import TimeTable from '../TimeTable';
import BottomBlock from '../BottomBlock';
import InfoLine from '../BottomBlock/InfoLine';
import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter'
import { toJS } from 'mobx';
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter';
const INDEX_KEY = 'network';
@ -36,7 +37,7 @@ const TYPE_TO_TAB = {
[ResourceType.IMG]: IMG,
[ResourceType.MEDIA]: MEDIA,
[ResourceType.OTHER]: OTHER,
}
};
const TAP_KEYS = [ALL, XHR, JS, CSS, IMG, MEDIA, OTHER] as const;
const TABS = TAP_KEYS.map((tab) => ({
@ -129,38 +130,109 @@ export function renderDuration(r: any) {
);
}
function renderStatus({ status, cached }: { status: string, cached: boolean }) {
function renderStatus({ status, cached }: { status: string; cached: boolean }) {
return (
<>
{cached ? (
<Tooltip title={"Served from cache"}>
<Tooltip title={'Served from cache'}>
<div className="flex items-center">
<span className="mr-1">{status}</span>
<Icon name="wifi" size={16} />
</div>
</Tooltip>
) : status}
) : (
status
)}
</>
)
);
}
function NetworkPanel({ startedAt }: { startedAt: number }) {
const { player, store } = React.useContext(PlayerContext)
function NetworkPanelCont({ startedAt }: { startedAt: number }) {
const { player, store } = React.useContext(PlayerContext);
const {
domContentLoadedTime,
loadTime,
domBuildingTime,
tabStates,
currentTab
} = store.get()
const { domContentLoadedTime, loadTime, domBuildingTime, tabStates, currentTab } = store.get();
const {
fetchList = [],
resourceList = [],
fetchListNow = [],
resourceListNow = []
} = tabStates[currentTab]
resourceListNow = [],
} = tabStates[currentTab];
return (
<NetworkPanelComp
loadTime={loadTime}
domBuildingTime={domBuildingTime}
domContentLoadedTime={domContentLoadedTime}
fetchList={fetchList}
resourceList={resourceList}
fetchListNow={fetchListNow}
resourceListNow={resourceListNow}
player={player}
startedAt={startedAt}
/>
);
}
function MobileNetworkPanelCont({ startedAt }: { startedAt: number }) {
const { player, store } = React.useContext(MobilePlayerContext);
const domContentLoadedTime = undefined;
const loadTime = undefined;
const domBuildingTime = undefined;
const {
fetchList = [],
resourceList = [],
fetchListNow = [],
resourceListNow = [],
} = store.get();
return (
<NetworkPanelComp
isMobile
loadTime={loadTime}
domBuildingTime={domBuildingTime}
domContentLoadedTime={domContentLoadedTime}
fetchList={fetchList}
resourceList={resourceList}
fetchListNow={fetchListNow}
resourceListNow={resourceListNow}
player={player}
startedAt={startedAt}
/>
);
}
interface Props {
domContentLoadedTime?: {
time: number;
value: number;
};
loadTime?: {
time: number;
value: number;
};
domBuildingTime?: number;
fetchList: Timed[];
resourceList: Timed[];
fetchListNow: Timed[];
resourceListNow: Timed[];
player: WebPlayer | MobilePlayer;
startedAt: number;
isMobile?: boolean;
}
const NetworkPanelComp = observer(({
loadTime,
domBuildingTime,
domContentLoadedTime,
fetchList,
resourceList,
fetchListNow,
resourceListNow,
player,
startedAt,
isMobile
}: Props) => {
const { showModal } = useModal();
const [sortBy, setSortBy] = useState('time');
const [sortAscending, setSortAscending] = useState(true);
@ -174,70 +246,87 @@ function NetworkPanel({ startedAt }: { startedAt: number }) {
const activeTab = devTools[INDEX_KEY].activeTab;
const activeIndex = devTools[INDEX_KEY].index;
const list = useMemo(() =>
// TODO: better merge (with body size info) - do it in player
resourceList.filter(res => !fetchList.some(ft => {
// res.url !== ft.url doesn't work on relative URLs appearing within fetchList (to-fix in player)
if (res.name === ft.name) {
if (res.time === ft.time) return true;
if (res.url.includes(ft.url)) {
return Math.abs(res.time - ft.time) < 350
|| Math.abs(res.timestamp - ft.timestamp) < 350;
}
}
const list = useMemo(
() =>
// TODO: better merge (with body size info) - do it in player
resourceList
.filter(
(res) =>
!fetchList.some((ft) => {
// res.url !== ft.url doesn't work on relative URLs appearing within fetchList (to-fix in player)
if (res.name === ft.name) {
if (res.time === ft.time) return true;
if (res.url.includes(ft.url)) {
return (
Math.abs(res.time - ft.time) < 350 ||
Math.abs(res.timestamp - ft.timestamp) < 350
);
}
}
if (res.name !== ft.name) { return false }
if (Math.abs(res.time - ft.time) > 250) { return false } // TODO: find good epsilons
if (Math.abs(res.duration - ft.duration) > 200) { return false }
if (res.name !== ft.name) {
return false;
}
if (Math.abs(res.time - ft.time) > 250) {
return false;
} // TODO: find good epsilons
if (Math.abs(res.duration - ft.duration) > 200) {
return false;
}
return true
}))
.concat(fetchList)
.sort((a, b) => a.time - b.time)
, [ resourceList.length, fetchList.length ])
return true;
})
)
.concat(fetchList)
.sort((a, b) => a.time - b.time),
[resourceList.length, fetchList.length]
);
let filteredList = useMemo(() => {
if (!showOnlyErrors) { return list }
return list.filter(it => parseInt(it.status) >= 400 || !it.success)
}, [ showOnlyErrors, list ])
if (!showOnlyErrors) {
return list;
}
return list.filter((it) => parseInt(it.status) >= 400 || !it.success);
}, [showOnlyErrors, list]);
filteredList = useRegExListFilterMemo(
filteredList,
it => [ it.status, it.name, it.type ],
filter,
)
filteredList = useTabListFilterMemo(filteredList, it => TYPE_TO_TAB[it.type], ALL, activeTab)
(it) => [it.status, it.name, it.type],
filter
);
filteredList = useTabListFilterMemo(filteredList, (it) => TYPE_TO_TAB[it.type], ALL, activeTab);
const onTabClick = (activeTab: typeof TAP_KEYS[number]) => devTools.update(INDEX_KEY, { activeTab })
const onFilterChange = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => devTools.update(INDEX_KEY, { filter: value })
const onTabClick = (activeTab: (typeof TAP_KEYS)[number]) =>
devTools.update(INDEX_KEY, { activeTab });
const onFilterChange = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) =>
devTools.update(INDEX_KEY, { filter: value });
// AutoScroll
const [
timeoutStartAutoscroll,
stopAutoscroll,
] = useAutoscroll(
const [timeoutStartAutoscroll, stopAutoscroll] = useAutoscroll(
filteredList,
getLastItemTime(fetchListNow, resourceListNow),
activeIndex,
index => devTools.update(INDEX_KEY, { index })
)
const onMouseEnter = stopAutoscroll
(index) => devTools.update(INDEX_KEY, { index })
);
const onMouseEnter = stopAutoscroll;
const onMouseLeave = () => {
if (isDetailsModalActive) { return }
timeoutStartAutoscroll()
}
const resourcesSize = useMemo(() =>
resourceList.reduce(
(sum, { decodedBodySize }) => sum + (decodedBodySize || 0),
0,
), [ resourceList.length ])
const transferredSize = useMemo(() =>
resourceList.reduce(
(sum, { headerSize, encodedBodySize }) =>
sum + (headerSize || 0) + (encodedBodySize || 0),
0,
), [ resourceList.length ])
if (isDetailsModalActive) {
return;
}
timeoutStartAutoscroll();
};
const resourcesSize = useMemo(
() => resourceList.reduce((sum, { decodedBodySize }) => sum + (decodedBodySize || 0), 0),
[resourceList.length]
);
const transferredSize = useMemo(
() =>
resourceList.reduce(
(sum, { headerSize, encodedBodySize }) => sum + (headerSize || 0) + (encodedBodySize || 0),
0
),
[resourceList.length]
);
const referenceLines = useMemo(() => {
const arr = [];
@ -256,24 +345,29 @@ function NetworkPanel({ startedAt }: { startedAt: number }) {
}
return arr;
}, [ domContentLoadedTime, loadTime ])
}, [domContentLoadedTime, loadTime]);
const showDetailsModal = (item: any) => {
setIsDetailsModalActive(true)
setIsDetailsModalActive(true);
showModal(
<FetchDetailsModal time={item.time + startedAt} resource={item} rows={filteredList} fetchPresented={fetchList.length > 0} />,
<FetchDetailsModal
time={item.time + startedAt}
resource={item}
rows={filteredList}
fetchPresented={fetchList.length > 0}
/>,
{
right: true,
width: 500,
onClose: () => {
setIsDetailsModalActive(false)
timeoutStartAutoscroll()
}
setIsDetailsModalActive(false);
timeoutStartAutoscroll();
},
}
)
devTools.update(INDEX_KEY, { index: filteredList.indexOf(item) })
stopAutoscroll()
}
);
devTools.update(INDEX_KEY, { index: filteredList.indexOf(item) });
stopAutoscroll();
};
return (
<React.Fragment>
@ -286,13 +380,15 @@ function NetworkPanel({ startedAt }: { startedAt: number }) {
<BottomBlock.Header>
<div className="flex items-center">
<span className="font-semibold color-gray-medium mr-4">Network</span>
<Tabs
className="uppercase"
tabs={TABS}
active={activeTab}
onClick={onTabClick}
border={false}
/>
{isMobile ? null :
<Tabs
className="uppercase"
tabs={TABS}
active={activeTab}
onClick={onTabClick}
border={false}
/>
}
</div>
<Input
className="input-small"
@ -413,8 +509,17 @@ function NetworkPanel({ startedAt }: { startedAt: number }) {
</BottomBlock>
</React.Fragment>
);
}
})
export default connect((state: any) => ({
const WebNetworkPanel = connect((state: any) => ({
startedAt: state.getIn(['sessions', 'current']).startedAt,
}))(observer(NetworkPanel));
}))(observer(NetworkPanelCont));
const MobileNetworkPanel = connect((state: any) => ({
startedAt: state.getIn(['sessions', 'current']).startedAt,
}))(observer(MobileNetworkPanelCont));
export {
WebNetworkPanel,
MobileNetworkPanel
}

View file

@ -1 +1 @@
export { default } from './NetworkPanel'
export { WebNetworkPanel, MobileNetworkPanel } from './NetworkPanel';

View file

@ -1,8 +1,9 @@
import { Timed } from 'Player';
import React, { useEffect, useMemo, useState } from 'react';
import { observer } from 'mobx-react-lite';
import { Tabs, Input, NoContent, Icon } from 'UI';
import { List, CellMeasurer, AutoSizer } from 'react-virtualized';
import { PlayerContext } from 'App/components/Session/playerContext';
import { PlayerContext, MobilePlayerContext } from 'App/components/Session/playerContext';
import BottomBlock from '../BottomBlock';
import { useModal } from 'App/components/Modal';
import { useStore } from 'App/mstore';
@ -14,20 +15,47 @@ import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter';
import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache';
const mapNames = (type: string) => {
if (type === 'openreplay') return 'OpenReplay';
return type
}
const INDEX_KEY = 'stackEvent';
const ALL = 'ALL';
const TAB_KEYS = [ALL, ...typeList] as const;
const TABS = TAB_KEYS.map((tab) => ({ text: tab, key: tab }));
function StackEventPanel() {
type EventsList = Array<Timed & { name: string; source: string, key: string }>
export const WebStackEventPanel = observer(() => {
const { player, store } = React.useContext(PlayerContext);
const jump = (t: number) => player.jump(t);
const { currentTab, tabStates } = store.get();
const { stackList: list = [], stackListNow: listNow = [] } = tabStates[currentTab];
return <EventsPanel list={list as EventsList} listNow={listNow as EventsList} jump={jump} />;
});
export const MobileStackEventPanel = observer(() => {
const { player, store } = React.useContext(MobilePlayerContext);
const jump = (t: number) => player.jump(t);
const { eventList: list = [], eventListNow: listNow = [] } = store.get();
return <EventsPanel list={list as EventsList} listNow={listNow as EventsList} jump={jump} />;
});
function EventsPanel({
list,
listNow,
jump,
}: {
list: EventsList;
listNow: EventsList;
jump: (t: number) => void;
}) {
const {
sessionStore: { devTools }
sessionStore: { devTools },
} = useStore();
const { showModal } = useModal();
const [isDetailsModalActive, setIsDetailsModalActive] = useState(false); // TODO:embed that into useModal
@ -35,24 +63,23 @@ function StackEventPanel() {
const activeTab = devTools[INDEX_KEY].activeTab;
const activeIndex = devTools[INDEX_KEY].index;
let filteredList = useRegExListFilterMemo(list, it => it.name, filter);
filteredList = useTabListFilterMemo(filteredList, it => it.source, ALL, activeTab);
let filteredList = useRegExListFilterMemo(list, (it) => it.name, filter);
filteredList = useTabListFilterMemo(filteredList, (it) => it.source, ALL, activeTab);
const onTabClick = (activeTab: typeof TAB_KEYS[number]) => devTools.update(INDEX_KEY, { activeTab });
const onFilterChange = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => devTools.update(INDEX_KEY, { filter: value });
const tabs = useMemo(() =>
TABS.filter(({ key }) => key === ALL || list.some(({ source }) => key === source)),
const onTabClick = (activeTab: (typeof TAB_KEYS)[number]) =>
devTools.update(INDEX_KEY, { activeTab });
const onFilterChange = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) =>
devTools.update(INDEX_KEY, { filter: value });
const tabs = useMemo(
() => TABS.filter(({ key }) => key === ALL || list.some(({ source }) => key === source)),
[list.length]
);
const [
timeoutStartAutoscroll,
stopAutoscroll
] = useAutoscroll(
const [timeoutStartAutoscroll, stopAutoscroll] = useAutoscroll(
filteredList,
getLastItemTime(listNow),
activeIndex,
index => devTools.update(INDEX_KEY, { index })
(index) => devTools.update(INDEX_KEY, { index })
);
const onMouseEnter = stopAutoscroll;
const onMouseLeave = () => {
@ -66,17 +93,14 @@ function StackEventPanel() {
const showDetails = (item: any) => {
setIsDetailsModalActive(true);
showModal(
<StackEventModal event={item} />,
{
right: true,
width: 500,
onClose: () => {
setIsDetailsModalActive(false);
timeoutStartAutoscroll();
}
}
);
showModal(<StackEventModal event={item} />, {
right: true,
width: 500,
onClose: () => {
setIsDetailsModalActive(false);
timeoutStartAutoscroll();
},
});
devTools.update(INDEX_KEY, { index: filteredList.indexOf(item) });
stopAutoscroll();
};
@ -89,7 +113,6 @@ function StackEventPanel() {
}
}, [activeIndex]);
const _rowRenderer = ({ index, key, parent, style }: any) => {
const item = filteredList[index];
@ -121,29 +144,29 @@ function StackEventPanel() {
onMouseLeave={onMouseLeave}
>
<BottomBlock.Header>
<div className='flex items-center'>
<span className='font-semibold color-gray-medium mr-4'>Stack Events</span>
<Tabs tabs={tabs} active={activeTab} onClick={onTabClick} border={false} />
<div className="flex items-center">
<span className="font-semibold color-gray-medium mr-4">Stack Events</span>
<Tabs renameTab={mapNames} tabs={tabs} active={activeTab} onClick={onTabClick} border={false} />
</div>
<Input
className='input-small h-8'
placeholder='Filter by keyword'
icon='search'
name='filter'
className="input-small h-8"
placeholder="Filter by keyword"
icon="search"
name="filter"
height={28}
onChange={onFilterChange}
value={filter}
/>
</BottomBlock.Header>
<BottomBlock.Content className='overflow-y-auto'>
<BottomBlock.Content className="overflow-y-auto">
<NoContent
title={
<div className='capitalize flex items-center mt-16'>
<Icon name='info-circle' className='mr-2' size='18' />
<div className="capitalize flex items-center mt-16">
<Icon name="info-circle" className="mr-2" size="18" />
No Data
</div>
}
size='small'
size="small"
show={filteredList.length === 0}
>
<AutoSizer>
@ -157,7 +180,7 @@ function StackEventPanel() {
rowRenderer={_rowRenderer}
width={width}
height={height}
scrollToAlignment='center'
scrollToAlignment="center"
/>
)}
</AutoSizer>
@ -166,5 +189,3 @@ function StackEventPanel() {
</BottomBlock>
);
}
export default observer(StackEventPanel);

View file

@ -1 +1 @@
export { default } from './StackEventPanel';
export { WebStackEventPanel, MobileStackEventPanel } from './StackEventPanel';

View file

@ -15,7 +15,7 @@ const NoSessionsMessage = (props) => {
const activeSite = sites.find((s) => s.id === siteId);
const showNoSessions = !!activeSite && !activeSite.recorded;
const onboardingPath = withSiteId(onboardingRoute('installing'), siteId);
console.log('onboardingPath', onboardingPath, siteId);
return (
<>
{showNoSessions && (

View file

@ -12,12 +12,14 @@ import { useModal } from 'Components/Modal';
import { init as initProject } from 'Duck/site';
import NewSiteForm from 'Components/Client/Sites/NewSiteForm';
import { withStore } from 'App/mstore';
import { Icon } from 'UI'
const { Text } = Typography;
interface Site {
id: string;
host: string;
platform: 'web' | 'mobile';
}
interface Props extends RouteComponentProps {
@ -66,7 +68,7 @@ function ProjectDropdown(props: Props) {
{sites.map((site) => (
<Menu.Item
icon={<FolderOutlined />}
icon={<Icon name={site.platform === 'web' ? 'browser/browser' : 'mobile'} />}
key={site.id}
onClick={() => handleSiteChange(site.id)}
className='!py-2'

View file

@ -260,22 +260,21 @@ function SessionItem(props: RouteComponentProps & Props) {
showLabel={true}
/>
</div>
<div className='color-gray-medium flex items-center py-1'>
{userBrowser && (
<>
<span className='capitalize' style={{ maxWidth: '70px' }}>
<TextEllipsis
text={capitalize(userBrowser)}
popupProps={{ inverted: true, size: 'tiny' }}
/>
</span>
<Icon name='circle-fill' size={3} className='mx-4' />
</>
)}
<span className='capitalize' style={{ maxWidth: '70px' }}>
<div className="color-gray-medium flex items-center py-1">
{userBrowser ? (
<span className="capitalize" style={{ maxWidth: '70px' }}>
<TextEllipsis
text={capitalize(userBrowser)}
popupProps={{ inverted: true, size: 'tiny' }}
/>
</span>
) : null}
{userOs && userBrowser ? (
<Icon name="circle-fill" size={3} className="mx-4" />
) : null}
<span className={/ios/i.test(userOs) ? '' : 'capitalize'} style={{ maxWidth: '70px' }}>
<TextEllipsis
text={capitalize(userOs)}
text={/ios/i.test(userOs) ? 'iOS' : capitalize(userOs)}
popupProps={{ inverted: true, size: 'tiny' }}
/>
</span>

File diff suppressed because one or more lines are too long

View file

@ -1,10 +1,29 @@
import React from 'react';
import { Icon, Tooltip } from 'UI';
import cn from 'classnames';
import {IconNames} from "UI/SVG";
import styles from './segmentSelection.module.css';
class SegmentSelection extends React.Component {
setActiveItem = (item) => {
type Entry = { value: string, name: string, disabled?: boolean, icon?: IconNames };
interface Props<T extends Entry> {
className?: string;
name: string;
value: T;
list: T[];
onSelect: (_: null, data: { name: string, value: T['value']}) => void;
small?: boolean;
extraSmall?: boolean;
primary?: boolean;
size?: 'normal' | 'small' | 'extraSmall';
icons?: boolean;
disabled?: boolean;
disabledMessage?: string;
outline?: boolean;
}
class SegmentSelection<T extends Entry> extends React.Component<Props<T>> {
setActiveItem = (item: T) => {
this.props.onSelect(null, { name: this.props.name, value: item.value });
};
@ -49,7 +68,7 @@ class SegmentSelection extends React.Component {
<Icon
name={item.icon}
size={size === 'extraSmall' || size === 'small' || icons ? 14 : 20}
marginRight={item.name ? '6' : ''}
marginRight={item.name ? 6 : undefined}
/>
)}
<div className="leading-none">{item.name}</div>

View file

@ -2,7 +2,7 @@ import React from 'react';
import cn from 'classnames';
import stl from './tabs.module.css';
const Tabs = ({ tabs, active, onClick, border = true, className = '' }) => (
const Tabs = ({ tabs, active, onClick, border = true, className = '', renameTab = (tab) => tab }) => (
<div className={ cn(stl.tabs, className, { [ stl.bordered ]: border }) } role="tablist" >
{ tabs.map(({ key, text, hidden = false, disabled = false }) => (
<div
@ -11,9 +11,9 @@ const Tabs = ({ tabs, active, onClick, border = true, className = '' }) => (
data-hidden={ hidden }
onClick={ onClick && (() => onClick(key)) }
role="tab"
data-openreplay-label={text}
data-openreplay-label={renameTab(text)}
>
{ text }
{ renameTab(text) }
</div>
))}
</div>

View file

@ -191,10 +191,11 @@ const reducer = (state = initialState, action: IAction) => {
errors,
events,
issues,
crashes,
resources,
stackEvents,
userEvents
} = action.data as { errors: any[], events: any[], issues: any[], resources: any[], stackEvents: any[], userEvents: EventData[] };
} = action.data as { errors: any[], crashes: any[], events: any[], issues: any[], resources: any[], stackEvents: any[], userEvents: EventData[] };
const filterEvents = action.filter.events as Record<string, any>[];
const session = state.get('current') as Session;
const matching: number[] = [];
@ -228,6 +229,7 @@ const reducer = (state = initialState, action: IAction) => {
const newSession = session.addEvents(
events,
crashes,
errors,
issues,
resources,

View file

@ -1,7 +1,8 @@
import type { Timed } from './types';
import type { Timed } from 'Player';
export default class ListWalker<T extends Timed> {
private p = 0 /* Pointer to the "current" item */
/* Pointer to the "current" item */
private p = 0
constructor(private _list: Array<T> = []) {}
append(m: T): void {
@ -123,10 +124,12 @@ export default class ListWalker<T extends Timed> {
}
let changed = false;
// @ts-ignore
while (this.p < this.length && this.list[this.p][key] <= val) {
this.moveNext()
changed = true;
}
// @ts-ignore
while (this.p > 0 && this.list[ this.p - 1 ][key] > val) {
this.movePrev()
changed = true;

View file

@ -1,5 +1,7 @@
export interface Timed {
time: number
/** present in mobile events and in db events */
timestamp?: number
}
export interface Indexed {

View file

@ -1,50 +1,72 @@
import SimpleStore from './common/SimpleStore'
import type { Store, SessionFilesInfo } from './common/types'
import SimpleStore from './common/SimpleStore';
import type { Store, SessionFilesInfo } from './common/types';
import WebPlayer from './web/WebPlayer'
import WebLivePlayer from './web/WebLivePlayer'
import WebPlayer from './web/WebPlayer';
import WebLivePlayer from './web/WebLivePlayer';
type WebState = typeof WebPlayer.INITIAL_STATE
type WebPlayerStore = Store<WebState>
export type IWebPlayer = WebPlayer
export type IWebPlayerStore = WebPlayerStore
import IOSPlayer from 'Player/mobile/IOSPlayer';
type WebLiveState = typeof WebLivePlayer.INITIAL_STATE
type WebLivePlayerStore = Store<WebLiveState>
export type IWebLivePlayer = WebLivePlayer
export type IWebLivePlayerStore = WebLivePlayerStore
type IosState = typeof IOSPlayer.INITIAL_STATE;
type IOSPlayerStore = Store<IosState>;
export type IIosPlayer = IOSPlayer;
export type IIOSPlayerStore = IOSPlayerStore;
export function createWebPlayer(
session: SessionFilesInfo,
wrapStore?: (s:IWebPlayerStore) => IWebPlayerStore,
uiErrorHandler?: { error: (msg: string) => void }
): [IWebPlayer, IWebPlayerStore] {
let store: WebPlayerStore = new SimpleStore<WebState>({
...WebPlayer.INITIAL_STATE,
})
if (wrapStore) {
store = wrapStore(store)
}
type WebState = typeof WebPlayer.INITIAL_STATE;
type WebPlayerStore = Store<WebState>;
export type IWebPlayer = WebPlayer;
export type IWebPlayerStore = WebPlayerStore;
const player = new WebPlayer(store, session, false, false, uiErrorHandler)
return [player, store]
type WebLiveState = typeof WebLivePlayer.INITIAL_STATE;
type WebLivePlayerStore = Store<WebLiveState>;
export type IWebLivePlayer = WebLivePlayer;
export type IWebLivePlayerStore = WebLivePlayerStore;
export function createIOSPlayer(
session: SessionFilesInfo,
wrapStore?: (s: IOSPlayerStore) => IOSPlayerStore,
uiErrorHandler?: { error: (msg: string) => void }
): [IIosPlayer, IOSPlayerStore] {
let store: IOSPlayerStore = new SimpleStore<IosState>({
...IOSPlayer.INITIAL_STATE,
});
if (wrapStore) {
store = wrapStore(store);
}
const player = new IOSPlayer(store, session, uiErrorHandler);
return [player, store];
}
export function createWebPlayer(
session: SessionFilesInfo,
wrapStore?: (s: IWebPlayerStore) => IWebPlayerStore,
uiErrorHandler?: { error: (msg: string) => void }
): [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);
return [player, store];
}
export function createClickMapPlayer(
session: SessionFilesInfo,
wrapStore?: (s:IWebPlayerStore) => IWebPlayerStore,
uiErrorHandler?: { error: (msg: string) => void }
session: SessionFilesInfo,
wrapStore?: (s: IWebPlayerStore) => IWebPlayerStore,
uiErrorHandler?: { error: (msg: string) => void }
): [IWebPlayer, IWebPlayerStore] {
let store: WebPlayerStore = new SimpleStore<WebState>({
...WebPlayer.INITIAL_STATE,
})
if (wrapStore) {
store = wrapStore(store)
}
let store: WebPlayerStore = new SimpleStore<WebState>({
...WebPlayer.INITIAL_STATE,
});
if (wrapStore) {
store = wrapStore(store);
}
const player = new WebPlayer(store, session, false, true, uiErrorHandler)
return [player, store]
const player = new WebPlayer(store, session, false, true, uiErrorHandler);
return [player, store];
}
export function createLiveWebPlayer(

View file

@ -8,3 +8,7 @@ export * from './web/WebPlayer';
export * from './web/storageSelectors';
export * from './web/types';
export type { MarkedTarget } from './web/addons/TargetMarker'
export * from './mobile/IOSPlayer'
export * from './mobile/IOSMessageManager'
export * from './mobile/managers/TouchManager'

View file

@ -0,0 +1,92 @@
import ListWalker from '../common/ListWalker';
import ListWalkerWithMarks from '../common/ListWalkerWithMarks';
import type { Timed } from '../common/types';
const SIMPLE_LIST_NAMES = [
"event",
"exceptions",
"profiles",
"frustrations",
"performance"
] as const
const MARKED_LIST_NAMES = [ "log", "resource", "fetch", "stack" ] as const
const LIST_NAMES = [...SIMPLE_LIST_NAMES, ...MARKED_LIST_NAMES ] as const
type KeysList = `${typeof LIST_NAMES[number]}List`
type KeysListNow = `${typeof LIST_NAMES[number]}ListNow`
type KeysMarkedCountNow = `${typeof MARKED_LIST_NAMES[number]}MarkedCountNow`
type StateList = {
[key in KeysList]: Timed[]
}
type StateListNow = {
[key in KeysListNow]: Timed[]
}
type StateMarkedCountNow = {
[key in KeysMarkedCountNow]: number
}
type StateNow = StateListNow & StateMarkedCountNow
export type State = StateList & StateNow
// maybe use list object itself inside the store
export const INITIAL_STATE = LIST_NAMES.reduce((state, name) => {
state[`${name}List`] = []
state[`${name}ListNow`] = []
return state
}, MARKED_LIST_NAMES.reduce((state, name) => {
state[`${name}MarkedCountNow`] = 0
return state
}, {} as Partial<StateMarkedCountNow>) as Partial<State>
) as State
type SimpleListsObject = {
[key in typeof SIMPLE_LIST_NAMES[number]]: ListWalker<Timed>
}
type MarkedListsObject = {
[key in typeof MARKED_LIST_NAMES[number]]: ListWalkerWithMarks<Timed>
}
type ListsObject = SimpleListsObject & MarkedListsObject
export type InitialLists = {
[key in typeof LIST_NAMES[number]]: any[] // .isRed()?
}
export default class Lists {
lists: ListsObject
constructor(initialLists: Partial<InitialLists> = {}) {
const lists: Partial<ListsObject> = {}
for (const name of SIMPLE_LIST_NAMES) {
lists[name] = new ListWalker(initialLists[name])
}
for (const name of MARKED_LIST_NAMES) {
// TODO: provide types
lists[name] = new ListWalkerWithMarks((el) => el.isRed, initialLists[name])
}
this.lists = lists as ListsObject
}
getFullListsState(): StateList {
return LIST_NAMES.reduce((state, name) => {
state[`${name}List`] = this.lists[name].list
return state
}, {} as Partial<StateList>) as StateList
}
moveGetState(t: number): StateNow {
return LIST_NAMES.reduce((state, name) => {
const lastMsg = this.lists[name].moveGetLast(t) // index: name === 'exceptions' ? undefined : index);
if (lastMsg != null) {
state[`${name}ListNow`] = this.lists[name].listNow
}
return state
}, MARKED_LIST_NAMES.reduce((state, name) => {
state[`${name}MarkedCountNow`] = this.lists[name].markedCountNow // Red --> Marked
return state
}, {} as Partial<StateMarkedCountNow>) as Partial<State>
) as State
}
}

View file

@ -0,0 +1,264 @@
import logger from 'App/logger';
import { getResourceFromNetworkRequest } from "Player";
import type { Store } from 'Player';
import { IMessageManager } from 'Player/player/Animator';
import TouchManager from 'Player/mobile/managers/TouchManager';
import ActivityManager from '../web/managers/ActivityManager';
import Lists, {
InitialLists,
INITIAL_STATE as LISTS_INITIAL_STATE,
State as ListsState,
} from './IOSLists';
import IOSPerformanceTrackManager, { PerformanceChartPoint } from "Player/mobile/managers/IOSPerformanceTrackManager";
import { MType } from '../web/messages';
import type { Message } from '../web/messages';
import Screen, {
INITIAL_STATE as SCREEN_INITIAL_STATE,
State as ScreenState,
} from '../web/Screen/Screen';
import { Log } from './types/log'
import type { SkipInterval } from '../web/managers/ActivityManager';
export const performanceWarnings = ['thermalState', 'memoryWarning', 'lowDiskSpace', 'isLowPowerModeEnabled', 'batteryLevel']
const perfWarningFrustrations = {
thermalState: {
title: "Overheating",
icon: "thermometer-sun",
},
memoryWarning: {
title: "High Memory Usage",
icon: "memory-ios"
},
lowDiskSpace: {
title: "Low Disk Space",
icon: "low-disc-space"
},
isLowPowerModeEnabled: {
title: "Low Power Mode",
icon: "battery-charging"
},
batteryLevel: {
title: "Low Battery",
icon: "battery"
}
}
export interface State extends ScreenState, ListsState {
skipIntervals: SkipInterval[];
performanceChartData: PerformanceChartPoint[];
performanceChartTime: number;
location?: string;
error: boolean;
messagesLoading: boolean;
cssLoading: boolean;
ready: boolean;
lastMessageTime: number;
messagesProcessed: boolean;
eventCount: number;
updateWarnings: number;
}
const userEvents = [MType.IosSwipeEvent, MType.IosClickEvent, MType.IosInputEvent, MType.IosScreenChanges];
export default class IOSMessageManager implements IMessageManager {
static INITIAL_STATE: State = {
...SCREEN_INITIAL_STATE,
...LISTS_INITIAL_STATE,
updateWarnings: 0,
eventCount: 0,
performanceChartData: [],
performanceChartTime: 0,
skipIntervals: [],
error: false,
ready: false,
cssLoading: false,
lastMessageTime: 0,
messagesProcessed: false,
messagesLoading: false,
};
private activityManager: ActivityManager | null = null;
private performanceManager = new IOSPerformanceTrackManager();
private readonly sessionStart: number;
private lastMessageTime: number = 0;
private touchManager: TouchManager;
private lists: Lists;
constructor(
private readonly session: Record<string, any>,
private readonly state: Store<State & { time: number }>,
private readonly screen: Screen,
private readonly uiErrorHandler?: { error: (error: string) => void },
initialLists?: Partial<InitialLists>
) {
this.sessionStart = this.session.startedAt;
this.lists = new Lists(initialLists);
this.touchManager = new TouchManager(screen);
this.activityManager = new ActivityManager(this.session.duration.milliseconds); // only if not-live
}
public updateDimensions(dimensions: { width: number; height: number }) {
this.touchManager.updateDimensions(dimensions);
}
public updateLists(lists: Partial<InitialLists>) {
const exceptions = lists.exceptions
exceptions?.forEach(e => {
this.lists.lists.exceptions.insert(e);
this.lists.lists.log.insert(e)
})
lists.frustrations?.forEach(f => {
this.lists.lists.frustrations.insert(f);
})
const eventCount = this.lists.lists.event.count //lists?.event?.length || 0;
const currentState = this.state.get();
this.state.update({
eventCount: currentState.eventCount + eventCount,
...this.lists.getFullListsState(),
});
}
_sortMessagesHack() {
return;
}
private waitingForFiles: boolean = false;
public onFileReadSuccess = () => {
let newState: Partial<State> = {
...this.state.get(),
eventCount: this.lists?.lists.event?.length || 0,
performanceChartData: this.performanceManager.chartData,
...this.lists.getFullListsState(),
}
if (this.activityManager) {
this.activityManager.end();
newState['skipIntervals'] = this.activityManager.list
}
this.state.update(newState);
};
public onFileReadFailed = (e: any) => {
logger.error(e);
this.state.update({error: true});
this.uiErrorHandler?.error('Error requesting a session file');
};
public onFileReadFinally = () => {
this.waitingForFiles = false;
this.state.update({messagesProcessed: true});
};
public startLoading = () => {
this.waitingForFiles = true;
this.state.update({messagesProcessed: false});
this.setMessagesLoading(true);
};
resetMessageManagers() {
this.touchManager = new TouchManager(this.screen);
this.activityManager = new ActivityManager(this.session.duration.milliseconds);
}
move(t: number): any {
const stateToUpdate: Record<string, any> = {};
const lastPerformanceTrackMessage = this.performanceManager.moveGetLast(t);
if (lastPerformanceTrackMessage) {
Object.assign(stateToUpdate, {
performanceChartTime: lastPerformanceTrackMessage.time,
})
}
this.touchManager.move(t);
if (
this.waitingForFiles &&
this.lastMessageTime <= t &&
t !== this.session.duration.milliseconds
) {
this.setMessagesLoading(true);
}
Object.assign(stateToUpdate, this.lists.moveGetState(t))
Object.assign(stateToUpdate, { performanceListNow: this.lists.lists.performance.listNow })
Object.keys(stateToUpdate).length > 0 && this.state.update(stateToUpdate);
}
distributeMessage = (msg: Message & { tabId: string }): void => {
const lastMessageTime = Math.max(msg.time, this.lastMessageTime);
this.lastMessageTime = lastMessageTime;
this.state.update({lastMessageTime});
if (userEvents.includes(msg.tp)) {
this.activityManager?.updateAcctivity(msg.time);
}
switch (msg.tp) {
case MType.IosPerformanceEvent:
const performanceStats = ['background', 'memoryUsage', 'mainThreadCPU']
if (performanceStats.includes(msg.name)) {
this.performanceManager.append(msg);
}
if (performanceWarnings.includes(msg.name)) {
// @ts-ignore
const item = perfWarningFrustrations[msg.name]
this.lists.lists.performance.append({
...msg,
name: item.title,
techName: msg.name,
icon: item.icon,
type: 'ios_perf_event'
} as any)
}
break;
// case MType.IosInputEvent:
// console.log('input', msg)
// break;
case MType.IosNetworkCall:
this.lists.lists.fetch.insert(getResourceFromNetworkRequest(msg, this.sessionStart))
break;
case MType.IosEvent:
// @ts-ignore
this.lists.lists.event.insert({...msg, source: 'openreplay'});
break;
case MType.IosSwipeEvent:
case MType.IosClickEvent:
this.touchManager.append(msg);
break;
case MType.IosLog:
const log = {...msg, level: msg.severity}
// @ts-ignore
this.lists.lists.log.append(Log(log));
break;
default:
console.log(msg)
// stuff
break;
}
};
setMessagesLoading = (messagesLoading: boolean) => {
this.screen.display(!messagesLoading);
// @ts-ignore idk
this.state.update({messagesLoading, ready: !messagesLoading && !this.state.get().cssLoading});
};
private setSize({height, width}: { height: number; width: number }) {
this.screen.scale({height, width});
this.state.update({width, height});
}
// TODO: clean managers?
clean() {
this.state.update(IOSMessageManager.INITIAL_STATE);
}
}

View file

@ -0,0 +1,131 @@
import { Log, LogLevel, SessionFilesInfo } from 'Player'
import type { Store } from 'Player'
import MessageLoader from "Player/web/MessageLoader";
import Player from '../player/Player'
import Screen, { ScaleMode } from '../web/Screen/Screen'
import IOSMessageManager from "Player/mobile/IOSMessageManager";
export default class IOSPlayer extends Player {
static readonly INITIAL_STATE = {
...Player.INITIAL_STATE,
...MessageLoader.INITIAL_STATE,
...IOSMessageManager.INITIAL_STATE,
scale: 1,
}
public screen: Screen
protected messageManager: IOSMessageManager
protected readonly messageLoader: MessageLoader
constructor(
protected wpState: Store<any>,
session: SessionFilesInfo,
public readonly uiErrorHandler?: { error: (msg: string) => void }
) {
const screen = new Screen(true, ScaleMode.Embed)
const messageManager = new IOSMessageManager(session, wpState, screen, uiErrorHandler)
const messageLoader = new MessageLoader(
session,
wpState,
messageManager,
false,
uiErrorHandler
)
super(wpState, messageManager);
this.screen = screen
this.messageManager = messageManager
this.messageLoader = messageLoader
void messageLoader.loadFiles()
const endTime = session.duration?.valueOf() || 0
wpState.update({
session,
endTime,
})
}
attach = (parent: HTMLElement) => {
this.screen.attach(parent)
}
public updateDimensions(dimensions: { width: number; height: number }) {
return this.messageManager.updateDimensions(dimensions)
}
public updateLists(session: any) {
const exceptions = session.crashes.concat(session.errors || [])
const lists = {
event: session.events.map((e: Record<string, any>) => {
if (e.name === 'Click') e.name = 'Touch'
return e
}) || [],
frustrations: session.frustrations || [],
stack: session.stackEvents || [],
exceptions: exceptions.map(({ name, ...rest }: any) =>
Log({
level: LogLevel.ERROR,
value: name,
name,
message: rest.reason,
errorId: rest.crashId || rest.errorId,
...rest,
})
) || [],
}
return this.messageManager.updateLists(lists)
}
public updateOverlayStyle(style: Partial<CSSStyleDeclaration>) {
this.screen.updateOverlayStyle(style)
}
injectPlayer = (player: HTMLElement) => {
this.screen.addToBody(player)
this.screen.addMobileStyles()
window.addEventListener('resize', () =>
this.customScale(this.customConstrains.width, this.customConstrains.height)
)
}
scale = () => {
// const { width, height } = this.wpState.get()
if (!this.screen) return;
console.debug("using customConstrains to scale player")
// sometimes happens in live assist sessions for some reason
this.screen?.scale?.(this.customConstrains)
}
customConstrains = {
width: 0,
height: 0,
}
customScale = (width: number, height: number) => {
if (!this.screen) return;
this.screen?.scale?.({ width, height })
this.customConstrains = { width, height }
this.wpState.update({ scale: this.screen.getScale() })
}
addFullscreenBoundary = (isFullscreen?: boolean) => {
if (isFullscreen) {
this.screen?.addFullscreenBoundary()
} else {
this.screen?.addMobileStyles()
}
}
clean = () => {
super.clean()
this.screen.clean()
// @ts-ignore
this.screen = undefined;
this.messageLoader.clean()
// @ts-ignore
this.messageManager = undefined;
window.removeEventListener('resize', this.scale)
}
}

View file

@ -0,0 +1,66 @@
import ListWalker from "Player/common/ListWalker";
import type { IosPerformanceEvent } from "Player/web/messages";
const performanceEvTypes = {
MemoryUsage: 'memoryUsage',
MainThreadCPU: 'mainThreadCPU',
Background: 'background',
}
export type PerformanceChartPoint = {
time: number,
cpu: number | null,
memory: number | null,
isBackground: boolean,
}
export default class IOSPerformanceTrackManager extends ListWalker<IosPerformanceEvent> {
private chart: Array<PerformanceChartPoint> = [];
private isInBg = false;
lastData: { cpu: number | null, memory: number | null } = { cpu: null, memory: null };
append(msg: IosPerformanceEvent): void {
if (!Object.values(performanceEvTypes).includes(msg.name)) {
return console.log('Unsupported performance event type', msg.name)
}
let cpu: number | null = null;
let memory: number | null = null;
if (msg.time < 0) msg.time = 1;
if (msg.name === performanceEvTypes.Background) {
// @ts-ignore
const isBackground = msg.value === 1;
if (isBackground === this.isInBg) return;
this.isInBg = isBackground;
this.chart.push({
time: msg.time,
cpu: null,
memory: null,
isBackground,
})
return super.append(msg);
}
if (msg.name === performanceEvTypes.MemoryUsage) {
memory = msg.value;
cpu = this.lastData.cpu;
this.lastData.memory = memory;
}
if (msg.name === performanceEvTypes.MainThreadCPU) {
cpu = msg.value;
memory = this.lastData.memory;
this.lastData.cpu = cpu;
}
this.chart.push({
time: msg.time,
cpu,
memory,
isBackground: false,
});
super.append(msg);
}
get chartData(): Array<PerformanceChartPoint> {
return this.chart;
}
}

View file

@ -0,0 +1,49 @@
import {MOUSE_TRAIL} from "App/constants/storageKeys";
import ListWalker from 'Player/common/ListWalker';
import MouseTrail, { SwipeEvent } from 'Player/web/addons/MouseTrail';
import type {IosClickEvent, IosSwipeEvent} from 'Player/web/messages';
import {MType} from "Player/web/messages";
import type Screen from 'Player/web/Screen/Screen';
export default class TouchManager extends ListWalker<IosClickEvent | IosSwipeEvent> {
private touchTrail: MouseTrail | undefined;
private readonly removeTouchTrail: boolean = false;
constructor(private screen: Screen) {
super();
const canvas = document.createElement('canvas');
canvas.id = 'openreplay-touch-trail';
// canvas.className = styles.canvas;
this.removeTouchTrail = localStorage.getItem(MOUSE_TRAIL) === 'false'
if (!this.removeTouchTrail) {
this.touchTrail = new MouseTrail(canvas, true)
}
this.screen.overlay.appendChild(canvas);
this.touchTrail?.createContext();
}
public updateDimensions({ width, height }: { width: number; height: number; }) {
return this.touchTrail?.resizeCanvas(width, height);
}
public move(t: number) {
const lastTouch = this.moveGetLast(t)
if (!!lastTouch) {
if (lastTouch.tp === MType.IosSwipeEvent) {
return
// not using swipe rn
// this.touchTrail?.createSwipeTrail({
// x: lastTouch.x,
// y: lastTouch.y,
// direction: lastTouch.direction
// } as SwipeEvent)
} else {
this.screen.cursor.move(lastTouch)
this.screen.cursor.mobileClick()
}
}
}
}

View file

@ -0,0 +1,49 @@
export const enum LogLevel {
INFO = 'info',
LOG = 'log',
//ASSERT = 'assert', //?
WARN = 'warn',
ERROR = 'error',
EXCEPTION = 'exception',
}
export interface ILog {
content: string;
severity: "info" | "log" | "warn" | "error" | "exception";
time: number;
timestamp: number;
tp: number;
_index: number;
}
export const Log = (log: ILog) => ({
isRed: log.severity === LogLevel.EXCEPTION || log.severity === LogLevel.ERROR,
isYellow: log.severity === LogLevel.WARN,
value: log.content,
...log
})
// content
// :
// ">>>POST:https://foss.openreplay.com/ingest/v1/mobile/i\n<<<\n"
// length
// :
// 65
// severity
// :
// "info"
// tabId
// :
// "back-compatability"
// time
// :
// 10048
// timestamp
// :
// 1692966743780
// tp
// :
// 103
// _index
// :
// 50

View file

@ -0,0 +1,103 @@
const iPhone12ProSvg2 = `<svg xmlns="http://www.w3.org/2000/svg" width="432" height="881" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" xmlns:v="https://vecta.io/nano"><rect x="12.188" y="9.53" width="408" height="862" rx="59" stroke="#000" stroke-width="18"/><rect x="12.688" y="10.03" width="407" height="861" rx="58.5" stroke="#b6b6b6" stroke-width="17"/><rect x="13.688" y="11.03" width="405" height="859" rx="57.5" stroke="#000" stroke-width="15"/><path d="M427.284 265.897h2.5a2 2 0 0 1 2 2v98.625a2 2 0 0 1-2 2h-2.5V265.897z" fill="url(#A)"/><path d="M.784 330.332a2 2 0 0 1 2-2h2.5v63.996h-2.5a2 2 0 0 1-2-2v-59.996z" fill="url(#B)"/><path d="M.784 245.332a2 2 0 0 1 2-2h2.5v63.996h-2.5a2 2 0 0 1-2-2v-59.996z" fill="url(#C)"/><path d="M.784 182.332a2 2 0 0 1 2-2h2.5v32h-2.5a2 2 0 0 1-2-2v-28z" fill="url(#D)"/><path d="M124.735 17.949V5.174h189.803v12.775h-8.775a4 4 0 0 0-4 4v7.215c0 12.15-9.85 22-22 22H161.335c-12.15 0-22-9.85-22-22v-7.215a4 4 0 0 0-4-4h-10.6z" fill="#000"/><circle opacity=".34" cx="162.528" cy="29.219" r="5.62" fill="url(#E)"/><circle opacity=".34" cx="164.865" cy="31.417" r="1.283" fill="url(#F)"/><path opacity=".2" fill-rule="evenodd" d="M167.16 29.219a4.49 4.49 0 0 0 .02-.436c0-2.569-2.083-4.652-4.652-4.652s-4.652 2.083-4.652 4.652l.02.436c.22-2.365 2.21-4.216 4.632-4.216s4.412 1.851 4.632 4.216z" fill="#fff"/><defs><linearGradient id="A" x1="429.534" y1="265.897" x2="429.534" y2="368.522" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="B" x1="3.034" y1="328.332" x2="3.034" y2="392.328" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="C" x1="3.034" y1="243.332" x2="3.034" y2="307.328" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="D" x1="3.034" y1="180.332" x2="3.034" y2="212.332" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="E" x1="165.111" y1="28.357" x2="156.908" y2="28.493" xlink:href="#G"><stop stop-color="#2983ab"/><stop offset="1" stop-color="#1948ab"/></linearGradient><linearGradient id="F" x1="163.143" y1="30.133" x2="166.148" y2="32.7" xlink:href="#G"><stop stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff"/></linearGradient><linearGradient id="G" gradientUnits="userSpaceOnUse"/></defs></svg>`
const iPhone12ProMaxSvg2 = `<svg xmlns="http://www.w3.org/2000/svg" width="470" height="963" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" xmlns:v="https://vecta.io/nano"><rect x="12.188" y="9.53" width="446" height="944" rx="59" stroke="#000" stroke-width="18"/><rect x="12.688" y="10.03" width="445" height="943" rx="58.5" stroke="#b6b6b6" stroke-width="17"/><rect x="13.688" y="11.03" width="443" height="941" rx="57.5" stroke="#000" stroke-width="15"/><path d="M465.142 283.897h2.5a2 2 0 0 1 2 2v98.625a2 2 0 0 1-2 2h-2.5V283.897z" fill="url(#A)"/><path d="M.642 348.332a2 2 0 0 1 2-2h2.5v63.996h-2.5a2 2 0 0 1-2-2v-59.996z" fill="url(#B)"/><path d="M.642 263.332a2 2 0 0 1 2-2h2.5v63.996h-2.5a2 2 0 0 1-2-2v-59.996z" fill="url(#C)"/><path d="M.642 200.332a2 2 0 0 1 2-2h2.5v32h-2.5a2 2 0 0 1-2-2v-28z" fill="url(#D)"/><path d="M141.741 18.949V6.174h189.802v12.775h-8.775a4 4 0 0 0-4 4v7.215c0 12.15-9.85 22-22 22H178.341c-12.15 0-22-9.85-22-22v-7.215a4 4 0 0 0-4-4h-10.6z" fill="#000"/><circle opacity=".34" cx="179.533" cy="30.219" r="5.62" fill="url(#E)"/><circle opacity=".34" cx="181.87" cy="32.417" r="1.283" fill="url(#F)"/><path opacity=".2" fill-rule="evenodd" d="M184.166 30.219l.02-.436c0-2.569-2.083-4.652-4.653-4.652s-4.652 2.083-4.652 4.652l.02.436c.22-2.365 2.21-4.216 4.632-4.216s4.413 1.851 4.633 4.216z" fill="#fff"/><defs><linearGradient id="A" x1="467.392" y1="283.897" x2="467.392" y2="386.522" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="B" x1="2.892" y1="346.332" x2="2.892" y2="410.328" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="C" x1="2.892" y1="261.332" x2="2.892" y2="325.328" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="D" x1="2.892" y1="198.332" x2="2.892" y2="230.332" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="E" x1="182.117" y1="29.357" x2="173.914" y2="29.493" xlink:href="#G"><stop stop-color="#2983ab"/><stop offset="1" stop-color="#1948ab"/></linearGradient><linearGradient id="F" x1="180.148" y1="31.133" x2="183.153" y2="33.7" xlink:href="#G"><stop stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff"/></linearGradient><linearGradient id="G" gradientUnits="userSpaceOnUse"/></defs></svg>`
const iphone14ProSvg2 = `<svg xmlns="http://www.w3.org/2000/svg" width="440" height="895" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" xmlns:v="https://vecta.io/nano"><rect x="12.904" y="11.472" width="414" height="873" rx="62.5" stroke="#000" stroke-width="21"/><rect x="13.404" y="11.972" width="413" height="872" rx="62" stroke="#b6b6b6" stroke-width="20"/><path d="M364.404 13.472h-289c-33.413 0-60.5 27.087-60.5 60.5v748c0 33.413 27.087 60.5 60.5 60.5h289c33.413 0 60.5-27.087 60.5-60.5v-748c0-33.413-27.087-60.5-60.5-60.5z" stroke="#000" stroke-width="17"/><path d="M435.284 265.897h2.5a2 2 0 0 1 2 2v98.625a2 2 0 0 1-2 2h-2.5V265.897z" fill="url(#A)"/><path d="M0 330.332a2 2 0 0 1 2-2h2.5v63.996H2a2 2 0 0 1-2-2v-59.996z" fill="url(#B)"/><path d="M0 245.332a2 2 0 0 1 2-2h2.5v63.996H2a2 2 0 0 1-2-2v-59.996z" fill="url(#C)"/><path d="M0 182.332a2 2 0 0 1 2-2h2.5v32H2a2 2 0 0 1-2-2v-28z" fill="url(#D)"/><rect x="156.5" y="33.483" width="123" height="36" rx="18" fill="#000"/><circle opacity=".34" cx="176.033" cy="51.483" r="5.62" fill="url(#E)"/><circle opacity=".34" cx="178.37" cy="53.681" r="1.283" fill="url(#F)"/><path opacity=".2" fill-rule="evenodd" d="M180.666 51.483l.02-.436c0-2.569-2.083-4.652-4.653-4.652s-4.652 2.083-4.652 4.652l.02.436c.22-2.365 2.21-4.217 4.632-4.217s4.413 1.851 4.633 4.216z" fill="#fff"/><defs><linearGradient id="A" x1="437.534" y1="265.897" x2="437.534" y2="368.522" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="B" x1="2.25" y1="328.332" x2="2.25" y2="392.328" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="C" x1="2.25" y1="243.332" x2="2.25" y2="307.328" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="D" x1="2.25" y1="180.332" x2="2.25" y2="212.332" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="E" x1="178.617" y1="50.621" x2="170.414" y2="50.757" xlink:href="#G"><stop stop-color="#2983ab"/><stop offset="1" stop-color="#1948ab"/></linearGradient><linearGradient id="F" x1="176.648" y1="52.397" x2="179.653" y2="54.964" xlink:href="#G"><stop stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff"/></linearGradient><linearGradient id="G" gradientUnits="userSpaceOnUse"/></defs></svg>`
const iphone14ProMaxSvg2 = `<svg xmlns="http://www.w3.org/2000/svg" width="477" height="975" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" xmlns:v="https://vecta.io/nano"><rect x="12.904" y="11.472" width="451" height="953" rx="62.5" stroke="#000" stroke-width="21"/><rect x="13.404" y="11.972" width="450" height="952" rx="62" stroke="#b6b6b6" stroke-width="20"/><rect x="14.904" y="13.472" width="447" height="949" rx="60.5" stroke="#000" stroke-width="17"/><rect x="174.5" y="33.483" width="128" height="36" rx="18" fill="#000"/><circle opacity=".34" cx="194.033" cy="51.483" r="5.62" fill="url(#A)"/><circle opacity=".34" cx="196.37" cy="53.681" r="1.283" fill="url(#B)"/><path opacity=".2" fill-rule="evenodd" d="M198.666 51.483l.02-.436c0-2.569-2.083-4.652-4.653-4.652s-4.652 2.083-4.652 4.652l.02.436c.22-2.365 2.21-4.217 4.632-4.217s4.413 1.851 4.633 4.216z" fill="#fff"/><path d="M472.284 265.897h2.5a2 2 0 0 1 2 2v98.625a2 2 0 0 1-2 2h-2.5V265.897z" fill="url(#C)"/><path d="M0 330.332a2 2 0 0 1 2-2h2.5v63.996H2a2 2 0 0 1-2-2v-59.996z" fill="url(#D)"/><path d="M0 245.332a2 2 0 0 1 2-2h2.5v63.996H2a2 2 0 0 1-2-2v-59.996z" fill="url(#E)"/><path d="M0 182.332a2 2 0 0 1 2-2h2.5v32H2a2 2 0 0 1-2-2v-28z" fill="url(#F)"/><defs><linearGradient id="A" x1="196.617" y1="50.621" x2="188.414" y2="50.757" xlink:href="#G"><stop stop-color="#2983ab"/><stop offset="1" stop-color="#1948ab"/></linearGradient><linearGradient id="B" x1="194.648" y1="52.397" x2="197.653" y2="54.964" xlink:href="#G"><stop stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff"/></linearGradient><linearGradient id="C" x1="474.534" y1="265.897" x2="474.534" y2="368.522" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="D" x1="2.25" y1="328.332" x2="2.25" y2="392.328" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="E" x1="2.25" y1="243.332" x2="2.25" y2="307.328" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="F" x1="2.25" y1="180.332" x2="2.25" y2="212.332" xlink:href="#G"><stop stop-color="#575757"/><stop offset="1" stop-color="#222"/></linearGradient><linearGradient id="G" gradientUnits="userSpaceOnUse"/></defs></svg>`
// old svg frames
// const iPhone12ProSvg = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"438\" height=\"883\" fill=\"none\" xmlns:v=\"https://vecta.io/nano\"><path fill-rule=\"evenodd\" d=\"M70.284.332h298c35.898 0 65 29.102 65 65v752c0 35.898-29.102 65-65 65h-298c-35.899 0-65-29.102-65-65v-752c0-35.898 29.102-65 65-65zm0 19c-25.405 0-46 20.595-46 46v752c0 25.405 20.595 46 46 46h298c25.405 0 46-20.595 46-46v-752c0-25.405-20.595-46-46-46h-298z\" fill=\"#000\"/><path d=\"M433.284 262.897h2.5a2 2 0 0 1 2 2v98.625a2 2 0 0 1-2 2h-2.5V262.897zM.784 327.332a2 2 0 0 1 2-2h2.5v63.996h-2.5a2 2 0 0 1-2-2v-59.996zm0-85a2 2 0 0 1 2-2h2.5v63.996h-2.5a2 2 0 0 1-2-2v-59.996z\" fill=\"#222\"/><path d=\"M.784 179.332a2 2 0 0 1 2-2h2.5v32h-2.5a2 2 0 0 1-2-2v-28z\" fill=\"#444\"/><path d=\"M123.735 18.949V6.174h189.803v12.775h-8.775a4 4 0 0 0-4 4v7.215c0 12.15-9.85 22-22 22H160.335c-12.15 0-22-9.85-22-22v-7.215a4 4 0 0 0-4-4h-10.6z\" fill=\"#000\"/><circle opacity=\".34\" cx=\"161.528\" cy=\"30.219\" r=\"5.62\" fill=\"url(#A)\"/><circle opacity=\".34\" cx=\"163.865\" cy=\"32.417\" r=\"1.283\" fill=\"url(#B)\"/><path opacity=\".2\" fill-rule=\"evenodd\" d=\"M166.16 30.219a4.49 4.49 0 0 0 .02-.436c0-2.569-2.083-4.652-4.652-4.652s-4.652 2.083-4.652 4.652l.02.436c.22-2.365 2.21-4.216 4.632-4.216s4.412 1.851 4.632 4.216z\" fill=\"#fff\"/><defs><linearGradient id=\"A\" x1=\"164.111\" y1=\"29.357\" x2=\"155.908\" y2=\"29.493\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#2983ab\"/><stop offset=\"1\" stop-color=\"#1948ab\"/></linearGradient><linearGradient id=\"B\" x1=\"162.143\" y1=\"31.133\" x2=\"165.148\" y2=\"33.7\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#fff\" stop-opacity=\"0\"/><stop offset=\"1\" stop-color=\"#fff\"/></linearGradient></defs></svg>"
// const iPhone12ProMaxSvg = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"476\" height=\"965\" fill=\"none\" xmlns:v=\"https://vecta.io/nano\"><path fill-rule=\"evenodd\" d=\"M69.495.332h336.001c35.898 0 65 29.102 65 65v834c0 35.898-29.102 65-65 65h-336c-35.899 0-65-29.102-65-65v-834c0-35.898 29.101-65 65-65zm0 19c-25.405 0-46 20.595-46 46v834c0 25.405 20.595 46 46 46h336.001c25.405 0 46-20.595 46-46v-834c0-25.405-20.595-46-46-46h-336z\" fill=\"#000\"/><path d=\"M470.142 281.897h2.5a2 2 0 0 1 2 2v98.625a2 2 0 0 1-2 2h-2.5V281.897zM.642 346.332a2 2 0 0 1 2-2h2.5v63.996h-2.5a2 2 0 0 1-2-2v-59.996zm0-85a2 2 0 0 1 2-2h2.5v63.996h-2.5a2 2 0 0 1-2-2v-59.996z\" fill=\"#222\"/><path d=\"M.642 198.332a2 2 0 0 1 2-2h2.5v32h-2.5a2 2 0 0 1-2-2v-28z\" fill=\"#444\"/><path d=\"M142.741 18.949V6.174h189.802v12.775h-8.775a4 4 0 0 0-4 4v7.215c0 12.15-9.85 22-22 22H179.341c-12.15 0-22-9.85-22-22v-7.215a4 4 0 0 0-4-4h-10.6z\" fill=\"#000\"/><circle opacity=\".34\" cx=\"180.533\" cy=\"30.219\" r=\"5.62\" fill=\"url(#A)\"/><circle opacity=\".34\" cx=\"182.87\" cy=\"32.417\" r=\"1.283\" fill=\"url(#B)\"/><path opacity=\".2\" fill-rule=\"evenodd\" d=\"M185.166 30.219l.02-.436c0-2.569-2.083-4.652-4.653-4.652s-4.652 2.083-4.652 4.652l.02.436c.22-2.365 2.21-4.216 4.632-4.216s4.413 1.851 4.633 4.216z\" fill=\"#fff\"/><defs><linearGradient id=\"A\" x1=\"183.117\" y1=\"29.357\" x2=\"174.914\" y2=\"29.493\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#2983ab\"/><stop offset=\"1\" stop-color=\"#1948ab\"/></linearGradient><linearGradient id=\"B\" x1=\"181.148\" y1=\"31.133\" x2=\"184.153\" y2=\"33.7\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#fff\" stop-opacity=\"0\"/><stop offset=\"1\" stop-color=\"#fff\"/></linearGradient></defs></svg>"
// const iphone14ProSvg = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"436\" height=\"891\" fill=\"none\" xmlns:v=\"https://vecta.io/nano\"><rect x=\"11.904\" y=\"10.472\" width=\"412\" height=\"871\" rx=\"61.5\" stroke=\"#b6b6b6\" stroke-width=\"19\"/><rect x=\"12.904\" y=\"11.472\" width=\"410\" height=\"869\" rx=\"60.5\" stroke=\"#000\" stroke-width=\"17\"/><rect x=\"154.5\" y=\"31.483\" width=\"123\" height=\"36\" rx=\"18\" fill=\"#000\"/><circle opacity=\".34\" cx=\"174.033\" cy=\"49.483\" r=\"5.62\" fill=\"url(#A)\"/><circle opacity=\".34\" cx=\"176.37\" cy=\"51.681\" r=\"1.283\" fill=\"url(#B)\"/><path opacity=\".2\" fill-rule=\"evenodd\" d=\"M178.666 49.483l.02-.436c0-2.569-2.083-4.652-4.653-4.652s-4.652 2.083-4.652 4.652l.02.436c.22-2.365 2.21-4.217 4.632-4.217s4.413 1.851 4.633 4.216z\" fill=\"#fff\"/><path d=\"M431.284 263.897h2.5a2 2 0 0 1 2 2v98.625a2 2 0 0 1-2 2h-2.5V263.897zM0 328.332a2 2 0 0 1 2-2h2.5v63.996H2a2 2 0 0 1-2-2v-59.996zm0-85a2 2 0 0 1 2-2h2.5v63.996H2a2 2 0 0 1-2-2v-59.996z\" fill=\"#222\"/><path d=\"M0 180.332a2 2 0 0 1 2-2h2.5v32H2a2 2 0 0 1-2-2v-28z\" fill=\"#444\"/><defs><linearGradient id=\"A\" x1=\"176.617\" y1=\"48.621\" x2=\"168.414\" y2=\"48.757\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#2983ab\"/><stop offset=\"1\" stop-color=\"#1948ab\"/></linearGradient><linearGradient id=\"B\" x1=\"174.648\" y1=\"50.397\" x2=\"177.653\" y2=\"52.964\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#fff\" stop-opacity=\"0\"/><stop offset=\"1\" stop-color=\"#fff\"/></linearGradient></defs></svg>"
// const iphone14ProMaxSvg = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"473\" height=\"971\" fill=\"none\" xmlns:v=\"https://vecta.io/nano\"><rect x=\"11.904\" y=\"10.472\" width=\"449\" height=\"951\" rx=\"61.5\" stroke=\"#b6b6b6\" stroke-width=\"19\"/><rect x=\"12.904\" y=\"11.472\" width=\"447\" height=\"949\" rx=\"60.5\" stroke=\"#000\" stroke-width=\"17\"/><rect x=\"172.5\" y=\"31.483\" width=\"128\" height=\"36\" rx=\"18\" fill=\"#000\"/><circle opacity=\".34\" cx=\"192.033\" cy=\"49.483\" r=\"5.62\" fill=\"url(#A)\"/><circle opacity=\".34\" cx=\"194.37\" cy=\"51.681\" r=\"1.283\" fill=\"url(#B)\"/><path opacity=\".2\" fill-rule=\"evenodd\" d=\"M196.666 49.483l.02-.436c0-2.569-2.083-4.652-4.653-4.652s-4.652 2.083-4.652 4.652l.02.436c.22-2.365 2.21-4.217 4.632-4.217s4.413 1.851 4.633 4.216z\" fill=\"#fff\"/><path d=\"M468.284 263.897h2.5a2 2 0 0 1 2 2v98.625a2 2 0 0 1-2 2h-2.5V263.897zM0 328.332a2 2 0 0 1 2-2h2.5v63.996H2a2 2 0 0 1-2-2v-59.996zm0-85a2 2 0 0 1 2-2h2.5v63.996H2a2 2 0 0 1-2-2v-59.996z\" fill=\"#222\"/><path d=\"M0 180.332a2 2 0 0 1 2-2h2.5v32H2a2 2 0 0 1-2-2v-28z\" fill=\"#444\"/><defs><linearGradient id=\"A\" x1=\"194.617\" y1=\"48.621\" x2=\"186.414\" y2=\"48.757\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#2983ab\"/><stop offset=\"1\" stop-color=\"#1948ab\"/></linearGradient><linearGradient id=\"B\" x1=\"192.648\" y1=\"50.397\" x2=\"195.653\" y2=\"52.964\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#fff\" stop-opacity=\"0\"/><stop offset=\"1\" stop-color=\"#fff\"/></linearGradient></defs></svg>"
const screenResolutions = {
iPhone12Pro: {
margin: '18px 0 0 21px',
screen: {
width: 391,
height: 845,
},
shell: {
width: 438,
height: 883,
},
},
iPhone12ProMax: {
margin: '18px 0 0 21px',
screen: {
width: 429,
height: 927,
},
shell: {
width: 476,
height: 965,
},
},
iPhone14Pro: {
margin: '21px 0 0 23px',
screen: {
width: 394,
height: 853,
},
shell: {
width: 436,
height: 891,
},
},
iPhone14ProMax: {
margin: '21px 0 0 23px',
screen: {
width: 431,
height: 933,
},
shell: {
width: 473,
height: 971,
},
},
};
/**
* @param modelName - device.safeDescription from DeviceKit
* @returns - returns a phone shell svg and styles for inner screen dimensions
* plus margins from shell
* _______
* currently mapped: 12, 12pro, map, 13, 13pro, max, 14, 14 pro, 14 max/plus
*
* everything else is considered as 12 pro
* */
export function mapIphoneModel(modelName: string) {
const iPhone12Pro = [
"iPhone 12",
"iPhone 12 Pro",
"iPhone 13",
"iPhone 13 Pro",
"iPhone 14"
];
const iPhone12ProMax = [
"iPhone 12 Pro Max",
"iPhone 13 Pro Max",
"iPhone 14 Plus"
];
const iPhone14Pro = [
"iPhone 14 Pro"
];
const iPhone14ProMax = [
"iPhone 14 Pro Max"
];
if (iPhone12Pro.includes(modelName)) {
return { svg: iPhone12ProSvg2, styles: screenResolutions.iPhone12Pro } as const;
} else if (iPhone12ProMax.includes(modelName)) {
return { svg: iPhone12ProMaxSvg2, styles: screenResolutions.iPhone12ProMax } as const;
} else if (iPhone14Pro.includes(modelName)) {
return { svg: iphone14ProSvg2, styles: screenResolutions.iPhone14Pro } as const;
} else if (iPhone14ProMax.includes(modelName)) {
return { svg: iphone14ProMaxSvg2, styles: screenResolutions.iPhone14ProMax } as const;
} else {
return { svg: iPhone12ProSvg2, styles: screenResolutions.iPhone12Pro} as const; // Default fallback
}
}

View file

@ -1,5 +1,5 @@
import type { Store, Interval } from '../common/types';
import MessageManager from 'App/player/web/MessageManager'
import {Message} from "Player/web/messages";
import type { Store, Interval } from 'Player';
const fps = 60
@ -21,6 +21,18 @@ const cancelAnimationFrame =
window.mozCancelAnimationFrame ||
window.clearTimeout
export interface IMessageManager {
onFileReadSuccess(): void;
onFileReadFailed(e: any): void;
onFileReadFinally(): void;
startLoading(): void;
resetMessageManagers(): void;
move(t: number): any;
distributeMessage(msg: Message): void;
setMessagesLoading(messagesLoading: boolean): void;
clean(): void;
_sortMessagesHack: (msgs: Message[]) => void;
}
export interface SetState {
time: number
@ -56,7 +68,7 @@ export default class Animator {
private animationFrameRequestId: number = 0
constructor(private store: Store<GetState>, private mm: MessageManager) {
constructor(private store: Store<GetState>, private mm: IMessageManager) {
// @ts-ignore
window.playerJump = this.jump.bind(this)

View file

@ -1,11 +1,10 @@
import * as typedLocalStorage from './localStorage';
import type { Store } from '../common/types';
import Animator from './Animator';
import Animator, { IMessageManager } from './Animator';
import type { GetState as AnimatorGetState } from './Animator';
import MessageManager from "Player/web/MessageManager";
export const SPEED_OPTIONS = [0.5, 1, 2, 4, 8, 16]
import type { Message } from "Player/web/messages";
/* == separate this == */
const HIGHEST_SPEED = 16
@ -35,7 +34,7 @@ export default class Player extends Animator {
speed: initialSpeed,
} as const
constructor(private pState: Store<State & AnimatorGetState>, private manager: MessageManager) {
constructor(private pState: Store<State & AnimatorGetState>, private manager: IMessageManager) {
super(pState, manager)
// Autoplay
@ -114,4 +113,4 @@ export default class Player extends Animator {
this.manager.clean()
}
}
}

View file

@ -1,4 +1,5 @@
import type { Store, SessionFilesInfo } from 'Player';
import {IMessageManager} from "Player/player/Animator";
import { decryptSessionBytes } from './network/crypto';
import MFileReader from './messages/MFileReader';
import { loadFiles, requestEFSDom, requestEFSDevtools } from './network/loadFiles';
@ -6,7 +7,6 @@ import type {
Message,
} from './messages';
import logger from 'App/logger';
import MessageManager from "Player/web/MessageManager";
interface State {
@ -27,7 +27,7 @@ export default class MessageLoader {
constructor(
private readonly session: SessionFilesInfo,
private store: Store<State>,
private messageManager: MessageManager,
private messageManager: IMessageManager,
private isClickmap: boolean,
private uiErrorHandler?: { error: (msg: string) => void }
) {}

View file

@ -74,7 +74,7 @@ export default class Cursor {
}
click() {
const styleList = this.isMobile ? styles.clickedMobile : styles.clicked
const styleList = styles.clicked
this.cursor.classList.add(styleList)
this.onClick?.()
setTimeout(() => {
@ -82,6 +82,22 @@ export default class Cursor {
}, 600)
}
clickTimeout?: NodeJS.Timeout
mobileClick() {
const styleList = styles.mobileTouch
if (this.clickTimeout) {
clearTimeout(this.clickTimeout)
this.cursor.classList.remove(styleList)
this.clickTimeout = undefined
}
this.cursor.classList.add(styleList)
this.onClick?.()
this.clickTimeout = setTimeout(() => {
this.cursor.classList.remove(styleList)
this.clickTimeout = undefined
}, 600)
}
setOnClickHook(callback: () => void) {
this.onClick = callback
}

View file

@ -84,6 +84,15 @@ export default class Screen {
this.cursor = new Cursor(this.overlay, isMobile) // TODO: move outside
}
addMobileStyles() {
this.iframe.className = styles.mobileIframe
this.screen.className = styles.mobileScreen
}
addFullscreenBoundary() {
this.screen.className = styles.mobileScreenFullview
}
clean() {
this.iframe?.remove?.();
this.overlay?.remove?.();
@ -100,6 +109,13 @@ export default class Screen {
this.parentElement = parentElement;
}
addToBody(el: HTMLElement) {
if (this.document) {
this.document.body.style.margin = '0';
this.document.body.appendChild(el)
}
}
getParentElement(): HTMLElement | null {
return this.parentElement
}
@ -236,7 +252,7 @@ export default class Screen {
})
this.boundingRect = this.screen.getBoundingClientRect();
this.onUpdateHook(width, height)
this.onUpdateHook?.(width, height)
}
setOnUpdate(cb: any) {
@ -255,6 +271,10 @@ export default class Screen {
}, 750)
}
public updateOverlayStyle(style: Partial<CSSStyleDeclaration>) {
Object.assign(this.overlay.style, style)
}
public clearSelection() {
if (this.selectionTargets.start && this.selectionTargets.end) {
this.overlay.removeChild(this.selectionTargets.start);

View file

@ -68,9 +68,41 @@
}
}
.cursor.clickedMobile::after {
-webkit-animation: anim-effect-sanja 1s ease-out forwards;
animation: anim-effect-sanja 1s ease-out forwards;
.mobileTouch {
width: 25px;
height: 25px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 42 42'%3E%3Cstyle%3E @keyframes x2menhl6cupbu94auppeu01e_t %7B 0%25 %7B transform: translate(23px, 23px) scale(1, 1) translate(-2px, -2px); %7D 62.5%25 %7B transform: translate(23px, 23px) scale(1, 1) translate(-2px, -2px); animation-timing-function: cubic-bezier(0, 0, 0.6, 1); %7D 75%25 %7B transform: translate(23px, 23px) scale(0.9, 0.9) translate(-2px, -2px); animation-timing-function: cubic-bezier(0, 0, 0.6, 1); %7D 100%25 %7B transform: translate(23px, 23px) scale(1, 1) translate(-2px, -2px); %7D %7D @keyframes x2menhl6cupbu94auppeu01e_sw %7B 0%25 %7B stroke-width: 2px; %7D 62.5%25 %7B stroke-width: 2px; animation-timing-function: cubic-bezier(0, 0, 0.6, 1); %7D 75%25 %7B stroke-width: 5px; animation-timing-function: cubic-bezier(0, 0, 0.6, 1); %7D 100%25 %7B stroke-width: 2px; %7D %7D %3C/style%3E%3Cellipse rx='20' ry='20' fill='rgba(0,0,0,0.50)' stroke='%23fff' stroke-width='2' transform='translate(23,23) translate(-2,-2)' style='animation: 0.6s infinite linear both x2menhl6cupbu94auppeu01e_t, 0.6s infinite linear both x2menhl6cupbu94auppeu01e_sw;' /%3E%3C/svg%3E")!important;
}
@keyframes touch-animation {
0% {
transform: translate(23px, 23px) scale(1, 1) translate(-2px, -2px);
}
31% {
transform: translate(23px, 23px) scale(1, 1) translate(-2px, -2px);
animation-timing-function: cubic-bezier(0, 0, 0.6, 1);
}
47.5% {
transform: translate(23px, 23px) scale(0.9, 0.9) translate(-2px, -2px);
animation-timing-function: cubic-bezier(0, 0, 0.6, 1);
}
50% {
transform: translate(23px, 23px) scale(1, 1) translate(-2px, -2px);
}
51% {
stroke-width: 2px;
}
62.5% {
stroke-width: 2px;
animation-timing-function: cubic-bezier(0, 0, 0.6, 1);
}
75% {
stroke-width: 5px;
animation-timing-function: cubic-bezier(0, 0, 0.6, 1);
}
100% {
stroke-width: 2px;
}
}
@-webkit-keyframes anim-effect-sanja {

View file

@ -24,4 +24,33 @@
.highlightoff {
opacity: 0;
transition: all 0.25s cubic-bezier(0, 0, 0.4, 1.0);
}
.mobileScreen {
user-select: none;
position: absolute;
transform-origin: left top;
top: 50%;
left: 50%;
background-color: rgb(223, 223, 223);
border-radius: 70px;
box-shadow: 0 0 30px 0 rgba(0,0,0,0.2); /* 0 0 70px 30px rgba(0,0,0,0.1); */
margin-top: -20px;
}
.mobileScreenFullview {
user-select: none;
position: absolute;
transform-origin: left top;
top: 50%;
left: 50%;
background-color: rgb(223, 223, 223);
border-radius: 70px;
box-shadow: 0 0 30px 0 rgba(0,0,0,0.2);
margin-top: 0!important;
}
.mobileIframe {
position: absolute;
border: none;
background: none;
}

View file

@ -5,6 +5,8 @@
const LINE_DURATION = 3.5;
const LINE_WIDTH_START = 5;
export type SwipeEvent = { x: number; y: number; direction: 'up' | 'down' | 'left' | 'right' }
export default class MouseTrail {
public isActive = true;
public context: CanvasRenderingContext2D;
@ -13,13 +15,15 @@ export default class MouseTrail {
* 1 - every frame,
* 2 - every 2nd frame
* and so on, doesn't always work properly
* but 1 doesnt affect performance so we fine
* but 1 doesnt affect performance so we're fine
* */
private drawOnFrame = 1;
private currentFrame = 0;
private lineDuration = LINE_DURATION;
private points: Point[] = [];
private swipePoints: Point[] = []
constructor(private readonly canvas: HTMLCanvasElement) {
constructor(private readonly canvas: HTMLCanvasElement, isNativeMobile: boolean = false) {
// @ts-ignore patching window
window.requestAnimFrame =
window.requestAnimationFrame ||
@ -34,6 +38,10 @@ export default class MouseTrail {
function (callback: any) {
window.setTimeout(callback, 1000 / 60);
};
if (isNativeMobile) {
this.lineDuration = 5
}
}
resizeCanvas = (w: number, h: number) => {
@ -73,10 +81,31 @@ export default class MouseTrail {
}
};
createSwipeTrail = ({ x, y, direction }: SwipeEvent) => {
const startCoords = this.calculateTrail({ x, y, direction });
this.addPoint(startCoords.x, startCoords.y);
this.addPoint(x, y);
}
calculateTrail = ({ x, y, direction }: SwipeEvent) => {
switch (direction) {
case 'up':
return { x, y: y - 20 };
case 'down':
return { x, y: y + 20 };
case 'left':
return { x: x - 20, y };
case 'right':
return { x: x + 20, y };
default:
return { x, y };
}
}
animatePoints = () => {
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height);
const duration = (LINE_DURATION * 1000) / 60;
const duration = (this.lineDuration * 1000) / 60;
let point, lastPoint;
for (let i = 0; i < this.points.length; i++) {

View file

@ -14,10 +14,9 @@ class SkipIntervalCls {
export type SkipInterval = InstanceType<typeof SkipIntervalCls>;
export default class ActivityManager extends ListWalker<SkipInterval> {
private endTime: number = 0;
private minInterval: number = 0;
private readonly endTime: number = 0;
private readonly minInterval: number = 0;
private lastActivity: number = 0;
constructor(duration: number) {
super();

View file

@ -9,7 +9,7 @@ import { MOUSE_TRAIL } from "App/constants/storageKeys";
export default class MouseMoveManager extends ListWalker<MouseMove> {
private hoverElements: Array<Element> = []
private mouseTrail: MouseTrail | undefined
private removeMouseTrail = false
private readonly removeMouseTrail: boolean = false
constructor(private screen: Screen) {
super()

View file

@ -101,7 +101,7 @@ export default class MFileReader extends RawMessageReader {
const index = this.noIndexes ? 0 : this.getLastMessageID()
const msg = Object.assign(rewriteMessage(rMsg), {
time: this.currentTime,
time: this.currentTime || rMsg.timestamp - this.startTime!,
tabId: this.currentTab,
}, !this.noIndexes ? { _index: index } : {})

View file

@ -719,7 +719,7 @@ export default class RawMessageReader extends PrimitiveReader {
const name = this.readString(); if (name === null) { return resetPointer() }
const payload = this.readString(); if (payload === null) { return resetPointer() }
return {
tp: MType.IosCustomEvent,
tp: MType.IosEvent,
timestamp,
length,
name,
@ -805,6 +805,18 @@ export default class RawMessageReader extends PrimitiveReader {
};
}
case 104: {
const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() }
const length = this.readUint(); if (length === null) { return resetPointer() }
const content = this.readString(); if (content === null) { return resetPointer() }
return {
tp: MType.IosInternalError,
timestamp,
length,
content,
};
}
case 105: {
const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() }
const length = this.readUint(); if (length === null) { return resetPointer() }
@ -847,6 +859,22 @@ export default class RawMessageReader extends PrimitiveReader {
};
}
case 111: {
const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() }
const type = this.readString(); if (type === null) { return resetPointer() }
const contextString = this.readString(); if (contextString === null) { return resetPointer() }
const context = this.readString(); if (context === null) { return resetPointer() }
const payload = this.readString(); if (payload === null) { return resetPointer() }
return {
tp: MType.IosIssueEvent,
timestamp,
type,
contextString,
context,
payload,
};
}
default:
throw new Error(`Unrecognizable message type: ${ tp }; Pointer at the position ${this.p} of ${this.buf.length}`)
return null;

View file

@ -3,7 +3,8 @@
import { MType } from './raw.gen'
const DOM_TYPES = [0,4,5,6,7,8,9,10,11,12,13,14,15,16,18,19,20,37,38,49,50,51,54,55,57,58,59,60,61,67,69,70,71,72,73,74,75,76,77,113,114,117,118,93,96,100,101,102,103,105,106]
const IOS_TYPES = [90,91,92,93,94,95,96,97,98,100,101,102,103,104,105,106,107,110,111]
const DOM_TYPES = [0,4,5,6,7,8,9,10,11,12,13,14,15,16,18,19,20,37,38,49,50,51,54,55,57,58,59,60,61,67,69,70,71,72,73,74,75,76,77,113,114,117,118]
export function isDOMType(t: MType) {
return DOM_TYPES.includes(t)
}

View file

@ -61,14 +61,16 @@ import type {
RawResourceTiming,
RawTabChange,
RawTabData,
RawIosCustomEvent,
RawIosEvent,
RawIosScreenChanges,
RawIosClickEvent,
RawIosInputEvent,
RawIosPerformanceEvent,
RawIosLog,
RawIosInternalError,
RawIosNetworkCall,
RawIosSwipeEvent,
RawIosIssueEvent,
} from './raw.gen'
export type Message = RawMessage & Timed
@ -188,7 +190,7 @@ export type TabChange = RawTabChange & Timed
export type TabData = RawTabData & Timed
export type IosCustomEvent = RawIosCustomEvent & Timed
export type IosEvent = RawIosEvent & Timed
export type IosScreenChanges = RawIosScreenChanges & Timed
@ -200,7 +202,11 @@ export type IosPerformanceEvent = RawIosPerformanceEvent & Timed
export type IosLog = RawIosLog & Timed
export type IosInternalError = RawIosInternalError & Timed
export type IosNetworkCall = RawIosNetworkCall & Timed
export type IosSwipeEvent = RawIosSwipeEvent & Timed
export type IosIssueEvent = RawIosIssueEvent & Timed

View file

@ -59,14 +59,16 @@ export const enum MType {
ResourceTiming = 116,
TabChange = 117,
TabData = 118,
IosCustomEvent = 93,
IosEvent = 93,
IosScreenChanges = 96,
IosClickEvent = 100,
IosInputEvent = 101,
IosPerformanceEvent = 102,
IosLog = 103,
IosInternalError = 104,
IosNetworkCall = 105,
IosSwipeEvent = 106,
IosIssueEvent = 111,
}
@ -474,8 +476,8 @@ export interface RawTabData {
tabId: string,
}
export interface RawIosCustomEvent {
tp: MType.IosCustomEvent,
export interface RawIosEvent {
tp: MType.IosEvent,
timestamp: number,
length: number,
name: string,
@ -526,6 +528,13 @@ export interface RawIosLog {
content: string,
}
export interface RawIosInternalError {
tp: MType.IosInternalError,
timestamp: number,
length: number,
content: string,
}
export interface RawIosNetworkCall {
tp: MType.IosNetworkCall,
timestamp: number,
@ -549,5 +558,14 @@ export interface RawIosSwipeEvent {
direction: string,
}
export interface RawIosIssueEvent {
tp: MType.IosIssueEvent,
timestamp: number,
type: string,
contextString: string,
context: string,
payload: string,
}
export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequestDeprecated | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTimingDeprecated | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawNetworkRequest | RawSelectionChange | RawMouseThrashing | RawResourceTiming | RawTabChange | RawTabData | RawIosCustomEvent | RawIosScreenChanges | RawIosClickEvent | RawIosInputEvent | RawIosPerformanceEvent | RawIosLog | RawIosNetworkCall | RawIosSwipeEvent;
export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequestDeprecated | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTimingDeprecated | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawNetworkRequest | RawSelectionChange | RawMouseThrashing | RawResourceTiming | RawTabChange | RawTabData | RawIosEvent | RawIosScreenChanges | RawIosClickEvent | RawIosInputEvent | RawIosPerformanceEvent | RawIosLog | RawIosInternalError | RawIosNetworkCall | RawIosSwipeEvent | RawIosIssueEvent;

View file

@ -60,12 +60,14 @@ export const TP_MAP = {
116: MType.ResourceTiming,
117: MType.TabChange,
118: MType.TabData,
93: MType.IosCustomEvent,
93: MType.IosEvent,
96: MType.IosScreenChanges,
100: MType.IosClickEvent,
101: MType.IosInputEvent,
102: MType.IosPerformanceEvent,
103: MType.IosLog,
104: MType.IosInternalError,
105: MType.IosNetworkCall,
106: MType.IosSwipeEvent,
111: MType.IosIssueEvent,
} as const

View file

@ -1,4 +1,4 @@
import type { ResourceTiming, NetworkRequest, Fetch } from '../messages'
import type {ResourceTiming, NetworkRequest, Fetch, IosNetworkCall} from '../messages'
export const enum ResourceType {
XHR = 'xhr',
@ -103,7 +103,7 @@ export function getResourceFromResourceTiming(msg: ResourceTiming, sessStart: nu
})
}
export function getResourceFromNetworkRequest(msg: NetworkRequest | Fetch, sessStart: number) {
export function getResourceFromNetworkRequest(msg: NetworkRequest | Fetch | IosNetworkCall, sessStart: number) {
return Resource({
...msg,
// @ts-ignore

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-battery-charging" viewBox="0 0 16 16">
<path d="M9.585 2.568a.5.5 0 0 1 .226.58L8.677 6.832h1.99a.5.5 0 0 1 .364.843l-5.334 5.667a.5.5 0 0 1-.842-.49L5.99 9.167H4a.5.5 0 0 1-.364-.843l5.333-5.667a.5.5 0 0 1 .616-.09z"/>
<path d="M2 4h4.332l-.94 1H2a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h2.38l-.308 1H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/>
<path d="M2 6h2.45L2.908 7.639A1.5 1.5 0 0 0 3.313 10H2V6zm8.595-2-.308 1H12a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H9.276l-.942 1H12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1.405z"/>
<path d="M12 10h-1.783l1.542-1.639c.097-.103.178-.218.241-.34V10zm0-3.354V6h-.646a1.5 1.5 0 0 1 .646.646zM16 8a1.5 1.5 0 0 1-1.5 1.5v-3A1.5 1.5 0 0 1 16 8z"/>
</svg>

After

Width:  |  Height:  |  Size: 719 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-battery" viewBox="0 0 16 16">
<path d="M0 6a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6zm2-1a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H2zm14 3a1.5 1.5 0 0 1-1.5 1.5v-3A1.5 1.5 0 0 1 16 8z"/>
</svg>

After

Width:  |  Height:  |  Size: 294 B

View file

@ -0,0 +1,10 @@
<svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_82_1859)">
<path d="M10.7141 6.08031H5.91944V1.28567C5.91944 1.22674 5.87123 1.17852 5.8123 1.17852H5.46408C4.76056 1.17737 4.06375 1.31534 3.41374 1.58449C2.76373 1.85364 2.17336 2.24866 1.67658 2.74683C1.18736 3.23454 0.797544 3.81267 0.528817 4.44906C0.249181 5.10911 0.105689 5.81883 0.106942 6.53567C0.10579 7.23919 0.243758 7.93601 0.512909 8.58601C0.78206 9.23602 1.17708 9.82639 1.67525 10.3232C2.16676 10.8147 2.73998 11.2017 3.37748 11.4709C4.03753 11.7506 4.74724 11.8941 5.46408 11.8928C6.16761 11.894 6.86442 11.756 7.51443 11.4868C8.16444 11.2177 8.75481 10.8227 9.25158 10.3245C9.7431 9.83299 10.1302 9.25978 10.3994 8.62228C10.679 7.96222 10.8225 7.25251 10.8212 6.53567V6.18745C10.8212 6.12852 10.773 6.08031 10.7141 6.08031ZM8.594 9.6937C8.17928 10.1052 7.68745 10.4308 7.14669 10.6519C6.60593 10.873 6.02686 10.9852 5.44266 10.9821C4.26275 10.9767 3.15382 10.5147 2.31944 9.68031C1.47971 8.84058 1.01766 7.72361 1.01766 6.53567C1.01766 5.34772 1.47971 4.23076 2.31944 3.39103C3.05069 2.65978 3.99221 2.21379 5.00873 2.11201V6.99103H9.88775C9.78462 8.0129 9.33462 8.95978 8.594 9.6937ZM11.8927 5.33567L11.8578 4.95799C11.744 3.72451 11.1962 2.56067 10.315 1.6821C9.43312 0.801913 8.27166 0.257184 7.03105 0.141918L6.65203 0.107096C6.58908 0.101739 6.53551 0.149953 6.53551 0.2129V5.3571C6.53551 5.41602 6.58373 5.46424 6.64266 5.46424L11.7855 5.45085C11.8485 5.45085 11.898 5.39727 11.8927 5.33567ZM7.44355 4.5562V1.1196C8.28721 1.29618 9.06143 1.71399 9.67212 2.32227C10.2842 2.93299 10.7034 3.70977 10.8775 4.54683L7.44355 4.5562Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_82_1859">
<rect width="12" height="12" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-memory" viewBox="0 0 16 16">
<path d="M1 3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h4.586a1 1 0 0 0 .707-.293l.353-.353a.5.5 0 0 1 .708 0l.353.353a1 1 0 0 0 .707.293H15a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H1Zm.5 1h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-4a.5.5 0 0 1 .5-.5Zm5 0h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-4a.5.5 0 0 1 .5-.5Zm4.5.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-4ZM2 10v2H1v-2h1Zm2 0v2H3v-2h1Zm2 0v2H5v-2h1Zm3 0v2H8v-2h1Zm2 0v2h-1v-2h1Zm2 0v2h-1v-2h1Zm2 0v2h-1v-2h1Z"/>
</svg>

After

Width:  |  Height:  |  Size: 603 B

Some files were not shown because too many files have changed in this diff Show more