init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,983 @@
/**
* The constant for the event type 'track'.
* TODO: keep these constants in a single place. Can we import them from
* lib-jitsi-meet's AnalyticsEvents somehow?
*
* @type {string}
*/
const TYPE_TRACK = 'track';
/**
* The constant for the event type 'UI' (User Interaction).
* TODO: keep these constants in a single place. Can we import them from
* lib-jitsi-meet's AnalyticsEvents somehow?
*
* @type {string}
*/
const TYPE_UI = 'ui';
/**
* The identifier for the "pinned" action. The local participant has pinned a
* participant to remain on large video.
*
* @type {String}
*/
export const ACTION_PINNED = 'pinned';
/**
* The identifier for the "unpinned" action. The local participant has unpinned
* a participant so the participant doesn't remain permanently on local large
* video.
*
* @type {String}
*/
export const ACTION_UNPINNED = 'unpinned';
/**
* The identifier for the "pressed" action for shortcut events. This action
* means that a button was pressed (and not yet released).
*
* @type {String}
*/
export const ACTION_SHORTCUT_PRESSED = 'pressed';
/**
* The identifier for the "released" action for shortcut events. This action
* means that a button which was previously pressed was released.
*
* @type {String}
*/
export const ACTION_SHORTCUT_RELEASED = 'released';
/**
* The identifier for the "triggered" action for shortcut events. This action
* means that a button was pressed, and we don't care about whether it was
* released or will be released in the future.
*
* @type {String}
*/
export const ACTION_SHORTCUT_TRIGGERED = 'triggered';
/**
* The name of the keyboard shortcut or toolbar button for muting audio.
*/
export const AUDIO_MUTE = 'audio.mute';
/**
* The name of the keyboard shortcut or toolbar button for muting desktop sharing.
*/
export const DESKTOP_MUTE = 'desktop.mute';
/**
* The name of the keyboard shortcut or toolbar button for muting video.
*/
export const VIDEO_MUTE = 'video.mute';
/**
* Creates an event which indicates that a certain action was requested through
* the jitsi-meet API.
*
* @param {string} action - The action which was requested through the
* jitsi-meet API.
* @param {Object} attributes - Attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createApiEvent(action: string, attributes = {}) {
return {
action,
attributes,
source: 'jitsi-meet-api'
};
}
/**
* Creates an event which indicates that the audio-only mode has been changed.
*
* @param {boolean} enabled - True if audio-only is enabled, false otherwise.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createAudioOnlyChangedEvent(enabled: boolean) {
return {
action: `audio.only.${enabled ? 'enabled' : 'disabled'}`
};
}
/**
* Creates an event for about the JitsiConnection.
*
* @param {string} action - The action that the event represents.
* @param {boolean} attributes - Additional attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createConnectionEvent(action: string, attributes = {}) {
return {
action,
actionSubject: 'connection',
attributes
};
}
/**
* Creates an event which indicates an action occurred in the calendar
* integration UI.
*
* @param {string} eventName - The name of the calendar UI event.
* @param {Object} attributes - Attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createCalendarClickedEvent(eventName: string, attributes = {}) {
return {
action: 'clicked',
actionSubject: eventName,
attributes,
source: 'calendar',
type: TYPE_UI
};
}
/**
* Creates an event which indicates that the calendar container is shown and
* selected.
*
* @param {Object} attributes - Attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createCalendarSelectedEvent(attributes = {}) {
return {
action: 'selected',
attributes,
source: 'calendar',
type: TYPE_UI
};
}
/**
* Creates an event indicating that a calendar has been connected.
*
* @param {boolean} attributes - Additional attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createCalendarConnectedEvent(attributes = {}) {
return {
action: 'connected',
actionSubject: 'calendar',
attributes
};
}
/**
* Creates an event which indicates an action occurred in the recent list
* integration UI.
*
* @param {string} eventName - The name of the recent list UI event.
* @param {Object} attributes - Attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createRecentClickedEvent(eventName: string, attributes = {}) {
return {
action: 'clicked',
actionSubject: eventName,
attributes,
source: 'recent.list',
type: TYPE_UI
};
}
/**
* Creates an event which indicate an action occurred in the chrome extension banner.
*
* @param {boolean} installPressed - Whether the user pressed install or `x` - cancel.
* @param {Object} attributes - Attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createChromeExtensionBannerEvent(installPressed: boolean, attributes = {}) {
return {
action: installPressed ? 'install' : 'cancel',
attributes,
source: 'chrome.extension.banner',
type: TYPE_UI
};
}
/**
* Creates an event which indicates that the recent list container is shown and
* selected.
*
* @param {Object} attributes - Attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createRecentSelectedEvent(attributes = {}) {
return {
action: 'selected',
attributes,
source: 'recent.list',
type: TYPE_UI
};
}
/**
* Creates an event for an action on the deep linking page.
*
* @param {string} action - The action that the event represents.
* @param {string} actionSubject - The subject that was acted upon.
* @param {boolean} attributes - Additional attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createDeepLinkingPageEvent(
action: string, actionSubject: string, attributes = {}) {
return {
action,
actionSubject,
source: 'deepLinkingPage',
attributes
};
}
/**
* Creates an event which indicates that a device was changed.
*
* @param {string} mediaType - The media type of the device ('audio' or
* 'video').
* @param {string} deviceType - The type of the device ('input' or 'output').
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createDeviceChangedEvent(mediaType: string, deviceType: string) {
return {
action: 'device.changed',
attributes: {
'device_type': deviceType,
'media_type': mediaType
}
};
}
/**
* Creates an event indicating that an action related to E2EE occurred.
*
* @param {string} action - The action which occurred.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createE2EEEvent(action: string) {
return {
action,
actionSubject: 'e2ee'
};
}
/**
* Creates an event which specifies that the feedback dialog has been opened.
*
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createFeedbackOpenEvent() {
return {
action: 'feedback.opened'
};
}
/**
* Creates an event for an action regarding the AddPeopleDialog (invites).
*
* @param {string} action - The action that the event represents.
* @param {string} actionSubject - The subject that was acted upon.
* @param {boolean} attributes - Additional attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createInviteDialogEvent(
action: string, actionSubject: string, attributes = {}) {
return {
action,
actionSubject,
attributes,
source: 'inviteDialog'
};
}
/**
* Creates an event which reports about the current network information reported by the operating system.
*
* @param {boolean} isOnline - Tells whether or not the internet is reachable.
* @param {string} [networkType] - Network type, see {@code NetworkInfo} type defined by the 'base/net-info' feature.
* @param {Object} [details] - Extra info, see {@code NetworkInfo} type defined by the 'base/net-info' feature.
* @returns {Object}
*/
export function createNetworkInfoEvent({ isOnline, networkType, details }:
{ details?: Object; isOnline: boolean; networkType?: string; }) {
const attributes: {
details?: Object;
isOnline: boolean;
networkType?: string;
} = { isOnline };
// Do no include optional stuff or Amplitude handler will log warnings.
networkType && (attributes.networkType = networkType);
details && (attributes.details = details);
return {
action: 'network.info',
attributes
};
}
/**
* Creates a "not allowed error" event.
*
* @param {string} type - The type of the error.
* @param {string} reason - The reason for the error.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createNotAllowedErrorEvent(type: string, reason: string) {
return {
action: 'not.allowed.error',
attributes: {
reason,
type
}
};
}
/**
* Creates an "offer/answer failure" event.
*
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createOfferAnswerFailedEvent() {
return {
action: 'offer.answer.failure'
};
}
/**
* Creates a "page reload" event.
*
* @param {string} reason - The reason for the reload.
* @param {number} timeout - The timeout in seconds after which the page is
* scheduled to reload.
* @param {Object} details - The details for the error.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createPageReloadScheduledEvent(reason: string, timeout: number, details: Object = {}) {
return {
action: 'page.reload.scheduled',
attributes: {
reason,
timeout,
...details
}
};
}
/**
* Creates a "pinned" or "unpinned" event.
*
* @param {string} action - The action ("pinned" or "unpinned").
* @param {string} participantId - The ID of the participant which was pinned.
* @param {Object} attributes - Attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createPinnedEvent(action: string, participantId: string, attributes = {}) {
return {
type: TYPE_TRACK,
action,
actionSubject: 'participant',
objectType: 'participant',
objectId: participantId,
attributes
};
}
/**
* Creates a poll event.
* The following events will be created:
* - poll.created
* - poll.vote.checked
* - poll.vote.sent
* - poll.vote.skipped
* - poll.vote.detailsViewed
* - poll.vote.changed
* - poll.option.added
* - poll.option.moved
* - poll.option.removed.
*
* @param {string} action - The action.
* @returns {Object}
*/
export function createPollEvent(action: string) {
return {
action: `poll.${action}`
};
}
/**
* Creates an event which indicates that a button in the profile panel was
* clicked.
*
* @param {string} buttonName - The name of the button.
* @param {Object} attributes - Attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createProfilePanelButtonEvent(buttonName: string, attributes = {}) {
return {
action: 'clicked',
actionSubject: buttonName,
attributes,
source: 'profile.panel',
type: TYPE_UI
};
}
/**
* Creates an event which indicates that a specific button on one of the
* recording-related dialogs was clicked.
*
* @param {string} dialogName - The name of the dialog (e.g. 'start' or 'stop').
* @param {string} buttonName - The name of the button (e.g. 'confirm' or
* 'cancel').
* @param {Object} attributes - Attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createRecordingDialogEvent(
dialogName: string, buttonName: string, attributes = {}) {
return {
action: 'clicked',
actionSubject: buttonName,
attributes,
source: `${dialogName}.recording.dialog`,
type: TYPE_UI
};
}
/**
* Creates an event which indicates that a specific button on one of the
* liveStreaming-related dialogs was clicked.
*
* @param {string} dialogName - The name of the dialog (e.g. 'start' or 'stop').
* @param {string} buttonName - The name of the button (e.g. 'confirm' or
* 'cancel').
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createLiveStreamingDialogEvent(dialogName: string, buttonName: string) {
return {
action: 'clicked',
actionSubject: buttonName,
source: `${dialogName}.liveStreaming.dialog`,
type: TYPE_UI
};
}
/**
* Creates an event with the local tracks duration.
*
* @param {Object} duration - The object with the duration of the local tracks.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createLocalTracksDurationEvent(duration: {
audio: { value: number; };
conference: { value: number; };
video: {
camera: { value: number; };
desktop: { value: number; };
};
}) {
const { audio, video, conference } = duration;
const { camera, desktop } = video;
return {
action: 'local.tracks.durations',
attributes: {
audio: audio.value,
camera: camera.value,
conference: conference.value,
desktop: desktop.value
}
};
}
/**
* Creates an event which indicates that an action related to recording has
* occurred.
*
* @param {string} action - The action (e.g. 'start' or 'stop').
* @param {string} type - The recording type (e.g. 'file' or 'live').
* @param {number} value - The duration of the recording in seconds (for stop
* action).
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createRecordingEvent(action: string, type: string, value?: number) {
return {
action,
actionSubject: `recording.${type}`,
attributes: {
value
}
};
}
/**
* Creates an event which indicates that the same conference has been rejoined.
*
* @param {string} url - The full conference URL.
* @param {number} lastConferenceDuration - How many seconds user stayed in the previous conference.
* @param {number} timeSinceLeft - How many seconds since the last conference was left.
* @returns {Object} The event in a format suitable for sending via sendAnalytics.
*/
export function createRejoinedEvent({ url, lastConferenceDuration, timeSinceLeft }: {
lastConferenceDuration: number;
timeSinceLeft: number;
url: string;
}) {
return {
action: 'rejoined',
attributes: {
lastConferenceDuration,
timeSinceLeft,
url
}
};
}
/**
* Creates an event which specifies that the "confirm" button on the remote
* mute dialog has been clicked.
*
* @param {string} participantId - The ID of the participant that was remotely
* muted.
* @param {string} mediaType - The media type of the channel to mute.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createRemoteMuteConfirmedEvent(participantId: string, mediaType: string) {
return {
action: 'clicked',
attributes: {
'participant_id': participantId,
'media_type': mediaType
},
source: 'remote.mute.button',
type: TYPE_UI
};
}
/**
* Creates an event which indicates that one of the buttons in the "remote
* video menu" was clicked.
*
* @param {string} buttonName - The name of the button.
* @param {Object} attributes - Attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createRemoteVideoMenuButtonEvent(buttonName: string, attributes = {}) {
return {
action: 'clicked',
actionSubject: buttonName,
attributes,
source: 'remote.video.menu',
type: TYPE_UI
};
}
/**
* The rtcstats websocket onclose event. We send this to amplitude in order
* to detect trace ws prematurely closing.
*
* @param {Object} closeEvent - The event with which the websocket closed.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createRTCStatsTraceCloseEvent(closeEvent: { code: string; reason: string; }) {
const event: {
action: string;
code?: string;
reason?: string;
source: string;
} = {
action: 'trace.onclose',
source: 'rtcstats'
};
event.code = closeEvent.code;
event.reason = closeEvent.reason;
return event;
}
/**
* Creates an event indicating that an action related to screen sharing
* occurred (e.g. It was started or stopped).
*
* @param {string} action - The action which occurred.
* @param {number?} value - The screenshare duration in seconds.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createScreenSharingEvent(action: string, value = null) {
return {
action,
actionSubject: 'screen.sharing',
attributes: {
value
}
};
}
/**
* Creates an event which indicates the screen sharing video is not displayed when it needs to be displayed.
*
* @param {Object} attributes - Additional information that describes the issue.
* @returns {Object} The event in a format suitable for sending via sendAnalytics.
*/
export function createScreenSharingIssueEvent(attributes = {}) {
return {
action: 'screen.sharing.issue',
attributes
};
}
/**
* Creates an event associated with the "shared video" feature.
*
* @param {string} action - The action that the event represents.
* @param {Object} attributes - Attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createSharedVideoEvent(action: string, attributes = {}) {
return {
action,
attributes,
actionSubject: 'shared.video'
};
}
/**
* Creates an event associated with a shortcut being pressed, released or
* triggered. By convention, where appropriate an attribute named 'enable'
* should be used to indicate the action which resulted by the shortcut being
* pressed (e.g. Whether screen sharing was enabled or disabled).
*
* @param {string} shortcut - The identifier of the shortcut which produced
* an action.
* @param {string} action - The action that the event represents (one
* of ACTION_SHORTCUT_PRESSED, ACTION_SHORTCUT_RELEASED
* or ACTION_SHORTCUT_TRIGGERED).
* @param {Object} attributes - Attributes to attach to the event.
* @param {string} source - The event's source.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createShortcutEvent(
shortcut: string,
action = ACTION_SHORTCUT_TRIGGERED,
attributes = {},
source = 'keyboard.shortcut') {
return {
action,
actionSubjectId: shortcut,
attributes,
source,
type: TYPE_UI
};
}
/**
* Creates an event which indicates the "start audio only" configuration.
*
* @param {boolean} audioOnly - Whether "start audio only" is enabled or not.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createStartAudioOnlyEvent(audioOnly: boolean) {
return {
action: 'start.audio.only',
attributes: {
enabled: audioOnly
}
};
}
/**
* Creates an event which indicates the "start silent" configuration.
*
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createStartSilentEvent() {
return {
action: 'start.silent'
};
}
/**
* Creates an event which indicates that HTMLAudioElement.play has failed.
*
* @param {string} elementID - The ID of the HTMLAudioElement.
* @returns {Object} The event in a format suitable for sending via sendAnalytics.
*/
export function createAudioPlayErrorEvent(elementID: string) {
return {
action: 'audio.play.error',
attributes: {
elementID
}
};
}
/**
* Creates an event which indicates that HTMLAudioElement.play has succeeded after a prior failure.
*
* @param {string} elementID - The ID of the HTMLAudioElement.
* @returns {Object} The event in a format suitable for sending via sendAnalytics.
*/
export function createAudioPlaySuccessEvent(elementID: string) {
return {
action: 'audio.play.success',
attributes: {
elementID
}
};
}
/**
* Creates an event which indicates the "start muted" configuration.
*
* @param {string} source - The source of the configuration, 'local' or
* 'remote' depending on whether it comes from the static configuration (i.e.
* {@code config.js}) or comes dynamically from Jicofo.
* @param {boolean} audioMute - Whether the configuration requests that audio
* is muted.
* @param {boolean} videoMute - Whether the configuration requests that video
* is muted.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createStartMutedConfigurationEvent(
source: string,
audioMute: boolean,
videoMute: boolean) {
return {
action: 'start.muted.configuration',
attributes: {
source,
'audio_mute': audioMute,
'video_mute': videoMute
}
};
}
/**
* Automatically changing the mute state of a media track in order to match
* the current stored state in redux.
*
* @param {string} mediaType - The track's media type ('audio' or 'video').
* @param {boolean} muted - Whether the track is being muted or unmuted as
* as result of the sync operation.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createSyncTrackStateEvent(mediaType: string, muted: boolean) {
return {
action: 'sync.track.state',
attributes: {
'media_type': mediaType,
muted
}
};
}
/**
* Creates an event associated with a toolbar button being clicked/pressed. By
* convention, where appropriate an attribute named 'enable' should be used to
* indicate the action which resulted by the shortcut being pressed (e.g.
* Whether screen sharing was enabled or disabled).
*
* @param {string} buttonName - The identifier of the toolbar button which was
* clicked/pressed.
* @param {Object} attributes - Attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createToolbarEvent(buttonName: string, attributes = {}) {
return {
action: 'clicked',
actionSubject: buttonName,
attributes,
source: 'toolbar.button',
type: TYPE_UI
};
}
/**
* Creates an event associated with a reaction button being clicked/pressed.
*
* @param {string} buttonName - The identifier of the reaction button which was
* clicked/pressed.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createReactionMenuEvent(buttonName: string) {
return {
action: 'clicked',
actionSubject: 'button',
source: 'reaction',
buttonName,
type: TYPE_UI
};
}
/**
* Creates an event associated with disabling of reaction sounds.
*
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createReactionSoundsDisabledEvent() {
return {
action: 'disabled',
actionSubject: 'sounds',
source: 'reaction.settings',
type: TYPE_UI
};
}
/**
* Creates an event which indicates that a local track was muted.
*
* @param {string} mediaType - The track's media type ('audio' or 'video').
* @param {string} reason - The reason the track was muted (e.g. It was
* triggered by the "initial mute" option, or a previously muted track was
* replaced (e.g. When a new device was used)).
* @param {boolean} muted - Whether the track was muted or unmuted.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createTrackMutedEvent(mediaType: string, reason: string, muted = true) {
return {
action: 'track.muted',
attributes: {
'media_type': mediaType,
muted,
reason
}
};
}
/**
* Creates an event for joining a vpaas conference.
*
* @param {string} tenant - The conference tenant.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createVpaasConferenceJoinedEvent(tenant: string) {
return {
action: 'vpaas.conference.joined',
attributes: {
tenant
}
};
}
/**
* Creates an event for an action on the welcome page.
*
* @param {string} action - The action that the event represents.
* @param {string} actionSubject - The subject that was acted upon.
* @param {boolean} attributes - Additional attributes to attach to the event.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createWelcomePageEvent(action: string, actionSubject?: string, attributes = {}) {
return {
action,
actionSubject,
attributes,
source: 'welcomePage'
};
}
/**
* Creates an event which indicates a screenshot of the screensharing has been taken.
*
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createScreensharingCaptureTakenEvent() {
return {
action: 'screen.sharing.capture.taken'
};
}
/**
* Creates an event for an action on breakout rooms.
*
* @param {string} actionSubject - The subject that was acted upon.
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createBreakoutRoomsEvent(actionSubject: string) {
return {
action: 'clicked',
actionSubject: `${actionSubject}.button`,
source: 'breakout.rooms'
};
}
/**
* Creates an event which indicates a GIF was sent.
*
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createGifSentEvent() {
return {
action: 'gif.sent'
};
}
/**
* Creates an event which indicates the whiteboard was opened.
*
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createOpenWhiteboardEvent() {
return {
action: 'whiteboard.open'
};
}
/**
* Creates an event which indicates the whiteboard limit was enforced.
*
* @returns {Object} The event in a format suitable for sending via
* sendAnalytics.
*/
export function createRestrictWhiteboardEvent() {
return {
action: 'whiteboard.restrict'
};
}

