snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
20
packages/frontend/track/package.json
Normal file
20
packages/frontend/track/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@affine/track",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"version": "0.27.5",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine/debug": "workspace:*",
|
||||
"@sentry/react": "^10.53.1",
|
||||
"nanoid": "^5.1.6",
|
||||
"react": "^19.2.1",
|
||||
"react-router-dom": "^6.30.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.1",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
148
packages/frontend/track/src/__tests__/auto.spec.ts
Normal file
148
packages/frontend/track/src/__tests__/auto.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { enableAutoTrack, makeTracker } from '../auto';
|
||||
|
||||
describe('callable events chain', () => {
|
||||
const call = vi.fn();
|
||||
const track = makeTracker(call);
|
||||
|
||||
beforeEach(() => {
|
||||
call.mockClear();
|
||||
});
|
||||
|
||||
test('should call track with event and props', () => {
|
||||
// @ts-expect-error fake chain
|
||||
track.pageA.segmentA.moduleA.eventA();
|
||||
|
||||
expect(call).toBeCalledWith('eventA', {
|
||||
page: 'pageA',
|
||||
segment: 'segmentA',
|
||||
module: 'moduleA',
|
||||
});
|
||||
});
|
||||
|
||||
test('should be able to override props', () => {
|
||||
// @ts-expect-error fake chain
|
||||
track.pageA.segmentA.moduleA.eventA({ page: 'pageB', control: 'controlA' });
|
||||
|
||||
expect(call).toBeCalledWith('eventA', {
|
||||
page: 'pageB',
|
||||
segment: 'segmentA',
|
||||
module: 'moduleA',
|
||||
control: 'controlA',
|
||||
});
|
||||
});
|
||||
|
||||
test('should be able to append custom props', () => {
|
||||
// @ts-expect-error fake chain
|
||||
track.pageA.segmentA.moduleA.eventA({ custom: 'prop' });
|
||||
|
||||
expect(call).toBeCalledWith('eventA', {
|
||||
page: 'pageA',
|
||||
segment: 'segmentA',
|
||||
module: 'moduleA',
|
||||
custom: 'prop',
|
||||
});
|
||||
});
|
||||
|
||||
test('should be able to ignore matrix named with placeholder `$`', () => {
|
||||
// @ts-expect-error fake chain
|
||||
track.$.segmentA.moduleA.eventA();
|
||||
// @ts-expect-error fake chain
|
||||
track.pageA.$.moduleA.eventA();
|
||||
// @ts-expect-error fake chain
|
||||
track.pageA.segmentA.$.eventA();
|
||||
// @ts-expect-error fake chain
|
||||
track.$.$.$.eventA();
|
||||
|
||||
const args = [
|
||||
{
|
||||
segment: 'segmentA',
|
||||
module: 'moduleA',
|
||||
},
|
||||
{
|
||||
page: 'pageA',
|
||||
module: 'moduleA',
|
||||
},
|
||||
{
|
||||
page: 'pageA',
|
||||
segment: 'segmentA',
|
||||
},
|
||||
{},
|
||||
];
|
||||
|
||||
args.forEach((arg, i) => {
|
||||
expect(call).toHaveBeenNthCalledWith(i + 1, 'eventA', arg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto track with dom dataset', () => {
|
||||
const root = document.createElement('div');
|
||||
const call = vi.fn();
|
||||
beforeAll(() => {
|
||||
call.mockReset();
|
||||
root.innerHTML = '';
|
||||
return enableAutoTrack(root, call);
|
||||
});
|
||||
|
||||
test('should ignore if data-event-props not set', () => {
|
||||
const nonTrackBtn = document.createElement('button');
|
||||
root.append(nonTrackBtn);
|
||||
|
||||
nonTrackBtn.click();
|
||||
|
||||
expect(call).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('should track event with props', () => {
|
||||
const btn = document.createElement('button');
|
||||
btn.dataset.eventProps = 'allDocs.header.actions.createDoc';
|
||||
root.append(btn);
|
||||
|
||||
btn.click();
|
||||
|
||||
expect(call).toBeCalledWith('createDoc', {
|
||||
page: 'allDocs',
|
||||
segment: 'header',
|
||||
module: 'actions',
|
||||
});
|
||||
});
|
||||
|
||||
test('should track event with single', () => {
|
||||
const btn = document.createElement('button');
|
||||
btn.dataset.eventProps = 'allDocs.header.actions.createDoc';
|
||||
btn.dataset.eventArg = 'test';
|
||||
root.append(btn);
|
||||
|
||||
btn.click();
|
||||
|
||||
expect(call).toBeCalledWith('createDoc', {
|
||||
page: 'allDocs',
|
||||
segment: 'header',
|
||||
module: 'actions',
|
||||
arg: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
test('should track event with multiple args', () => {
|
||||
const btn = document.createElement('button');
|
||||
btn.dataset.eventProps = 'allDocs.header.actions.createDoc';
|
||||
btn.dataset.eventArgsFoo = 'bar';
|
||||
btn.dataset.eventArgsBaz = 'qux';
|
||||
root.append(btn);
|
||||
|
||||
btn.click();
|
||||
|
||||
expect(call).toBeCalledWith('createDoc', {
|
||||
page: 'allDocs',
|
||||
segment: 'header',
|
||||
module: 'actions',
|
||||
foo: 'bar',
|
||||
baz: 'qux',
|
||||
});
|
||||
});
|
||||
});
|
||||
80
packages/frontend/track/src/__tests__/tracker.spec.ts
Normal file
80
packages/frontend/track/src/__tests__/tracker.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { resetTrackerState } from '../state';
|
||||
import { tracker } from '../tracker';
|
||||
|
||||
const { sendTelemetryEvent, setTelemetryContext } = vi.hoisted(() => ({
|
||||
sendTelemetryEvent: vi.fn().mockResolvedValue({ queued: true }),
|
||||
setTelemetryContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../telemetry', () => ({
|
||||
sendTelemetryEvent,
|
||||
setTelemetryContext,
|
||||
}));
|
||||
|
||||
const buildConfig = {
|
||||
appVersion: '0.0.0',
|
||||
appBuildType: 'stable',
|
||||
editorVersion: '0.0.0',
|
||||
isElectron: false,
|
||||
isMobileEdition: false,
|
||||
distribution: 'test',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as any).BUILD_CONFIG = buildConfig;
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
sendTelemetryEvent.mockClear();
|
||||
setTelemetryContext.mockClear();
|
||||
vi.useRealTimers();
|
||||
resetTrackerState();
|
||||
});
|
||||
|
||||
describe('tracker session signals', () => {
|
||||
test('sends first_visit and session_start on first event', () => {
|
||||
tracker.track('test_event');
|
||||
|
||||
const events = sendTelemetryEvent.mock.calls.map(call => call[0]);
|
||||
expect(events.map(event => event.eventName)).toEqual([
|
||||
'first_visit',
|
||||
'session_start',
|
||||
'test_event',
|
||||
]);
|
||||
|
||||
const firstVisit = events[0];
|
||||
expect(typeof firstVisit.params?.session_id).toBe('number');
|
||||
expect(firstVisit.params?.session_number).toBe(1);
|
||||
expect(firstVisit.params?.engagement_time_msec).toBe(1);
|
||||
});
|
||||
|
||||
test('does not repeat first_visit for later events', () => {
|
||||
tracker.track('event_a');
|
||||
tracker.track('event_b');
|
||||
|
||||
const names = sendTelemetryEvent.mock.calls.map(call => call[0].eventName);
|
||||
expect(names.filter(name => name === 'first_visit')).toHaveLength(1);
|
||||
expect(names.filter(name => name === 'session_start')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('increments session_number after idle timeout', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||
|
||||
tracker.track('event_a');
|
||||
sendTelemetryEvent.mockClear();
|
||||
|
||||
vi.setSystemTime(new Date('2024-01-01T01:00:00Z'));
|
||||
tracker.track('event_b');
|
||||
|
||||
const events = sendTelemetryEvent.mock.calls.map(call => call[0]);
|
||||
const sessionStart = events.find(
|
||||
event => event.eventName === 'session_start'
|
||||
);
|
||||
expect(sessionStart?.params?.session_number).toBe(2);
|
||||
});
|
||||
});
|
||||
125
packages/frontend/track/src/auto.ts
Normal file
125
packages/frontend/track/src/auto.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
|
||||
import type { CallableEventsChain, EventsUnion } from './types';
|
||||
|
||||
const logger = new DebugLogger('mixpanel');
|
||||
|
||||
interface TrackFn {
|
||||
(event: string, props: Record<string, any>): void;
|
||||
}
|
||||
|
||||
const levels = ['page', 'segment', 'module', 'event'] as const;
|
||||
export function makeTracker(trackFn: TrackFn): CallableEventsChain {
|
||||
function makeTrackerInner(level: number, info: Record<string, string>) {
|
||||
const proxy = new Proxy({} as Record<string, any>, {
|
||||
get(target, prop) {
|
||||
if (
|
||||
typeof prop !== 'string' ||
|
||||
prop === '$$typeof' /* webpack hot-reload reads this prop */
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (levels[level] === 'event') {
|
||||
return (arg: string | Record<string, any>) => {
|
||||
trackFn(prop, {
|
||||
...info,
|
||||
...(typeof arg === 'string' ? { arg } : arg),
|
||||
});
|
||||
};
|
||||
} else {
|
||||
let levelProxy = target[prop];
|
||||
if (levelProxy) {
|
||||
return levelProxy;
|
||||
}
|
||||
|
||||
levelProxy = makeTrackerInner(
|
||||
level + 1,
|
||||
prop === '$' ? { ...info } : { ...info, [levels[level]]: prop }
|
||||
);
|
||||
target[prop] = levelProxy;
|
||||
return levelProxy;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return proxy;
|
||||
}
|
||||
|
||||
return makeTrackerInner(0, {}) as CallableEventsChain;
|
||||
}
|
||||
|
||||
/**
|
||||
* listen on clicking on all subtree elements and auto track events if defined
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```html
|
||||
* <button
|
||||
* data-event-chain='$.cmdk.settings.changeLanguage'
|
||||
* data-event-arg='cn'
|
||||
* <!-- or -->
|
||||
* data-event-args-foo='bar'
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function enableAutoTrack(root: HTMLElement, trackFn: TrackFn) {
|
||||
const listener = (e: Event) => {
|
||||
const el = e.target as HTMLElement | null;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const dataset = el.dataset;
|
||||
|
||||
if (dataset['eventProps']) {
|
||||
const args: Record<string, any> = {};
|
||||
if (dataset['eventArg'] !== undefined) {
|
||||
args['arg'] = dataset['eventArg'];
|
||||
} else {
|
||||
for (const argName of Object.keys(dataset)) {
|
||||
if (argName.startsWith('eventArgs')) {
|
||||
args[argName.slice(9).toLowerCase()] = dataset[argName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const props = dataset['eventProps']
|
||||
.split('.')
|
||||
.map(name => (name === '$' ? undefined : name));
|
||||
if (props.length !== levels.length) {
|
||||
logger.error('Invalid event props on element', el);
|
||||
return;
|
||||
}
|
||||
|
||||
const event = props[3];
|
||||
|
||||
if (!event) {
|
||||
logger.error('Invalid event props on element', el);
|
||||
return;
|
||||
}
|
||||
|
||||
trackFn(event, {
|
||||
page: props[0] as any,
|
||||
segment: props[1],
|
||||
module: props[2],
|
||||
...args,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
root.addEventListener('click', listener, {});
|
||||
return () => {
|
||||
root.removeEventListener('click', listener);
|
||||
};
|
||||
}
|
||||
|
||||
declare module 'react' {
|
||||
// we have to declare `T` but it's actually not used
|
||||
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
interface HTMLAttributes<T> {
|
||||
'data-event-props'?: EventsUnion;
|
||||
'data-event-arg'?: string;
|
||||
'data-event-args-control'?: string;
|
||||
}
|
||||
}
|
||||
872
packages/frontend/track/src/events.ts
Normal file
872
packages/frontend/track/src/events.ts
Normal file
@@ -0,0 +1,872 @@
|
||||
// let '$' stands for unspecific matrix
|
||||
|
||||
// SECTION: app events
|
||||
type GeneralEvents = 'openMigrationDataHelp';
|
||||
type CmdkEvents = 'quickSearch' | 'recentDocs' | 'searchResultsDocs';
|
||||
type AppEvents =
|
||||
| 'checkUpdates'
|
||||
| 'downloadUpdate'
|
||||
| 'downloadApp'
|
||||
| 'quitAndInstall'
|
||||
| 'openChangelog'
|
||||
| 'dismissChangelog'
|
||||
| 'contactUs'
|
||||
| 'findInPage';
|
||||
type NavigationEvents =
|
||||
| 'openInNewTab'
|
||||
| 'openInSplitView'
|
||||
| 'openInPeekView'
|
||||
| 'switchTab'
|
||||
| 'switchSplitView'
|
||||
| 'tabAction'
|
||||
| 'splitViewAction'
|
||||
| 'navigate'
|
||||
| 'goBack'
|
||||
| 'goForward'
|
||||
| 'toggle' // toggle navigation panel
|
||||
| 'open'
|
||||
| 'close'; // openclose modal/diaglog
|
||||
|
||||
// END SECTIONalias
|
||||
|
||||
// SECTION: doc events
|
||||
type WorkspaceEvents =
|
||||
| 'createWorkspace'
|
||||
| 'upgradeWorkspace'
|
||||
| 'enableCloudWorkspace'
|
||||
| 'import'
|
||||
| 'export'
|
||||
| 'openWorkspaceList';
|
||||
type DocEvents =
|
||||
| 'openDoc'
|
||||
| 'createDoc'
|
||||
| 'quickStart'
|
||||
| 'renameDoc'
|
||||
| 'linkDoc'
|
||||
| 'deleteDoc'
|
||||
| 'restoreDoc'
|
||||
| 'switchPageMode'
|
||||
| 'openDocOptionsMenu'
|
||||
| 'openDocInfo'
|
||||
| 'copyBlockToLink'
|
||||
| 'loadDoc'
|
||||
| 'bookmark'
|
||||
| 'editProperty'
|
||||
| 'editPropertyMeta'
|
||||
| 'addProperty'
|
||||
| 'editDisplayMenu'
|
||||
| 'navigateAllDocsRouter'
|
||||
| 'navigatePinedCollectionRouter'
|
||||
| 'htmlBlockPreviewFailed';
|
||||
type EditorEvents =
|
||||
| 'bold'
|
||||
| 'italic'
|
||||
| 'underline'
|
||||
| 'strikeThrough'
|
||||
| 'foldEdgelessNote';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: setting events
|
||||
type SettingEvents =
|
||||
| 'openSettings'
|
||||
| 'changeAppSetting'
|
||||
| 'changeEditorSetting'
|
||||
| 'recoverArchivedWorkspace'
|
||||
| 'deleteArchivedWorkspace'
|
||||
| 'deleteUnusedBlob';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: organize events
|
||||
type CollectionEvents =
|
||||
| 'createCollection'
|
||||
| 'deleteCollection'
|
||||
| 'renameCollection'
|
||||
| 'addDocToCollection'
|
||||
| 'editCollection'
|
||||
| 'addPinnedCollection';
|
||||
type FolderEvents =
|
||||
| 'createFolder'
|
||||
| 'renameFolder'
|
||||
| 'moveFolder'
|
||||
| 'deleteFolder';
|
||||
type TagEvents = 'createTag' | 'deleteTag' | 'renameTag' | 'tagDoc';
|
||||
type FavoriteEvents = 'toggleFavorite';
|
||||
type OrganizeItemEvents =
|
||||
// doc, link, folder, collection, tag
|
||||
| 'createOrganizeItem'
|
||||
| 'renameOrganizeItem'
|
||||
| 'moveOrganizeItem'
|
||||
| 'deleteOrganizeItem'
|
||||
| 'orderOrganizeItem'
|
||||
| 'removeOrganizeItem';
|
||||
type OrganizeEvents =
|
||||
| OrganizeItemEvents
|
||||
| CollectionEvents
|
||||
| FolderEvents
|
||||
| TagEvents
|
||||
| FavoriteEvents;
|
||||
|
||||
type DNDEvents = 'dragStart' | 'drag' | 'drop';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: cloud events
|
||||
type ShareEvents =
|
||||
| 'createShareLink'
|
||||
| 'copyShareLink'
|
||||
| 'openShareMenu'
|
||||
| 'share';
|
||||
type DocRoleEvents =
|
||||
| 'modifyDocDefaultRole'
|
||||
| 'modifyUserDocRole'
|
||||
| 'inviteUserDocRole';
|
||||
type AuthEvents =
|
||||
| 'requestSignIn'
|
||||
| 'signIn'
|
||||
| 'signInFail'
|
||||
| 'signedIn'
|
||||
| 'signOut'
|
||||
| 'deleteAccount';
|
||||
type AccountEvents = 'uploadAvatar' | 'removeAvatar' | 'updateUserName';
|
||||
type PaymentEvents =
|
||||
| 'viewPlans'
|
||||
| 'bookDemo'
|
||||
| 'checkout'
|
||||
| 'subscribe'
|
||||
| 'changeSubscriptionRecurring'
|
||||
| 'confirmChangingSubscriptionRecurring'
|
||||
| 'cancelSubscription'
|
||||
| 'confirmCancelingSubscription'
|
||||
| 'resumeSubscription'
|
||||
| 'confirmResumingSubscription';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: ai
|
||||
type AIEvents = 'addEmbeddingDoc';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: attachment
|
||||
type AttachmentEvents =
|
||||
| 'openAttachmentInFullscreen'
|
||||
| 'openAttachmentInNewTab'
|
||||
| 'openAttachmentInPeekView'
|
||||
| 'openAttachmentInSplitView'
|
||||
| 'openPDFRendererFail';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: template
|
||||
type TemplateEvents = 'openTemplateListMenu';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: notification
|
||||
type NotificationEvents = 'openInbox' | 'clickNotification';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: Integration
|
||||
type IntegrationEvents =
|
||||
| 'connectIntegration'
|
||||
| 'disconnectIntegration'
|
||||
| 'modifyIntegrationSettings'
|
||||
| 'startIntegrationImport'
|
||||
| 'selectIntegrationImport'
|
||||
| 'confirmIntegrationImport'
|
||||
| 'abortIntegrationImport'
|
||||
| 'completeIntegrationImport'
|
||||
| 'createCalendarDocEvent';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: journal
|
||||
type MeetingEvents =
|
||||
| 'toggleRecordingBar'
|
||||
| 'startRecording'
|
||||
| 'dismissRecording'
|
||||
| 'finishRecording'
|
||||
| 'transcribeRecording'
|
||||
| 'openTranscribeNotes'
|
||||
| 'toggleMeetingFeatureFlag'
|
||||
| 'activeMenubarAppItem';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: mention
|
||||
type MentionEvents = 'mentionMember' | 'noAccessPrompted';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: workspace embedding
|
||||
type WorkspaceEmbeddingEvents =
|
||||
| 'toggleWorkspaceEmbedding'
|
||||
| 'addAdditionalDocs'
|
||||
| 'addIgnoredDocs';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: comment events
|
||||
// Add events for comment actions
|
||||
type CommentEvents =
|
||||
| 'createComment'
|
||||
| 'editComment'
|
||||
| 'deleteComment'
|
||||
| 'resolveComment';
|
||||
// END SECTION
|
||||
|
||||
// SECTION: apply model
|
||||
type ApplyModelEvents =
|
||||
| 'acceptAll'
|
||||
| 'rejectAll'
|
||||
| 'accept'
|
||||
| 'reject'
|
||||
| 'apply'
|
||||
| 'copy';
|
||||
// END SECTION
|
||||
|
||||
type UserEvents =
|
||||
| GeneralEvents
|
||||
| AppEvents
|
||||
| NavigationEvents
|
||||
| WorkspaceEvents
|
||||
| DocEvents
|
||||
| EditorEvents
|
||||
| SettingEvents
|
||||
| CmdkEvents
|
||||
| OrganizeEvents
|
||||
| ShareEvents
|
||||
| DocRoleEvents
|
||||
| AuthEvents
|
||||
| AccountEvents
|
||||
| PaymentEvents
|
||||
| DNDEvents
|
||||
| AIEvents
|
||||
| CommentEvents
|
||||
| AttachmentEvents
|
||||
| TemplateEvents
|
||||
| NotificationEvents
|
||||
| IntegrationEvents
|
||||
| MeetingEvents
|
||||
| MentionEvents
|
||||
| WorkspaceEmbeddingEvents
|
||||
| ApplyModelEvents;
|
||||
|
||||
interface PageDivision {
|
||||
[page: string]: {
|
||||
[segment: string]: {
|
||||
[module: string]: UserEvents[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface PageEvents extends PageDivision {
|
||||
// page: {
|
||||
// $: {}
|
||||
// ^ if empty
|
||||
// segment: {
|
||||
// module: ['event1', 'event2']
|
||||
// },
|
||||
// },
|
||||
// to: page.$.segment.module.event1()
|
||||
$: {
|
||||
$: {
|
||||
$: ['createWorkspace', 'checkout'];
|
||||
auth: [
|
||||
'requestSignIn',
|
||||
'signIn',
|
||||
'signedIn',
|
||||
'signInFail',
|
||||
'signOut',
|
||||
'deleteAccount',
|
||||
];
|
||||
};
|
||||
sharePanel: {
|
||||
$: [
|
||||
'createShareLink',
|
||||
'copyShareLink',
|
||||
'export',
|
||||
'open',
|
||||
'modifyDocDefaultRole',
|
||||
'modifyUserDocRole',
|
||||
'inviteUserDocRole',
|
||||
];
|
||||
};
|
||||
docInfoPanel: {
|
||||
$: ['open'];
|
||||
property: ['editProperty', 'addProperty', 'editPropertyMeta'];
|
||||
databaseProperty: ['editProperty'];
|
||||
};
|
||||
settingsPanel: {
|
||||
menu: ['openSettings'];
|
||||
workspace: [
|
||||
'viewPlans',
|
||||
'export',
|
||||
'addProperty',
|
||||
'editPropertyMeta',
|
||||
'deleteUnusedBlob',
|
||||
];
|
||||
archivedWorkspaces: [
|
||||
'recoverArchivedWorkspace',
|
||||
'deleteArchivedWorkspace',
|
||||
];
|
||||
profileAndBadge: ['viewPlans'];
|
||||
accountUsage: ['viewPlans'];
|
||||
accountSettings: ['uploadAvatar', 'removeAvatar', 'updateUserName'];
|
||||
plans: [
|
||||
'checkout',
|
||||
'subscribe',
|
||||
'changeSubscriptionRecurring',
|
||||
'confirmChangingSubscriptionRecurring',
|
||||
'cancelSubscription',
|
||||
'confirmCancelingSubscription',
|
||||
'resumeSubscription',
|
||||
'confirmResumingSubscription',
|
||||
];
|
||||
billing: ['viewPlans', 'bookDemo'];
|
||||
about: ['checkUpdates', 'downloadUpdate', 'changeAppSetting'];
|
||||
integrationList: [
|
||||
'connectIntegration',
|
||||
'disconnectIntegration',
|
||||
'modifyIntegrationSettings',
|
||||
'startIntegrationImport',
|
||||
'selectIntegrationImport',
|
||||
'confirmIntegrationImport',
|
||||
'abortIntegrationImport',
|
||||
'completeIntegrationImport',
|
||||
];
|
||||
meetings: ['toggleMeetingFeatureFlag'];
|
||||
indexerEmbedding: [
|
||||
'toggleWorkspaceEmbedding',
|
||||
'addAdditionalDocs',
|
||||
'addIgnoredDocs',
|
||||
];
|
||||
};
|
||||
cmdk: {
|
||||
recent: ['recentDocs'];
|
||||
results: ['searchResultsDocs'];
|
||||
general: ['copyShareLink', 'goBack', 'goForward', 'findInPage'];
|
||||
creation: ['createDoc'];
|
||||
workspace: ['createWorkspace'];
|
||||
settings: ['openSettings', 'changeAppSetting'];
|
||||
navigation: ['navigate'];
|
||||
editor: [
|
||||
'toggleFavorite',
|
||||
'switchPageMode',
|
||||
'createDoc',
|
||||
'export',
|
||||
'deleteDoc',
|
||||
'restoreDoc',
|
||||
];
|
||||
docInfo: ['open'];
|
||||
docHistory: ['open'];
|
||||
updates: ['quitAndInstall'];
|
||||
help: ['contactUs', 'openChangelog'];
|
||||
};
|
||||
navigationPanel: {
|
||||
$: ['quickSearch', 'createDoc', 'navigate', 'openSettings', 'toggle'];
|
||||
organize: [
|
||||
'createOrganizeItem',
|
||||
'renameOrganizeItem',
|
||||
'moveOrganizeItem',
|
||||
'deleteOrganizeItem',
|
||||
'orderOrganizeItem',
|
||||
'openInNewTab',
|
||||
'openInSplitView',
|
||||
'toggleFavorite',
|
||||
'drop',
|
||||
];
|
||||
docs: ['createDoc', 'deleteDoc', 'linkDoc', 'drop', 'openDoc'];
|
||||
collections: [
|
||||
'createDoc',
|
||||
'addDocToCollection',
|
||||
'removeOrganizeItem',
|
||||
'drop',
|
||||
'editCollection',
|
||||
];
|
||||
folders: ['createDoc', 'drop'];
|
||||
tags: ['createDoc', 'tagDoc', 'drop'];
|
||||
favorites: ['createDoc', 'drop'];
|
||||
migrationData: ['openMigrationDataHelp'];
|
||||
bottomButtons: [
|
||||
'downloadApp',
|
||||
'quitAndInstall',
|
||||
'openChangelog',
|
||||
'dismissChangelog',
|
||||
];
|
||||
others: ['navigate'];
|
||||
importModal: ['open'];
|
||||
workspaceList: [
|
||||
'requestSignIn',
|
||||
'open',
|
||||
'createWorkspace',
|
||||
'createDoc',
|
||||
'openSettings',
|
||||
];
|
||||
profileAndBadge: ['openSettings'];
|
||||
journal: ['navigate'];
|
||||
};
|
||||
aiOnboarding: {
|
||||
dialog: ['viewPlans'];
|
||||
};
|
||||
docHistory: {
|
||||
$: ['open', 'close', 'switchPageMode', 'viewPlans'];
|
||||
};
|
||||
importModal: {
|
||||
$: ['open', 'import', 'createDoc'];
|
||||
};
|
||||
paywall: {
|
||||
storage: ['viewPlans'];
|
||||
aiAction: ['viewPlans'];
|
||||
};
|
||||
appTabsHeader: {
|
||||
$: ['tabAction', 'dragStart'];
|
||||
};
|
||||
header: {
|
||||
$: ['dragStart'];
|
||||
actions: [
|
||||
'createDoc',
|
||||
'createWorkspace',
|
||||
'switchPageMode',
|
||||
'toggleFavorite',
|
||||
'openDocInfo',
|
||||
'renameDoc',
|
||||
];
|
||||
docOptions: [
|
||||
'open',
|
||||
'deleteDoc',
|
||||
'renameDoc',
|
||||
'switchPageMode',
|
||||
'createDoc',
|
||||
'import',
|
||||
'toggleFavorite',
|
||||
'export',
|
||||
];
|
||||
history: ['open'];
|
||||
pageInfo: ['open'];
|
||||
importModal: ['open'];
|
||||
snapshot: ['import', 'export'];
|
||||
};
|
||||
chatPanel: {
|
||||
chatPanelInput: ['addEmbeddingDoc'];
|
||||
};
|
||||
intelligence: {
|
||||
chatPanelInput: ['addEmbeddingDoc'];
|
||||
};
|
||||
commentPanel: {
|
||||
$: ['createComment', 'editComment', 'deleteComment', 'resolveComment'];
|
||||
};
|
||||
attachment: {
|
||||
$: [
|
||||
'openAttachmentInFullscreen',
|
||||
'openAttachmentInNewTab',
|
||||
'openAttachmentInPeekView',
|
||||
'openAttachmentInSplitView',
|
||||
'openPDFRendererFail',
|
||||
];
|
||||
};
|
||||
sidebar: {
|
||||
newDoc: ['quickStart'];
|
||||
template: ['openTemplateListMenu', 'quickStart'];
|
||||
notifications: ['openInbox', 'clickNotification'];
|
||||
};
|
||||
splitViewIndicator: {
|
||||
$: ['splitViewAction', 'openInSplitView', 'openInPeekView'];
|
||||
};
|
||||
};
|
||||
doc: {
|
||||
$: {
|
||||
$: ['loadDoc'];
|
||||
};
|
||||
editor: {
|
||||
slashMenu: ['linkDoc', 'createDoc', 'bookmark'];
|
||||
atMenu: [
|
||||
'linkDoc',
|
||||
'import',
|
||||
'createDoc',
|
||||
'mentionMember',
|
||||
'noAccessPrompted',
|
||||
];
|
||||
quickSearch: ['createDoc'];
|
||||
formatToolbar: ['bold'];
|
||||
pageRef: ['navigate'];
|
||||
toolbar: [
|
||||
'copyBlockToLink',
|
||||
'openInSplitView',
|
||||
'openInNewTab',
|
||||
'openInPeekView',
|
||||
];
|
||||
aiActions: ['requestSignIn'];
|
||||
starterBar: ['quickStart', 'openTemplateListMenu'];
|
||||
audioBlock: ['transcribeRecording', 'openTranscribeNotes'];
|
||||
codeBlock: ['htmlBlockPreviewFailed'];
|
||||
};
|
||||
inlineDocInfo: {
|
||||
$: ['toggle'];
|
||||
property: ['editProperty', 'editPropertyMeta', 'addProperty'];
|
||||
databaseProperty: ['editProperty'];
|
||||
};
|
||||
sidepanel: {
|
||||
property: ['addProperty', 'editPropertyMeta'];
|
||||
journal: ['createCalendarDocEvent'];
|
||||
};
|
||||
biDirectionalLinksPanel: {
|
||||
$: ['toggle'];
|
||||
backlinkTitle: ['toggle', 'navigate'];
|
||||
backlinkPreview: ['navigate'];
|
||||
};
|
||||
};
|
||||
edgeless: {
|
||||
pageBlock: {
|
||||
headerToolbar: [
|
||||
'toggle',
|
||||
'openDocInfo',
|
||||
'copyBlockToLink',
|
||||
'switchPageMode',
|
||||
];
|
||||
};
|
||||
};
|
||||
workspace: {
|
||||
$: {
|
||||
$: ['upgradeWorkspace'];
|
||||
};
|
||||
};
|
||||
allDocs: {
|
||||
header: {
|
||||
navigation: ['navigateAllDocsRouter', 'navigatePinedCollectionRouter'];
|
||||
actions: ['createDoc', 'createWorkspace'];
|
||||
displayMenu: ['editDisplayMenu'];
|
||||
viewMode: ['editDisplayMenu'];
|
||||
collection: ['editCollection', 'addPinnedCollection'];
|
||||
};
|
||||
list: {
|
||||
doc: ['openDoc'];
|
||||
docMenu: [
|
||||
'createDoc',
|
||||
'deleteDoc',
|
||||
'openInSplitView',
|
||||
'toggleFavorite',
|
||||
'openInNewTab',
|
||||
'openDocInfo',
|
||||
];
|
||||
};
|
||||
};
|
||||
collection: {
|
||||
docList: {
|
||||
docMenu: ['removeOrganizeItem'];
|
||||
};
|
||||
collection: {
|
||||
$: ['editCollection'];
|
||||
};
|
||||
};
|
||||
tag: {};
|
||||
trash: {};
|
||||
subscriptionLanding: {
|
||||
$: {
|
||||
$: ['checkout'];
|
||||
};
|
||||
};
|
||||
menubarApp: {
|
||||
menubarActionsMenu: {
|
||||
menubarActionsList: ['activeMenubarAppItem', 'startRecording'];
|
||||
};
|
||||
};
|
||||
popup: {
|
||||
$: {
|
||||
recordingBar: [
|
||||
'toggleRecordingBar',
|
||||
'startRecording',
|
||||
'dismissRecording',
|
||||
'finishRecording',
|
||||
];
|
||||
};
|
||||
};
|
||||
clipper: {
|
||||
$: {
|
||||
$: ['createDoc'];
|
||||
};
|
||||
};
|
||||
applyModel: {
|
||||
widget: {
|
||||
page: ['acceptAll', 'rejectAll'];
|
||||
block: ['accept', 'reject'];
|
||||
};
|
||||
chat: {
|
||||
$: ['apply', 'accept', 'reject', 'copy'];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type OrganizeItemType = 'doc' | 'folder' | 'collection' | 'tag' | 'favorite';
|
||||
type OrganizeItemArgs =
|
||||
| {
|
||||
type: 'link';
|
||||
target: OrganizeItemType;
|
||||
}
|
||||
| {
|
||||
type: OrganizeItemType;
|
||||
};
|
||||
|
||||
type PaymentEventArgs = {
|
||||
plan: string;
|
||||
recurring: string;
|
||||
};
|
||||
|
||||
type AttachmentEventArgs = {
|
||||
type: string; // file type
|
||||
};
|
||||
|
||||
type TabActionControlType =
|
||||
| 'click'
|
||||
| 'dnd'
|
||||
| 'midClick'
|
||||
| 'xButton'
|
||||
| 'contextMenu';
|
||||
type TabActionType =
|
||||
| 'pin'
|
||||
| 'unpin'
|
||||
| 'close'
|
||||
| 'refresh'
|
||||
| 'moveTab'
|
||||
| 'openInSplitView'
|
||||
| 'openInNewTab'
|
||||
| 'switchSplitView'
|
||||
| 'switchTab'
|
||||
| 'separateTabs';
|
||||
|
||||
type SplitViewActionControlType = 'menu' | 'indicator';
|
||||
type SplitViewActionType = 'open' | 'close' | 'move' | 'closeOthers';
|
||||
|
||||
type AuthArgs = {
|
||||
method: 'password' | 'magic-link' | 'oauth' | 'otp';
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
type ImportStatus = 'importing' | 'failed' | 'success';
|
||||
type ImportArgs = {
|
||||
type: string;
|
||||
status?: ImportStatus;
|
||||
error?: string;
|
||||
result?: {
|
||||
docCount: number;
|
||||
};
|
||||
};
|
||||
type IntegrationArgs<T extends Record<string, any>> = {
|
||||
type: string;
|
||||
control:
|
||||
| 'Readwise Card'
|
||||
| 'Readwise settings'
|
||||
| 'Readwise import list'
|
||||
| 'Calendar Setting';
|
||||
} & T;
|
||||
|
||||
type RecordingEventArgs = {
|
||||
type: 'Meeting record';
|
||||
method?: string;
|
||||
option?: 'Auto transcribing' | 'handle transcribing' | 'on' | 'off';
|
||||
};
|
||||
|
||||
type ApplyModelArgs = {
|
||||
/* User's complete instruction */
|
||||
instruction?: string;
|
||||
/* Split individual semantic change requests */
|
||||
operation?: string;
|
||||
};
|
||||
|
||||
export type EventArgs = {
|
||||
createWorkspace: { flavour: string };
|
||||
signIn: AuthArgs;
|
||||
signedIn: AuthArgs;
|
||||
signInFail: AuthArgs & { reason: string };
|
||||
viewPlans: PaymentEventArgs;
|
||||
checkout: PaymentEventArgs;
|
||||
subscribe: PaymentEventArgs;
|
||||
cancelSubscription: PaymentEventArgs;
|
||||
confirmCancelingSubscription: PaymentEventArgs;
|
||||
resumeSubscription: PaymentEventArgs;
|
||||
confirmResumingSubscription: PaymentEventArgs;
|
||||
changeSubscriptionRecurring: PaymentEventArgs;
|
||||
confirmChangingSubscriptionRecurring: PaymentEventArgs;
|
||||
navigate: { to: string };
|
||||
openSettings: { to: string };
|
||||
changeAppSetting: { key: string; value: string | boolean | number };
|
||||
changeEditorSetting: { key: string; value: string | boolean | number };
|
||||
createOrganizeItem: OrganizeItemArgs;
|
||||
renameOrganizeItem: OrganizeItemArgs;
|
||||
moveOrganizeItem: OrganizeItemArgs;
|
||||
removeOrganizeItem: OrganizeItemArgs;
|
||||
deleteOrganizeItem: OrganizeItemArgs;
|
||||
orderOrganizeItem: OrganizeItemArgs;
|
||||
openInNewTab: { type: OrganizeItemType };
|
||||
openInSplitView: { type: OrganizeItemType; route?: string };
|
||||
tabAction: {
|
||||
type?: OrganizeItemType;
|
||||
control: TabActionControlType;
|
||||
action: TabActionType;
|
||||
};
|
||||
splitViewAction: {
|
||||
control: SplitViewActionControlType;
|
||||
action: SplitViewActionType;
|
||||
};
|
||||
toggleFavorite: OrganizeItemArgs & { on: boolean };
|
||||
toggle: { type: 'collapse' | 'expand' };
|
||||
createDoc: { mode?: 'edgeless' | 'page' };
|
||||
quickStart: { with: 'page' | 'edgeless' | 'template' | 'ai' };
|
||||
switchPageMode: { mode: 'edgeless' | 'page' };
|
||||
createShareLink: { mode: 'edgeless' | 'page' };
|
||||
copyShareLink: {
|
||||
type: 'default' | 'doc' | 'whiteboard' | 'block' | 'element';
|
||||
};
|
||||
import: ImportArgs;
|
||||
export: { type: string };
|
||||
copyBlockToLink: {
|
||||
type: string;
|
||||
};
|
||||
editProperty: { type: string };
|
||||
editPropertyMeta: { type: string; field: string };
|
||||
addProperty: { type: string; control: 'at menu' | 'property list' };
|
||||
linkDoc: { type: string; journal: boolean };
|
||||
drop: { type: string };
|
||||
dragStart: { type: string };
|
||||
addEmbeddingDoc: {
|
||||
type?: 'page' | 'edgeless';
|
||||
control: 'addButton' | 'atMenu' | 'dragDrop';
|
||||
method: 'doc' | 'cur-doc' | 'file' | 'tags' | 'collections' | 'suggestion';
|
||||
};
|
||||
openAttachmentInFullscreen: AttachmentEventArgs;
|
||||
openAttachmentInNewTab: AttachmentEventArgs;
|
||||
openAttachmentInPeekView: AttachmentEventArgs;
|
||||
openAttachmentInSplitView: AttachmentEventArgs;
|
||||
modifyUserDocRole: { role: string };
|
||||
modifyDocDefaultRole: { role: string };
|
||||
inviteUserDocRole: {
|
||||
control: 'member list';
|
||||
role: string;
|
||||
};
|
||||
openInbox: { unreadCount: number };
|
||||
clickNotification: {
|
||||
type: string;
|
||||
item: 'read' | 'button' | 'dismiss';
|
||||
button?: string;
|
||||
};
|
||||
connectIntegration: IntegrationArgs<{ result: 'success' | 'failed' }>;
|
||||
disconnectIntegration: IntegrationArgs<{ method: 'keep' | 'delete' }>;
|
||||
modifyIntegrationSettings: IntegrationArgs<{
|
||||
item: string;
|
||||
option: any;
|
||||
method: any;
|
||||
}>;
|
||||
startIntegrationImport: IntegrationArgs<{
|
||||
method: 'new' | 'withtimestamp' | 'cleartimestamp';
|
||||
}>;
|
||||
selectIntegrationImport: IntegrationArgs<{
|
||||
method: 'single' | 'all';
|
||||
option: 'on' | 'off';
|
||||
}>;
|
||||
confirmIntegrationImport: IntegrationArgs<{
|
||||
method: 'new' | 'withtimestamp';
|
||||
}>;
|
||||
abortIntegrationImport: IntegrationArgs<{
|
||||
time: number;
|
||||
done: number;
|
||||
total: number;
|
||||
}>;
|
||||
completeIntegrationImport: IntegrationArgs<{
|
||||
time: number;
|
||||
done: number;
|
||||
total: number;
|
||||
}>;
|
||||
toggleRecordingBar: RecordingEventArgs & {
|
||||
method: string;
|
||||
appName: string;
|
||||
};
|
||||
startRecording: RecordingEventArgs & {
|
||||
method: string;
|
||||
appName: string;
|
||||
};
|
||||
dismissRecording: RecordingEventArgs & {
|
||||
method: string;
|
||||
appName: string;
|
||||
};
|
||||
finishRecording: RecordingEventArgs & {
|
||||
method: 'fail' | 'success';
|
||||
appName: string;
|
||||
};
|
||||
transcribeRecording: RecordingEventArgs & {
|
||||
method: 'fail' | 'success';
|
||||
option: 'Auto transcribing' | 'handle transcribing';
|
||||
};
|
||||
openTranscribeNotes: RecordingEventArgs & {
|
||||
method: 'success' | 'reach limit' | 'not signed in' | 'not owner';
|
||||
option: 'on' | 'off';
|
||||
};
|
||||
toggleMeetingFeatureFlag: RecordingEventArgs & {
|
||||
option: 'on' | 'off';
|
||||
};
|
||||
activeMenubarAppItem: RecordingEventArgs & {
|
||||
control:
|
||||
| 'Open Journal'
|
||||
| 'New Page'
|
||||
| 'New Edgeless'
|
||||
| 'Start recording meeting'
|
||||
| 'Stop recording'
|
||||
| 'Open AFFiNE'
|
||||
| 'About AFFiNE'
|
||||
| 'Meeting Settings'
|
||||
| 'Quit AFFiNE Completely';
|
||||
};
|
||||
mentionMember: {
|
||||
type: 'member' | 'invite' | 'more';
|
||||
};
|
||||
htmlBlockPreviewFailed: {
|
||||
type: string;
|
||||
};
|
||||
noAccessPrompted: {};
|
||||
loadDoc: {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
time: number;
|
||||
success: boolean;
|
||||
};
|
||||
toggleWorkspaceEmbedding: {
|
||||
type: 'Embedding';
|
||||
control: 'Workspace embedding';
|
||||
option: 'on' | 'off';
|
||||
};
|
||||
addAdditionalDocs: {
|
||||
type: 'Embedding';
|
||||
control: 'Select doc';
|
||||
docType: string;
|
||||
};
|
||||
addIgnoredDocs: {
|
||||
type: 'Embedding';
|
||||
control: 'Additional docs';
|
||||
result: 'success' | 'failure';
|
||||
};
|
||||
editDisplayMenu: {
|
||||
control:
|
||||
| 'groupBy'
|
||||
| 'orderBy'
|
||||
| 'displayProperties'
|
||||
| 'listViewOptions'
|
||||
| 'quickActions';
|
||||
type: string;
|
||||
};
|
||||
navigateAllDocsRouter: {
|
||||
control: string;
|
||||
};
|
||||
navigatePinedCollectionRouter: {
|
||||
control: 'all' | 'user-custom-collection';
|
||||
};
|
||||
resolveComment: { type: 'on' | 'off' };
|
||||
createComment: {
|
||||
type: 'root' | 'node';
|
||||
withAttachment: boolean;
|
||||
withMention: boolean;
|
||||
category: string;
|
||||
};
|
||||
editComment: { type: 'root' | 'node' };
|
||||
deleteComment: { type: 'root' | 'node' };
|
||||
accept: ApplyModelArgs;
|
||||
reject: ApplyModelArgs;
|
||||
apply: ApplyModelArgs;
|
||||
};
|
||||
|
||||
// for type checking
|
||||
// if it complains, check the definition of [EventArgs] to make sure it's key is a subset of [UserEvents]
|
||||
export const YOU_MUST_DEFINE_ARGS_WITH_WRONG_EVENT_NAME: keyof EventArgs extends UserEvents
|
||||
? true
|
||||
: false = true;
|
||||
|
||||
export type Events = PageEvents;
|
||||
24
packages/frontend/track/src/index.ts
Normal file
24
packages/frontend/track/src/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { enableAutoTrack, makeTracker } from './auto';
|
||||
import { type EventArgs, type Events } from './events';
|
||||
import { sentry } from './sentry';
|
||||
import {
|
||||
flushTelemetry,
|
||||
setTelemetryContext,
|
||||
setTelemetryTransport,
|
||||
} from './telemetry';
|
||||
import { tracker } from './tracker';
|
||||
export const track = makeTracker((event, props) => {
|
||||
tracker.track(event, props);
|
||||
});
|
||||
|
||||
export {
|
||||
enableAutoTrack,
|
||||
type EventArgs,
|
||||
type Events,
|
||||
flushTelemetry,
|
||||
sentry,
|
||||
setTelemetryContext,
|
||||
setTelemetryTransport,
|
||||
tracker,
|
||||
};
|
||||
export default track;
|
||||
53
packages/frontend/track/src/sentry.ts
Normal file
53
packages/frontend/track/src/sentry.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
createRoutesFromChildren,
|
||||
matchRoutes,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
} from 'react-router-dom';
|
||||
|
||||
function createSentry() {
|
||||
let client: Sentry.BrowserClient | undefined;
|
||||
const wrapped = {
|
||||
init() {
|
||||
if (!globalThis.SENTRY_RELEASE) {
|
||||
// https://docs.sentry.io/platforms/javascript/guides/react/#configure
|
||||
client = Sentry.init({
|
||||
dsn: BUILD_CONFIG.SENTRY_DSN,
|
||||
debug: BUILD_CONFIG.debug ?? false,
|
||||
environment: BUILD_CONFIG.appBuildType,
|
||||
integrations: [
|
||||
Sentry.reactRouterV6BrowserTracingIntegration({
|
||||
useEffect,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
createRoutesFromChildren,
|
||||
matchRoutes,
|
||||
}),
|
||||
],
|
||||
}) as Sentry.BrowserClient;
|
||||
|
||||
Sentry.setTags({
|
||||
distribution: BUILD_CONFIG.distribution,
|
||||
appVersion: BUILD_CONFIG.appVersion,
|
||||
editorVersion: BUILD_CONFIG.editorVersion,
|
||||
});
|
||||
}
|
||||
},
|
||||
enable() {
|
||||
if (client) {
|
||||
client.getOptions().enabled = true;
|
||||
}
|
||||
},
|
||||
disable() {
|
||||
if (client) {
|
||||
client.getOptions().enabled = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
export const sentry = createSentry();
|
||||
99
packages/frontend/track/src/state.ts
Normal file
99
packages/frontend/track/src/state.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export type TrackProperties = Record<string, unknown> | undefined;
|
||||
|
||||
export type Middleware = (
|
||||
name: string,
|
||||
properties?: TrackProperties
|
||||
) => Record<string, unknown>;
|
||||
|
||||
type TrackerState = {
|
||||
enabled: boolean;
|
||||
clientStorage: Storage | null;
|
||||
clientId: string;
|
||||
pendingFirstVisit: boolean;
|
||||
sessionId: number;
|
||||
sessionNumber: number;
|
||||
lastActivityMs: number;
|
||||
sessionStartSent: boolean;
|
||||
engagementTrackingEnabled: boolean;
|
||||
visibleSinceMs: number | null;
|
||||
pendingEngagementMs: number;
|
||||
visibilityChangeHandler: (() => void) | null;
|
||||
pageHideHandler: (() => void) | null;
|
||||
userId: string | undefined;
|
||||
userProperties: Record<string, unknown>;
|
||||
middlewares: Set<Middleware>;
|
||||
};
|
||||
|
||||
const CLIENT_ID_KEY = 'affine_telemetry_client_id';
|
||||
|
||||
export let trackerState = createTrackerState();
|
||||
|
||||
export function resetTrackerState() {
|
||||
cleanupTrackerState(trackerState);
|
||||
trackerState = createTrackerState();
|
||||
}
|
||||
|
||||
function createTrackerState(): TrackerState {
|
||||
const clientStorage = localStorageSafe();
|
||||
const hasClientId = !!clientStorage?.getItem(CLIENT_ID_KEY);
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
clientStorage,
|
||||
clientId: readPersistentId(CLIENT_ID_KEY, clientStorage),
|
||||
pendingFirstVisit: !hasClientId,
|
||||
sessionId: 0,
|
||||
sessionNumber: 0,
|
||||
lastActivityMs: 0,
|
||||
sessionStartSent: false,
|
||||
engagementTrackingEnabled: false,
|
||||
visibleSinceMs: null,
|
||||
pendingEngagementMs: 0,
|
||||
visibilityChangeHandler: null,
|
||||
pageHideHandler: null,
|
||||
userId: undefined,
|
||||
userProperties: {},
|
||||
middlewares: new Set<Middleware>(),
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupTrackerState(state: TrackerState) {
|
||||
if (state.visibilityChangeHandler && typeof document !== 'undefined') {
|
||||
document.removeEventListener(
|
||||
'visibilitychange',
|
||||
state.visibilityChangeHandler
|
||||
);
|
||||
}
|
||||
if (state.pageHideHandler && typeof window !== 'undefined') {
|
||||
window.removeEventListener('pagehide', state.pageHideHandler);
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistentId(key: string, storage: Storage | null, renew = false) {
|
||||
if (!storage) {
|
||||
return nanoid();
|
||||
}
|
||||
if (!renew) {
|
||||
const existing = storage.getItem(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
const id = nanoid();
|
||||
try {
|
||||
storage.setItem(key, id);
|
||||
} catch {
|
||||
return id;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function localStorageSafe(): Storage | null {
|
||||
try {
|
||||
return typeof localStorage === 'undefined' ? null : localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
155
packages/frontend/track/src/telemetry.ts
Normal file
155
packages/frontend/track/src/telemetry.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
|
||||
export type TelemetryEvent = {
|
||||
schemaVersion: 1;
|
||||
eventName: string;
|
||||
params?: Record<string, unknown>;
|
||||
userProperties?: Record<string, unknown>;
|
||||
userId?: string;
|
||||
clientId: string;
|
||||
sessionId?: string | number;
|
||||
eventId: string;
|
||||
timestampMicros?: number;
|
||||
context?: {
|
||||
appVersion?: string;
|
||||
editorVersion?: string;
|
||||
environment?: string;
|
||||
distribution?: string;
|
||||
channel?: 'stable' | 'beta' | 'internal' | 'canary';
|
||||
isDesktop?: boolean;
|
||||
isMobile?: boolean;
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
url?: string;
|
||||
referrer?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TelemetryContext = {
|
||||
isAuthed: boolean;
|
||||
isSelfHosted: boolean;
|
||||
channel: 'stable' | 'beta' | 'internal' | 'canary';
|
||||
userId?: string;
|
||||
userProperties?: Record<string, unknown>;
|
||||
officialEndpoint: string;
|
||||
};
|
||||
|
||||
export type TelemetryAck =
|
||||
| { ok: true; accepted: number; dropped: number }
|
||||
| { ok: false; error: { name: string; message: string } };
|
||||
|
||||
export type TelemetryTransport = {
|
||||
setContext: (context: TelemetryContext) => Promise<void> | void;
|
||||
track: (event: TelemetryEvent) => Promise<{ queued: boolean }> | void;
|
||||
pageview?: (event: TelemetryEvent) => Promise<{ queued: boolean }> | void;
|
||||
flush?: () => Promise<TelemetryAck> | void;
|
||||
};
|
||||
|
||||
type TelemetryContextUpdate = Partial<TelemetryContext> & {
|
||||
userProperties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type TelemetryContextUpdateOptions = {
|
||||
replaceUserProperties?: boolean;
|
||||
};
|
||||
|
||||
const logger = new DebugLogger('telemetry');
|
||||
const pendingEvents: TelemetryEvent[] = [];
|
||||
const PENDING_LIMIT = 500;
|
||||
|
||||
const defaultChannel =
|
||||
BUILD_CONFIG.appBuildType === 'beta' ||
|
||||
BUILD_CONFIG.appBuildType === 'internal' ||
|
||||
BUILD_CONFIG.appBuildType === 'canary' ||
|
||||
BUILD_CONFIG.appBuildType === 'stable'
|
||||
? BUILD_CONFIG.appBuildType
|
||||
: 'stable';
|
||||
|
||||
let context: TelemetryContext = {
|
||||
isAuthed: false,
|
||||
isSelfHosted: false,
|
||||
channel: defaultChannel,
|
||||
officialEndpoint: '',
|
||||
userProperties: {},
|
||||
};
|
||||
|
||||
let transport: TelemetryTransport | null = null;
|
||||
|
||||
export function setTelemetryTransport(next: TelemetryTransport | null) {
|
||||
transport = next;
|
||||
if (!transport) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyTransportContext(context);
|
||||
flushPending().catch(error => {
|
||||
logger.error('failed to flush pending telemetry events', error);
|
||||
});
|
||||
}
|
||||
|
||||
export function setTelemetryContext(
|
||||
update: TelemetryContextUpdate,
|
||||
options: TelemetryContextUpdateOptions = {}
|
||||
) {
|
||||
const nextUserProps = options.replaceUserProperties
|
||||
? (update.userProperties ?? {})
|
||||
: {
|
||||
...context.userProperties,
|
||||
...update.userProperties,
|
||||
};
|
||||
|
||||
context = {
|
||||
...context,
|
||||
...update,
|
||||
userProperties: nextUserProps,
|
||||
};
|
||||
|
||||
applyTransportContext(context);
|
||||
}
|
||||
|
||||
export function getTelemetryContext() {
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function sendTelemetryEvent(event: TelemetryEvent) {
|
||||
if (!transport) {
|
||||
enqueuePending(event);
|
||||
return { queued: true };
|
||||
}
|
||||
|
||||
return await transport.track(event);
|
||||
}
|
||||
|
||||
export async function flushTelemetry() {
|
||||
if (!transport?.flush) {
|
||||
return { ok: true, accepted: 0, dropped: 0 } as const;
|
||||
}
|
||||
return await transport.flush();
|
||||
}
|
||||
|
||||
async function flushPending() {
|
||||
if (!transport || pendingEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const events = pendingEvents.splice(0, pendingEvents.length);
|
||||
for (const event of events) {
|
||||
await transport.track(event);
|
||||
}
|
||||
}
|
||||
|
||||
function enqueuePending(event: TelemetryEvent) {
|
||||
if (pendingEvents.length >= PENDING_LIMIT) {
|
||||
pendingEvents.shift();
|
||||
}
|
||||
pendingEvents.push(event);
|
||||
}
|
||||
|
||||
function applyTransportContext(next: TelemetryContext) {
|
||||
if (!transport) {
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(transport.setContext(next)).catch(error => {
|
||||
logger.error('failed to set telemetry context', error);
|
||||
});
|
||||
}
|
||||
505
packages/frontend/track/src/tracker.ts
Normal file
505
packages/frontend/track/src/tracker.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { type Middleware, trackerState, type TrackProperties } from './state';
|
||||
import type { TelemetryEvent } from './telemetry';
|
||||
import { sendTelemetryEvent, setTelemetryContext } from './telemetry';
|
||||
|
||||
const logger = new DebugLogger('telemetry');
|
||||
|
||||
type RawTrackProperties = Record<string, unknown> | object | undefined;
|
||||
|
||||
const SESSION_ID_KEY = 'affine_telemetry_session_id';
|
||||
const SESSION_NUMBER_KEY = 'affine_telemetry_session_number';
|
||||
const SESSION_NUMBER_CURRENT_KEY = 'affine_telemetry_session_number_current';
|
||||
const LAST_ACTIVITY_KEY = 'affine_telemetry_last_activity_ms';
|
||||
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
export const tracker = {
|
||||
init() {
|
||||
this.register({
|
||||
appVersion: BUILD_CONFIG.appVersion,
|
||||
environment: BUILD_CONFIG.appBuildType,
|
||||
editorVersion: BUILD_CONFIG.editorVersion,
|
||||
isDesktop: BUILD_CONFIG.isElectron,
|
||||
isMobile: BUILD_CONFIG.isMobileEdition,
|
||||
distribution: BUILD_CONFIG.distribution,
|
||||
});
|
||||
},
|
||||
|
||||
register(props: Record<string, unknown>) {
|
||||
trackerState.userProperties = {
|
||||
...trackerState.userProperties,
|
||||
...props,
|
||||
};
|
||||
setTelemetryContext({ userProperties: trackerState.userProperties });
|
||||
},
|
||||
|
||||
reset() {
|
||||
trackerState.userId = undefined;
|
||||
trackerState.userProperties = {};
|
||||
startNewSession(Date.now(), sessionStorageSafe());
|
||||
setTelemetryContext(
|
||||
{
|
||||
userId: trackerState.userId,
|
||||
userProperties: trackerState.userProperties,
|
||||
},
|
||||
{ replaceUserProperties: true }
|
||||
);
|
||||
this.init();
|
||||
},
|
||||
|
||||
track(eventName: string, properties?: RawTrackProperties) {
|
||||
if (!trackerState.enabled) {
|
||||
return;
|
||||
}
|
||||
const middlewareProperties = Array.from(trackerState.middlewares).reduce(
|
||||
(acc, middleware) => {
|
||||
return middleware(eventName, acc);
|
||||
},
|
||||
normalizeProperties(properties)
|
||||
);
|
||||
logger.debug('track', eventName, middlewareProperties);
|
||||
dispatchEvents(buildQueuedEvents(eventName, middlewareProperties));
|
||||
},
|
||||
|
||||
track_pageview(properties?: { location?: string; [key: string]: unknown }) {
|
||||
if (!trackerState.enabled) {
|
||||
return;
|
||||
}
|
||||
const middlewareProperties = Array.from(trackerState.middlewares).reduce(
|
||||
(acc, middleware) => {
|
||||
return middleware('track_pageview', acc);
|
||||
},
|
||||
normalizeProperties(properties)
|
||||
);
|
||||
const pageLocation =
|
||||
typeof middlewareProperties?.location === 'string'
|
||||
? middlewareProperties.location
|
||||
: getLocationHref();
|
||||
const pageTitle = getDocumentTitle();
|
||||
const params = {
|
||||
...middlewareProperties,
|
||||
location: pageLocation,
|
||||
pageTitle: pageTitle ?? middlewareProperties?.pageTitle,
|
||||
};
|
||||
logger.debug('track_pageview', params);
|
||||
dispatchEvents(buildQueuedEvents('track_pageview', params));
|
||||
},
|
||||
|
||||
middleware(cb: Middleware): () => void {
|
||||
trackerState.middlewares.add(cb);
|
||||
return () => {
|
||||
trackerState.middlewares.delete(cb);
|
||||
};
|
||||
},
|
||||
|
||||
opt_out_tracking() {
|
||||
trackerState.enabled = false;
|
||||
},
|
||||
|
||||
opt_in_tracking() {
|
||||
trackerState.enabled = true;
|
||||
},
|
||||
|
||||
has_opted_in_tracking() {
|
||||
return trackerState.enabled;
|
||||
},
|
||||
|
||||
has_opted_out_tracking() {
|
||||
return !trackerState.enabled;
|
||||
},
|
||||
|
||||
identify(nextUserId?: string) {
|
||||
trackerState.userId = nextUserId ? String(nextUserId) : undefined;
|
||||
setTelemetryContext({ userId: trackerState.userId });
|
||||
},
|
||||
|
||||
get people() {
|
||||
return {
|
||||
set: (props: Record<string, unknown>) => {
|
||||
trackerState.userProperties = {
|
||||
...trackerState.userProperties,
|
||||
...props,
|
||||
};
|
||||
setTelemetryContext({ userProperties: trackerState.userProperties });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function dispatchEvents(events: TelemetryEvent[]) {
|
||||
for (const event of events) {
|
||||
void sendTelemetryEvent(event).catch(error => {
|
||||
logger.error(`failed to send telemetry event ${event.eventName}`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueuedEvents(
|
||||
eventName: string,
|
||||
params?: Record<string, unknown>,
|
||||
options: { now?: number; engagementMs?: number } = {}
|
||||
) {
|
||||
const now = options.now ?? Date.now();
|
||||
const {
|
||||
sessionId: nextSessionId,
|
||||
sessionNumber: nextSessionNumber,
|
||||
preEvents,
|
||||
} = prepareSession(now);
|
||||
const engagementMs = options.engagementMs ?? consumeEngagementTime(now);
|
||||
const eventParams = mergeSessionParams(
|
||||
params,
|
||||
nextSessionId,
|
||||
nextSessionNumber,
|
||||
engagementMs
|
||||
);
|
||||
return [...preEvents, buildEvent(eventName, eventParams)];
|
||||
}
|
||||
|
||||
function prepareSession(now: number) {
|
||||
const sessionStorage = sessionStorageSafe();
|
||||
if (sessionStorage) {
|
||||
const storedSessionId = readPositiveNumber(sessionStorage, SESSION_ID_KEY);
|
||||
const storedLastActivity = readPositiveNumber(
|
||||
sessionStorage,
|
||||
LAST_ACTIVITY_KEY
|
||||
);
|
||||
const expired =
|
||||
!storedSessionId ||
|
||||
!storedLastActivity ||
|
||||
now - storedLastActivity > SESSION_TIMEOUT_MS;
|
||||
|
||||
if (expired) {
|
||||
startNewSession(now, sessionStorage);
|
||||
} else {
|
||||
trackerState.sessionId = storedSessionId;
|
||||
trackerState.sessionNumber = readCurrentSessionNumber(
|
||||
sessionStorage,
|
||||
trackerState.clientStorage
|
||||
);
|
||||
updateLastActivity(now, sessionStorage);
|
||||
}
|
||||
} else {
|
||||
const expired =
|
||||
!trackerState.sessionId ||
|
||||
!trackerState.lastActivityMs ||
|
||||
now - trackerState.lastActivityMs > SESSION_TIMEOUT_MS;
|
||||
if (expired) {
|
||||
startNewSession(now, null);
|
||||
} else {
|
||||
trackerState.lastActivityMs = now;
|
||||
if (!trackerState.sessionNumber) {
|
||||
trackerState.sessionNumber = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const preEvents: TelemetryEvent[] = [];
|
||||
if (trackerState.pendingFirstVisit) {
|
||||
trackerState.pendingFirstVisit = false;
|
||||
preEvents.push(
|
||||
buildEvent(
|
||||
'first_visit',
|
||||
mergeSessionParams(
|
||||
{},
|
||||
trackerState.sessionId,
|
||||
trackerState.sessionNumber,
|
||||
1
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!trackerState.sessionStartSent) {
|
||||
trackerState.sessionStartSent = true;
|
||||
preEvents.push(
|
||||
buildEvent(
|
||||
'session_start',
|
||||
mergeSessionParams(
|
||||
{},
|
||||
trackerState.sessionId,
|
||||
trackerState.sessionNumber,
|
||||
1
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
return {
|
||||
sessionId: trackerState.sessionId,
|
||||
sessionNumber: trackerState.sessionNumber,
|
||||
preEvents,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSessionParams(
|
||||
params: Record<string, unknown> | undefined,
|
||||
nextSessionId: number,
|
||||
nextSessionNumber: number,
|
||||
engagementMs: number
|
||||
) {
|
||||
const merged: Record<string, unknown> = {
|
||||
...params,
|
||||
};
|
||||
if (Number.isFinite(nextSessionId) && nextSessionId > 0) {
|
||||
merged.session_id = nextSessionId;
|
||||
}
|
||||
if (Number.isFinite(nextSessionNumber) && nextSessionNumber > 0) {
|
||||
merged.session_number = nextSessionNumber;
|
||||
}
|
||||
if (Number.isFinite(engagementMs)) {
|
||||
merged.engagement_time_msec = engagementMs;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function startNewSession(now: number, sessionStorage: Storage | null) {
|
||||
trackerState.sessionId = Math.floor(now / 1000);
|
||||
trackerState.sessionNumber = incrementSessionNumber(
|
||||
trackerState.clientStorage,
|
||||
sessionStorage
|
||||
);
|
||||
updateLastActivity(now, sessionStorage);
|
||||
writeNumber(sessionStorage, SESSION_ID_KEY, trackerState.sessionId);
|
||||
trackerState.sessionStartSent = false;
|
||||
resetEngagementState(now);
|
||||
}
|
||||
|
||||
function updateLastActivity(now: number, sessionStorage: Storage | null) {
|
||||
trackerState.lastActivityMs = now;
|
||||
writeNumber(sessionStorage, LAST_ACTIVITY_KEY, now);
|
||||
}
|
||||
|
||||
function consumeEngagementTime(now: number) {
|
||||
initEngagementTracking(now);
|
||||
if (trackerState.visibleSinceMs !== null) {
|
||||
trackerState.pendingEngagementMs += now - trackerState.visibleSinceMs;
|
||||
trackerState.visibleSinceMs = now;
|
||||
}
|
||||
const engagementMs = Math.max(
|
||||
0,
|
||||
Math.round(trackerState.pendingEngagementMs)
|
||||
);
|
||||
trackerState.pendingEngagementMs = 0;
|
||||
return engagementMs;
|
||||
}
|
||||
|
||||
function resetEngagementState(now: number) {
|
||||
trackerState.pendingEngagementMs = 0;
|
||||
trackerState.visibleSinceMs = isDocumentVisible() ? now : null;
|
||||
}
|
||||
|
||||
function initEngagementTracking(now: number) {
|
||||
if (
|
||||
trackerState.engagementTrackingEnabled ||
|
||||
typeof document === 'undefined'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
trackerState.engagementTrackingEnabled = true;
|
||||
resetEngagementState(now);
|
||||
|
||||
trackerState.visibilityChangeHandler = () => {
|
||||
const now = Date.now();
|
||||
if (trackerState.visibleSinceMs !== null) {
|
||||
trackerState.pendingEngagementMs += now - trackerState.visibleSinceMs;
|
||||
}
|
||||
trackerState.visibleSinceMs = isDocumentVisible() ? now : null;
|
||||
if (!isDocumentVisible()) {
|
||||
dispatchUserEngagement(now);
|
||||
}
|
||||
};
|
||||
document.addEventListener(
|
||||
'visibilitychange',
|
||||
trackerState.visibilityChangeHandler
|
||||
);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
trackerState.pageHideHandler = () => {
|
||||
dispatchUserEngagement(Date.now());
|
||||
};
|
||||
window.addEventListener('pagehide', trackerState.pageHideHandler);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchUserEngagement(now: number) {
|
||||
if (!trackerState.enabled) {
|
||||
return;
|
||||
}
|
||||
const engagementMs = consumeEngagementTime(now);
|
||||
if (engagementMs <= 0) {
|
||||
return;
|
||||
}
|
||||
dispatchEvents(
|
||||
buildQueuedEvents(
|
||||
'user_engagement',
|
||||
{ engagement_time_msec: engagementMs },
|
||||
{ now, engagementMs }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isDocumentVisible() {
|
||||
try {
|
||||
return (
|
||||
typeof document !== 'undefined' && document.visibilityState !== 'hidden'
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function readPositiveNumber(storage: Storage | null, key: string) {
|
||||
if (!storage) {
|
||||
return undefined;
|
||||
}
|
||||
const raw = storage.getItem(key);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function writeNumber(storage: Storage | null, key: string, value: number) {
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
storage.setItem(key, String(value));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function readCurrentSessionNumber(
|
||||
sessionStorage: Storage,
|
||||
localStorage: Storage | null
|
||||
) {
|
||||
const current = readPositiveNumber(
|
||||
sessionStorage,
|
||||
SESSION_NUMBER_CURRENT_KEY
|
||||
);
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const fallback = localStorage
|
||||
? (readPositiveNumber(localStorage, SESSION_NUMBER_KEY) ?? 1)
|
||||
: trackerState.sessionNumber || 1;
|
||||
|
||||
writeNumber(sessionStorage, SESSION_NUMBER_CURRENT_KEY, fallback);
|
||||
if (localStorage && !readPositiveNumber(localStorage, SESSION_NUMBER_KEY)) {
|
||||
writeNumber(localStorage, SESSION_NUMBER_KEY, fallback);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function incrementSessionNumber(
|
||||
localStorage: Storage | null,
|
||||
sessionStorage: Storage | null
|
||||
) {
|
||||
if (!localStorage) {
|
||||
const next = (trackerState.sessionNumber || 0) + 1;
|
||||
writeNumber(sessionStorage, SESSION_NUMBER_CURRENT_KEY, next);
|
||||
return next;
|
||||
}
|
||||
const current = readPositiveNumber(localStorage, SESSION_NUMBER_KEY) ?? 0;
|
||||
const next = current + 1;
|
||||
writeNumber(localStorage, SESSION_NUMBER_KEY, next);
|
||||
writeNumber(sessionStorage, SESSION_NUMBER_CURRENT_KEY, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildEvent(
|
||||
eventName: string,
|
||||
params?: Record<string, unknown>
|
||||
): TelemetryEvent {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
eventName,
|
||||
params,
|
||||
userId: trackerState.userId,
|
||||
userProperties: trackerState.userProperties,
|
||||
clientId: trackerState.clientId,
|
||||
sessionId: trackerState.sessionId,
|
||||
eventId: nanoid(),
|
||||
timestampMicros: Date.now() * 1000,
|
||||
context: buildContext(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildContext(): TelemetryEvent['context'] {
|
||||
return {
|
||||
appVersion: BUILD_CONFIG.appVersion,
|
||||
editorVersion: BUILD_CONFIG.editorVersion,
|
||||
environment: BUILD_CONFIG.appBuildType,
|
||||
distribution: BUILD_CONFIG.distribution,
|
||||
channel: BUILD_CONFIG.appBuildType as NonNullable<
|
||||
TelemetryEvent['context']
|
||||
>['channel'],
|
||||
isDesktop: BUILD_CONFIG.isElectron,
|
||||
isMobile: BUILD_CONFIG.isMobileEdition,
|
||||
locale: getLocale(),
|
||||
timezone: getTimezone(),
|
||||
url: getLocationHref(),
|
||||
referrer: getReferrer(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProperties(properties?: RawTrackProperties): TrackProperties {
|
||||
if (!properties) {
|
||||
return undefined;
|
||||
}
|
||||
return properties as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function sessionStorageSafe(): Storage | null {
|
||||
try {
|
||||
return typeof sessionStorage === 'undefined' ? null : sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocale() {
|
||||
try {
|
||||
return typeof navigator === 'undefined' ? undefined : navigator.language;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getTimezone() {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocationHref() {
|
||||
try {
|
||||
return typeof location === 'undefined' ? undefined : location.href;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getReferrer() {
|
||||
try {
|
||||
return typeof document === 'undefined' ? undefined : document.referrer;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getDocumentTitle() {
|
||||
try {
|
||||
return typeof document === 'undefined' ? undefined : document.title;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
70
packages/frontend/track/src/types.ts
Normal file
70
packages/frontend/track/src/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { EventArgs, Events } from './events';
|
||||
|
||||
type EventPropsOverride = {
|
||||
page?: keyof Events;
|
||||
segment?: string;
|
||||
module?: string;
|
||||
control?: string;
|
||||
};
|
||||
|
||||
export type CallableEventsChain = {
|
||||
[Page in keyof Events]: {
|
||||
[Segment in keyof Events[Page]]: {
|
||||
[Module in keyof Events[Page][Segment]]: {
|
||||
[
|
||||
Event in Events[Page][Segment][Module][number]
|
||||
]: Event extends keyof EventArgs
|
||||
? (
|
||||
// we make all args partial to simply satisfies nullish type checking
|
||||
args?: Partial<EventArgs[Event]> & EventPropsOverride
|
||||
) => void
|
||||
: (args?: EventPropsOverride) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type EventsUnion = {
|
||||
[Page in keyof Events]: {
|
||||
[Segment in keyof Events[Page]]: {
|
||||
[Module in keyof Events[Page][Segment]]: {
|
||||
[
|
||||
Event in Events[Page][Segment][Module][number]
|
||||
]: `${Page & string}.${Segment & string}.${Module & string}.${Event & string}`;
|
||||
}[Events[Page][Segment][Module][number]];
|
||||
}[keyof Events[Page][Segment]];
|
||||
}[keyof Events[Page]];
|
||||
}[keyof Events];
|
||||
|
||||
// page > segment > module > [events]
|
||||
type IsFourLevelsDeep<
|
||||
T,
|
||||
Depth extends number[] = [],
|
||||
> = Depth['length'] extends 3
|
||||
? T extends Array<any>
|
||||
? true
|
||||
: false
|
||||
: T extends object
|
||||
? {
|
||||
[K in keyof T]: IsFourLevelsDeep<T[K], [...Depth, 0]>;
|
||||
}[keyof T] extends true
|
||||
? true
|
||||
: false
|
||||
: false;
|
||||
|
||||
// for type checking
|
||||
export const _assertIsAllEventsDefinedInFourLevels: IsFourLevelsDeep<Events> = true;
|
||||
|
||||
export interface EventProps {
|
||||
// location
|
||||
page?: keyof Events;
|
||||
segment?: string;
|
||||
module?: string;
|
||||
control?: string;
|
||||
arg?: string;
|
||||
|
||||
// entity
|
||||
type?: string;
|
||||
category?: string;
|
||||
id?: string;
|
||||
}
|
||||
10
packages/frontend/track/tsconfig.json
Normal file
10
packages/frontend/track/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.web.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [{ "path": "../../common/debug" }]
|
||||
}
|
||||
Reference in New Issue
Block a user