diff --git a/apps/dotcom/client/src/utils/csp.ts b/apps/dotcom/client/src/utils/csp.ts index a455ba6b4dba..43c5d47ba09b 100644 --- a/apps/dotcom/client/src/utils/csp.ts +++ b/apps/dotcom/client/src/utils/csp.ts @@ -13,6 +13,9 @@ export const cspDirectives: { [key: string]: string[] } = { `https://*.ingest.sentry.io`, `https://*.ingest.us.sentry.io`, 'https://*.analytics.google.com', + 'https://www.google-analytics.com', + 'https://*.googletagmanager.com', + 'https://www.googletagmanager.com', // for thumbnail server 'http://localhost:5002', 'https://*.clerk.accounts.dev', @@ -41,6 +44,9 @@ export const cspDirectives: { [key: string]: string[] } = { // embeds that have scripts 'https://gist.github.com', 'https://www.googletagmanager.com', + 'https://*.googletagmanager.com', + 'https://www.google-analytics.com', + 'https://*.google-analytics.com', 'https://analytics.tldraw.com', 'https://static.reo.dev', ], diff --git a/packages/sync-core/api-report.api.md b/packages/sync-core/api-report.api.md index b8c640694580..39028a062f12 100644 --- a/packages/sync-core/api-report.api.md +++ b/packages/sync-core/api-report.api.md @@ -263,6 +263,9 @@ export interface TLPingRequest { type: 'ping'; } +// @internal (undocumented) +export type TLPresenceMode = 'full' | 'solo'; + // @internal (undocumented) export interface TLPushRequest { // (undocumented) @@ -439,6 +442,7 @@ export class TLSyncClient = Store onLoad(self: TLSyncClient): void; onSyncError(reason: string): void; presence: Signal; + presenceMode?: Signal; socket: TLPersistentClientSocket; store: S; }); @@ -458,6 +462,8 @@ export class TLSyncClient = Store isReadonly: boolean; }) => void; // (undocumented) + readonly presenceMode: Signal | undefined; + // (undocumented) readonly presenceState: Signal | undefined; // (undocumented) readonly socket: TLPersistentClientSocket; diff --git a/packages/sync-core/src/index.ts b/packages/sync-core/src/index.ts index 9ec4b678bfce..00b6d3ffdbc9 100644 --- a/packages/sync-core/src/index.ts +++ b/packages/sync-core/src/index.ts @@ -38,6 +38,7 @@ export { type SubscribingFn, type TLPersistentClientSocket, type TLPersistentClientSocketStatus, + type TLPresenceMode, type TlSocketStatusChangeEvent, type TLSocketStatusListener, } from './lib/TLSyncClient' diff --git a/packages/sync-core/src/lib/TLSyncClient.ts b/packages/sync-core/src/lib/TLSyncClient.ts index eb4306d0c3cd..04c7e5153d44 100644 --- a/packages/sync-core/src/lib/TLSyncClient.ts +++ b/packages/sync-core/src/lib/TLSyncClient.ts @@ -82,6 +82,9 @@ export type TLSocketStatusListener = (params: TlSocketStatusChangeEvent) => void /** @internal */ export type TLPersistentClientSocketStatus = 'online' | 'offline' | 'error' + +/** @internal */ +export type TLPresenceMode = 'solo' | 'full' /** * A socket that can be used to send and receive messages to the server. It should handle staying * open and reconnecting when the connection is lost. In actual client code this will be a wrapper @@ -139,6 +142,7 @@ export class TLSyncClient = Store readonly socket: TLPersistentClientSocket readonly presenceState: Signal | undefined + readonly presenceMode: Signal | undefined // isOnline is true when we have an open socket connection and we have // established a connection with the server room (i.e. we have received a 'connect' message) @@ -178,6 +182,7 @@ export class TLSyncClient = Store store: S socket: TLPersistentClientSocket presence: Signal + presenceMode?: Signal onLoad(self: TLSyncClient): void onSyncError(reason: string): void onAfterConnect?(self: TLSyncClient, details: { isReadonly: boolean }): void @@ -197,6 +202,7 @@ export class TLSyncClient = Store let didLoad = false this.presenceState = config.presence + this.presenceMode = config.presenceMode this.disposables.push( // when local 'user' changes are made, send them to the server @@ -274,6 +280,8 @@ export class TLSyncClient = Store this.disposables.push( react('pushPresence', () => { if (this.didCancel?.()) return this.close() + const mode = this.presenceMode?.get() + if (mode !== 'full') return this.pushPresence(this.presenceState!.get()) }) ) @@ -399,6 +407,10 @@ export class TLSyncClient = Store // this.store.applyDiff(stashedChanges, false) this.onAfterConnect?.(this, { isReadonly: event.isReadonly }) + const presence = this.presenceState?.get() + if (presence) { + this.pushPresence(presence) + } }) this.lastServerClock = event.serverClock diff --git a/packages/sync-core/src/test/presenceMode.test.ts b/packages/sync-core/src/test/presenceMode.test.ts new file mode 100644 index 000000000000..5a0061d6f81e --- /dev/null +++ b/packages/sync-core/src/test/presenceMode.test.ts @@ -0,0 +1,147 @@ +import { atom, computed, Signal } from '@tldraw/state' +import { BaseRecord, createRecordType, RecordId, Store, StoreSchema } from '@tldraw/store' +import { TLSyncClient } from '../lib/TLSyncClient' +import { TestServer } from './TestServer' +import { TestSocketPair } from './TestSocketPair' + +jest.mock('@tldraw/utils', () => { + return { + ...jest.requireActual('@tldraw/utils'), + fpsThrottle: jest.fn((fn) => fn), + } +}) + +const disposables: Array<() => void> = [] + +afterEach(() => { + for (const dispose of disposables) { + dispose() + } + disposables.length = 0 +}) + +interface User extends BaseRecord<'user', RecordId> { + name: string + age: number +} + +interface Presence extends BaseRecord<'presence', RecordId> { + name: string + age: number +} + +const Presence = createRecordType('presence', { + scope: 'presence', + validator: { validate: (value) => value as Presence }, +}) + +const User = createRecordType('user', { + scope: 'document', + validator: { validate: (value) => value as User }, +}) + +type R = User | Presence + +const schema = StoreSchema.create({ user: User, presence: Presence }) + +class TestInstance { + server: TestServer + socketPair: TestSocketPair + client: TLSyncClient + + hasLoaded = false + + constructor(presenceSignal: Signal, presenceMode?: 'solo' | 'full') { + this.server = new TestServer(schema) + this.socketPair = new TestSocketPair('test_presence_mode', this.server) + this.socketPair.connect() + + this.client = new TLSyncClient({ + store: new Store({ schema, props: {} }), + socket: this.socketPair.clientSocket, + onLoad: () => { + this.hasLoaded = true + }, + onSyncError: jest.fn((reason) => { + throw new Error('onSyncError: ' + reason) + }), + presence: presenceSignal, + presenceMode: presenceMode ? computed('', () => presenceMode) : undefined, + }) + + disposables.push(() => { + this.client.close() + }) + } + + flush() { + this.server.flushDebouncingMessages() + + while (this.socketPair.getNeedsFlushing()) { + this.socketPair.flushClientSentEvents() + this.socketPair.flushServerSentEvents() + } + } +} + +test('presence is pushed on change when mode is full', () => { + const presence = Presence.create({ name: 'bob', age: 10 }) + const presenceSignal = atom('', presence) + + const t = new TestInstance(presenceSignal, 'full') + t.socketPair.connect() + t.flush() + + const session = t.server.room.sessions.values().next().value + expect(session).toBeDefined() + expect(session?.presenceId).toBeDefined() + expect(t.server.room.documents.get(session!.presenceId!)?.state).toMatchObject({ + name: 'bob', + age: 10, + }) + + presenceSignal.set(Presence.create({ name: 'bob', age: 11 })) + t.flush() + expect(t.server.room.documents.get(session!.presenceId!)?.state).toMatchObject({ + name: 'bob', + age: 11, + }) + + presenceSignal.set(Presence.create({ name: 'bob', age: 12 })) + t.flush() + expect(t.server.room.documents.get(session!.presenceId!)?.state).toMatchObject({ + name: 'bob', + age: 12, + }) +}) + +test('presence is only pushed once on connect when mode is solo', () => { + const presence = Presence.create({ name: 'bob', age: 10 }) + const presenceSignal = atom('', presence) + + const t = new TestInstance(presenceSignal, 'solo') + t.socketPair.connect() + t.flush() + + const session = t.server.room.sessions.values().next().value + expect(session).toBeDefined() + expect(session?.presenceId).toBeDefined() + expect(t.server.room.documents.get(session!.presenceId!)?.state).toMatchObject({ + name: 'bob', + age: 10, + }) + + presenceSignal.set(Presence.create({ name: 'bob', age: 11 })) + t.flush() + expect(t.server.room.documents.get(session!.presenceId!)?.state).not.toMatchObject({ + name: 'bob', + age: 11, + }) + + presenceSignal.set(Presence.create({ name: 'bob', age: 12 })) + t.flush() + expect(t.server.room.documents.get(session!.presenceId!)?.state).not.toMatchObject({ + name: 'bob', + age: 12, + }) +}) diff --git a/packages/sync/src/useSync.ts b/packages/sync/src/useSync.ts index e5a0d42d1a4b..55f54f0851ee 100644 --- a/packages/sync/src/useSync.ts +++ b/packages/sync/src/useSync.ts @@ -2,6 +2,7 @@ import { atom, isSignal, transact } from '@tldraw/state' import { useAtom } from '@tldraw/state-react' import { ClientWebSocketAdapter, + TLPresenceMode, TLRemoteSyncError, TLSyncClient, TLSyncErrorCloseEventReason, @@ -165,6 +166,15 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt }) }) + const otherUserPresences = store.query.ids('instance_presence', () => ({ + userId: { neq: userPreferences.get().id }, + })) + + const presenceMode = computed('presenceMode', () => { + if (otherUserPresences.get().size === 0) return 'solo' + return 'full' + }) + const client = new TLSyncClient({ store, socket, @@ -210,6 +220,7 @@ export function useSync(opts: UseSyncOptions & TLStoreSchemaOptions): RemoteTLSt }) }, presence, + presenceMode, }) return () => {