[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, messages.MsgUnbindNodes,
// Mobile messages // Mobile messages
messages.MsgIOSSessionStart, messages.MsgIOSSessionEnd, messages.MsgIOSUserID, messages.MsgIOSUserAnonymousID, 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.MsgIOSClickEvent, messages.MsgIOSSwipeEvent, messages.MsgIOSInputEvent,
messages.MsgIOSCrash, messages.MsgIOSIssueEvent, messages.MsgIOSCrash, messages.MsgIOSIssueEvent,
} }

View file

@ -72,21 +72,21 @@ func (s *saverImpl) handleMobileMessage(msg Message) error {
case *IOSSessionEnd: case *IOSSessionEnd:
return s.pg.InsertIOSSessionEnd(m.SessionID(), m) return s.pg.InsertIOSSessionEnd(m.SessionID(), m)
case *IOSUserID: 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 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 return nil
case *IOSUserAnonymousID: 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 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 return nil
case *IOSMetadata: case *IOSMetadata:
return s.sessions.UpdateMetadata(m.SessionID(), m.Key, m.Value) return s.sessions.UpdateMetadata(m.SessionID(), m.Key, m.Value)
case *IOSCustomEvent: case *IOSEvent:
return s.pg.InsertIOSCustomEvent(session, m) return s.pg.InsertIOSEvent(session, m)
case *IOSClickEvent: case *IOSClickEvent:
if err := s.pg.InsertIOSClickEvent(session, m); err != nil { if err := s.pg.InsertIOSClickEvent(session, m); err != nil {
return err return err

View file

@ -18,7 +18,7 @@ func (conn *Conn) InsertIOSSessionEnd(sessionID uint64, e *messages.IOSSessionEn
return nil 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 { if err := conn.InsertCustomEvent(session.SessionID, e.Timestamp, truncSqIdx(e.Index), e.Name, e.Payload); err != nil {
return err return err
} }

View file

@ -2,7 +2,7 @@
package messages package messages
func IsReplayerType(id int) bool { 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 { func IsIOSType(id int) bool {
@ -10,5 +10,5 @@ func IsIOSType(id int) bool {
} }
func IsDOMType(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: case *IOSMetadata:
return msg.Timestamp return msg.Timestamp
case *IOSCustomEvent: case *IOSEvent:
return msg.Timestamp return msg.Timestamp
case *IOSUserID: case *IOSUserID:

View file

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

View file

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

View file

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

View file

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

View file

@ -1258,7 +1258,7 @@ cdef class IOSMetadata(PyMessage):
self.value = value self.value = value
cdef class IOSCustomEvent(PyMessage): cdef class IOSEvent(PyMessage):
cdef public int __id__ cdef public int __id__
cdef public unsigned long timestamp cdef public unsigned long timestamp
cdef public unsigned long length cdef public unsigned long length
@ -1277,26 +1277,26 @@ cdef class IOSUserID(PyMessage):
cdef public int __id__ cdef public int __id__
cdef public unsigned long timestamp cdef public unsigned long timestamp
cdef public unsigned long length 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.__id__ = 94
self.timestamp = timestamp self.timestamp = timestamp
self.length = length self.length = length
self.value = value self.id = id
cdef class IOSUserAnonymousID(PyMessage): cdef class IOSUserAnonymousID(PyMessage):
cdef public int __id__ cdef public int __id__
cdef public unsigned long timestamp cdef public unsigned long timestamp
cdef public unsigned long length 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.__id__ = 95
self.timestamp = timestamp self.timestamp = timestamp
self.length = length self.length = length
self.value = value self.id = id
cdef class IOSScreenChanges(PyMessage): cdef class IOSScreenChanges(PyMessage):

View file

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

View file

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

View file

@ -113,7 +113,7 @@ function Integrations(props: Props) {
<div className='mb-4' /> <div className='mb-4' />
{filteredIntegrations.map((cat: any) => ( {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) => ( {cat.integrations.map((integration: any) => (
<IntegrationItem <IntegrationItem
integrated={integratedList.includes(integration.slug)} integrated={integratedList.includes(integration.slug)}

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect, ChangeEvent, FormEvent } from 'react'; import React, { useState, useEffect, ChangeEvent, FormEvent } from 'react';
import { connect, ConnectedProps } from 'react-redux'; 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 { save, edit, update, fetchList, remove } from 'Duck/site';
import { pushNewSite } from 'Duck/user'; import { pushNewSite } from 'Duck/user';
import { setSiteId } from 'Duck/site'; import { setSiteId } from 'Duck/site';
@ -115,6 +115,20 @@ const NewSiteForm = ({
className={styles.input} className={styles.input}
/> />
</Form.Field> </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"> <div className="mt-6 flex justify-between">
<Button <Button
variant="primary" variant="primary"

View file

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

View file

@ -79,7 +79,7 @@ export default class SiteDropdown extends React.PureComponent {
<div className="border-b border-dashed my-1" /> <div className="border-b border-dashed my-1" />
{sites.map((site) => ( {sites.map((site) => (
<li key={site.id} onClick={() => this.switchSite(site.id)}> <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> <span className="ml-3">{site.host}</span>
</li> </li>
))} ))}

View file

@ -1,3 +1,4 @@
import { Segmented } from 'antd';
import React from 'react'; import React from 'react';
import CircleNumber from '../CircleNumber'; import CircleNumber from '../CircleNumber';
import MetadataList from '../MetadataList/MetadataList'; import MetadataList from '../MetadataList/MetadataList';
@ -10,6 +11,32 @@ import withPageTitle from 'App/components/hocs/withPageTitle';
interface Props extends WithOnboardingProps {} interface Props extends WithOnboardingProps {}
function IdentifyUsersTab(props: Props) { 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 ( return (
<> <>
<h1 className="flex items-center px-4 py-3 border-b justify-between"> <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 className="ml-3">Identify Users</div>
</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"> <Button variant="text-primary" icon="question-circle" className="ml-2">
See Documentation See Documentation
</Button> </Button>
</a> </a>
</h1> </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="grid grid-cols-6 gap-4 w-full p-4">
<div className="col-span-4"> <div className="col-span-4">
<div> <div>
@ -34,12 +74,17 @@ function IdentifyUsersTab(props: Props) {
</div> </div>
</div> </div>
<div className="flex items-center my-2"> {platform.value === 'web' ? (
<Icon name="info-circle" color="gray-darkest" /> <HighlightCode className="js" text={`tracker.setUserID('john@doe.com');`} />
<span className="ml-2">OpenReplay keeps the last communicated user ID.</span> ) : (
</div> <HighlightCode className="swift" text={`ORTracker.shared.setUserID('john@doe.com');`} />
)}
<HighlightCode className="js" text={`tracker.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>
<div className="col-span-2"> <div className="col-span-2">
<DocCard <DocCard
@ -78,7 +123,9 @@ function IdentifyUsersTab(props: Props) {
Use the <span className="highlight-blue">setMetadata</span> method in your code to 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). inject custom user data in the form of a key/value pair (string).
</div> </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> </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 React from 'react';
import OnboardingTabs from '../OnboardingTabs'; import OnboardingTabs from '../OnboardingTabs';
import MobileOnboardingTabs from '../OnboardingTabs/OnboardingMobileTabs'
import ProjectFormButton from '../ProjectFormButton'; import ProjectFormButton from '../ProjectFormButton';
import { Button, Icon } from 'UI'; import {Button, Icon } from 'UI';
import withOnboarding from '../withOnboarding'; import withOnboarding from '../withOnboarding';
import { WithOnboardingProps } from '../withOnboarding'; import { WithOnboardingProps } from '../withOnboarding';
import { OB_TABS } from 'App/routes'; import { OB_TABS } from 'App/routes';
import withPageTitle from 'App/components/hocs/withPageTitle'; import withPageTitle from 'App/components/hocs/withPageTitle';
import { Segmented } from "antd";
interface Props extends WithOnboardingProps {} interface Props extends WithOnboardingProps {}
function InstallOpenReplayTab(props: Props) { 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; const { site } = props;
React.useEffect(() => {
if (site.platform) setPlatform(platforms.find(({ value }) => value === site.platform) || platforms[0])
}, [site])
return ( return (
<> <>
<h1 className="flex items-center px-4 py-3 border-b justify-between"> <h1 className="flex items-center px-4 py-3 border-b justify-between">
@ -27,12 +38,18 @@ function InstallOpenReplayTab(props: Props) {
</Button> </Button>
</a> </a>
</h1> </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="p-4">
<div className="mb-6 text-lg font-medium"> {
Setup OpenReplay through NPM package <span className="text-sm">(recommended)</span> or platform.value === 'web' ? <Snippet site={site} /> : <MobileOnboardingTabs site={site} />
script. }
</div>
<OnboardingTabs site={site} />
</div> </div>
<div className="border-t px-4 py-3 flex justify-end"> <div className="border-t px-4 py-3 flex justify-end">
<Button <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)); export default withOnboarding(withPageTitle("Project Setup - OpenReplay")(InstallOpenReplayTab));

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,18 @@
import { createContext } from 'react'; import { createContext, Context } from 'react';
import { import {
IWebPlayer, IWebPlayer,
IIosPlayer,
IIOSPlayerStore,
IWebPlayerStore, IWebPlayerStore,
IWebLivePlayer, IWebLivePlayer,
IWebLivePlayerStore, IWebLivePlayerStore,
} from 'Player' } from 'Player'
export interface IOSPlayerContext {
player: IIosPlayer
store: IIOSPlayerStore
}
export interface IPlayerContext { export interface IPlayerContext {
player: IWebPlayer player: IWebPlayer
store: IWebPlayerStore, store: IWebPlayerStore,
@ -16,9 +23,15 @@ export interface ILivePlayerContext {
store: IWebLivePlayerStore store: IWebLivePlayerStore
} }
type ContextType = type WebContextType =
| IPlayerContext | IPlayerContext
| ILivePlayerContext | ILivePlayerContext
export const defaultContextValue = { player: undefined, store: undefined}
// @ts-ignore type MobileContextType = IOSPlayerContext
export const PlayerContext = createContext<ContextType>(defaultContextValue);
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 => { 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; return true;
} }
if (evt.type === TYPES.CLICK || evt.type === TYPES.INPUT) { if (evt.type === TYPES.CLICK || evt.type === TYPES.INPUT) {
@ -73,12 +73,22 @@ const Event: React.FC<Props> = ({
case TYPES.LOCATION: case TYPES.LOCATION:
title = 'Visited'; title = 'Visited';
body = event.url; 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; break;
case TYPES.CLICK: case TYPES.CLICK:
title = 'Clicked'; title = 'Clicked';
body = event.label; body = event.label;
icon = isFrustration ? 'click_hesitation' : 'click'; icon = isFrustration ? 'event/click_hesitation' : 'event/click';
isFrustration isFrustration
? Object.assign(tooltip, { ? Object.assign(tooltip, {
disabled: false, disabled: false,
@ -89,7 +99,7 @@ const Event: React.FC<Props> = ({
case TYPES.INPUT: case TYPES.INPUT:
title = 'Input'; title = 'Input';
body = event.value; body = event.value;
icon = isFrustration ? 'input_hesitation' : 'input'; icon = isFrustration ? 'event/input_hesitation' : 'event/input';
isFrustration isFrustration
? Object.assign(tooltip, { ? Object.assign(tooltip, {
disabled: false, disabled: false,
@ -98,18 +108,19 @@ const Event: React.FC<Props> = ({
: null; : null;
break; break;
case TYPES.CLICKRAGE: case TYPES.CLICKRAGE:
title = `${event.count} Clicks`; case TYPES.TAPRAGE:
title = event.count ? `${event.count} Clicks` : 'Click Rage';
body = event.label; body = event.label;
icon = 'clickrage'; icon = 'event/clickrage';
break; break;
case TYPES.IOS_VIEW: case TYPES.IOS_VIEW:
title = 'View'; title = 'View';
body = event.name; body = event.name;
icon = 'ios_view'; icon = 'event/ios_view';
break; break;
case 'mouse_thrashing': case 'mouse_thrashing':
title = 'Mouse Thrashing'; title = 'Mouse Thrashing';
icon = 'mouse_thrashing'; icon = 'event/mouse_thrashing';
break; break;
} }
@ -123,7 +134,7 @@ const Event: React.FC<Props> = ({
> >
<div className={cn(cls.main, 'flex flex-col w-full')}> <div className={cn(cls.main, 'flex flex-col w-full')}>
<div className={cn('flex items-center w-full', { 'px-4': isLocation })}> <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='ml-3 w-full'>
<div className='flex w-full items-first justify-between'> <div className='flex w-full items-first justify-between'>
<div className='flex items-center w-full' style={{ minWidth: '0' }}> <div className='flex items-center w-full' style={{ minWidth: '0' }}>
@ -160,6 +171,7 @@ const Event: React.FC<Props> = ({
const isFrustration = isFrustrationEvent(event); const isFrustration = isFrustrationEvent(event);
const mobileTypes = [TYPES.TOUCH, TYPES.SWIPE, TYPES.TAPRAGE]
return ( return (
<div <div
ref={wrapperRef} ref={wrapperRef}
@ -172,12 +184,13 @@ const Event: React.FC<Props> = ({
[cls.selected]: selected, [cls.selected]: selected,
[cls.showSelection]: showSelection, [cls.showSelection]: showSelection,
[cls.red]: isRed, [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.inputType]: event.type === TYPES.INPUT,
[cls.frustration]: isFrustration, [cls.frustration]: isFrustration,
[cls.highlight]: presentInSearch, [cls.highlight]: presentInSearch,
[cls.lastInGroup]: whiteBg, [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} onClick={onClick}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}

View file

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

View file

@ -14,7 +14,7 @@ import {
import { error as errorRoute } from 'App/routes'; import { error as errorRoute } from 'App/routes';
import Autoscroll from '../Autoscroll'; import Autoscroll from '../Autoscroll';
import BottomBlock from '../BottomBlock'; 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'; import { observer } from 'mobx-react-lite';
interface IProps { interface IProps {
@ -23,7 +23,52 @@ interface IProps {
errorStack: Record<string, any>; 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 { player, store } = React.useContext(PlayerContext);
const { tabStates, currentTab } = store.get(); const { tabStates, currentTab } = store.get();
const { logListNow: logs = [], exceptionsList: exceptions = [] } = tabStates[currentTab] 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']), errorStack: state.getIn(['sessions', 'errorStack']),
sourcemapUploaded: state.getIn(['sessions', 'sourcemapUploaded']), sourcemapUploaded: state.getIn(['sessions', 'sourcemapUploaded']),
loading: state.getIn(['sessions', 'fetchErrorStackList', 'loading']), 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 OverviewPanelContainer from './components/OverviewPanelContainer';
import { NoContent, Icon } from 'UI'; import { NoContent, Icon } from 'UI';
import { observer } from 'mobx-react-lite'; 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 { store } = React.useContext(PlayerContext);
const [dataLoaded, setDataLoaded] = React.useState(false); const [dataLoaded, setDataLoaded] = React.useState(false);
const [selectedFeatures, setSelectedFeatures] = React.useState([ const [selectedFeatures, setSelectedFeatures] = React.useState([
@ -28,7 +91,6 @@ function OverviewPanel({ issuesList }: { issuesList: Record<string, any>[] }) {
currentTab, currentTab,
tabStates, tabStates,
} = store.get(); } = store.get();
const states = Object.values(tabStates)
const stackEventList = tabStates[currentTab]?.stackList || [] const stackEventList = tabStates[currentTab]?.stackList || []
const eventsList = tabStates[currentTab]?.eventList || [] const eventsList = tabStates[currentTab]?.eventList || []
@ -73,69 +135,101 @@ function OverviewPanel({ issuesList }: { issuesList: Record<string, any>[] }) {
} }
}, [resourceList, issuesList, exceptionsList, eventsList, stackEventList, performanceChartData, currentTab]); }, [resourceList, issuesList, exceptionsList, eventsList, stackEventList, performanceChartData, currentTab]);
return ( return <PanelComponent resources={resources} endTime={endTime} selectedFeatures={selectedFeatures} fetchPresented={fetchPresented} setSelectedFeatures={setSelectedFeatures} />
<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>
);
} }
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) => ({ (state: any) => ({
issuesList: state.getIn(['sessions', 'current']).issues, issuesList: state.getIn(['sessions', 'current']).issues,
}), }),
{ {
toggleBottomBlock, 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'; const PERFORMANCE = 'PERFORMANCE';
export const HELP_MESSAGE: any = { 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', 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', PERFORMANCE: 'Summary of this sessions memory, and CPU consumption on the timeline',
FRUSTRATIONS: 'Indicates user frustrations in the session', FRUSTRATIONS: 'Indicates user frustrations in the session',
}; };

View file

@ -76,8 +76,9 @@ const TimelinePointer = React.memo((props: Props) => {
const elData = { name: '', icon: ''} 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.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.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 === 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 ( return (
<Tooltip <Tooltip
@ -99,7 +100,7 @@ const TimelinePointer = React.memo((props: Props) => {
<Tooltip <Tooltip
title={ title={
<div className=""> <div className="">
<b>{'Stack Event'}</b> <b>{item.name || 'Stack Event'}</b>
</div> </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 React from 'react';
import { connect } from 'react-redux'; 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 { observer } from 'mobx-react-lite';
import { import {
AreaChart, 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 }) => { const HeapTooltip = ({ active, payload }) => {
if (!payload) return null; if (!payload) return null;
if (!active || payload.length < 2) 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 }) => { const NodesCountTooltip = ({ active, payload }) => {
if (!payload) return null; if (!payload) return null;
if (!active || !payload || payload.length === 0) 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({ function Performance({
userDeviceHeapSize, userDeviceHeapSize,
}: { }: {
@ -226,6 +458,7 @@ function Performance({
const availableCount = [fps, cpu, heap, nodes].reduce((c, av) => (av ? c + 1 : c), 0); const availableCount = [fps, cpu, heap, nodes].reduce((c, av) => (av ? c + 1 : c), 0);
const height = availableCount === 0 ? '0' : `${100 / availableCount}%`; const height = availableCount === 0 ? '0' : `${100 / availableCount}%`;
console.log(_data)
return ( return (
<BottomBlock> <BottomBlock>
<BottomBlock.Header> <BottomBlock.Header>

View file

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

View file

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

View file

@ -12,13 +12,17 @@ interface Props {
export default function SessionInfoItem(props: Props) { export default function SessionInfoItem(props: Props) {
const { label, icon, value, comp, isLast = false } = props const { label, icon, value, comp, isLast = false } = props
return ( return (
<div className={cn("flex items-center w-full py-2 color-gray-dark", {'border-b' : !isLast})}> <div className={cn('flex items-center w-full py-2 color-gray-dark', { 'border-b': !isLast })}>
<div className="px-2 capitalize" style={{ width: '30px' }}> <div className="px-2 capitalize" style={{ width: '30px' }}>
{ icon && <Icon name={icon} size="16" /> } {icon && <Icon name={icon} size="16" />}
{ comp && comp } {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> </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); return integrations.some((i) => i.token);
}); }, [props.integrations]);
const { showModal, hideModal } = useModal(); const { showModal, hideModal } = useModal();

View file

@ -13,7 +13,6 @@ import { useModal } from 'App/components/Modal';
import useAutoscroll, { getLastItemTime } from '../useAutoscroll'; import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter' import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter'
import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache' import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache'
import { toJS } from 'mobx'
const ALL = 'ALL'; const ALL = 'ALL';
const INFO = 'INFO'; const INFO = 'INFO';
@ -60,7 +59,7 @@ const getIconProps = (level: any) => {
const INDEX_KEY = 'console'; const INDEX_KEY = 'console';
function ConsolePanel({ isLive }: { isLive: boolean }) { function ConsolePanel({ isLive }: { isLive?: boolean }) {
const { const {
sessionStore: { devTools }, sessionStore: { devTools },
} = useStore() } = 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 = () => { const toggleExpand = () => {
setExpanded(!expanded); setExpanded(!expanded);
}; };
return ( return (
<div <div
style={style} style={style}
@ -52,14 +53,14 @@ function ConsoleRow(props: Props) {
{canExpand && ( {canExpand && (
<Icon name={expanded ? 'caret-down-fill' : 'caret-right-fill'} className="mr-2" /> <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> </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> </div>
{canExpand && {canExpand &&
expanded && expanded &&
lines.map((l: string, i: number) => ( 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} {l}
</div> </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 React, { useMemo, useState } from 'react';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { Duration } from 'luxon'; import { Duration } from 'luxon';
import { Tooltip, Tabs, Input, NoContent, Icon, Toggler } from 'UI'; import { Tooltip, Tabs, Input, NoContent, Icon, Toggler } from 'UI';
import { ResourceType } from 'Player'; import { ResourceType, Timed } from 'Player';
import { formatBytes } from 'App/utils'; import { formatBytes } from 'App/utils';
import { formatMs } from 'App/date'; import { formatMs } from 'App/date';
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
import FetchDetailsModal from 'Shared/FetchDetailsModal'; 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 { useStore } from 'App/mstore';
import { connect } from 'react-redux' import { connect } from 'react-redux';
import TimeTable from '../TimeTable'; import TimeTable from '../TimeTable';
import BottomBlock from '../BottomBlock'; import BottomBlock from '../BottomBlock';
import InfoLine from '../BottomBlock/InfoLine'; import InfoLine from '../BottomBlock/InfoLine';
import useAutoscroll, { getLastItemTime } from '../useAutoscroll'; import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter' import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter';
import { toJS } from 'mobx';
const INDEX_KEY = 'network'; const INDEX_KEY = 'network';
@ -36,7 +37,7 @@ const TYPE_TO_TAB = {
[ResourceType.IMG]: IMG, [ResourceType.IMG]: IMG,
[ResourceType.MEDIA]: MEDIA, [ResourceType.MEDIA]: MEDIA,
[ResourceType.OTHER]: OTHER, [ResourceType.OTHER]: OTHER,
} };
const TAP_KEYS = [ALL, XHR, JS, CSS, IMG, MEDIA, OTHER] as const; const TAP_KEYS = [ALL, XHR, JS, CSS, IMG, MEDIA, OTHER] as const;
const TABS = TAP_KEYS.map((tab) => ({ 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 ( return (
<> <>
{cached ? ( {cached ? (
<Tooltip title={"Served from cache"}> <Tooltip title={'Served from cache'}>
<div className="flex items-center"> <div className="flex items-center">
<span className="mr-1">{status}</span> <span className="mr-1">{status}</span>
<Icon name="wifi" size={16} /> <Icon name="wifi" size={16} />
</div> </div>
</Tooltip> </Tooltip>
) : status} ) : (
status
)}
</> </>
) );
} }
function NetworkPanel({ startedAt }: { startedAt: number }) { function NetworkPanelCont({ startedAt }: { startedAt: number }) {
const { player, store } = React.useContext(PlayerContext) const { player, store } = React.useContext(PlayerContext);
const { const { domContentLoadedTime, loadTime, domBuildingTime, tabStates, currentTab } = store.get();
domContentLoadedTime,
loadTime,
domBuildingTime,
tabStates,
currentTab
} = store.get()
const { const {
fetchList = [], fetchList = [],
resourceList = [], resourceList = [],
fetchListNow = [], fetchListNow = [],
resourceListNow = [] resourceListNow = [],
} = tabStates[currentTab] } = 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 { showModal } = useModal();
const [sortBy, setSortBy] = useState('time'); const [sortBy, setSortBy] = useState('time');
const [sortAscending, setSortAscending] = useState(true); const [sortAscending, setSortAscending] = useState(true);
@ -174,70 +246,87 @@ function NetworkPanel({ startedAt }: { startedAt: number }) {
const activeTab = devTools[INDEX_KEY].activeTab; const activeTab = devTools[INDEX_KEY].activeTab;
const activeIndex = devTools[INDEX_KEY].index; const activeIndex = devTools[INDEX_KEY].index;
const list = useMemo(() => const list = useMemo(
// TODO: better merge (with body size info) - do it in player () =>
resourceList.filter(res => !fetchList.some(ft => { // TODO: better merge (with body size info) - do it in player
// res.url !== ft.url doesn't work on relative URLs appearing within fetchList (to-fix in player) resourceList
if (res.name === ft.name) { .filter(
if (res.time === ft.time) return true; (res) =>
if (res.url.includes(ft.url)) { !fetchList.some((ft) => {
return Math.abs(res.time - ft.time) < 350 // res.url !== ft.url doesn't work on relative URLs appearing within fetchList (to-fix in player)
|| Math.abs(res.timestamp - ft.timestamp) < 350; 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 (res.name !== ft.name) {
if (Math.abs(res.time - ft.time) > 250) { return false } // TODO: find good epsilons return false;
if (Math.abs(res.duration - ft.duration) > 200) { 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 return true;
})) })
.concat(fetchList) )
.sort((a, b) => a.time - b.time) .concat(fetchList)
, [ resourceList.length, fetchList.length ]) .sort((a, b) => a.time - b.time),
[resourceList.length, fetchList.length]
);
let filteredList = useMemo(() => { let filteredList = useMemo(() => {
if (!showOnlyErrors) { return list } if (!showOnlyErrors) {
return list.filter(it => parseInt(it.status) >= 400 || !it.success) return list;
}, [ showOnlyErrors, list ]) }
return list.filter((it) => parseInt(it.status) >= 400 || !it.success);
}, [showOnlyErrors, list]);
filteredList = useRegExListFilterMemo( filteredList = useRegExListFilterMemo(
filteredList, filteredList,
it => [ it.status, it.name, it.type ], (it) => [it.status, it.name, it.type],
filter, filter
) );
filteredList = useTabListFilterMemo(filteredList, it => TYPE_TO_TAB[it.type], ALL, activeTab) filteredList = useTabListFilterMemo(filteredList, (it) => TYPE_TO_TAB[it.type], ALL, activeTab);
const onTabClick = (activeTab: typeof TAP_KEYS[number]) => devTools.update(INDEX_KEY, { activeTab }) const onTabClick = (activeTab: (typeof TAP_KEYS)[number]) =>
const onFilterChange = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => devTools.update(INDEX_KEY, { filter: value }) devTools.update(INDEX_KEY, { activeTab });
const onFilterChange = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) =>
devTools.update(INDEX_KEY, { filter: value });
// AutoScroll // AutoScroll
const [ const [timeoutStartAutoscroll, stopAutoscroll] = useAutoscroll(
timeoutStartAutoscroll,
stopAutoscroll,
] = useAutoscroll(
filteredList, filteredList,
getLastItemTime(fetchListNow, resourceListNow), getLastItemTime(fetchListNow, resourceListNow),
activeIndex, activeIndex,
index => devTools.update(INDEX_KEY, { index }) (index) => devTools.update(INDEX_KEY, { index })
) );
const onMouseEnter = stopAutoscroll const onMouseEnter = stopAutoscroll;
const onMouseLeave = () => { const onMouseLeave = () => {
if (isDetailsModalActive) { return } if (isDetailsModalActive) {
timeoutStartAutoscroll() 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 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 referenceLines = useMemo(() => {
const arr = []; const arr = [];
@ -256,24 +345,29 @@ function NetworkPanel({ startedAt }: { startedAt: number }) {
} }
return arr; return arr;
}, [ domContentLoadedTime, loadTime ]) }, [domContentLoadedTime, loadTime]);
const showDetailsModal = (item: any) => { const showDetailsModal = (item: any) => {
setIsDetailsModalActive(true) setIsDetailsModalActive(true);
showModal( 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, right: true,
width: 500, width: 500,
onClose: () => { onClose: () => {
setIsDetailsModalActive(false) setIsDetailsModalActive(false);
timeoutStartAutoscroll() timeoutStartAutoscroll();
} },
} }
) );
devTools.update(INDEX_KEY, { index: filteredList.indexOf(item) }) devTools.update(INDEX_KEY, { index: filteredList.indexOf(item) });
stopAutoscroll() stopAutoscroll();
} };
return ( return (
<React.Fragment> <React.Fragment>
@ -286,13 +380,15 @@ function NetworkPanel({ startedAt }: { startedAt: number }) {
<BottomBlock.Header> <BottomBlock.Header>
<div className="flex items-center"> <div className="flex items-center">
<span className="font-semibold color-gray-medium mr-4">Network</span> <span className="font-semibold color-gray-medium mr-4">Network</span>
<Tabs {isMobile ? null :
className="uppercase" <Tabs
tabs={TABS} className="uppercase"
active={activeTab} tabs={TABS}
onClick={onTabClick} active={activeTab}
border={false} onClick={onTabClick}
/> border={false}
/>
}
</div> </div>
<Input <Input
className="input-small" className="input-small"
@ -413,8 +509,17 @@ function NetworkPanel({ startedAt }: { startedAt: number }) {
</BottomBlock> </BottomBlock>
</React.Fragment> </React.Fragment>
); );
} })
export default connect((state: any) => ({ const WebNetworkPanel = connect((state: any) => ({
startedAt: state.getIn(['sessions', 'current']).startedAt, 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 React, { useEffect, useMemo, useState } from 'react';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { Tabs, Input, NoContent, Icon } from 'UI'; import { Tabs, Input, NoContent, Icon } from 'UI';
import { List, CellMeasurer, AutoSizer } from 'react-virtualized'; 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 BottomBlock from '../BottomBlock';
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
@ -14,20 +15,47 @@ import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter'; import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter';
import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache'; import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache';
const mapNames = (type: string) => {
if (type === 'openreplay') return 'OpenReplay';
return type
}
const INDEX_KEY = 'stackEvent'; const INDEX_KEY = 'stackEvent';
const ALL = 'ALL'; const ALL = 'ALL';
const TAB_KEYS = [ALL, ...typeList] as const; const TAB_KEYS = [ALL, ...typeList] as const;
const TABS = TAB_KEYS.map((tab) => ({ text: tab, key: tab })); 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 { player, store } = React.useContext(PlayerContext);
const jump = (t: number) => player.jump(t); const jump = (t: number) => player.jump(t);
const { currentTab, tabStates } = store.get(); const { currentTab, tabStates } = store.get();
const { stackList: list = [], stackListNow: listNow = [] } = tabStates[currentTab]; 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 { const {
sessionStore: { devTools } sessionStore: { devTools },
} = useStore(); } = useStore();
const { showModal } = useModal(); const { showModal } = useModal();
const [isDetailsModalActive, setIsDetailsModalActive] = useState(false); // TODO:embed that into useModal const [isDetailsModalActive, setIsDetailsModalActive] = useState(false); // TODO:embed that into useModal
@ -35,24 +63,23 @@ function StackEventPanel() {
const activeTab = devTools[INDEX_KEY].activeTab; const activeTab = devTools[INDEX_KEY].activeTab;
const activeIndex = devTools[INDEX_KEY].index; const activeIndex = devTools[INDEX_KEY].index;
let filteredList = useRegExListFilterMemo(list, it => it.name, filter); let filteredList = useRegExListFilterMemo(list, (it) => it.name, filter);
filteredList = useTabListFilterMemo(filteredList, it => it.source, ALL, activeTab); filteredList = useTabListFilterMemo(filteredList, (it) => it.source, ALL, activeTab);
const onTabClick = (activeTab: typeof TAB_KEYS[number]) => devTools.update(INDEX_KEY, { activeTab }); const onTabClick = (activeTab: (typeof TAB_KEYS)[number]) =>
const onFilterChange = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => devTools.update(INDEX_KEY, { filter: value }); devTools.update(INDEX_KEY, { activeTab });
const tabs = useMemo(() => const onFilterChange = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) =>
TABS.filter(({ key }) => key === ALL || list.some(({ source }) => key === source)), devTools.update(INDEX_KEY, { filter: value });
const tabs = useMemo(
() => TABS.filter(({ key }) => key === ALL || list.some(({ source }) => key === source)),
[list.length] [list.length]
); );
const [ const [timeoutStartAutoscroll, stopAutoscroll] = useAutoscroll(
timeoutStartAutoscroll,
stopAutoscroll
] = useAutoscroll(
filteredList, filteredList,
getLastItemTime(listNow), getLastItemTime(listNow),
activeIndex, activeIndex,
index => devTools.update(INDEX_KEY, { index }) (index) => devTools.update(INDEX_KEY, { index })
); );
const onMouseEnter = stopAutoscroll; const onMouseEnter = stopAutoscroll;
const onMouseLeave = () => { const onMouseLeave = () => {
@ -66,17 +93,14 @@ function StackEventPanel() {
const showDetails = (item: any) => { const showDetails = (item: any) => {
setIsDetailsModalActive(true); setIsDetailsModalActive(true);
showModal( showModal(<StackEventModal event={item} />, {
<StackEventModal event={item} />, right: true,
{ width: 500,
right: true, onClose: () => {
width: 500, setIsDetailsModalActive(false);
onClose: () => { timeoutStartAutoscroll();
setIsDetailsModalActive(false); },
timeoutStartAutoscroll(); });
}
}
);
devTools.update(INDEX_KEY, { index: filteredList.indexOf(item) }); devTools.update(INDEX_KEY, { index: filteredList.indexOf(item) });
stopAutoscroll(); stopAutoscroll();
}; };
@ -89,7 +113,6 @@ function StackEventPanel() {
} }
}, [activeIndex]); }, [activeIndex]);
const _rowRenderer = ({ index, key, parent, style }: any) => { const _rowRenderer = ({ index, key, parent, style }: any) => {
const item = filteredList[index]; const item = filteredList[index];
@ -121,29 +144,29 @@ function StackEventPanel() {
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
> >
<BottomBlock.Header> <BottomBlock.Header>
<div className='flex items-center'> <div className="flex items-center">
<span className='font-semibold color-gray-medium mr-4'>Stack Events</span> <span className="font-semibold color-gray-medium mr-4">Stack Events</span>
<Tabs tabs={tabs} active={activeTab} onClick={onTabClick} border={false} /> <Tabs renameTab={mapNames} tabs={tabs} active={activeTab} onClick={onTabClick} border={false} />
</div> </div>
<Input <Input
className='input-small h-8' className="input-small h-8"
placeholder='Filter by keyword' placeholder="Filter by keyword"
icon='search' icon="search"
name='filter' name="filter"
height={28} height={28}
onChange={onFilterChange} onChange={onFilterChange}
value={filter} value={filter}
/> />
</BottomBlock.Header> </BottomBlock.Header>
<BottomBlock.Content className='overflow-y-auto'> <BottomBlock.Content className="overflow-y-auto">
<NoContent <NoContent
title={ title={
<div className='capitalize flex items-center mt-16'> <div className="capitalize flex items-center mt-16">
<Icon name='info-circle' className='mr-2' size='18' /> <Icon name="info-circle" className="mr-2" size="18" />
No Data No Data
</div> </div>
} }
size='small' size="small"
show={filteredList.length === 0} show={filteredList.length === 0}
> >
<AutoSizer> <AutoSizer>
@ -157,7 +180,7 @@ function StackEventPanel() {
rowRenderer={_rowRenderer} rowRenderer={_rowRenderer}
width={width} width={width}
height={height} height={height}
scrollToAlignment='center' scrollToAlignment="center"
/> />
)} )}
</AutoSizer> </AutoSizer>
@ -166,5 +189,3 @@ function StackEventPanel() {
</BottomBlock> </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 activeSite = sites.find((s) => s.id === siteId);
const showNoSessions = !!activeSite && !activeSite.recorded; const showNoSessions = !!activeSite && !activeSite.recorded;
const onboardingPath = withSiteId(onboardingRoute('installing'), siteId); const onboardingPath = withSiteId(onboardingRoute('installing'), siteId);
console.log('onboardingPath', onboardingPath, siteId);
return ( return (
<> <>
{showNoSessions && ( {showNoSessions && (

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,3 +8,7 @@ export * from './web/WebPlayer';
export * from './web/storageSelectors'; export * from './web/storageSelectors';
export * from './web/types'; export * from './web/types';
export type { MarkedTarget } from './web/addons/TargetMarker' 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 {Message} from "Player/web/messages";
import MessageManager from 'App/player/web/MessageManager' import type { Store, Interval } from 'Player';
const fps = 60 const fps = 60
@ -21,6 +21,18 @@ const cancelAnimationFrame =
window.mozCancelAnimationFrame || window.mozCancelAnimationFrame ||
window.clearTimeout 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 { export interface SetState {
time: number time: number
@ -56,7 +68,7 @@ export default class Animator {
private animationFrameRequestId: number = 0 private animationFrameRequestId: number = 0
constructor(private store: Store<GetState>, private mm: MessageManager) { constructor(private store: Store<GetState>, private mm: IMessageManager) {
// @ts-ignore // @ts-ignore
window.playerJump = this.jump.bind(this) window.playerJump = this.jump.bind(this)

View file

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

View file

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

View file

@ -74,7 +74,7 @@ export default class Cursor {
} }
click() { click() {
const styleList = this.isMobile ? styles.clickedMobile : styles.clicked const styleList = styles.clicked
this.cursor.classList.add(styleList) this.cursor.classList.add(styleList)
this.onClick?.() this.onClick?.()
setTimeout(() => { setTimeout(() => {
@ -82,6 +82,22 @@ export default class Cursor {
}, 600) }, 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) { setOnClickHook(callback: () => void) {
this.onClick = callback this.onClick = callback
} }

View file

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

View file

@ -68,9 +68,41 @@
} }
} }
.cursor.clickedMobile::after { .mobileTouch {
-webkit-animation: anim-effect-sanja 1s ease-out forwards; width: 25px;
animation: anim-effect-sanja 1s ease-out forwards; 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 { @-webkit-keyframes anim-effect-sanja {

View file

@ -24,4 +24,33 @@
.highlightoff { .highlightoff {
opacity: 0; opacity: 0;
transition: all 0.25s cubic-bezier(0, 0, 0.4, 1.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_DURATION = 3.5;
const LINE_WIDTH_START = 5; const LINE_WIDTH_START = 5;
export type SwipeEvent = { x: number; y: number; direction: 'up' | 'down' | 'left' | 'right' }
export default class MouseTrail { export default class MouseTrail {
public isActive = true; public isActive = true;
public context: CanvasRenderingContext2D; public context: CanvasRenderingContext2D;
@ -13,13 +15,15 @@ export default class MouseTrail {
* 1 - every frame, * 1 - every frame,
* 2 - every 2nd frame * 2 - every 2nd frame
* and so on, doesn't always work properly * 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 drawOnFrame = 1;
private currentFrame = 0; private currentFrame = 0;
private lineDuration = LINE_DURATION;
private points: Point[] = []; private points: Point[] = [];
private swipePoints: Point[] = []
constructor(private readonly canvas: HTMLCanvasElement) { constructor(private readonly canvas: HTMLCanvasElement, isNativeMobile: boolean = false) {
// @ts-ignore patching window // @ts-ignore patching window
window.requestAnimFrame = window.requestAnimFrame =
window.requestAnimationFrame || window.requestAnimationFrame ||
@ -34,6 +38,10 @@ export default class MouseTrail {
function (callback: any) { function (callback: any) {
window.setTimeout(callback, 1000 / 60); window.setTimeout(callback, 1000 / 60);
}; };
if (isNativeMobile) {
this.lineDuration = 5
}
} }
resizeCanvas = (w: number, h: number) => { 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 = () => { animatePoints = () => {
this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height); 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; let point, lastPoint;
for (let i = 0; i < this.points.length; i++) { for (let i = 0; i < this.points.length; i++) {

View file

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

View file

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

View file

@ -101,7 +101,7 @@ export default class MFileReader extends RawMessageReader {
const index = this.noIndexes ? 0 : this.getLastMessageID() const index = this.noIndexes ? 0 : this.getLastMessageID()
const msg = Object.assign(rewriteMessage(rMsg), { const msg = Object.assign(rewriteMessage(rMsg), {
time: this.currentTime, time: this.currentTime || rMsg.timestamp - this.startTime!,
tabId: this.currentTab, tabId: this.currentTab,
}, !this.noIndexes ? { _index: index } : {}) }, !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 name = this.readString(); if (name === null) { return resetPointer() }
const payload = this.readString(); if (payload === null) { return resetPointer() } const payload = this.readString(); if (payload === null) { return resetPointer() }
return { return {
tp: MType.IosCustomEvent, tp: MType.IosEvent,
timestamp, timestamp,
length, length,
name, 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: { case 105: {
const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() } const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() }
const length = this.readUint(); if (length === 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: default:
throw new Error(`Unrecognizable message type: ${ tp }; Pointer at the position ${this.p} of ${this.buf.length}`) throw new Error(`Unrecognizable message type: ${ tp }; Pointer at the position ${this.p} of ${this.buf.length}`)
return null; return null;

View file

@ -3,7 +3,8 @@
import { MType } from './raw.gen' 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) { export function isDOMType(t: MType) {
return DOM_TYPES.includes(t) return DOM_TYPES.includes(t)
} }

View file

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

View file

@ -59,14 +59,16 @@ export const enum MType {
ResourceTiming = 116, ResourceTiming = 116,
TabChange = 117, TabChange = 117,
TabData = 118, TabData = 118,
IosCustomEvent = 93, IosEvent = 93,
IosScreenChanges = 96, IosScreenChanges = 96,
IosClickEvent = 100, IosClickEvent = 100,
IosInputEvent = 101, IosInputEvent = 101,
IosPerformanceEvent = 102, IosPerformanceEvent = 102,
IosLog = 103, IosLog = 103,
IosInternalError = 104,
IosNetworkCall = 105, IosNetworkCall = 105,
IosSwipeEvent = 106, IosSwipeEvent = 106,
IosIssueEvent = 111,
} }
@ -474,8 +476,8 @@ export interface RawTabData {
tabId: string, tabId: string,
} }
export interface RawIosCustomEvent { export interface RawIosEvent {
tp: MType.IosCustomEvent, tp: MType.IosEvent,
timestamp: number, timestamp: number,
length: number, length: number,
name: string, name: string,
@ -526,6 +528,13 @@ export interface RawIosLog {
content: string, content: string,
} }
export interface RawIosInternalError {
tp: MType.IosInternalError,
timestamp: number,
length: number,
content: string,
}
export interface RawIosNetworkCall { export interface RawIosNetworkCall {
tp: MType.IosNetworkCall, tp: MType.IosNetworkCall,
timestamp: number, timestamp: number,
@ -549,5 +558,14 @@ export interface RawIosSwipeEvent {
direction: string, 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, 116: MType.ResourceTiming,
117: MType.TabChange, 117: MType.TabChange,
118: MType.TabData, 118: MType.TabData,
93: MType.IosCustomEvent, 93: MType.IosEvent,
96: MType.IosScreenChanges, 96: MType.IosScreenChanges,
100: MType.IosClickEvent, 100: MType.IosClickEvent,
101: MType.IosInputEvent, 101: MType.IosInputEvent,
102: MType.IosPerformanceEvent, 102: MType.IosPerformanceEvent,
103: MType.IosLog, 103: MType.IosLog,
104: MType.IosInternalError,
105: MType.IosNetworkCall, 105: MType.IosNetworkCall,
106: MType.IosSwipeEvent, 106: MType.IosSwipeEvent,
111: MType.IosIssueEvent,
} as const } 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 { export const enum ResourceType {
XHR = 'xhr', 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({ return Resource({
...msg, ...msg,
// @ts-ignore // @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