View File

@@ -0,0 +1,29 @@
/**
* The type of (redux) action which signals that local media duration has changed.
*
* {
* type: UPDATE_LOCAL_TRACKS_DURATION,
* localTracksDuration: Object
* }
*/
export const UPDATE_LOCAL_TRACKS_DURATION = 'UPDATE_LOCAL_TRACKS_DURATION';
/**
* The type of (redux) action which sets the isInitialized redux prop.
*
* {
* type: SET_INITIALIZED,
* value: boolean
* }
*/
export const SET_INITIALIZED = 'SET_INITIALIZED';
/**
* The type of (redux) action which updates the initial permanent properties.
*
* {
* type: SET_INITIAL_PERMANENT_PROPERTIES,
* properties: Object
* }
*/
export const SET_INITIAL_PERMANENT_PROPERTIES = 'SET_INITIAL_PERMANENT_PROPERTIES';

View File

@@ -0,0 +1,25 @@
import { IStore } from '../app/types';
import { analytics } from '../base/lib-jitsi-meet';
import { SET_INITIAL_PERMANENT_PROPERTIES } from './actionTypes';
/**
* Updates a permanentProperty.
*
* @param {Object} properties - An object with properties to be updated.
* @returns {Function}
*/
export function setPermanentProperty(properties: Object) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const { isInitialized = false } = getState()['features/analytics'];
if (isInitialized) {
analytics.addPermanentProperties(properties);
} else {
dispatch({
type: SET_INITIAL_PERMANENT_PROPERTIES,
properties
});
}
};
}

