diff --git a/apps/dotcom-worker/package.json b/apps/dotcom-worker/package.json index 9e71e78d4..b137d976f 100644 --- a/apps/dotcom-worker/package.json +++ b/apps/dotcom-worker/package.json @@ -23,6 +23,7 @@ "dependencies": { "@supabase/auth-helpers-remix": "^0.2.2", "@supabase/supabase-js": "^2.33.2", + "@tldraw/dotcom-shared": "workspace:*", "@tldraw/store": "workspace:*", "@tldraw/tlschema": "workspace:*", "@tldraw/tlsync": "workspace:*", diff --git a/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts b/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts index ed6a37d8e..4746320dc 100644 --- a/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts +++ b/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts @@ -2,8 +2,11 @@ /// import { SupabaseClient } from '@supabase/supabase-js' +import { ROOM_OPEN_MODE, type RoomOpenMode } from '@tldraw/dotcom-shared' import { + DBLoadResultType, RoomSnapshot, + TLCloseEventCode, TLServer, TLServerEvent, TLSyncRoom, @@ -19,6 +22,7 @@ import { PERSIST_INTERVAL_MS } from './config' import { getR2KeyForRoom } from './r2' import { Analytics, Environment } from './types' import { createSupabaseClient } from './utils/createSupabaseClient' +import { getSlug } from './utils/roomOpenMode' import { throttle } from './utils/throttle' const MAX_CONNECTIONS = 50 @@ -88,12 +92,22 @@ export class TLDrawDurableObject extends TLServer { readonly router = Router() .get( '/r/:roomId', - (req) => this.extractDocumentInfoFromRequest(req), + (req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_WRITE), + (req) => this.onRequest(req) + ) + .get( + '/v/:roomId', + (req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_ONLY_LEGACY), + (req) => this.onRequest(req) + ) + .get( + '/ro/:roomId', + (req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_ONLY), (req) => this.onRequest(req) ) .post( '/r/:roomId/restore', - (req) => this.extractDocumentInfoFromRequest(req), + (req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_WRITE), (req) => this.onRestore(req) ) .all('*', () => new Response('Not found', { status: 404 })) @@ -113,8 +127,11 @@ export class TLDrawDurableObject extends TLServer { get documentInfo() { return assertExists(this._documentInfo, 'documentInfo must be present') } - extractDocumentInfoFromRequest = async (req: IRequest) => { - const slug = assertExists(req.params.roomId, 'roomId must be present') + extractDocumentInfoFromRequest = async (req: IRequest, roomOpenMode: RoomOpenMode) => { + const slug = assertExists( + await getSlug(this.env, req.params.roomId, roomOpenMode), + 'roomId must be present' + ) if (this._documentInfo) { assert(this._documentInfo.slug === slug, 'slug must match') } else { @@ -226,9 +243,10 @@ export class TLDrawDurableObject extends TLServer { const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair() // Handle the connection (see TLServer) + let connectionResult: DBLoadResultType try { // block concurrency while initializing the room if that needs to happen - await this.controller.blockConcurrencyWhile(() => + connectionResult = await this.controller.blockConcurrencyWhile(() => this.handleConnection({ socket: serverWebSocket as any, persistenceKey: this.documentInfo.slug!, @@ -253,6 +271,12 @@ export class TLDrawDurableObject extends TLServer { this.schedulePersist() }) + if (connectionResult === 'room_not_found') { + // If the room is not found, we need to accept and then immediately close the connection + // with our custom close code. + serverWebSocket.close(TLCloseEventCode.NOT_FOUND, 'Room not found') + } + return new Response(null, { status: 101, webSocket: clientWebSocket }) } diff --git a/apps/dotcom-worker/src/lib/routes/createRoom.ts b/apps/dotcom-worker/src/lib/routes/createRoom.ts index c6a450ce4..43899c56e 100644 --- a/apps/dotcom-worker/src/lib/routes/createRoom.ts +++ b/apps/dotcom-worker/src/lib/routes/createRoom.ts @@ -1,24 +1,22 @@ -import { SerializedSchema, SerializedStore } from '@tldraw/store' -import { TLRecord } from '@tldraw/tlschema' +import { CreateRoomRequestBody } from '@tldraw/dotcom-shared' import { RoomSnapshot, schema } from '@tldraw/tlsync' import { IRequest } from 'itty-router' import { nanoid } from 'nanoid' import { getR2KeyForRoom } from '../r2' import { Environment } from '../types' import { validateSnapshot } from '../utils/validateSnapshot' - -type SnapshotRequestBody = { - schema: SerializedSchema - snapshot: SerializedStore -} +import { isAllowedOrigin } from '../worker' // Sets up a new room based on a provided snapshot, e.g. when a user clicks the "Share" buttons or the "Fork project" buttons. export async function createRoom(request: IRequest, env: Environment): Promise { // The data sent from the client will include the data for the new room - const data = (await request.json()) as SnapshotRequestBody + const data = (await request.json()) as CreateRoomRequestBody + if (!isAllowedOrigin(data.origin)) { + return Response.json({ error: true, message: 'Not allowed' }, { status: 406 }) + } // There's a chance the data will be invalid, so we check it first - const snapshotResult = validateSnapshot(data) + const snapshotResult = validateSnapshot(data.snapshot) if (!snapshotResult.ok) { return Response.json({ error: true, message: snapshotResult.error }, { status: 400 }) } @@ -40,6 +38,11 @@ export async function createRoom(request: IRequest, env: Environment): Promise - parent_slug?: string | string[] | undefined -} - export async function createRoomSnapshot(request: IRequest, env: Environment): Promise { const data = (await request.json()) as CreateSnapshotRequestBody diff --git a/apps/dotcom-worker/src/lib/routes/getReadonlySlug.ts b/apps/dotcom-worker/src/lib/routes/getReadonlySlug.ts new file mode 100644 index 000000000..e1eb4f5c5 --- /dev/null +++ b/apps/dotcom-worker/src/lib/routes/getReadonlySlug.ts @@ -0,0 +1,30 @@ +import { GetReadonlySlugResponseBody } from '@tldraw/dotcom-shared' +import { lns } from '@tldraw/utils' +import { IRequest } from 'itty-router' +import { Environment } from '../types' + +// Return a URL to a readonly version of the room +export async function getReadonlySlug(request: IRequest, env: Environment): Promise { + const roomId = request.params.roomId + if (!roomId) { + return new Response('Bad request', { + status: 400, + }) + } + + let slug = await env.SLUG_TO_READONLY_SLUG.get(roomId) + let isLegacy = false + + if (!slug) { + // For all newly created rooms we add the readonly slug to the KV store. + // If it does not exist there it means we are trying to get a slug for an old room. + slug = lns(roomId) + isLegacy = true + } + return new Response( + JSON.stringify({ + slug, + isLegacy, + } satisfies GetReadonlySlugResponseBody) + ) +} diff --git a/apps/dotcom-worker/src/lib/routes/joinExistingRoom.ts b/apps/dotcom-worker/src/lib/routes/joinExistingRoom.ts index 99569fe99..b90b87113 100644 --- a/apps/dotcom-worker/src/lib/routes/joinExistingRoom.ts +++ b/apps/dotcom-worker/src/lib/routes/joinExistingRoom.ts @@ -1,11 +1,16 @@ +import { RoomOpenMode } from '@tldraw/dotcom-shared' import { IRequest } from 'itty-router' import { Environment } from '../types' import { fourOhFour } from '../utils/fourOhFour' import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong' +import { getSlug } from '../utils/roomOpenMode' -// This is the entry point for joining an existing room -export async function joinExistingRoom(request: IRequest, env: Environment): Promise { - const roomId = request.params.roomId +export async function joinExistingRoom( + request: IRequest, + env: Environment, + roomOpenMode: RoomOpenMode +): Promise { + const roomId = await getSlug(env, request.params.roomId, roomOpenMode) if (!roomId) return fourOhFour() if (isRoomIdTooLong(roomId)) return roomIdIsTooLong() diff --git a/apps/dotcom-worker/src/lib/types.ts b/apps/dotcom-worker/src/lib/types.ts index 3505ad5c6..9fdbd8ef0 100644 --- a/apps/dotcom-worker/src/lib/types.ts +++ b/apps/dotcom-worker/src/lib/types.ts @@ -17,6 +17,9 @@ export interface Environment { ROOMS: R2Bucket ROOMS_HISTORY_EPHEMERAL: R2Bucket + SLUG_TO_READONLY_SLUG: KVNamespace + READONLY_SLUG_TO_SLUG: KVNamespace + // env vars SUPABASE_URL: string | undefined SUPABASE_KEY: string | undefined diff --git a/apps/dotcom-worker/src/lib/utils/roomOpenMode.ts b/apps/dotcom-worker/src/lib/utils/roomOpenMode.ts new file mode 100644 index 000000000..2e1626605 --- /dev/null +++ b/apps/dotcom-worker/src/lib/utils/roomOpenMode.ts @@ -0,0 +1,17 @@ +import { ROOM_OPEN_MODE, RoomOpenMode } from '@tldraw/dotcom-shared' +import { exhaustiveSwitchError, lns } from '@tldraw/utils' +import { Environment } from '../types' + +export async function getSlug(env: Environment, slug: string | null, roomOpenMode: RoomOpenMode) { + if (!slug) return null + switch (roomOpenMode) { + case ROOM_OPEN_MODE.READ_WRITE: + return slug + case ROOM_OPEN_MODE.READ_ONLY: + return await env.READONLY_SLUG_TO_SLUG.get(slug) + case ROOM_OPEN_MODE.READ_ONLY_LEGACY: + return lns(slug) + default: + exhaustiveSwitchError(roomOpenMode) + } +} diff --git a/apps/dotcom-worker/src/lib/worker.ts b/apps/dotcom-worker/src/lib/worker.ts index 9c415186c..d2100e10a 100644 --- a/apps/dotcom-worker/src/lib/worker.ts +++ b/apps/dotcom-worker/src/lib/worker.ts @@ -1,11 +1,13 @@ /// /// +import { ROOM_OPEN_MODE } from '@tldraw/dotcom-shared' import { Router, createCors } from 'itty-router' import { env } from 'process' import Toucan from 'toucan-js' import { createRoom } from './routes/createRoom' import { createRoomSnapshot } from './routes/createRoomSnapshot' import { forwardRoomRequest } from './routes/forwardRoomRequest' +import { getReadonlySlug } from './routes/getReadonlySlug' import { getRoomHistory } from './routes/getRoomHistory' import { getRoomHistorySnapshot } from './routes/getRoomHistorySnapshot' import { getRoomSnapshot } from './routes/getRoomSnapshot' @@ -24,9 +26,12 @@ const router = Router() .post('/new-room', createRoom) .post('/snapshots', createRoomSnapshot) .get('/snapshot/:roomId', getRoomSnapshot) - .get('/r/:roomId', joinExistingRoom) + .get('/r/:roomId', (req, env) => joinExistingRoom(req, env, ROOM_OPEN_MODE.READ_WRITE)) + .get('/v/:roomId', (req, env) => joinExistingRoom(req, env, ROOM_OPEN_MODE.READ_ONLY_LEGACY)) + .get('/ro/:roomId', (req, env) => joinExistingRoom(req, env, ROOM_OPEN_MODE.READ_ONLY)) .get('/r/:roomId/history', getRoomHistory) .get('/r/:roomId/history/:timestamp', getRoomHistorySnapshot) + .get('/readonly-slug/:roomId', getReadonlySlug) .post('/r/:roomId/restore', forwardRoomRequest) .all('*', fourOhFour) @@ -70,7 +75,7 @@ const Worker = { }, } -function isAllowedOrigin(origin: string) { +export function isAllowedOrigin(origin: string) { if (origin === 'http://localhost:3000') return true if (origin === 'http://localhost:5420') return true if (origin.endsWith('.tldraw.com')) return true diff --git a/apps/dotcom-worker/tsconfig.json b/apps/dotcom-worker/tsconfig.json index b4d7ff869..e7a2d4918 100644 --- a/apps/dotcom-worker/tsconfig.json +++ b/apps/dotcom-worker/tsconfig.json @@ -7,6 +7,9 @@ "emitDeclarationOnly": false }, "references": [ + { + "path": "../../packages/dotcom-shared" + }, { "path": "../../packages/store" }, diff --git a/apps/dotcom-worker/wrangler.toml b/apps/dotcom-worker/wrangler.toml index 98368c816..6d6163a63 100644 --- a/apps/dotcom-worker/wrangler.toml +++ b/apps/dotcom-worker/wrangler.toml @@ -114,3 +114,36 @@ bucket_name = "rooms-history-ephemeral-preview" [[env.production.r2_buckets]] binding = "ROOMS_HISTORY_EPHEMERAL" bucket_name = "rooms-history-ephemeral" + +#################### Key value storage #################### +[[env.dev.kv_namespaces]] +binding = "SLUG_TO_READONLY_SLUG" +id = "847a6bded62045c6808dda6a275ef96c" + +[[env.dev.kv_namespaces]] +binding = "READONLY_SLUG_TO_SLUG" +id = "0a83acab40374ccd918cc9d755741714" + +[[env.preview.kv_namespaces]] +binding = "SLUG_TO_READONLY_SLUG" +id = "847a6bded62045c6808dda6a275ef96c" + +[[env.preview.kv_namespaces]] +binding = "READONLY_SLUG_TO_SLUG" +id = "0a83acab40374ccd918cc9d755741714" + +[[env.staging.kv_namespaces]] +binding = "SLUG_TO_READONLY_SLUG" +id = "847a6bded62045c6808dda6a275ef96c" + +[[env.staging.kv_namespaces]] +binding = "READONLY_SLUG_TO_SLUG" +id = "0a83acab40374ccd918cc9d755741714" + +[[env.production.kv_namespaces]] +binding = "SLUG_TO_READONLY_SLUG" +id = "2fb5fc7f7ca54a5a9dfae1b07a30a778" + +[[env.production.kv_namespaces]] +binding = "READONLY_SLUG_TO_SLUG" +id = "96be6637b281412ab35b2544539d78e8" diff --git a/apps/dotcom/package.json b/apps/dotcom/package.json index bf5700284..c2cd34985 100644 --- a/apps/dotcom/package.json +++ b/apps/dotcom/package.json @@ -23,6 +23,7 @@ "@sentry/integrations": "^7.34.0", "@sentry/react": "^7.77.0", "@tldraw/assets": "workspace:*", + "@tldraw/dotcom-shared": "workspace:*", "@tldraw/tlsync": "workspace:*", "@tldraw/utils": "workspace:*", "@vercel/analytics": "^1.1.1", diff --git a/apps/dotcom/scripts/build.ts b/apps/dotcom/scripts/build.ts index e9e734921..69373d5f8 100644 --- a/apps/dotcom/scripts/build.ts +++ b/apps/dotcom/scripts/build.ts @@ -8,6 +8,7 @@ import json5 from 'json5' import { nicelog } from '../../../scripts/lib/nicelog' import { T } from '@tldraw/validate' +import { getMultiplayerServerURL } from '../vite.config' // We load the list of routes that should be forwarded to our SPA's index.html here. // It uses a jest snapshot file because deriving the set of routes from our @@ -56,9 +57,7 @@ async function build() { // rewrite api calls to the multiplayer server { src: '^/api(/(.*))?$', - dest: `${ - process.env.MULTIPLAYER_SERVER?.replace(/^ws/, 'http') ?? 'http://127.0.0.1:8787' - }$1`, + dest: `${getMultiplayerServerURL()}$1`, check: true, }, // cache static assets immutably diff --git a/apps/dotcom/src/__snapshots__/routes.test.tsx.snap b/apps/dotcom/src/__snapshots__/routes.test.tsx.snap index db16700e1..4d6d55f9b 100644 --- a/apps/dotcom/src/__snapshots__/routes.test.tsx.snap +++ b/apps/dotcom/src/__snapshots__/routes.test.tsx.snap @@ -26,6 +26,10 @@ exports[`the_routes 1`] = ` "reactRouterPattern": "/r/:roomId", "vercelRouterPattern": "^/r/[^/]*/?$", }, + { + "reactRouterPattern": "/ro/:roomId", + "vercelRouterPattern": "^/ro/[^/]*/?$", + }, { "reactRouterPattern": "/s/:roomId", "vercelRouterPattern": "^/s/[^/]*/?$", diff --git a/apps/dotcom/src/components/ErrorPage/ErrorPage.tsx b/apps/dotcom/src/components/ErrorPage/ErrorPage.tsx index 9a275d8dd..ba5aebb13 100644 --- a/apps/dotcom/src/components/ErrorPage/ErrorPage.tsx +++ b/apps/dotcom/src/components/ErrorPage/ErrorPage.tsx @@ -1,4 +1,5 @@ import { Link } from 'react-router-dom' +import { isInIframe } from '../../utils/iFrame' export function ErrorPage({ icon, @@ -6,8 +7,8 @@ export function ErrorPage({ }: { icon?: boolean messages: { header: string; para1: string; para2?: string } - redirectTo?: string }) { + const inIframe = isInIframe() return (
@@ -19,8 +20,8 @@ export function ErrorPage({

{messages.para1}

{messages.para2 &&

{messages.para2}

}
- - Take me home. + + {inIframe ? 'Open tldraw.' : 'Back to tldraw.'}
diff --git a/apps/dotcom/src/components/IFrameProtector.tsx b/apps/dotcom/src/components/IFrameProtector.tsx index b4fd9f06d..95309432f 100644 --- a/apps/dotcom/src/components/IFrameProtector.tsx +++ b/apps/dotcom/src/components/IFrameProtector.tsx @@ -2,12 +2,13 @@ import { ReactNode, useEffect, useState } from 'react' import { LoadingScreen } from 'tldraw' import { version } from '../../version' import { useUrl } from '../hooks/useUrl' +import { getParentOrigin, isInIframe } from '../utils/iFrame' import { trackAnalyticsEvent } from '../utils/trackAnalyticsEvent' /* If we're in an iframe, we need to figure out whether we're on a whitelisted host (e.g. tldraw itself) or a not-allowed host (e.g. someone else's website). Some websites embed tldraw in iframes and this is kinda -risky for us and for them, too—and hey, if we decide to offer a hosted thing, then that's another stor +risky for us and for them, too—and hey, if we decide to offer a hosted thing, then that's another story. Figuring this out is a little tricky because the same code here is going to run on: - the website as a top window (tldraw-top) @@ -26,33 +27,45 @@ and we should show an annoying messsage. If we're not in an iframe, we don't need to do anything. */ +export const ROOM_CONTEXT = { + PUBLIC_MULTIPLAYER: 'public-multiplayer', + PUBLIC_READONLY: 'public-readonly', + PUBLIC_SNAPSHOT: 'public-snapshot', + HISTORY_SNAPSHOT: 'history-snapshot', + HISTORY: 'history', + LOCAL: 'local', +} as const +type $ROOM_CONTEXT = (typeof ROOM_CONTEXT)[keyof typeof ROOM_CONTEXT] + +const EMBEDDED_STATE = { + IFRAME_UNKNOWN: 'iframe-unknown', + IFRAME_NOT_ALLOWED: 'iframe-not-allowed', + NOT_IFRAME: 'not-iframe', + IFRAME_OK: 'iframe-ok', +} as const +type $EMBEDDED_STATE = (typeof EMBEDDED_STATE)[keyof typeof EMBEDDED_STATE] + // Which routes do we allow to be embedded in tldraw.com itself? -const WHITELIST_CONTEXT = ['public-multiplayer', 'public-readonly', 'public-snapshot'] +const WHITELIST_CONTEXT: $ROOM_CONTEXT[] = [ + ROOM_CONTEXT.PUBLIC_MULTIPLAYER, + ROOM_CONTEXT.PUBLIC_READONLY, + ROOM_CONTEXT.PUBLIC_SNAPSHOT, +] const EXPECTED_QUESTION = 'are we cool?' const EXPECTED_RESPONSE = 'yes' + version -const isInIframe = () => { - return typeof window !== 'undefined' && (window !== window.top || window.self !== window.parent) -} - export function IFrameProtector({ slug, context, children, }: { slug: string - context: - | 'public-multiplayer' - | 'public-readonly' - | 'public-snapshot' - | 'history-snapshot' - | 'history' - | 'local' + context: $ROOM_CONTEXT children: ReactNode }) { - const [embeddedState, setEmbeddedState] = useState< - 'iframe-unknown' | 'iframe-not-allowed' | 'not-iframe' | 'iframe-ok' - >(isInIframe() ? 'iframe-unknown' : 'not-iframe') + const [embeddedState, setEmbeddedState] = useState<$EMBEDDED_STATE>( + isInIframe() ? EMBEDDED_STATE.IFRAME_UNKNOWN : EMBEDDED_STATE.NOT_IFRAME + ) const url = useUrl() @@ -76,24 +89,28 @@ export function IFrameProtector({ if (event.data === EXPECTED_RESPONSE) { // todo: check the origin? - setEmbeddedState('iframe-ok') + setEmbeddedState(EMBEDDED_STATE.IFRAME_OK) clearTimeout(timeout) } } window.addEventListener('message', handleMessageEvent, false) - if (embeddedState === 'iframe-unknown') { + if (embeddedState === EMBEDDED_STATE.IFRAME_UNKNOWN) { // We iframe embeddings on multiplayer or readonly if (WHITELIST_CONTEXT.includes(context)) { window.parent.postMessage(EXPECTED_QUESTION, '*') // todo: send to a specific origin? timeout = setTimeout(() => { - setEmbeddedState('iframe-not-allowed') - trackAnalyticsEvent('connect_to_room_in_iframe', { slug, context }) + setEmbeddedState(EMBEDDED_STATE.IFRAME_NOT_ALLOWED) + trackAnalyticsEvent('connect_to_room_in_iframe', { + slug, + context, + origin: getParentOrigin(), + }) }, 1000) } else { // We don't allow iframe embeddings on other routes - setEmbeddedState('iframe-not-allowed') + setEmbeddedState(EMBEDDED_STATE.IFRAME_NOT_ALLOWED) } } @@ -103,12 +120,12 @@ export function IFrameProtector({ } }, [embeddedState, slug, context]) - if (embeddedState === 'iframe-unknown') { + if (embeddedState === EMBEDDED_STATE.IFRAME_UNKNOWN) { // We're in an iframe, but we don't know if it's a tldraw iframe - return Loading in an iframe... + return Loading in an iframe… } - if (embeddedState === 'iframe-not-allowed') { + if (embeddedState === EMBEDDED_STATE.IFRAME_NOT_ALLOWED) { // We're in an iframe and its not one of ours return (
diff --git a/apps/dotcom/src/components/MultiplayerEditor.tsx b/apps/dotcom/src/components/MultiplayerEditor.tsx index a5f575b53..10f53df4e 100644 --- a/apps/dotcom/src/components/MultiplayerEditor.tsx +++ b/apps/dotcom/src/components/MultiplayerEditor.tsx @@ -1,3 +1,4 @@ +import { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from '@tldraw/dotcom-shared' import { useCallback, useEffect } from 'react' import { DefaultContextMenu, @@ -18,7 +19,6 @@ import { TldrawUiMenuItem, ViewSubmenu, atom, - lns, useActions, useValue, } from 'tldraw' @@ -104,19 +104,17 @@ const components: TLComponents = { } export function MultiplayerEditor({ - isReadOnly, + roomOpenMode, roomSlug, }: { - isReadOnly: boolean + roomOpenMode: RoomOpenMode roomSlug: string }) { const handleUiEvent = useHandleUiEvents() - const roomId = isReadOnly ? lns(roomSlug) : roomSlug - const storeWithStatus = useRemoteSyncClient({ - uri: `${MULTIPLAYER_SERVER}/r/${roomId}`, - roomId, + uri: `${MULTIPLAYER_SERVER}/${RoomOpenModeToPath[roomOpenMode]}/${roomSlug}`, + roomId: roomSlug, }) const isOffline = @@ -128,16 +126,22 @@ export function MultiplayerEditor({ const sharingUiOverrides = useSharing() const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true }) const cursorChatOverrides = useCursorChat() + const isReadonly = + roomOpenMode === ROOM_OPEN_MODE.READ_ONLY || roomOpenMode === ROOM_OPEN_MODE.READ_ONLY_LEGACY const handleMount = useCallback( (editor: Editor) => { - ;(window as any).app = editor - ;(window as any).editor = editor - editor.updateInstanceState({ isReadonly: isReadOnly }) + if (!isReadonly) { + ;(window as any).app = editor + ;(window as any).editor = editor + } + editor.updateInstanceState({ + isReadonly, + }) editor.registerExternalAssetHandler('file', createAssetFromFile) editor.registerExternalAssetHandler('url', createAssetFromUrl) }, - [isReadOnly] + [isReadonly] ) if (storeWithStatus.error) { @@ -151,7 +155,7 @@ export function MultiplayerEditor({ assetUrls={assetUrls} onMount={handleMount} overrides={[sharingUiOverrides, fileSystemUiOverrides, cursorChatOverrides]} - initialState={isReadOnly ? 'hand' : 'select'} + initialState={isReadonly ? 'hand' : 'select'} onUiEvent={handleUiEvent} components={components} autoFocus diff --git a/apps/dotcom/src/components/ShareMenu.tsx b/apps/dotcom/src/components/ShareMenu.tsx index 1ea4810ac..0e44a8a98 100644 --- a/apps/dotcom/src/components/ShareMenu.tsx +++ b/apps/dotcom/src/components/ShareMenu.tsx @@ -1,4 +1,9 @@ import * as Popover from '@radix-ui/react-popover' +import { + GetReadonlySlugResponseBody, + ROOM_OPEN_MODE, + RoomOpenModeToPath, +} from '@tldraw/dotcom-shared' import React, { useEffect, useState } from 'react' import { TldrawUiMenuContextProvider, @@ -15,29 +20,71 @@ import { createQRCodeImageDataString } from '../utils/qrcode' import { SHARE_PROJECT_ACTION, SHARE_SNAPSHOT_ACTION } from '../utils/sharing' import { ShareButton } from './ShareButton' +const SHARE_CURRENT_STATE = { + OFFLINE: 'offline', + SHARED_READ_WRITE: 'shared-read-write', + SHARED_READ_ONLY: 'shared-read-only', +} as const +type ShareCurrentState = (typeof SHARE_CURRENT_STATE)[keyof typeof SHARE_CURRENT_STATE] + type ShareState = { - state: 'offline' | 'shared' | 'readonly' + state: ShareCurrentState qrCodeDataUrl: string url: string - readonlyUrl: string + readonlyUrl: string | null readonlyQrCodeDataUrl: string } +function isSharedReadonlyUrl(pathname: string) { + return ( + pathname.startsWith(`/${RoomOpenModeToPath[ROOM_OPEN_MODE.READ_ONLY]}/`) || + pathname.startsWith(`/${RoomOpenModeToPath[ROOM_OPEN_MODE.READ_ONLY_LEGACY]}/`) + ) +} + +function isSharedReadWriteUrl(pathname: string) { + return pathname.startsWith('/r/') +} + function getFreshShareState(): ShareState { - const isShared = window.location.href.includes('/r/') - const isReadOnly = window.location.href.includes('/v/') + const isSharedReadWrite = isSharedReadWriteUrl(window.location.pathname) + const isSharedReadOnly = isSharedReadonlyUrl(window.location.pathname) return { - state: isShared ? 'shared' : isReadOnly ? 'readonly' : 'offline', + state: isSharedReadWrite + ? SHARE_CURRENT_STATE.SHARED_READ_WRITE + : isSharedReadOnly + ? SHARE_CURRENT_STATE.SHARED_READ_ONLY + : SHARE_CURRENT_STATE.OFFLINE, url: window.location.href, - readonlyUrl: window.location.href.includes('/r/') - ? getShareUrl(window.location.href, true) - : window.location.href, + readonlyUrl: isSharedReadOnly ? window.location.href : null, qrCodeDataUrl: '', readonlyQrCodeDataUrl: '', } } +async function getReadonlyUrl() { + const pathname = window.location.pathname + const isReadOnly = isSharedReadonlyUrl(pathname) + if (isReadOnly) return window.location.href + + const segments = pathname.split('/') + + const roomId = segments[2] + const result = await fetch(`/api/readonly-slug/${roomId}`) + if (!result.ok) return + + const data = (await result.json()) as GetReadonlySlugResponseBody + if (!data.slug) return + + segments[1] = + RoomOpenModeToPath[data.isLegacy ? ROOM_OPEN_MODE.READ_ONLY_LEGACY : ROOM_OPEN_MODE.READ_ONLY] + segments[2] = data.slug + const newPathname = segments.join('/') + + return `${window.location.origin}${newPathname}${window.location.search}` +} + /** @public */ export const ShareMenu = React.memo(function ShareMenu() { const msg = useTranslation() @@ -50,25 +97,24 @@ export const ShareMenu = React.memo(function ShareMenu() { const [isUploading, setIsUploading] = useState(false) const [isUploadingSnapshot, setIsUploadingSnapshot] = useState(false) - const [isReadOnlyLink, setIsReadOnlyLink] = useState(shareState.state === 'readonly') + const isReadOnlyLink = shareState.state === SHARE_CURRENT_STATE.SHARED_READ_ONLY const currentShareLinkUrl = isReadOnlyLink ? shareState.readonlyUrl : shareState.url const currentQrCodeUrl = isReadOnlyLink ? shareState.readonlyQrCodeDataUrl : shareState.qrCodeDataUrl const [didCopy, setDidCopy] = useState(false) + const [didCopyReadonlyLink, setDidCopyReadonlyLink] = useState(false) const [didCopySnapshotLink, setDidCopySnapshotLink] = useState(false) useEffect(() => { - if (shareState.state === 'offline') { + if (shareState.state === SHARE_CURRENT_STATE.OFFLINE) { return } let cancelled = false const shareUrl = getShareUrl(window.location.href, false) - const readonlyShareUrl = getShareUrl(window.location.href, true) - - if (!shareState.qrCodeDataUrl && shareState.state === 'shared') { + if (!shareState.qrCodeDataUrl && shareState.state === SHARE_CURRENT_STATE.SHARED_READ_WRITE) { // Fetch the QR code data URL createQRCodeImageDataString(shareUrl).then((dataUrl) => { if (!cancelled) { @@ -77,14 +123,16 @@ export const ShareMenu = React.memo(function ShareMenu() { }) } - if (!shareState.readonlyQrCodeDataUrl) { - // fetch the readonly QR code data URL - createQRCodeImageDataString(readonlyShareUrl).then((dataUrl) => { - if (!cancelled) { - setShareState((s) => ({ ...s, readonlyShareUrl, readonlyQrCodeDataUrl: dataUrl })) - } - }) - } + getReadonlyUrl().then((readonlyUrl) => { + if (readonlyUrl && !shareState.readonlyQrCodeDataUrl) { + // fetch the readonly QR code data URL + createQRCodeImageDataString(readonlyUrl).then((dataUrl) => { + if (!cancelled) { + setShareState((s) => ({ ...s, readonlyUrl, readonlyQrCodeDataUrl: dataUrl })) + } + }) + } + }) const interval = setInterval(() => { const url = window.location.href @@ -115,7 +163,8 @@ export const ShareMenu = React.memo(function ShareMenu() { alignOffset={4} > - {shareState.state === 'shared' || shareState.state === 'readonly' ? ( + {shareState.state === SHARE_CURRENT_STATE.SHARED_READ_WRITE || + shareState.state === SHARE_CURRENT_STATE.SHARED_READ_ONLY ? ( <>