diff --git a/CHANGELOG.md b/CHANGELOG.md index 973bb8cc6a2..fb721252509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## vNEXT (not yet released) +## v3.23.1 + +### `@liveblocks/react-ui` + +- Improve version history components for versions without authors, which can + occur with server-only changes. Add `HISTORY_VERSION_SUMMARY_AUTHORS_LIST` and + update `HISTORY_VERSION_PREVIEW_AUTHORS_LIST` overrides to reflect this. + ## v3.23.0 This release introduces `LiveFile`: a new data structure to store files such as @@ -768,7 +776,8 @@ metadata to individual comments in the same way as thread metadata. This release introduces group mentions (e.g. `@engineering`) across all packages and first-class support for tenants. Learn more about [group mentions](https://liveblocks.io/docs/ready-made-features/comments/users-and-mentions) -and [tenants](https://liveblocks.io/docs/authentication/organizations) in the docs. +and [tenants](https://liveblocks.io/docs/authentication/organizations) in the +docs. ### `@liveblocks/client` diff --git a/docs/pages/api-reference/liveblocks-react-blocknote.mdx b/docs/pages/api-reference/liveblocks-react-blocknote.mdx index 74570a1c35c..b7cdaac6a3a 100644 --- a/docs/pages/api-reference/liveblocks-react-blocknote.mdx +++ b/docs/pages/api-reference/liveblocks-react-blocknote.mdx @@ -742,6 +742,11 @@ and logic for restoring. To render a list of versions, see Learn how to set this up in our [version history guide](/docs/guides/how-to-add-version-history-to-your-app). +A version can have no authors when it contains only server-side changes. In that +case, `HistoryVersionPreview` displays “Unattributed edits”. You can customize +this text with +[`HISTORY_VERSION_PREVIEW_AUTHORS_LIST`](/docs/api-reference/liveblocks-react-ui#Overrides-History-version-preview). + #### Usage [#HistoryVersionPreview-usage] ```tsx diff --git a/docs/pages/api-reference/liveblocks-react-lexical.mdx b/docs/pages/api-reference/liveblocks-react-lexical.mdx index befbf4eb1d2..61f3d85c260 100644 --- a/docs/pages/api-reference/liveblocks-react-lexical.mdx +++ b/docs/pages/api-reference/liveblocks-react-lexical.mdx @@ -1702,6 +1702,11 @@ render a list of versions, see Learn how to set this up in our [version history guide](/docs/guides/how-to-add-version-history-to-your-app). +A version can have no authors when it contains only server-side changes. In that +case, `HistoryVersionPreview` displays “Unattributed edits”. You can customize +this text with +[`HISTORY_VERSION_PREVIEW_AUTHORS_LIST`](/docs/api-reference/liveblocks-react-ui#Overrides-History-version-preview). + #### Usage ```tsx diff --git a/docs/pages/api-reference/liveblocks-react-tiptap.mdx b/docs/pages/api-reference/liveblocks-react-tiptap.mdx index 32d8cee91b9..b51138a452c 100644 --- a/docs/pages/api-reference/liveblocks-react-tiptap.mdx +++ b/docs/pages/api-reference/liveblocks-react-tiptap.mdx @@ -1650,6 +1650,11 @@ logic for restoring. To render a list of versions, see Learn how to set this up in our [version history guide](/docs/guides/how-to-add-version-history-to-your-app). +A version can have no authors when it contains only server-side changes. In that +case, `HistoryVersionPreview` displays “Unattributed edits”. You can customize +this text with +[`HISTORY_VERSION_PREVIEW_AUTHORS_LIST`](/docs/api-reference/liveblocks-react-ui#Overrides-History-version-preview). + #### Usage [#HistoryVersionPreview-usage] ```tsx diff --git a/docs/pages/api-reference/liveblocks-react-ui.mdx b/docs/pages/api-reference/liveblocks-react-ui.mdx index bd286e947b0..4e585f846c4 100644 --- a/docs/pages/api-reference/liveblocks-react-ui.mdx +++ b/docs/pages/api-reference/liveblocks-react-ui.mdx @@ -4261,6 +4261,11 @@ up in our Displays a version summary which includes the author and date. +A version can have no authors when it contains only server-side changes. In that +case, `HistoryVersionSummary` displays “Unattributed”. You can customize this +text with +[`HISTORY_VERSION_SUMMARY_AUTHORS_LIST`](/docs/api-reference/liveblocks-react-ui#Overrides-History-version-summary). + ```tsx { @@ -4271,7 +4276,7 @@ Displays a version summary which includes the author and date. /> ``` -##### Props [#HistoryVersionSummary-props] +#### Props [#HistoryVersionSummary-props] @@ -4285,7 +4290,7 @@ Displays a version summary which includes the author and date. -#### HistoryVersionSummaryList +### HistoryVersionSummaryList Displays a list of version summaries for a document’s history including authors and dates. @@ -4305,7 +4310,7 @@ and dates. ``` -##### Props [#HistoryVersionSummaryList-props] +#### Props [#HistoryVersionSummaryList-props] @@ -4723,19 +4728,28 @@ and | `INBOX_NOTIFICATION_THREAD_MENTION` | Thread mention text | `"X mentioned you in Y"` | | `INBOX_NOTIFICATION_TEXT_MENTION` | Text mention text | `"X mentioned you in Y"` | -##### History version preview [#Overrides-History-version-preview] +##### History version summary [#Overrides-History-version-summary] -History version preview overrides can be set on both -[`HistoryVersionSummary`](/docs/api-reference/liveblocks-react-ui#HistoryVersionSummary) -and +History version summary overrides can be set on [`LiveblocksUiConfig`](/docs/api-reference/liveblocks-react-ui#LiveblocksUiConfig). +The authors list is `undefined` when the version has no authors. + +| Name | Description | Default value | +| -------------------------------------- | ----------------- | ------------------------- | +| `HISTORY_VERSION_SUMMARY_AUTHORS_LIST` | Authors list text | `"X"` or `"Unattributed"` | -| Name | Description | Default value | -| -------------------------------------- | -------------------- | ----------------------- | -| `HISTORY_VERSION_PREVIEW_AUTHORS_LIST` | Authors list text | `"Edits from X"` | -| `HISTORY_VERSION_PREVIEW_RESTORE` | Restore button label | `"Restore"` | -| `HISTORY_VERSION_PREVIEW_EMPTY` | Empty state text | `"No content."` | -| `HISTORY_VERSION_PREVIEW_ERROR` | Error message | `"There was an error…"` | +##### History version preview [#Overrides-History-version-preview] + +History version preview overrides can be set on +[`LiveblocksUiConfig`](/docs/api-reference/liveblocks-react-ui#LiveblocksUiConfig). +The authors list is `undefined` when the version has no authors. + +| Name | Description | Default value | +| -------------------------------------- | -------------------- | ------------------------------------------ | +| `HISTORY_VERSION_PREVIEW_AUTHORS_LIST` | Authors list text | `"Edits from X"` or `"Unattributed edits"` | +| `HISTORY_VERSION_PREVIEW_RESTORE` | Restore button label | `"Restore"` | +| `HISTORY_VERSION_PREVIEW_EMPTY` | Empty state text | `"No content."` | +| `HISTORY_VERSION_PREVIEW_ERROR` | Error message | `"There was an error…"` | ##### AI composer [#Overrides-AI-composer] diff --git a/e2e/next-sandbox/pages/index.tsx b/e2e/next-sandbox/pages/index.tsx index d258e2a35bc..9f4992435ce 100644 --- a/e2e/next-sandbox/pages/index.tsx +++ b/e2e/next-sandbox/pages/index.tsx @@ -37,6 +37,9 @@ export default function Home() { LiveObject +
  • + LiveFile +
  • Stress Test
  • diff --git a/e2e/next-sandbox/pages/storage/file.tsx b/e2e/next-sandbox/pages/storage/file.tsx new file mode 100644 index 00000000000..c64b4c15984 --- /dev/null +++ b/e2e/next-sandbox/pages/storage/file.tsx @@ -0,0 +1,322 @@ +import { LiveFile, LiveList } from "@liveblocks/client"; +import type { LiveFileData } from "@liveblocks/client"; +import { createRoomContext } from "@liveblocks/react"; +import { useState } from "react"; + +import { + getRoomFromUrl, + randomInt, + Row, + styles, + useRenderCount, +} from "../../utils"; +import Button from "../../utils/Button"; +import { createLiveblocksClient } from "../../utils/createClient"; + +const client = createLiveblocksClient(); + +const { + RoomProvider, + useFileUrl, + useMutation, + useSelf, + useStorage, + useSyncStatus, + useUploadFile, +} = createRoomContext }>(client); + +// Above 5 MB the client switches from a single PUT to a multipart upload, so +// this is the only way to exercise that path from the browser. +const MULTIPART_THRESHOLD = 5 * 1024 * 1024; + +function makeTextFile(): File { + return new File([`hello from the sandbox at ${Date.now()}`], "hello.txt", { + type: "text/plain", + }); +} + +const SHAPES = [ + "rect", + "circle", + "triangle", + "pentagon", + "hexagon", + "octagon", +] as const; + +type Shape = (typeof SHAPES)[number]; + +const POLYGON_SIDES: Partial> = { + triangle: 3, + pentagon: 5, + hexagon: 6, + octagon: 8, +}; + +const SIZE = 120; + +/** Corners of a regular n-gon, first one pointing straight up. */ +function polygonPoints( + sides: number, + cx: number, + cy: number, + r: number +): string { + return Array.from({ length: sides }, (_, i) => { + const angle = (2 * Math.PI * i) / sides - Math.PI / 2; + const x = cx + r * Math.cos(angle); + const y = cy + r * Math.sin(angle); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(" "); +} + +function shapeMarkup( + shape: Shape, + fill: string, + cx: number, + cy: number, + r: number +): string { + const sides = POLYGON_SIDES[shape]; + if (sides !== undefined) { + return ``; // prettier-ignore + } + if (shape === "circle") { + return ``; + } + // Deliberately not square, so the rotation is actually visible + const w = r * 1.6; + const h = r; + return ``; // prettier-ignore +} + +function rotated(markup: string, degrees: number, cx: number, cy: number) { + return `${markup}`; +} + +/** + * A random shape in a random colour at a random angle, so uploads are told + * apart by their picture rather than by reading ids. The filename says which + * shape it is, which is what matches a rendered image back to its row. + * + * It's an image because rendering it in an is the one thing only a real + * browser can prove: the signed URL is fetched with no Authorization header. + */ +function makeImageFile(): File { + const shape = SHAPES[randomInt(SHAPES.length)]; + const hue = randomInt(360); + const center = SIZE / 2; + + const svg = ` + ${rotated(shapeMarkup(shape, `hsl(${hue} 70% 55%)`, center, center, SIZE * 0.4), randomInt(360), center, center)} +`; + + return new File([svg], `${shape}-${hue}.svg`, { type: "image/svg+xml" }); +} + +const BIG_CANVAS = 2000; + +/** + * The same thing, but scaled up past the multipart threshold by scattering + * thousands of shapes across a big canvas. + * + * Making it a real image rather than random bytes is the whole trick: SVG in + * an only renders if it is well-formed XML, so a part that arrives out + * of order, twice, or not at all shows up as a broken image. A binary blob of + * the right total length would look perfectly fine. + */ +function makeBigImageFile(): File { + const chunks = [ + ``, + ``, + ]; + + // Everything emitted here is ASCII, so string length is byte length. + let size = chunks[0].length + chunks[1].length; + let shapes = 0; + + while (size < MULTIPART_THRESHOLD + 1024 * 1024) { + const cx = randomInt(BIG_CANVAS); + const cy = randomInt(BIG_CANVAS); + const chunk = rotated( + shapeMarkup( + SHAPES[randomInt(SHAPES.length)], + `hsl(${randomInt(360)} 70% 55%)`, + cx, + cy, + 12 + randomInt(48) + ), + randomInt(360), + cx, + cy + ); + + chunks.push(chunk); + size += chunk.length; + shapes++; + } + chunks.push(""); + + return new File(chunks, `big-${shapes}-shapes.svg`, { + type: "image/svg+xml", + }); +} + +export default function Home() { + const roomId = getRoomFromUrl(); + return ( + + + + ); +} + +function Sandbox() { + const renderCount = useRenderCount(); + const files = useStorage((root) => root.files); + const me = useSelf(); + const syncStatus = useSyncStatus(); + const uploadFile = useUploadFile(); + + const [status, setStatus] = useState("idle"); + + const addFile = useMutation(({ storage }, file: LiveFile) => { + storage.get("files").push(file); + }, []); + + const clear = useMutation(({ storage }) => { + storage.get("files").clear(); + }, []); + + async function upload(file: File) { + setStatus(`uploading ${file.name} (${file.size} bytes)…`); + try { + const started = Date.now(); + const liveFile = await uploadFile(file); + addFile(liveFile); + setStatus(`uploaded ${liveFile.name} in ${Date.now() - started}ms`); + } catch (err) { + setStatus(`FAILED: ${err instanceof Error ? err.message : String(err)}`); + } + } + + /** + * Reference a file whose bytes were never uploaded. The server must refuse + * the op; outside production the client throws when it sees the rejection, + * so expect a red error in the console rather than a new row. + */ + function referenceGhost() { + setStatus("referencing a file that was never uploaded…"); + addFile( + new LiveFile({ + id: `fl_${"0".repeat(21)}`, + name: "ghost.txt", + size: 1, + mimeType: "text/plain", + }) + ); + } + + if (files === null || me === null) { + return
    Loading...
    ; + } + + return ( +
    +

    + Home › Storage › LiveFile +

    + +
    + + + + + + + + + +
    + + + + + + + + + +
    + + {files.map((file) => ( + + ))} +
    + ); +} + +function FileRow({ file }: { file: LiveFileData }) { + const { url, error, isLoading } = useFileUrl(file); + + return ( +
    + + + + + +
    + + {url && file.mimeType.startsWith("image/") ? ( + // Fetched by the browser with no Authorization header, which is the + // whole point of the URL being signed. + {file.name} + ) : null} + + {url && !file.mimeType.startsWith("image/") ? ( + + download {file.name} + + ) : null} +
    + ); +} diff --git a/packages/liveblocks-chat-sdk-adapter/package.json b/packages/liveblocks-chat-sdk-adapter/package.json index e2c1dd7ae23..e4b817a4143 100644 --- a/packages/liveblocks-chat-sdk-adapter/package.json +++ b/packages/liveblocks-chat-sdk-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/chat-sdk-adapter", - "version": "3.23.0", + "version": "3.23.1", "description": "Liveblocks adapter for the Chat SDK.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-client/package.json b/packages/liveblocks-client/package.json index 1940406ca08..7f8cc327ed7 100644 --- a/packages/liveblocks-client/package.json +++ b/packages/liveblocks-client/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/client", - "version": "3.23.0", + "version": "3.23.1", "description": "A client that lets you interact with Liveblocks servers. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-core/package.json b/packages/liveblocks-core/package.json index 8a1eb4f516f..ecefa725184 100644 --- a/packages/liveblocks-core/package.json +++ b/packages/liveblocks-core/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/core", - "version": "3.23.0", + "version": "3.23.1", "description": "Private internals for Liveblocks. DO NOT import directly from this package!", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveFile.devserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveFile.devserver.test.ts new file mode 100644 index 00000000000..8f7c2b15ca6 --- /dev/null +++ b/packages/liveblocks-core/src/crdts/__tests__/LiveFile.devserver.test.ts @@ -0,0 +1,113 @@ +/** + * LiveFile tests that run against the real dev server. + * + * For edge cases that require precise control over wire-level ops, see + * LiveFile.mockserver.test.ts. + */ +import { describe, expect, test, vi } from "vitest"; + +import { enterAndConnect, enterConnectAndGetStorage, initRoom } from "../../__tests__/_devserver"; // prettier-ignore +import { createStorageFileId } from "../../lib/createIds"; +import { LiveFile } from "../LiveFile"; +import type { LiveObject } from "../LiveObject"; + +const CONTENTS = "hello world"; + +const DEV_SERVER = `http://localhost:${process.env.LIVEBLOCKS_DEV_SERVER_PORT ?? 1154}`; + +type ServerStorage = { + data: Record; +}; + +/** + * Read a room's Storage straight from the server, bypassing the client's own + * (optimistic) copy. + */ +async function fetchServerStorage(roomId: string): Promise { + const resp = await fetch( + `${DEV_SERVER}/v2/rooms/${encodeURIComponent(roomId)}/storage`, + { headers: { Authorization: "Bearer sk_localdev" } } + ); + return (await resp.json()) as ServerStorage; +} + +function makeFile(name = "hello.txt"): File { + return new File([CONTENTS], name, { type: "text/plain" }); +} + +describe("LiveFile", () => { + test("uploads a file and reports its server-measured metadata", async () => { + const roomId = await initRoom({ liveblocksType: "LiveObject", data: {} }); + const { room } = await enterAndConnect(roomId); + + const liveFile = await room.uploadFile(makeFile()); + + expect(liveFile.name).toEqual("hello.txt"); + expect(liveFile.size).toEqual(CONTENTS.length); + expect(liveFile.mimeType).toEqual("text/plain"); + expect(liveFile.id).toMatch(/^fl_/); + }); + + test("an uploaded file can be referenced from Storage and read back", async () => { + const roomId = await initRoom({ liveblocksType: "LiveObject", data: {} }); + const { room, storage } = await enterConnectAndGetStorage(roomId); + + const liveFile = await room.uploadFile(makeFile()); + (storage.root as LiveObject>).set("file", liveFile); + + await vi.waitFor(() => + expect(storage.root.toJSON()).toEqual({ + file: { + id: liveFile.id, + name: "hello.txt", + size: CONTENTS.length, + mimeType: "text/plain", + }, + }) + ); + }); + + test("getFileUrl returns a URL the bytes can actually be fetched from", async () => { + const roomId = await initRoom({ liveblocksType: "LiveObject", data: {} }); + const { room, storage } = await enterConnectAndGetStorage(roomId); + + const liveFile = await room.uploadFile(makeFile()); + (storage.root as LiveObject>).set("file", liveFile); + await vi.waitFor(() => + expect(storage.root.toJSON()).toHaveProperty("file") + ); + + const url = await room.getFileUrl(liveFile); + + // Fetched the way a browser would: no Authorization header + const resp = await fetch(url); + expect(resp.status).toEqual(200); + expect(await resp.text()).toEqual(CONTENTS); + }); + + test("the server's size wins over whatever the client claims", async () => { + const roomId = await initRoom({ liveblocksType: "LiveObject", data: {} }); + const { room, storage } = await enterConnectAndGetStorage(roomId); + + const uploaded = await room.uploadFile(makeFile()); + + // Same file id, but lying about its size + const liar = new LiveFile({ ...uploaded.data, size: 1 }); + (storage.root as LiveObject>).set("file", liar); + + // The claim is corrected where it counts. Note this asserts the *server's* + // view: the lying client keeps its own optimistic value locally, since the + // ack carries no corrected payload to reconcile against. + await vi.waitFor(async () => { + const stored = await fetchServerStorage(roomId); + expect(stored.data.file?.data.size).toEqual(CONTENTS.length); + }); + }); + + test("getFileUrl fails for a file id nothing knows about", async () => { + const roomId = await initRoom({ liveblocksType: "LiveObject", data: {} }); + const { room } = await enterAndConnect(roomId); + + await expect(room.getFileUrl(createStorageFileId())).rejects.toThrow(); + }); +}); diff --git a/packages/liveblocks-emails/package.json b/packages/liveblocks-emails/package.json index a9e1f2bdd94..2022039d33e 100644 --- a/packages/liveblocks-emails/package.json +++ b/packages/liveblocks-emails/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/emails", - "version": "3.23.0", + "version": "3.23.1", "description": "A set of functions and utilities to make sending emails based on Liveblocks notification events easy. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-node-lexical/package.json b/packages/liveblocks-node-lexical/package.json index 7dde9146f94..41ccaf3e22c 100644 --- a/packages/liveblocks-node-lexical/package.json +++ b/packages/liveblocks-node-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node-lexical", - "version": "3.23.0", + "version": "3.23.1", "description": "A server-side utility that lets you modify lexical documents hosted in Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-node-prosemirror/package.json b/packages/liveblocks-node-prosemirror/package.json index 22cb15386a9..7c340f03581 100644 --- a/packages/liveblocks-node-prosemirror/package.json +++ b/packages/liveblocks-node-prosemirror/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node-prosemirror", - "version": "3.23.0", + "version": "3.23.1", "description": "A server-side utility that lets you modify prosemirror and tiptap documents hosted in Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-node/package.json b/packages/liveblocks-node/package.json index 3fe40e93cb0..cd82eac9fc9 100644 --- a/packages/liveblocks-node/package.json +++ b/packages/liveblocks-node/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node", - "version": "3.23.0", + "version": "3.23.1", "description": "A server-side utility that lets you set up a Liveblocks authentication endpoint. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react-blocknote/package.json b/packages/liveblocks-react-blocknote/package.json index 963f55fe392..4e24754d913 100644 --- a/packages/liveblocks-react-blocknote/package.json +++ b/packages/liveblocks-react-blocknote/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-blocknote", - "version": "3.23.0", + "version": "3.23.1", "description": "An integration of BlockNote + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react-flow/package.json b/packages/liveblocks-react-flow/package.json index eb462ba34ef..d709afd4a29 100644 --- a/packages/liveblocks-react-flow/package.json +++ b/packages/liveblocks-react-flow/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-flow", - "version": "3.23.0", + "version": "3.23.1", "description": "An integration of React Flow to enable collaboration and realtime cursors with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react-lexical/package.json b/packages/liveblocks-react-lexical/package.json index deb65162d28..0536ce26418 100644 --- a/packages/liveblocks-react-lexical/package.json +++ b/packages/liveblocks-react-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-lexical", - "version": "3.23.0", + "version": "3.23.1", "description": "An integration of Lexical + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react-lexical/src/version-history/history-version-preview.tsx b/packages/liveblocks-react-lexical/src/version-history/history-version-preview.tsx index 0bf6eb4636e..05b0197c580 100644 --- a/packages/liveblocks-react-lexical/src/version-history/history-version-preview.tsx +++ b/packages/liveblocks-react-lexical/src/version-history/history-version-preview.tsx @@ -200,14 +200,16 @@ export const HistoryVersionPreview = forwardRef<
    {$.HISTORY_VERSION_PREVIEW_AUTHORS_LIST( - ( - - ))} - formatRemaining={$.LIST_REMAINING_USERS} - truncate={AUTHORS_TRUNCATE} - locale={$.locale} - /> + version.authors.length > 0 ? ( + ( + + ))} + formatRemaining={$.LIST_REMAINING_USERS} + truncate={AUTHORS_TRUNCATE} + locale={$.locale} + /> + ) : undefined )}
    diff --git a/packages/liveblocks-react-tiptap/package.json b/packages/liveblocks-react-tiptap/package.json index 1159e7a9854..dce91956fe3 100644 --- a/packages/liveblocks-react-tiptap/package.json +++ b/packages/liveblocks-react-tiptap/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-tiptap", - "version": "3.23.0", + "version": "3.23.1", "description": "An integration of TipTap + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react-tiptap/src/version-history/HistoryVersionPreview.tsx b/packages/liveblocks-react-tiptap/src/version-history/HistoryVersionPreview.tsx index 2d54a97ad2c..f8a0b1835ff 100644 --- a/packages/liveblocks-react-tiptap/src/version-history/HistoryVersionPreview.tsx +++ b/packages/liveblocks-react-tiptap/src/version-history/HistoryVersionPreview.tsx @@ -91,14 +91,16 @@ export const HistoryVersionPreview = forwardRef<
    {$.HISTORY_VERSION_PREVIEW_AUTHORS_LIST( - ( - - ))} - formatRemaining={$.LIST_REMAINING_USERS} - truncate={AUTHORS_TRUNCATE} - locale={$.locale} - /> + version.authors.length > 0 ? ( + ( + + ))} + formatRemaining={$.LIST_REMAINING_USERS} + truncate={AUTHORS_TRUNCATE} + locale={$.locale} + /> + ) : undefined )}
    diff --git a/packages/liveblocks-react-ui/package.json b/packages/liveblocks-react-ui/package.json index 4581197baa9..3720d67e7b9 100644 --- a/packages/liveblocks-react-ui/package.json +++ b/packages/liveblocks-react-ui/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-ui", - "version": "3.23.0", + "version": "3.23.1", "description": "A set of React pre-built components for the Liveblocks products. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react-ui/src/components/HistoryVersionSummary.tsx b/packages/liveblocks-react-ui/src/components/HistoryVersionSummary.tsx index 076a299d538..fec0494655f 100644 --- a/packages/liveblocks-react-ui/src/components/HistoryVersionSummary.tsx +++ b/packages/liveblocks-react-ui/src/components/HistoryVersionSummary.tsx @@ -42,14 +42,18 @@ export const HistoryVersionSummary = forwardRef< className="lb-date lb-history-version-summary-date" /> - ( - - ))} - formatRemaining={$.LIST_REMAINING_USERS} - truncate={AUTHORS_TRUNCATE} - locale={$.locale} - /> + {$.HISTORY_VERSION_SUMMARY_AUTHORS_LIST( + version.authors.length > 0 ? ( + ( + + ))} + formatRemaining={$.LIST_REMAINING_USERS} + truncate={AUTHORS_TRUNCATE} + locale={$.locale} + /> + ) : undefined + )} ); diff --git a/packages/liveblocks-react-ui/src/overrides.tsx b/packages/liveblocks-react-ui/src/overrides.tsx index bd1aa1efbe4..571b402592b 100644 --- a/packages/liveblocks-react-ui/src/overrides.tsx +++ b/packages/liveblocks-react-ui/src/overrides.tsx @@ -126,8 +126,16 @@ export interface InboxNotificationOverrides { ) => ReactNode; } +export interface HistoryVersionSummaryOverrides { + HISTORY_VERSION_SUMMARY_AUTHORS_LIST: ( + list: ReactNode | undefined + ) => ReactNode; +} + export interface HistoryVersionPreviewOverrides { - HISTORY_VERSION_PREVIEW_AUTHORS_LIST: (list: ReactNode) => ReactNode; + HISTORY_VERSION_PREVIEW_AUTHORS_LIST: ( + list: ReactNode | undefined + ) => ReactNode; HISTORY_VERSION_PREVIEW_RESTORE: string; HISTORY_VERSION_PREVIEW_EMPTY: ReactNode; HISTORY_VERSION_PREVIEW_ERROR: (error: Error) => ReactNode; @@ -139,6 +147,7 @@ export type Overrides = LocalizationOverrides & CommentOverrides & ThreadOverrides & InboxNotificationOverrides & + HistoryVersionSummaryOverrides & HistoryVersionPreviewOverrides & AiComposerOverrides & AiChatMessageOverrides & @@ -243,9 +252,10 @@ export const defaultOverrides: Overrides = { {user} mentioned you{room ? <> in {room} : null} ), - HISTORY_VERSION_PREVIEW_AUTHORS_LIST: (list: ReactNode) => ( - <>Edits from {list} - ), + HISTORY_VERSION_SUMMARY_AUTHORS_LIST: (list: ReactNode | undefined) => + list === undefined ? "Unattributed" : list, + HISTORY_VERSION_PREVIEW_AUTHORS_LIST: (list: ReactNode | undefined) => + list === undefined ? "Unattributed edits" : <>Edits from {list}, HISTORY_VERSION_PREVIEW_RESTORE: "Restore", HISTORY_VERSION_PREVIEW_EMPTY: "No content.", HISTORY_VERSION_PREVIEW_ERROR: () => diff --git a/packages/liveblocks-react-ui/src/styles/index.css b/packages/liveblocks-react-ui/src/styles/index.css index 78a5fea9452..1a21174fd5c 100644 --- a/packages/liveblocks-react-ui/src/styles/index.css +++ b/packages/liveblocks-react-ui/src/styles/index.css @@ -2373,6 +2373,8 @@ min-inline-size: 0; padding: var(--lb-spacing); background: var(--lb-dynamic-background); + outline: none; + text-align: start; transition-property: background; &:where( diff --git a/packages/liveblocks-react/package.json b/packages/liveblocks-react/package.json index 4ede2185691..d92c39152da 100644 --- a/packages/liveblocks-react/package.json +++ b/packages/liveblocks-react/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react", - "version": "3.23.0", + "version": "3.23.1", "description": "A set of React hooks and providers to use Liveblocks declaratively. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-redux/package.json b/packages/liveblocks-redux/package.json index c5c06c541ad..c789d608aba 100644 --- a/packages/liveblocks-redux/package.json +++ b/packages/liveblocks-redux/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/redux", - "version": "3.23.0", + "version": "3.23.1", "description": "A store enhancer to integrate Liveblocks into Redux stores. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-yjs/package.json b/packages/liveblocks-yjs/package.json index 2d3c9429789..34ac5f49a7c 100644 --- a/packages/liveblocks-yjs/package.json +++ b/packages/liveblocks-yjs/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/yjs", - "version": "3.23.0", + "version": "3.23.1", "description": "Integrate your existing or new Yjs documents with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-zustand/package.json b/packages/liveblocks-zustand/package.json index 944ef761313..ac2856206aa 100644 --- a/packages/liveblocks-zustand/package.json +++ b/packages/liveblocks-zustand/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/zustand", - "version": "3.23.0", + "version": "3.23.1", "description": "A middleware for Zustand to automatically synchronize your stores with Liveblocks. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.",