View File

@@ -0,0 +1,339 @@
// @ts-expect-error
import { API_ID } from '../../../modules/API/constants';
import { getName as getAppName } from '../app/functions';
import { IStore } from '../app/types';
import { getAnalyticsRoomName } from '../base/conference/functions';
import checkChromeExtensionsInstalled from '../base/environment/checkChromeExtensionsInstalled';
import {
isMobileBrowser
} from '../base/environment/utils';
import JitsiMeetJS, {
analytics,
browser
} from '../base/lib-jitsi-meet';
import { isAnalyticsEnabled } from '../base/lib-jitsi-meet/functions.any';
import { isEmbedded } from '../base/util/embedUtils';
import { getJitsiMeetGlobalNS } from '../base/util/helpers';
import { loadScript } from '../base/util/loadScript';
import { parseURLParams } from '../base/util/parseURLParams';
import { parseURIString } from '../base/util/uri';
import { isPrejoinPageVisible } from '../prejoin/functions';
import AmplitudeHandler from './handlers/AmplitudeHandler';
import MatomoHandler from './handlers/MatomoHandler';
import logger from './logger';
/**
* Sends an event through the lib-jitsi-meet AnalyticsAdapter interface.
*
* @param {Object} event - The event to send. It should be formatted as
* described in AnalyticsAdapter.js in lib-jitsi-meet.
* @returns {void}
*/
export function sendAnalytics(event: Object) {
try {
analytics.sendEvent(event);
} catch (e) {
logger.warn(`Error sending analytics event: ${e}`);
}
}
/**
* Return saved amplitude identity info such as session id, device id and user id. We assume these do not change for
* the duration of the conference.
*
* @returns {Object}
*/
export function getAmplitudeIdentity() {
return analytics.amplitudeIdentityProps;
}
/**
* Resets the analytics adapter to its initial state - removes handlers, cache,
* disabled state, etc.
*
* @returns {void}
*/
export function resetAnalytics() {
analytics.reset();
}
/**
* Creates the analytics handlers.
*
* @param {Store} store - The redux store in which the specified {@code action} is being dispatched.
* @returns {Promise} Resolves with the handlers that have been successfully loaded.
*/
export async function createHandlers({ getState }: IStore) {
getJitsiMeetGlobalNS().analyticsHandlers = [];
if (!isAnalyticsEnabled(getState)) {
// Avoid all analytics processing if there are no handlers, since no event would be sent.
analytics.dispose();
return [];
}
const state = getState();
const config = state['features/base/config'];
const { locationURL } = state['features/base/connection'];
const host = locationURL ? locationURL.host : '';
const {
analytics: analyticsConfig = {},
deploymentInfo
} = config;
const {
amplitudeAPPKey,
blackListedEvents,
scriptURLs,
matomoEndpoint,
matomoSiteID,
whiteListedEvents
} = analyticsConfig;
const { group, user } = state['features/base/jwt'];
const handlerConstructorOptions = {
amplitudeAPPKey,
blackListedEvents,
envType: deploymentInfo?.envType || 'dev',
matomoEndpoint,
matomoSiteID,
group,
host,
product: deploymentInfo?.product,
subproduct: deploymentInfo?.environment,
user: user?.id,
version: JitsiMeetJS.version,
whiteListedEvents
};
const handlers = [];
if (amplitudeAPPKey) {
try {
const amplitude = new AmplitudeHandler(handlerConstructorOptions);
analytics.amplitudeIdentityProps = amplitude.getIdentityProps();
handlers.push(amplitude);
} catch (e) {
logger.error('Failed to initialize Amplitude handler', e);
}
}
if (matomoEndpoint && matomoSiteID) {
try {
const matomo = new MatomoHandler(handlerConstructorOptions);
handlers.push(matomo);
} catch (e) {
logger.error('Failed to initialize Matomo handler', e);
}
}
if (Array.isArray(scriptURLs) && scriptURLs.length > 0) {
let externalHandlers;
try {
externalHandlers = await _loadHandlers(scriptURLs, handlerConstructorOptions);
handlers.push(...externalHandlers);
} catch (e) {
logger.error('Failed to initialize external analytics handlers', e);
}
}
// Avoid all analytics processing if there are no handlers, since no event would be sent.
if (handlers.length === 0) {
analytics.dispose();
}
logger.info(`Initialized ${handlers.length} analytics handlers`);
return handlers;
}
/**
* Inits JitsiMeetJS.analytics by setting permanent properties and setting the handlers from the loaded scripts.
* NOTE: Has to be used after JitsiMeetJS.init. Otherwise analytics will be null.
*
* @param {Store} store - The redux store in which the specified {@code action} is being dispatched.
* @param {Array<Object>} handlers - The analytics handlers.
* @returns {boolean} - True if the analytics were successfully initialized and false otherwise.
*/
export function initAnalytics(store: IStore, handlers: Array<Object>): boolean {
const { getState, dispatch } = store;
if (!isAnalyticsEnabled(getState) || handlers.length === 0) {
return false;
}
const state = getState();
const config = state['features/base/config'];
const {
deploymentInfo
} = config;
const { group, server } = state['features/base/jwt'];
const { locationURL = { href: '' } } = state['features/base/connection'];
const { tenant } = parseURIString(locationURL.href) || {};
const params = parseURLParams(locationURL.href) ?? {};
const permanentProperties: {
appName?: string;
externalApi?: boolean;
group?: string;
inIframe?: boolean;
isPromotedFromVisitor?: boolean;
isVisitor?: boolean;
overwritesCustomButtonsWithURL?: boolean;
overwritesDefaultLogoUrl?: boolean;
overwritesDeploymentUrls?: boolean;
overwritesLiveStreamingUrls?: boolean;
overwritesSupportUrl?: boolean;
server?: string;
tenant?: string;
wasLobbyVisible?: boolean;
wasPrejoinDisplayed?: boolean;
websocket?: boolean;
} & typeof deploymentInfo = {};
if (server) {
permanentProperties.server = server;
}
if (group) {
permanentProperties.group = group;
}
// Report the application name
permanentProperties.appName = getAppName();
// Report if user is using websocket
permanentProperties.websocket = typeof config.websocket === 'string';
// Report if user is using the external API
permanentProperties.externalApi = typeof API_ID === 'number';
// Report if we are loaded in iframe
permanentProperties.inIframe = isEmbedded();
// Report the tenant from the URL.
permanentProperties.tenant = tenant || '/';
permanentProperties.wasPrejoinDisplayed = isPrejoinPageVisible(state);
// Currently we don't know if there will be lobby. We will update it to true if we go through lobby.
permanentProperties.wasLobbyVisible = false;
// Setting visitor properties to false by default. We will update them later if it turns out we are visitor.
permanentProperties.isVisitor = false;
permanentProperties.isPromotedFromVisitor = false;
// TODO: Temporary metric. To be removed once we don't need it.
permanentProperties.overwritesSupportUrl = 'interfaceConfig.SUPPORT_URL' in params;
permanentProperties.overwritesDefaultLogoUrl = 'config.defaultLogoUrl' in params;
const deploymentUrlsConfig = params['config.deploymentUrls'] ?? {};
permanentProperties.overwritesDeploymentUrls
= 'config.deploymentUrls.downloadAppsUrl' in params || 'config.deploymentUrls.userDocumentationURL' in params
|| (typeof deploymentUrlsConfig === 'object'
&& ('downloadAppsUrl' in deploymentUrlsConfig || 'userDocumentationURL' in deploymentUrlsConfig));
const liveStreamingConfig = params['config.liveStreaming'] ?? {};
permanentProperties.overwritesLiveStreamingUrls
= ('interfaceConfig.LIVE_STREAMING_HELP_LINK' in params)
|| ('config.liveStreaming.termsLink' in params)
|| ('config.liveStreaming.dataPrivacyLink' in params)
|| ('config.liveStreaming.helpLink' in params)
|| (typeof params['config.liveStreaming'] === 'object' && 'config.liveStreaming' in params
&& (
'termsLink' in liveStreamingConfig
|| 'dataPrivacyLink' in liveStreamingConfig
|| 'helpLink' in liveStreamingConfig
)
);
permanentProperties.overwritesCustomButtonsWithURL = 'config.customToolbarButtons' in params;
// Optionally, include local deployment information based on the
// contents of window.config.deploymentInfo.
if (deploymentInfo) {
for (const key in deploymentInfo) {
if (deploymentInfo.hasOwnProperty(key)) {
permanentProperties[key as keyof typeof deploymentInfo] = deploymentInfo[
key as keyof typeof deploymentInfo];
}
}
}
analytics.addPermanentProperties({
...permanentProperties,
...getState()['features/analytics'].initialPermanentProperties
});
analytics.setConferenceName(getAnalyticsRoomName(state, dispatch));
// Set the handlers last, since this triggers emptying of the cache
analytics.setAnalyticsHandlers(handlers);
if (!isMobileBrowser() && browser.isChromiumBased()) {
const bannerCfg = state['features/base/config'].chromeExtensionBanner;
checkChromeExtensionsInstalled(bannerCfg).then(extensionsInstalled => {
if (extensionsInstalled?.length) {
analytics.addPermanentProperties({
hasChromeExtension: extensionsInstalled.some(ext => ext)
});
}
});
}
return true;
}
/**
* Tries to load the scripts for the external analytics handlers and creates them.
*
* @param {Array} scriptURLs - The array of script urls to load.
* @param {Object} handlerConstructorOptions - The default options to pass when creating handlers.
* @private
* @returns {Promise} Resolves with the handlers that have been successfully loaded and rejects if there are no handlers
* loaded or the analytics is disabled.
*/
function _loadHandlers(scriptURLs: string[] = [], handlerConstructorOptions: Object) {
const promises: Promise<{ error?: Error; type: string; url?: string; }>[] = [];
for (const url of scriptURLs) {
promises.push(
loadScript(url).then(
() => {
return { type: 'success' };
},
(error: Error) => {
return {
type: 'error',
error,
url
};
}));
}
return Promise.all(promises).then(values => {
for (const el of values) {
if (el.type === 'error') {
logger.warn(`Failed to load ${el.url}: ${el.error}`);
}
}
const handlers = [];
for (const Handler of getJitsiMeetGlobalNS().analyticsHandlers) {
// Catch any error while loading to avoid skipping analytics in case
// of multiple scripts.
try {
handlers.push(new Handler(handlerConstructorOptions));
} catch (error) {
logger.warn(`Error creating analytics handler: ${error}`);
}
}
logger.debug(`Loaded ${handlers.length} external analytics handlers`);
return handlers;
});
}

View File

@@ -0,0 +1,115 @@
export interface IEvent {
action?: string;
actionSubject?: string;
attributes?: {
[key: string]: string | undefined;
};
name?: string;
source?: string;
type?: string;
}
interface IOptions {
amplitudeAPPKey?: string;
blackListedEvents?: string[];
envType?: string;
group?: string;
host?: string;
matomoEndpoint?: string;
matomoSiteID?: string;
product?: string;
subproduct?: string;
user?: string;
version?: string;
whiteListedEvents?: string[];
}
/**
* Abstract implementation of analytics handler.
*/
export default class AbstractHandler {
_enabled: boolean;
_whiteListedEvents: Array<string> | undefined;
_blackListedEvents: Array<string> | undefined;
/**
* Creates new instance.
*
* @param {Object} options - Optional parameters.
*/
constructor(options: IOptions = {}) {
this._enabled = false;
this._whiteListedEvents = options.whiteListedEvents;
// FIXME:
// Keeping the list with the very noisy events so that we don't flood with events whoever hasn't configured
// white/black lists yet. We need to solve this issue properly by either making these events not so noisy or
// by removing them completely from the code.
this._blackListedEvents = [
...(options.blackListedEvents || []), // eslint-disable-line no-extra-parens
'e2e_rtt', 'rtp.stats', 'rtt.by.region', 'available.device', 'stream.switch.delay', 'ice.state.changed',
'ice.duration', 'peer.conn.status.duration'
];
}
/**
* Extracts a name for the event from the event properties.
*
* @param {Object} event - The analytics event.
* @returns {string} - The extracted name.
*/
_extractName(event: IEvent) {
// Page events have a single 'name' field.
if (event.type === 'page') {
return event.name;
}
const {
action,
actionSubject,
source
} = event;
// All events have action, actionSubject, and source fields. All
// three fields are required, and often jitsi-meet and
// lib-jitsi-meet use the same value when separate values are not
// necessary (i.e. event.action == event.actionSubject).
// Here we concatenate these three fields, but avoid adding the same
// value twice, because it would only make the event's name harder
// to read.
let name = action;
if (actionSubject && actionSubject !== action) {
name = `${actionSubject}.${action}`;
}
if (source && source !== action) {
name = `${source}.${name}`;
}
return name;
}
/**
* Checks if an event should be ignored or not.
*
* @param {Object} event - The event.
* @returns {boolean}
*/
_shouldIgnore(event: IEvent) {
if (!event || !this._enabled) {
return true;
}
const name = this._extractName(event) ?? '';
if (Array.isArray(this._whiteListedEvents)) {
return this._whiteListedEvents.indexOf(name) === -1;
}
if (Array.isArray(this._blackListedEvents)) {
return this._blackListedEvents.indexOf(name) !== -1;
}
return false;
}
}

View File

@@ -0,0 +1,91 @@
import { Identify } from '@amplitude/analytics-core';
import logger from '../logger';
import AbstractHandler, { IEvent } from './AbstractHandler';
import { fixDeviceID } from './amplitude/fixDeviceID';
import amplitude, { initAmplitude } from './amplitude/lib';
/**
* Analytics handler for Amplitude.
*/
export default class AmplitudeHandler extends AbstractHandler {
/**
* Creates new instance of the Amplitude analytics handler.
*
* @param {Object} options - The amplitude options.
* @param {string} options.amplitudeAPPKey - The Amplitude app key required by the Amplitude API
* in the Amplitude events.
*/
constructor(options: any) {
super(options);
const {
amplitudeAPPKey,
user
} = options;
this._enabled = true;
initAmplitude(amplitudeAPPKey, user)
.then(() => {
logger.info('Amplitude initialized');
fixDeviceID(amplitude);
})
.catch(e => {
logger.error('Error initializing Amplitude', e);
this._enabled = false;
});
}
/**
* Sets the Amplitude user properties.
*
* @param {Object} userProps - The user properties.
* @returns {void}
*/
setUserProperties(userProps: any) {
if (this._enabled) {
const identify = new Identify();
// Set all properties
Object.entries(userProps).forEach(([ key, value ]) => {
identify.set(key, value as any);
});
amplitude.identify(identify);
}
}
/**
* Sends an event to Amplitude. The format of the event is described
* in AnalyticsAdapter in lib-jitsi-meet.
*
* @param {Object} event - The event in the format specified by
* lib-jitsi-meet.
* @returns {void}
*/
sendEvent(event: IEvent) {
if (this._shouldIgnore(event)) {
return;
}
const eventName = this._extractName(event) ?? '';
amplitude.track(eventName, event);
}
/**
* Return amplitude identity information.
*
* @returns {Object}
*/
getIdentityProps() {
return {
sessionId: amplitude.getSessionId(),
deviceId: amplitude.getDeviceId(),
userId: amplitude.getUserId()
};
}
}

View File

@@ -0,0 +1,170 @@
/* global _paq */
import { getJitsiMeetGlobalNS } from '../../base/util/helpers';
import AbstractHandler, { IEvent } from './AbstractHandler';
/**
* Analytics handler for Matomo.
*/
export default class MatomoHandler extends AbstractHandler {
_userProperties: Object;
/**
* Creates new instance of the Matomo handler.
*
* @param {Object} options - The matomo options.
* @param {string} options.matomoEndpoint - The Matomo endpoint.
* @param {string} options.matomoSiteID - The site ID.
*/
constructor(options: any) {
super(options);
this._userProperties = {};
if (!options.matomoEndpoint) {
throw new Error(
'Failed to initialize Matomo handler: no endpoint defined.'
);
}
if (!options.matomoSiteID) {
throw new Error(
'Failed to initialize Matomo handler: no site ID defined.'
);
}
this._enabled = true;
this._initMatomo(options);
}
/**
* Initializes the _paq object.
*
* @param {Object} options - The matomo options.
* @param {string} options.matomoEndpoint - The Matomo endpoint.
* @param {string} options.matomoSiteID - The site ID.
* @returns {void}
*/
_initMatomo(options: any) {
// @ts-ignore
const _paq = window._paq || [];
// @ts-ignore
window._paq = _paq;
_paq.push([ 'trackPageView' ]);
_paq.push([ 'enableLinkTracking' ]);
(function() {
// add trailing slash if needed
const u = options.matomoEndpoint.endsWith('/')
? options.matomoEndpoint
: `${options.matomoEndpoint}/`;
// configure the tracker
_paq.push([ 'setTrackerUrl', `${u}matomo.php` ]);
_paq.push([ 'setSiteId', options.matomoSiteID ]);
// insert the matomo script
const d = document,
g = d.createElement('script'),
s = d.getElementsByTagName('script')[0];
g.type = 'text/javascript';
g.async = true;
g.defer = true;
g.src = `${u}matomo.js`;
s.parentNode?.insertBefore(g, s);
})();
}
/**
* Extracts the integer to use for a Matomo event's value field
* from a lib-jitsi-meet analytics event.
*
* @param {Object} event - The lib-jitsi-meet analytics event.
* @returns {number} - The integer to use for the 'value' of a Matomo
* event, or NaN if the lib-jitsi-meet event doesn't contain a
* suitable value.
* @private
*/
_extractValue(event: IEvent) {
const value = event?.attributes?.value;
// Try to extract an integer from the 'value' attribute.
return Math.round(parseFloat(value ?? ''));
}
/**
* Sets the permanent properties for the current session.
*
* @param {Object} userProps - The permanent properties.
* @returns {void}
*/
setUserProperties(userProps: any = {}) {
if (!this._enabled) {
return;
}
const visitScope = [ 'user_agent', 'callstats_name', 'browser_name' ];
// add variables in the 'page' scope
Object.keys(userProps)
.filter(key => visitScope.indexOf(key) === -1)
.forEach((key, index) => {
// @ts-ignore
_paq.push([
'setCustomVariable',
1 + index,
key,
userProps[key],
'page'
]);
});
// add variables in the 'visit' scope
Object.keys(userProps)
.filter(key => visitScope.indexOf(key) !== -1)
.forEach((key, index) => {
// @ts-ignore
_paq.push([
'setCustomVariable',
1 + index,
key,
userProps[key],
'visit'
]);
});
}
/**
* This is the entry point of the API. The function sends an event to
* the Matomo endpoint. The format of the event is described in
* analyticsAdapter in lib-jitsi-meet.
*
* @param {Object} event - The event in the format specified by
* lib-jitsi-meet.
* @returns {void}
*/
sendEvent(event: IEvent) {
if (this._shouldIgnore(event)) {
return;
}
const value = this._extractValue(event);
const matomoEvent: Array<string | number | undefined> = [
'trackEvent', 'jitsi-meet', this._extractName(event) ];
if (!isNaN(value)) {
matomoEvent.push(value);
}
// @ts-ignore
_paq.push(matomoEvent);
}
}
const globalNS = getJitsiMeetGlobalNS();
globalNS.analyticsHandlers = globalNS.analyticsHandlers || [];
globalNS.analyticsHandlers.push(MatomoHandler);

View File

@@ -0,0 +1,33 @@
import { Types } from '@amplitude/analytics-react-native';
import DefaultPreference from 'react-native-default-preference';
import { getUniqueId } from 'react-native-device-info';
import logger from '../../logger';
/**
* Custom logic for setting the correct device id.
*
* @param {Types.ReactNativeClient} amplitude - The amplitude instance.
* @returns {void}
*/
export async function fixDeviceID(amplitude: Types.ReactNativeClient) {
await DefaultPreference.setName('jitsi-preferences');
const current = await DefaultPreference.get('amplitudeDeviceId');
if (current) {
amplitude.setDeviceId(current);
} else {
const uid = await getUniqueId();
if (!uid) {
logger.warn('Device ID is not set!');
return;
}
amplitude.setDeviceId(uid as string);
await DefaultPreference.set('amplitudeDeviceId', uid as string);
}
}

View File

@@ -0,0 +1,46 @@
import { Types } from '@amplitude/analytics-browser';
// @ts-ignore
import { jitsiLocalStorage } from '@jitsi/js-utils';
import logger from '../../logger';
/**
* Key used to store the device id in local storage.
*/
const DEVICE_ID_KEY = '__AMDID';
/**
* Custom logic for setting the correct device id.
*
* @param {Types.BrowserClient} amplitude - The amplitude instance.
* @returns {void}
*/
export function fixDeviceID(amplitude: Types.BrowserClient) {
const deviceId = jitsiLocalStorage.getItem(DEVICE_ID_KEY);
if (deviceId) {
// Set the device id in Amplitude.
try {
amplitude.setDeviceId(JSON.parse(deviceId));
} catch (error) {
logger.error('Failed to set device ID in Amplitude', error);
return Promise.resolve(false);
}
} else {
const newDeviceId = amplitude.getDeviceId();
if (newDeviceId) {
jitsiLocalStorage.setItem(DEVICE_ID_KEY, JSON.stringify(newDeviceId));
}
}
}
/**
* Returns the amplitude shared deviceId.
*
* @returns {string} - The amplitude deviceId.
*/
export function getDeviceID() {
return jitsiLocalStorage.getItem(DEVICE_ID_KEY);
}

View File

@@ -0,0 +1,15 @@
import amplitude from '@amplitude/analytics-react-native';
export default amplitude;
/**
* Initializes the Amplitude instance.
*
* @param {string} amplitudeAPPKey - The Amplitude app key.
* @param {string | undefined} user - The user ID.
* @returns {Promise} The initialized Amplitude instance.
*/
export function initAmplitude(
amplitudeAPPKey: string, user: string | undefined): Promise<unknown> {
return amplitude.init(amplitudeAPPKey, user, {}).promise;
}

View File

@@ -0,0 +1,38 @@
import { createInstance } from '@amplitude/analytics-browser';
const amplitude = createInstance();
export default amplitude;
/**
* Initializes the Amplitude instance.
*
* @param {string} amplitudeAPPKey - The Amplitude app key.
* @param {string | undefined} user - The user ID.
* @returns {Promise} The initialized Amplitude instance.
*/
export function initAmplitude(
amplitudeAPPKey: string, user: string | undefined): Promise<unknown> {
// Forces sending all events on exit (flushing) via sendBeacon.
window.addEventListener('pagehide', () => {
// Set https transport to use sendBeacon API.
amplitude.setTransport('beacon');
// Send all pending events to server.
amplitude.flush();
});
const options = {
autocapture: {
attribution: true,
pageViews: true,
sessions: false,
fileDownloads: false,
formInteractions: false,
elementInteractions: false
},
defaultTracking: false
};
return amplitude.init(amplitudeAPPKey, user, options).promise;
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/analytics');

View File

@@ -0,0 +1,216 @@
import { IReduxState } from '../app/types';
import {
CONFERENCE_JOINED,
CONFERENCE_WILL_LEAVE,
SET_ROOM
} from '../base/conference/actionTypes';
import { SET_CONFIG } from '../base/config/actionTypes';
import { SET_NETWORK_INFO } from '../base/net-info/actionTypes';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import {
TRACK_ADDED,
TRACK_REMOVED,
TRACK_UPDATED
} from '../base/tracks/actionTypes';
import {
getLocalAudioTrack,
getLocalVideoTrack
} from '../base/tracks/functions';
import { SET_LOBBY_VISIBILITY } from '../lobby/actionTypes';
import { getIsLobbyVisible } from '../lobby/functions';
import { I_AM_VISITOR_MODE } from '../visitors/actionTypes';
import { iAmVisitor } from '../visitors/functions';
import { createLocalTracksDurationEvent, createNetworkInfoEvent } from './AnalyticsEvents';
import { SET_INITIALIZED, UPDATE_LOCAL_TRACKS_DURATION } from './actionTypes';
import { setPermanentProperty } from './actions';
import { createHandlers, initAnalytics, resetAnalytics, sendAnalytics } from './functions';
/**
* Calculates the duration of the local tracks.
*
* @param {Object} state - The redux state.
* @returns {Object} - The local tracks duration.
*/
function calculateLocalTrackDuration(state: IReduxState) {
const now = Date.now();
const { localTracksDuration } = state['features/analytics'];
const { conference } = state['features/base/conference'];
const { audio, video } = localTracksDuration;
const { camera, desktop } = video;
const tracks = state['features/base/tracks'];
const audioTrack = getLocalAudioTrack(tracks);
const videoTrack = getLocalVideoTrack(tracks);
const newDuration = { ...localTracksDuration };
if (!audioTrack || audioTrack.muted || !conference) {
newDuration.audio = {
startedTime: -1,
value: audio.value + (audio.startedTime === -1 ? 0 : now - audio.startedTime)
};
} else if (audio.startedTime === -1) {
newDuration.audio.startedTime = now;
}
if (!videoTrack || videoTrack.muted || !conference) {
newDuration.video = {
camera: {
startedTime: -1,
value: camera.value + (camera.startedTime === -1 ? 0 : now - camera.startedTime)
},
desktop: {
startedTime: -1,
value: desktop.value + (desktop.startedTime === -1 ? 0 : now - desktop.startedTime)
}
};
} else {
const { videoType } = videoTrack;
if (video[videoType as keyof typeof video].startedTime === -1) {
newDuration.video[videoType as keyof typeof video].startedTime = now;
}
}
return {
...localTracksDuration,
...newDuration
};
}
/**
* Middleware which intercepts config actions to handle evaluating analytics
* config based on the config stored in the store.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case I_AM_VISITOR_MODE: {
const oldIAmVisitor = iAmVisitor(store.getState());
const result = next(action);
const newIAmVisitor = iAmVisitor(store.getState());
store.dispatch(setPermanentProperty({
isVisitor: newIAmVisitor,
isPromotedFromVisitor: oldIAmVisitor && !newIAmVisitor
}));
return result;
}
case SET_CONFIG:
if (navigator.product === 'ReactNative') {
// Resetting the analytics is currently not needed for web because
// the user will be redirected to another page and new instance of
// Analytics will be created and initialized.
resetAnalytics();
const { dispatch } = store;
dispatch({
type: SET_INITIALIZED,
value: false
});
}
break;
case SET_ROOM: {
// createHandlers is called before the SET_ROOM action is executed in order for Amplitude to initialize before
// the deeplinking logic is executed (after the SET_ROOM action) so that the Amplitude device id is available
// if needed.
const createHandlersPromise = createHandlers(store);
const result = next(action);
createHandlersPromise.then(handlers => {
if (initAnalytics(store, handlers)) {
store.dispatch({
type: SET_INITIALIZED,
value: true
});
}
});
return result;
}
}
const result = next(action);
switch (action.type) {
case CONFERENCE_JOINED: {
const { dispatch, getState } = store;
const state = getState();
dispatch({
type: UPDATE_LOCAL_TRACKS_DURATION,
localTracksDuration: {
...calculateLocalTrackDuration(state),
conference: {
startedTime: Date.now(),
value: 0
}
}
});
break;
}
case CONFERENCE_WILL_LEAVE: {
const { dispatch, getState } = store;
const state = getState();
const { localTracksDuration } = state['features/analytics'];
const newLocalTracksDuration = {
...calculateLocalTrackDuration(state),
conference: {
startedTime: -1,
value: Date.now() - localTracksDuration.conference.startedTime
}
};
sendAnalytics(createLocalTracksDurationEvent(newLocalTracksDuration));
dispatch({
type: UPDATE_LOCAL_TRACKS_DURATION,
localTracksDuration: newLocalTracksDuration
});
break;
}
case SET_LOBBY_VISIBILITY:
if (getIsLobbyVisible(store.getState())) {
store.dispatch(setPermanentProperty({
wasLobbyVisible: true
}));
}
break;
case SET_NETWORK_INFO:
sendAnalytics(
createNetworkInfoEvent({
isOnline: action.isOnline,
details: action.details,
networkType: action.networkType
}));
break;
case TRACK_ADDED:
case TRACK_REMOVED:
case TRACK_UPDATED: {
const { dispatch, getState } = store;
const state = getState();
const { localTracksDuration } = state['features/analytics'];
if (localTracksDuration.conference.startedTime === -1) {
// We don't want to track the media duration if the conference is not joined yet because otherwise we won't
// be able to compare them with the conference duration (from conference join to conference will leave).
break;
}
dispatch({
type: UPDATE_LOCAL_TRACKS_DURATION,
localTracksDuration: {
...localTracksDuration,
...calculateLocalTrackDuration(state)
}
});
break;
}
}
return result;
});

View File

@@ -0,0 +1,88 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
SET_INITIALIZED,
SET_INITIAL_PERMANENT_PROPERTIES,
UPDATE_LOCAL_TRACKS_DURATION
} from './actionTypes';
/**
* Initial state.
*/
const DEFAULT_STATE = {
isInitialized: false,
initialPermanentProperties: {},
localTracksDuration: {
audio: {
startedTime: -1,
value: 0
},
video: {
camera: {
startedTime: -1,
value: 0
},
desktop: {
startedTime: -1,
value: 0
}
},
conference: {
startedTime: -1,
value: 0
}
}
};
interface IValue {
startedTime: number;
value: number;
}
export interface IAnalyticsState {
initialPermanentProperties: Object;
isInitialized: boolean;
localTracksDuration: {
audio: IValue;
conference: IValue;
video: {
camera: IValue;
desktop: IValue;
};
};
}
/**
* Listen for actions which changes the state of the analytics feature.
*
* @param {Object} state - The Redux state of the feature features/analytics.
* @param {Object} action - Action object.
* @param {string} action.type - Type of action.
* @returns {Object}
*/
ReducerRegistry.register<IAnalyticsState>('features/analytics',
(state = DEFAULT_STATE, action): IAnalyticsState => {
switch (action.type) {
case SET_INITIALIZED:
return {
...state,
initialPermanentProperties: action.value ? state.initialPermanentProperties : {},
isInitialized: action.value
};
case SET_INITIAL_PERMANENT_PROPERTIES:
return {
...state,
initialPermanentProperties: {
...state.initialPermanentProperties,
...action.properties
}
};
case UPDATE_LOCAL_TRACKS_DURATION:
return {
...state,
localTracksDuration: action.localTracksDuration
};
default:
return state;
}
});