diff --git a/.github/assets/examples/comments-private.png b/.github/assets/examples/comments-private.png new file mode 100644 index 00000000000..523fcd510c4 Binary files /dev/null and b/.github/assets/examples/comments-private.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index b6dc1764c4b..9b99370f1b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ ## vNEXT (not yet released) +## v3.21.0 + +### All packages + +- Add support for public and private threads. Threads now have a `visibility` + property that is `"public"` by default but can be set to `"private"` when + created. Permissions can be used to decide which threads a user has access to, + and threads can also be queried by their visibility to create filtered views. +- Add scoped comments permissions such as `comments:public:write` and + `comments:private:none`. + +### `@liveblocks/client` + +- **Breaking:** Remove `type` and `kind` fields from `HistoryVersion` type. The + backend no longer returns these. +- Add `visibility` to `createThread`. +- Support querying by `visibility` in `getThreads`. + +### `@liveblocks/react` + +- Add `visibility` to `useCreateThread`. +- Support querying by `visibility` in `useThreads`. +- Add `useHistoryVersionYjsData()` hook to retrieve raw Yjs binary data for a + given version. Deprecate `useHistoryVersionData()` in its favor. + +### `@liveblocks/node` + +- Add `visibility` to `createThread`. +- Support querying by `visibility` in `getThreads`. + +### `@liveblocks/react-ui` + +- Add a `visibility` prop to `Composer`. +- Prevent `Composer` from collapsing after focusing and blurring unless it was + explicitly meant to support a collapsed state. + ## v3.20.1 ### `@liveblocks/client` diff --git a/docs/pages/api-reference/liveblocks-client.mdx b/docs/pages/api-reference/liveblocks-client.mdx index b69faf47bad..76879bd9a34 100644 --- a/docs/pages/api-reference/liveblocks-client.mdx +++ b/docs/pages/api-reference/liveblocks-client.mdx @@ -2914,6 +2914,11 @@ console.log(inboxNotifications); Only return `resolved` or `unresolved` threads. [Learn more](#filtering-resolved-status). + + Only return `public` or `private` threads. Permissions are taken into account so users + without access to private threads won’t receive them. [Learn + more](#filtering-visibility). + Only return `subscribed` or `unsubscribed` threads. [Learn more](#filtering-subscribed-status). @@ -2938,6 +2943,25 @@ const threads = await room.getThreads({ }); ``` +#### Filtering visibility [#filtering-visibility] + +You can filter threads based on visibility by passing `"public"` or `"private"` +to `query.visibility`. + +Permissions are taken into account, for example querying private threads returns +no threads if the current user does not have access to private threads. + +```ts +// Filtering for private threads +const threads = await room.getThreads({ + query: { + // +++ + visibility: "private", + // +++ + }, +}); +``` + #### Filtering subscribed status [#filtering-subscribed-status] You can filter threads by those that the user is subscribed to, or not, by @@ -3171,6 +3195,15 @@ const thread = await room.createThread({ Custom metadata to be attached to the thread, see [defining thread metadata](#defining-thread-metadata). + + Whether to create a public or private thread. Permissions are taken into + account so a user without write access to private threads can’t create a + private thread. + #### Creating thread content [#creating-thread-content] @@ -3245,6 +3278,31 @@ const metadata: Liveblocks["ThreadMetadata"] = { const thread = await room.createThread({ body, metadata }); ``` +#### Creating private threads [#creating-private-threads] + +Threads are public by default. To create a private thread, pass +`visibility: "private"`. + +```ts +const thread = await room.createThread({ + body, + // +++ + visibility: "private", + // +++ +}); +``` + +Permissions are taken into account when threads are created and retrieved. A +user without write access to private threads can’t create a private thread, and +users without read access to private threads won’t receive private threads from +[`room.getThreads`](#Room.getThreads) or [`room.getThread`](#Room.getThread). + + + +Private threads are only available on Team and Enterprise plans. + + + ### Room.deleteThread Deletes a thread by its ID. diff --git a/docs/pages/api-reference/liveblocks-node.mdx b/docs/pages/api-reference/liveblocks-node.mdx index 9db8c519565..74628f87f0a 100644 --- a/docs/pages/api-reference/liveblocks-node.mdx +++ b/docs/pages/api-reference/liveblocks-node.mdx @@ -49,8 +49,7 @@ const { body, status } = await liveblocks.identifyUser({ ``` - Learn how to - [get started with ID tokens](/docs/authentication#id-token). + Learn how to [get started with ID tokens](/docs/authentication#id-token). A number of options are also available, enabling you to set up permissions and @@ -91,10 +90,11 @@ expired. ##### Granting ID token permissions You can pass additional options to `identifyUser`, enabling you to create -complex [workspace permissions](/docs/authentication#id-token-workspace-permissions) and -[room permissions](/docs/authentication#id-token-room-permissions). For example, this -user can only see resources in the `acme-corp` workspace, and they’re part of a -`marketing` rooms group within it. +complex +[workspace permissions](/docs/authentication#id-token-workspace-permissions) and +[room permissions](/docs/authentication#id-token-room-permissions). For example, +this user can only see resources in the `acme-corp` workspace, and they’re part +of a `marketing` rooms group within it. ```ts const { body, status } = await liveblocks.identifyUser({ @@ -114,7 +114,8 @@ const { body, status } = await liveblocks.identifyUser({ ``` - Learn more about [ID token permissions](/docs/authentication#id-token-room-permissions). + Learn more about [ID token + permissions](/docs/authentication#id-token-room-permissions). ##### Text editor user data @@ -1893,9 +1894,9 @@ const { data: threads } = await liveblocks.getThreads({ console.log(threads); ``` -It’s also possible to filter threads by their string, boolean, and number -metadata using a query parameter. You can also pass `startsWith` to match the -start of a string. +It’s also possible to filter threads by visibility, resolved status, and their +string, boolean, and number metadata using a query parameter. You can also pass +`startsWith` to match the start of a string. ```ts const { data: threads } = await liveblocks.getThreads({ @@ -1905,6 +1906,10 @@ const { data: threads } = await liveblocks.getThreads({ query: { // Optional, filter based on resolved status resolved: false, + + // Optional, filter based on visibility + visibility: "private", + // Optional, filter for metadata values metadata: { status: "open", @@ -1926,8 +1931,9 @@ instead of a `query` object. #### Liveblocks.createThread [#post-rooms-roomId-threads] -Creates a new thread within a specific room, using room ID and thread data. This -is a wrapper around the +Creates a new thread within a specific room, using room ID and thread data. +Threads are public by default, but can be created as private by passing +`visibility: "private"`. This is a wrapper around the [Create Thread API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-threads) and returns the new thread. @@ -1991,8 +1997,8 @@ You can also convert a Markdown string to a `CommentBody` with -This method has a number of options, allowing for custom metadata and a creation -date for the comment. +This method has a number of options, allowing for custom metadata, thread +visibility, and a creation date for the comment. ```ts const thread = await liveblocks.createThread({ @@ -2006,6 +2012,9 @@ const thread = await liveblocks.createThread({ pinned: true, }, + // Optional, defaults to "public" + visibility: "private", + // Data for the first comment in the thread comment: { // The ID of the user that created the comment diff --git a/docs/pages/api-reference/liveblocks-react-ui.mdx b/docs/pages/api-reference/liveblocks-react-ui.mdx index 63f6916796c..71a46f9c2e4 100644 --- a/docs/pages/api-reference/liveblocks-react-ui.mdx +++ b/docs/pages/api-reference/liveblocks-react-ui.mdx @@ -1464,6 +1464,31 @@ declare global { } ``` +##### Creating private threads + +Threads are public by default. If you’d like the composer to create private +threads, you can add a `visibility` prop. + +```tsx +import { Composer } from "@liveblocks/react-ui"; + +// Creates a new private thread +function Component() { + return ; +} +``` + +Permissions are taken into account when threads are created and retrieved. A +user without write access to private threads can’t create a private thread, and +users without read access to private threads won’t receive private threads from +[`useThreads`](/docs/api-reference/liveblocks-react#useThreads). + + + +Private threads are only available on Team and Enterprise plans. + + + ##### Replying to a thread If you provide a `threadId`, then submitting the composer will add a new reply @@ -1562,6 +1587,14 @@ Learn more about mutation hooks under The metadata of the thread to create. + + Whether to create a public or private thread. Only applies when creating a + new thread, and requires write access to the selected visibility. + { // Can happen if you use Comments or Notifications case "CREATE_THREAD_ERROR": - const { roomId, threadId, commentId, body, metadata } = error.context; + const { roomId, threadId, commentId, body, visibility, metadata } = + error.context; break; case "DELETE_THREAD_ERROR": @@ -4074,8 +4075,9 @@ function Component() { Optional configuration object. - Optional query to filter threads by resolved status and metadata values. - [Learn more](/docs/api-reference/liveblocks-react#useThreads-query). + Optional query to filter threads by visibility, resolved status, subscribed + status, and metadata values. [Learn + more](/docs/api-reference/liveblocks-react#useThreads-query). Whether to scroll to a comment if the URL's hash is set to a comment ID. @@ -4116,11 +4118,13 @@ function Component() { #### Querying threads [#useThreads-query] It’s possible to return threads that match a certain query with the `query` -option. You can filter threads based on their resolved status, if the user is -subscribed to them, and metadata. Additionally, you can filter for metadata -strings that being with certain characters using `startsWith` and you can filter -for metadata numbers using `gt`, `lt`, `gte`, and `lte`. Returned threads match -the entire query. +option. You can filter threads based on their visibility, resolved status, if +the user is subscribed to them, and metadata. Additionally, you can filter for +metadata strings that begin with certain characters using `startsWith` and you +can filter for metadata numbers using `gt`, `lt`, `gte`, and `lte`. Returned +threads match the entire query. Permissions are taken into account so querying +private threads will return no private threads if the current user does not have +access to them. ```tsx // Returns threads that match the entire `query`, e.g. { color: "blue", pinned: true, ... } @@ -4129,6 +4133,9 @@ const { threads } = useThreads({ // Filter for unresolved threads resolved: false, + // Filter for private threads + visibility: "private", + // Filter for threads that the user is subscribed to subscribed: true, @@ -4280,7 +4287,9 @@ const { threads } = useThreads({ scrollOnLoad: false }); ### useCreateThread [@badge=RoomProvider] Returns a function that optimistically creates a thread with an initial comment, -and optionally some thread and comment metadata. +and optionally some thread metadata, comment metadata, and visibility. Threads +are public by default. Permissions are taken into account so a user without +write access to private threads can’t create a private one. ```tsx import { useCreateThread } from "@liveblocks/react/suspense"; @@ -4291,6 +4300,7 @@ const thread = createThread({ attachments: [], metadata: {}, commentMetadata: {}, + visibility: "private", }); ``` @@ -4300,7 +4310,8 @@ const thread = createThread({ type="(options: CreateThreadOptions) => ThreadData" > A function that creates a thread with an initial comment, and optionally - thread and comment metadata. Returns the optimistic thread object. + thread metadata, comment metadata, and visibility. Returns the optimistic + thread object. @@ -4316,7 +4327,8 @@ import { useErrorListener } from "@liveblocks/react/suspense"; useErrorListener((error) => { if (error.context.type === "CREATE_THREAD_ERROR") { - const { roomId, threadId, commentId, body, metadata } = error.context; + const { roomId, threadId, commentId, body, visibility, metadata } = + error.context; console.log(`Problem creating thread ${threadId}`); } }); @@ -6424,6 +6436,36 @@ const { versions, error, isLoading } = useHistoryVersions(); +### useHistoryVersionYjsData [@badge=RoomProvider] + +Returns the raw Yjs binary data for a given version of the room, for use with +Yjs-based editors (e.g. TipTap, Lexical, BlockNote). + +```tsx +import { useHistoryVersionYjsData } from "@liveblocks/react"; + +const { data, error, isLoading } = useHistoryVersionYjsData(versionId); +``` + + + + The ID of the version to retrieve. Obtained from the `id` field of a + `HistoryVersion` returned by [`useHistoryVersions`][]. + + + + + + The raw Yjs binary data for the version, or `undefined` while loading. + + + Whether the version data is currently being loaded. + + + Any error that occurred while loading the version data. + + + ## Miscellaneous ### useUser [@badge=Both] diff --git a/docs/pages/authentication/permissions.mdx b/docs/pages/authentication/permissions.mdx index e0d723cdb0e..e29d98b00bd 100644 --- a/docs/pages/authentication/permissions.mdx +++ b/docs/pages/authentication/permissions.mdx @@ -6,14 +6,67 @@ meta: --- Permissions define what an authenticated user can do with Liveblocks resources -such as rooms, comments, and feeds. +such as rooms, comments, and feeds. When using +[ID token authentication](/docs/authentication), permissions are set on rooms. +With [access tokens](/docs/authentication/access-token), you permissions are +granted when a user authenticates. + + + +| Permission | Resource | Description | +| ------------------------ | --------------- | ----------------------------------------------------- | +| **`*:read`** | | **Read access to everything.** | +| **`*:write`** | | **Write access to everything.** | +| `storage:read` | Storage | Read access to storage (Liveblocks Storage and Yjs). | +| `storage:write` | Storage | Write access to storage (Liveblocks Storage and Yjs). | +| `storage:none` | Storage | No access to storage (Liveblocks Storage and Yjs). | +| `comments:read` | Comments | Read access to public and private threads. | +| `comments:write` | Comments | Write access to public and private threads. | +| `comments:none` | Comments | No access to public and private threads. | +| `comments:public:read` | Public threads | Read access to public threads. | +| `comments:public:write` | Public threads | Write access to public threads. | +| `comments:public:none` | Public threads | No access to public threads. | +| `comments:private:read` | Private threads | Read access to private threads. | +| `comments:private:write` | Private threads | Write access to private threads. | +| `comments:private:none` | Private threads | No access to private threads. | +| `feeds:read` | Feeds | Read access to feeds. | +| `feeds:write` | Feeds | Write access to feeds. | +| `feeds:none` | Feeds | No access to feeds. | -With [ID tokens](/docs/authentication#id-token), permissions live on the room -and Liveblocks checks them when a user connects. With -[access tokens](/docs/authentication/access-token), you grant permissions when -you prepare a session. +
+ +## Example usage + +Permissions are set as an array of strings, in any order, and setting an empty +array means no access. You can use these permissions with Liveblocks APIs, for +example when creating a room. -## Permission format [#permission-format] +```ts +const room = await liveblocks.createRoom("my-room-id", { + // By default, nobody has access to the room + // +++ + defaultAccesses: [], + // +++ + + // `groupIds: ["viewer"]` users are read-only, but can leave comments + groupsAccesses: { + // +++ + viewer: ["*:read", "comments:write"], + // +++ + }, + + // `userId: "marc"` has full write access + usersAccesses: { + // +++ + "marc@example.com": ["*:write"], + // +++ + }, +}); +``` + +Note that `groupIds` and `userId` are set when authenticating a user. + +## More information [#permission-format] A user’s access to a room is defined by a list of permissions. @@ -43,8 +96,15 @@ You can opt into or opt out of access to specific room resources: - **Storage** with `storage:read`, `storage:write`, or `storage:none`. - **Comments** with `comments:read`, `comments:write`, or `comments:none`. +- **Public threads** with `comments:public:read`, `comments:public:write`, or + `comments:public:none`. +- **Private threads** with `comments:private:read`, `comments:private:write`, or + `comments:private:none`. - **Feeds** with `feeds:read`, `feeds:write`, or `feeds:none`. +The `comments:*` permissions apply to public and private threads. Use +`comments:public:*` and `comments:private:*` to override one visibility. + Here’s an example giving write access to everything except read-only access to storage: @@ -66,25 +126,31 @@ comments and no access to feeds: ]; ``` -### List of all permissions +Here’s an example giving write access to public threads, but no access to +private threads: + +```ts +[ + "*:write", + "comments:private:none", // Remove access to private threads +]; +``` + +### Public and private threads [#public-private-threads] - +By default, threads are visible to all users that have permission to read the +room, but they can be marked as private by passing `visibility: "private"` when +the thread is created. Users then need private comments permissions to create or +read private threads. For more information, read +[how to add private commenting to your app](/docs/guides/how-to-add-private-commenting-to-your-app), +or find a summary of the APIs under +[public and private threads](/docs/guides/how-to-use-public-and-private-threads). -| Permission | Resource | Description | -| ---------------- | -------- | ----------------------------------------------------- | -| **`*:read`** | | **Read access to everything.** | -| **`*:write`** | | **Write access to everything.** | -| `storage:read` | Storage | Read access to storage (Liveblocks Storage and Yjs). | -| `storage:write` | Storage | Write access to storage (Liveblocks Storage and Yjs). | -| `storage:none` | Storage | No access to storage (Liveblocks Storage and Yjs). | -| `comments:read` | Comments | Read access to comments. | -| `comments:write` | Comments | Write access to comments. | -| `comments:none` | Comments | No access to comments. | -| `feeds:read` | Feeds | Read access to feeds. | -| `feeds:write` | Feeds | Write access to feeds. | -| `feeds:none` | Feeds | No access to feeds. | + -
+Private threads are only available on Team and Enterprise plans. + + ## Where to use permissions [#where-to-use-permissions] diff --git a/docs/pages/collaboration-features/comments/concepts.mdx b/docs/pages/collaboration-features/comments/concepts.mdx index 4c404955d5a..fd0cff95a7f 100644 --- a/docs/pages/collaboration-features/comments/concepts.mdx +++ b/docs/pages/collaboration-features/comments/concepts.mdx @@ -37,6 +37,7 @@ Here’s an example of a thread object. roomId: "my-room-id", createdAt: Date , resolved: false, + visibility: "public", comments: [ // A list of comments in the thread // ... @@ -71,14 +72,16 @@ a thread. The first comment in a thread is displayed at the top. Here’s an example of a single comment inside a thread object. -```ts highlight="8-24" +```ts { type: "thread", id: "th_sf8s6sh...", roomId: "my-room-id", createdAt: Date , resolved: false, + visibility: "public", comments: [ + // +++ { type: "comment", threadId: "th_sf8s6sh...", @@ -96,6 +99,7 @@ Here’s an example of a single comment inside a thread object. // ... }, }, + // +++ // Other comments in the thread // ... @@ -142,3 +146,25 @@ hidden completely. A thread is only deleted after all its comments have been deleted. + +### Private comments + +Each thread has a `visibility` that is either `"public"` or `"private"`, and +threads are public by default. Public and private threads work in the same way, +but when authenticating users, you can select whether the user should have +access to public or private threads. + +Using the visibility option allows you to +[add private commenting to your app](/docs/guides/how-to-add-private-commenting-to-your-app), +enabling different two different tiers of commenting permissions in one room. +This is particularly useful for creating internal or team-only discussions, +whilst still allowing public comments for other users. + +For a summary of the APIs, read +[how to use public and private threads](/docs/guides/how-to-use-public-and-private-threads). + + + +Private threads are only available on Team and Enterprise plans. + + diff --git a/docs/pages/collaboration-features/comments/default-components.mdx b/docs/pages/collaboration-features/comments/default-components.mdx index f6940edcb20..94ac0424312 100644 --- a/docs/pages/collaboration-features/comments/default-components.mdx +++ b/docs/pages/collaboration-features/comments/default-components.mdx @@ -86,6 +86,7 @@ function Component() { [`Composer`][] can also be used in other ways: - [Adding metadata to a new thread](/docs/api-reference/liveblocks-react-ui#Adding-thread-metadata) +- [Creating private threads](/docs/api-reference/liveblocks-react-ui#Creating-private-threads) - [Replying to a thread](/docs/api-reference/liveblocks-react-ui#Replying-to-a-thread) - [Adding metadata to a reply](/docs/api-reference/liveblocks-react-ui#Adding-comment-metadata) - [Modifying a comment](/docs/api-reference/liveblocks-react-ui#Modifying-a-comment) diff --git a/docs/references/v2.openapi.json b/docs/references/v2.openapi.json index 5a698cb9582..4453e114889 100644 --- a/docs/references/v2.openapi.json +++ b/docs/references/v2.openapi.json @@ -126,7 +126,7 @@ }, "post": { "summary": "Create room", - "description": "This endpoint creates a new room. `id` and `defaultAccesses` are required. When provided with a `?idempotent` query argument, will not return a 409 when the room already exists, but instead return the existing room as-is. Corresponds to [`liveblocks.createRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-rooms), or to [`liveblocks.getOrCreateRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-or-create-rooms-roomId) when `?idempotent` is provided. \n- `defaultAccesses` could be `[]` or `[\"*:write\"]` (private or public). \n- `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field.\n- `usersAccesses` could be `[]` or `[\"*:write\"]` for every records. `usersAccesses` can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field.\n- `groupsAccesses` are optional fields.\n", + "description": "This endpoint creates a new room. `id` and `defaultAccesses` are required. When provided with a `?idempotent` query argument, will not return a 409 when the room already exists, but instead return the existing room as-is. Corresponds to [`liveblocks.createRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-rooms), or to [`liveblocks.getOrCreateRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-or-create-rooms-roomId) when `?idempotent` is provided. \n- `defaultAccesses` is the default room permission list, for example `[]`, `[\"*:read\"]`, `[\"*:write\"]`, or a more granular permission list. \n- `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field.\n- `usersAccesses` contains user-specific permission lists. It can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field.\n- `groupsAccesses` contains group-specific permission lists and is optional.\n", "tags": ["Room"], "parameters": [ { @@ -342,7 +342,7 @@ } }, "operationId": "update-room", - "description": "This endpoint updates specific properties of a room. Corresponds to [`liveblocks.updateRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-rooms-roomid). \n\nIt’s not necessary to provide the entire room’s information. \nSetting a property to `null` means to delete this property. For example, if you want to remove access to a specific user without losing other users: \n``{\n \"usersAccesses\": {\n \"john\": null\n }\n}``\n`defaultAccesses`, `metadata`, `usersAccesses`, `groupsAccesses` can be updated.\n\n- `defaultAccesses` could be `[]` or `[\"*:write\"]` (private or public). \n- `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field.\n- `usersAccesses` could be `[]` or `[\"*:write\"]` for every records. `usersAccesses` can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field.\n- `groupsAccesses` could be `[]` or `[\"*:write\"]` for every records. `groupsAccesses` can contain 1000 ids maximum. Id length has a limit of 256 characters. `groupsAccesses` is optional field.", + "description": "This endpoint updates specific properties of a room. Corresponds to [`liveblocks.updateRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-rooms-roomid). \n\nIt’s not necessary to provide the entire room’s information. \nSetting a property to `null` means to delete this property. For example, if you want to remove access to a specific user without losing other users: \n``{\n \"usersAccesses\": {\n \"john\": null\n }\n}``\n`defaultAccesses`, `metadata`, `usersAccesses`, `groupsAccesses` can be updated.\n\n- `defaultAccesses` is the default room permission list, for example `[]`, `[\"*:read\"]`, `[\"*:write\"]`, or a more granular permission list.\n- `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field.\n- `usersAccesses` contains user-specific permission lists. It can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field.\n- `groupsAccesses` contains group-specific permission lists and is optional.", "requestBody": { "required": true, "content": { @@ -575,7 +575,7 @@ } }, "operationId": "upsert-room", - "description": "This endpoint updates specific properties of a room. Corresponds to [`liveblocks.upsertRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#upsert-rooms-roomId). \n\nIt’s not necessary to provide the entire room’s information. \nSetting a property to `null` means to delete this property. For example, if you want to remove access to a specific user without losing other users: \n``{\n \"usersAccesses\": {\n \"john\": null\n }\n}``\n`defaultAccesses`, `metadata`, `usersAccesses`, `groupsAccesses` can be updated.\n\n- `defaultAccesses` could be `[]` or `[\"*:write\"]` (private or public). \n- `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field.\n- `usersAccesses` could be `[]` or `[\"*:write\"]` for every records. `usersAccesses` can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field.\n- `groupsAccesses` could be `[]` or `[\"*:write\"]` for every records. `groupsAccesses` can contain 1000 ids maximum. Id length has a limit of 256 characters. `groupsAccesses` is optional field.", + "description": "This endpoint updates specific properties of a room. Corresponds to [`liveblocks.upsertRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#upsert-rooms-roomId). \n\nIt’s not necessary to provide the entire room’s information. \nSetting a property to `null` means to delete this property. For example, if you want to remove access to a specific user without losing other users: \n``{\n \"usersAccesses\": {\n \"john\": null\n }\n}``\n`defaultAccesses`, `metadata`, `usersAccesses`, `groupsAccesses` can be updated.\n\n- `defaultAccesses` is the default room permission list, for example `[]`, `[\"*:read\"]`, `[\"*:write\"]`, or a more granular permission list.\n- `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field.\n- `usersAccesses` contains user-specific permission lists. It can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field.\n- `groupsAccesses` contains group-specific permission lists and is optional.", "requestBody": { "required": true, "content": { @@ -1487,10 +1487,10 @@ }, "/rooms/{roomId}/versions": { "get": { - "summary": "Get Yjs version history", + "summary": "Get Version History", "description": "This endpoint returns a list of version history snapshots for the room's Yjs document. The versions are returned sorted by creation date, from newest to oldest.", "tags": ["Yjs"], - "operationId": "get-yjs-versions", + "operationId": "get-version-history", "parameters": [ { "name": "roomId", @@ -1530,14 +1530,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetYjsVersionsResponse" + "$ref": "#/components/schemas/GetVersionHistoryResponse" }, "examples": { "example": { "value": { "data": [ { - "type": "historyVersion", "id": "vh_abc123", "createdAt": "2024-10-15T10:30:00.000Z", "authors": [ @@ -1547,8 +1546,7 @@ { "id": "user-456" } - ], - "kind": "yjs" + ] } ], "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI0LTEwLTE1VDEwOjMwOjAwLjAwMFoifQ==" @@ -1568,14 +1566,12 @@ "$ref": "#/components/responses/404" } } - } - }, - "/rooms/{roomId}/version/{versionId}": { - "get": { - "summary": "Get Yjs document version", - "description": "This endpoint returns a specific version of the room's Yjs document encoded as a binary Yjs update.", + }, + "post": { + "summary": "Create version history snapshot", + "description": "This endpoint creates a new version history snapshot for the room. Currently only works for Yjs.", "tags": ["Yjs"], - "operationId": "get-yjs-version", + "operationId": "create-version-history-snapshot", "parameters": [ { "name": "roomId", @@ -1586,26 +1582,24 @@ "description": "ID of the room", "example": "my-room-id" } - }, - { - "name": "versionId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "description": "ID of the version", - "example": "vh_abc123" - } } ], "responses": { "200": { - "description": "Success. Returns the Yjs document version as a binary stream.", + "description": "Success. Returns the created version ID.", "content": { - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/CreateVersionHistorySnapshotResponse" + }, + "examples": { + "example": { + "value": { + "data": { + "id": "vh_abc123" + } + } + } } } } @@ -1622,12 +1616,12 @@ } } }, - "/rooms/{roomId}/version": { - "post": { - "summary": "Create Yjs version snapshot", - "description": "This endpoint creates a new version history snapshot for the room's Yjs document.", + "/rooms/{roomId}/versions/{versionId}/yjs": { + "get": { + "summary": "Get Yjs document version", + "description": "This endpoint returns a specific version of the room's Yjs document encoded as a binary Yjs update.", "tags": ["Yjs"], - "operationId": "create-yjs-version", + "operationId": "get-yjs-version", "parameters": [ { "name": "roomId", @@ -1638,24 +1632,26 @@ "description": "ID of the room", "example": "my-room-id" } + }, + { + "name": "versionId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the version", + "example": "vh_abc123" + } } ], "responses": { "200": { - "description": "Success. Returns the created version ID.", + "description": "Success. Returns the Yjs document version as a binary stream.", "content": { - "application/json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/CreateYjsVersionResponse" - }, - "examples": { - "example": { - "value": { - "data": { - "id": "vh_abc123" - } - } - } + "type": "string", + "format": "binary" } } } @@ -1691,7 +1687,7 @@ { "schema": { "type": "string", - "description": "Query to filter threads. You can filter by `metadata` and `resolved`, for example, `metadata[\"status\"]:\"open\" AND metadata[\"color\"]:\"red\" AND resolved:true`. Learn more about [filtering threads with query language](https://liveblocks.io/docs/guides/how-to-filter-threads-using-query-language).", + "description": "Query to filter threads. You can filter by `metadata`, `resolved`, and `visibility`, for example, `metadata[\"status\"]:\"open\" AND metadata[\"color\"]:\"red\" AND resolved:true AND visibility:\"private\"`. Learn more about [filtering threads with query language](https://liveblocks.io/docs/guides/how-to-filter-threads-using-query-language).", "example": "metadata[\"color\"]:\"blue\"" }, "in": "query", @@ -1733,6 +1729,8 @@ ], "createdAt": "2019-08-24T14:15:22Z", "metadata": {}, + "resolved": false, + "visibility": "public", "updatedAt": "2019-08-24T14:15:22Z" } ] @@ -1799,9 +1797,12 @@ } ], "createdAt": "2022-07-13T14:32:50.697Z", + "updatedAt": "2022-07-13T14:32:50.697Z", "metadata": { "color": "blue" - } + }, + "resolved": false, + "visibility": "public" } } } @@ -1911,6 +1912,8 @@ ], "createdAt": "2019-08-24T14:15:22Z", "metadata": {}, + "resolved": false, + "visibility": "public", "updatedAt": "2019-08-24T14:15:22Z" } } @@ -7197,6 +7200,12 @@ "comments:read", "comments:write", "comments:none", + "comments:public:read", + "comments:public:write", + "comments:public:none", + "comments:private:read", + "comments:private:write", + "comments:private:none", "feeds:read", "feeds:write", "feeds:none" @@ -7915,18 +7924,14 @@ } } }, - "YjsVersion": { - "title": "YjsVersion", + "HistoryVersion": { + "title": "HistoryVersion", "type": "object", "properties": { "id": { "type": "string", "description": "Unique identifier for the version" }, - "type": { - "type": "string", - "const": "historyVersion" - }, "createdAt": { "type": "string", "format": "date-time", @@ -7944,47 +7949,27 @@ } }, "description": "List of users who contributed to this version" - }, - "kind": { - "type": "string", - "const": "yjs" } }, - "required": ["id", "type", "createdAt", "kind"], + "required": ["id", "createdAt", "authors"], "example": { "id": "vh_abc123", - "type": "historyVersion", "createdAt": "2024-10-15T10:30:00.000Z", - "authors": [ - { - "id": "user-123" - }, - { - "id": "user-456" - } - ], - "kind": "yjs" + "authors": [{ "id": "user-123" }, { "id": "user-456" }] } }, - "GetYjsVersionsResponse": { - "title": "GetYjsVersionsResponse", + "GetVersionHistoryResponse": { + "title": "GetVersionHistoryResponse", "type": "object", "properties": { "nextCursor": { "description": "Cursor for pagination to get the next page of results", - "oneOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "oneOf": [{ "type": "string" }, { "type": "null" }] }, "data": { "type": "array", "items": { - "$ref": "#/components/schemas/YjsVersion" + "$ref": "#/components/schemas/HistoryVersion" } } }, @@ -7993,22 +7978,16 @@ "example": { "data": [ { - "type": "historyVersion", "id": "vh_abc123", "createdAt": "2024-10-15T10:30:00.000Z", - "authors": [ - { - "id": "user-123" - } - ], - "kind": "yjs" + "authors": [{ "id": "user-123" }] } ], "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI0LTEwLTE1VDEwOjMwOjAwLjAwMFoifQ==" } }, - "CreateYjsVersionResponse": { - "title": "CreateYjsVersionResponse", + "CreateVersionHistorySnapshotResponse": { + "title": "CreateVersionHistorySnapshotResponse", "type": "object", "properties": { "data": { @@ -8070,7 +8049,8 @@ "createdAt": "2022-07-13T14:32:50.697Z", "updatedAt": "2022-07-13T14:32:50.697Z", "metadata": {}, - "resolved": false + "resolved": false, + "visibility": "public" } ] } @@ -8104,6 +8084,10 @@ "resolved": { "type": "boolean" }, + "visibility": { + "type": "string", + "enum": ["public", "private"] + }, "updatedAt": { "type": "string", "format": "date-time" @@ -8117,7 +8101,8 @@ "createdAt", "updatedAt", "metadata", - "resolved" + "resolved", + "visibility" ], "example": { "type": "thread", @@ -8145,7 +8130,8 @@ "metadata": { "color": "blue" }, - "resolved": false + "resolved": false, + "visibility": "public" } }, "Feed": { @@ -8365,6 +8351,11 @@ }, "metadata": { "$ref": "#/components/schemas/ThreadMetadata" + }, + "visibility": { + "type": "string", + "enum": ["public", "private"], + "default": "public" } }, "required": ["comment"], @@ -8384,7 +8375,8 @@ }, "metadata": { "color": "blue" - } + }, + "visibility": "private" } ] }, diff --git a/e2e/next-sandbox/pages/api/auth/access-token.ts b/e2e/next-sandbox/pages/api/auth/access-token.ts index ecb8d04a36e..2a064531e90 100644 --- a/e2e/next-sandbox/pages/api/auth/access-token.ts +++ b/e2e/next-sandbox/pages/api/auth/access-token.ts @@ -1,4 +1,4 @@ -import { nn } from "@liveblocks/core"; +import { nn, type Permission as PermissionToken } from "@liveblocks/core"; import { Liveblocks } from "@liveblocks/node"; import type { NextApiRequest, NextApiResponse } from "next"; @@ -17,6 +17,22 @@ const liveblocks = new Liveblocks({ ), }); +type QueryValue = string | string[] | undefined; + +function getQueryValues(value: QueryValue) { + if (value === undefined) { + return undefined; + } + + return Array.isArray(value) ? value : [value]; +} + +function getPermissions(value: QueryValue) { + const scopes = getQueryValues(value); + + return scopes as PermissionToken[] | undefined; +} + export default async function accessTokenAuth( req: NextApiRequest, res: NextApiResponse @@ -35,7 +51,9 @@ export default async function accessTokenAuth( }, } ); - session.allow("e2e*", session.FULL_ACCESS); + + const permissions = getPermissions(req.query.permissions); + session.allow("e2e*", permissions ?? session.FULL_ACCESS); const response = await session.authorize(); res.status(response.status).end(response.body); } diff --git a/e2e/next-sandbox/pages/comments/thread.tsx b/e2e/next-sandbox/pages/comments/thread.tsx index 34fbf176884..a0163cf0c57 100644 --- a/e2e/next-sandbox/pages/comments/thread.tsx +++ b/e2e/next-sandbox/pages/comments/thread.tsx @@ -39,6 +39,7 @@ function Sandbox() { createdAt: date, updatedAt: date, resolved: false, + visibility: "public", metadata: {}, comments: [ { diff --git a/e2e/next-sandbox/pages/comments/visibility.tsx b/e2e/next-sandbox/pages/comments/visibility.tsx new file mode 100644 index 00000000000..47db1036019 --- /dev/null +++ b/e2e/next-sandbox/pages/comments/visibility.tsx @@ -0,0 +1,338 @@ +import type { + BaseMetadata, + BaseUserMeta, + Json, + JsonObject, + ThreadVisibility, +} from "@liveblocks/core"; +import { + createRoomContext, + useErrorListener, + useSyncStatus, +} from "@liveblocks/react"; +import { useEffect, useMemo, useState } from "react"; + +import { getRoomFromUrl, Row } from "../../utils"; +import Button from "../../utils/Button"; +import { createLiveblocksClient } from "../../utils/createClient"; + +const E2E_CASE = "visibility-threads"; + +type VisibilityFilter = ThreadVisibility | "all"; +type PageMode = "read" | "create"; + +const client = createLiveblocksClient({ + authEndpoint: async () => { + const params = new URLSearchParams(); + const user = getOptionalUrlParam("user"); + const permissions = getUrlParamValues("permissions"); + + if (user !== undefined) { + params.set("user", user); + } + + for (const permission of permissions) { + params.append("permissions", permission); + } + + const query = params.toString(); + const response = await fetch( + query ? `/api/auth/access-token?${query}` : "/api/auth/access-token" + ); + + return response.json(); + }, +}); + +const { RoomProvider, useThreads, useCreateThread } = createRoomContext< + JsonObject, + never, + BaseUserMeta, + Json, + BaseMetadata +>(client); + +export default function Home() { + const roomId = getRoomFromUrl(); + + return ( + + + + ); +} + +function Sandbox() { + const params = usePageParams(); + + if (params === undefined) { + return null; + } + + if (params.mode === "create") { + return ; + } + + return ; +} + +function ReadSandbox({ + runId, + visibility, +}: { + runId: string; + visibility: VisibilityFilter; +}) { + const createThread = useCreateThread(); + const syncStatus = useSyncStatus({ smooth: true }); + const isSynced = syncStatus === "synchronized"; + + const metadata = useMemo( + () => ({ + e2eCase: E2E_CASE, + e2eRun: runId, + }), + [runId] + ); + + const query = useMemo( + () => (visibility === "all" ? undefined : { visibility }), + [visibility] + ); + + const result = useThreads(query === undefined ? {} : { query }); + const threads = result.threads ?? []; + const publicThreads = threads.filter( + (thread) => thread.visibility === "public" + ); + const privateThreads = threads.filter( + (thread) => thread.visibility === "private" + ); + const threadVisibilities = threads + .map((thread) => thread.visibility) + .sort(); + const error = "error" in result ? result.error?.message : undefined; + + return ( + <> + + + + + + + + + + + + +
+ + {visibility === "all" ? null : ( + + )} + + ); +} + +function CreateSandbox({ + runId, + visibility, +}: { + runId: string; + visibility: VisibilityFilter; +}) { + const createThread = useCreateThread(); + const syncStatus = useSyncStatus({ smooth: true }); + const [error, setError] = useState< + | { + message: string; + cause: string | undefined; + contextType: string | undefined; + } + | undefined + >(); + + const metadata = useMemo( + () => ({ + e2eCase: E2E_CASE, + e2eRun: runId, + }), + [runId] + ); + + useErrorListener((err) => { + setError({ + message: err.message, + cause: err.cause instanceof Error ? err.cause.message : undefined, + contextType: err.context.type, + }); + }); + + return ( + <> + + + + + + + + +
+ + {visibility === "all" ? null : ( + + )} + + ); +} + +function usePageParams() { + const [params, setParams] = useState< + | { + runId: string; + visibility: VisibilityFilter; + mode: PageMode; + } + | undefined + >(); + + useEffect(() => { + setParams({ + runId: getRunIdFromUrl(), + visibility: getVisibilityFilterFromUrl(), + mode: getPageModeFromUrl(), + }); + }, []); + + return params; +} + +function getPageModeFromUrl(): PageMode { + if (typeof window === "undefined") { + return "read"; + } + + const mode = getOptionalUrlParam("mode"); + if (mode === undefined || mode === "read") { + return "read"; + } + + if (mode === "create") { + return mode; + } + + throw new Error("Specify ?mode=read or ?mode=create in URL"); +} + +function getOptionalUrlParam(name: string): string | undefined { + if (typeof window === "undefined") { + return undefined; + } + + return new URL(window.location.href).searchParams.get(name) ?? undefined; +} + +function getUrlParamValues(name: string): string[] { + if (typeof window === "undefined") { + return []; + } + + return new URL(window.location.href).searchParams.getAll(name); +} + +function getRunIdFromUrl(): string { + if (typeof window === "undefined") { + return "run-id-placeholder-for-ssr"; + } + + const runId = getOptionalUrlParam("run"); + if (runId === undefined) { + throw new Error("Specify ?run= in URL, please"); + } + + return runId; +} + +function getVisibilityFilterFromUrl(): VisibilityFilter { + if (typeof window === "undefined") { + return "all"; + } + + const visibility = getOptionalUrlParam("visibility"); + if (visibility === "all") { + return visibility; + } + + if (visibility === "public" || visibility === "private") { + return visibility; + } + + throw new Error( + "Specify ?visibility=all, ?visibility=public, or ?visibility=private in URL" + ); +} diff --git a/e2e/next-sandbox/test/comments/visibility.test.ts b/e2e/next-sandbox/test/comments/visibility.test.ts new file mode 100644 index 00000000000..9aa14701b90 --- /dev/null +++ b/e2e/next-sandbox/test/comments/visibility.test.ts @@ -0,0 +1,498 @@ +import type { Page, TestInfo } from "@playwright/test"; +import { expect, test } from "@playwright/test"; + +import { genRoomId, preparePage, waitForJson } from "../utils"; + +const TEST_URL = "http://localhost:3007/comments/visibility"; +const SLOW = { timeout: 20_000 }; +const VISIBILITY_PERMISSIONS = { + public: ["*:read", "comments:none", "comments:public:write"], + private: ["*:read", "comments:none", "comments:private:write"], +} satisfies Record<"public" | "private", readonly string[]>; +const PUBLIC_COMMENTS_WRITE_PRIVATE_NONE_PERMISSIONS: readonly string[] = [ + "*:read", + "comments:write", + "comments:private:none", +]; +const BASE_WRITE_PERMISSIONS: readonly string[] = ["*:write"]; +const BROAD_COMMENTS_WRITE_PERMISSIONS: readonly string[] = [ + "*:read", + "comments:write", +]; + +test.describe("Thread visibility", () => { + let pages: Page[] = []; + + test.afterEach(async () => { + await Promise.all( + pages + .filter((page) => !page.isClosed()) + .map((page) => page.close()) + ); + pages = []; + }); + + test( + "creates and fetches public and private thread visibility", + async ({}, testInfo) => { + const run = [ + Date.now().toString(36), + testInfo.workerIndex, + testInfo.retry, + Math.random().toString(16).slice(2, 8), + ].join("-"); + const room = getRoomId(testInfo, run, "all"); + + const publicPage = await openPage({ + room, + run, + user: 1, + visibility: "public", + x: 0, + }); + + await waitForPageLoaded(publicPage); + + const publicThreadResponse$ = waitForCreateThreadResponse(publicPage); + await publicPage.click("#create-thread"); + await publicThreadResponse$; + await publicPage.close(); + + const privatePage = await openPage({ + room, + run, + user: 2, + visibility: "private", + x: 640, + }); + + await waitForPageLoaded(privatePage); + + const privateThreadResponse$ = waitForCreateThreadResponse(privatePage); + await privatePage.click("#create-thread"); + await privateThreadResponse$; + await privatePage.close(); + + const verifierPage = await openPage({ + room, + run, + user: 1, + visibility: "all", + x: 0, + }); + + await waitForPageLoaded(verifierPage); + await waitForJson(verifierPage, "#threadCount", 2, SLOW); + await waitForJson(verifierPage, "#publicThreadCount", 1, SLOW); + await waitForJson(verifierPage, "#privateThreadCount", 1, SLOW); + await waitForJson( + verifierPage, + "#threadVisibilities", + ["private", "public"], + SLOW + ); + await verifierPage.close(); + } + ); + + test( + "creates threads with matching visibility-specific permissions", + async ({}, testInfo) => { + const run = [ + Date.now().toString(36), + testInfo.workerIndex, + testInfo.retry, + Math.random().toString(16).slice(2, 8), + ].join("-"); + const publicRoom = getRoomId(testInfo, run, "public"); + const privateRoom = getRoomId(testInfo, run, "private"); + + await createThreadWithVisibilityPermissions({ + room: publicRoom, + run, + user: 1, + visibility: "public", + x: 0, + }); + await verifyPersistedVisibility({ + room: publicRoom, + run, + user: 1, + visibility: "public", + x: 0, + }); + + await createThreadWithVisibilityPermissions({ + room: privateRoom, + run, + user: 2, + visibility: "private", + x: 640, + }); + await verifyPersistedVisibility({ + room: privateRoom, + run, + user: 2, + visibility: "private", + x: 640, + }); + } + ); + + test( + "inherits base and broad comments permissions for thread visibility", + async ({}, testInfo) => { + const run = [ + Date.now().toString(36), + testInfo.workerIndex, + testInfo.retry, + Math.random().toString(16).slice(2, 8), + ].join("-"); + const cases = [ + { + id: "base-write-public", + user: 1, + visibility: "public", + permissions: BASE_WRITE_PERMISSIONS, + x: 0, + }, + { + id: "base-write-private", + user: 1, + visibility: "private", + permissions: BASE_WRITE_PERMISSIONS, + x: 640, + }, + { + id: "comments-write-public", + user: 2, + visibility: "public", + permissions: BROAD_COMMENTS_WRITE_PERMISSIONS, + x: 0, + }, + { + id: "comments-write-private", + user: 2, + visibility: "private", + permissions: BROAD_COMMENTS_WRITE_PERMISSIONS, + x: 640, + }, + ] satisfies Array<{ + id: string; + user: number; + visibility: "public" | "private"; + permissions: readonly string[]; + x: number; + }>; + + for (const testCase of cases) { + const room = getRoomId(testInfo, run, testCase.id); + + await createThreadWithExplicitPermissions({ + room, + run, + user: testCase.user, + visibility: testCase.visibility, + permissions: testCase.permissions, + x: testCase.x, + }); + await verifyPersistedVisibility({ + room, + run, + user: testCase.user, + visibility: testCase.visibility, + x: testCase.x, + }); + } + } + ); + + test( + "rejects threads created with mismatched visibility-specific permissions", + async ({}, testInfo) => { + const run = [ + Date.now().toString(36), + testInfo.workerIndex, + testInfo.retry, + Math.random().toString(16).slice(2, 8), + ].join("-"); + const room = getRoomId(testInfo, run, "mismatch"); + + const page = await openPage({ + room, + run, + user: 1, + visibility: "private", + permissions: VISIBILITY_PERMISSIONS.public, + mode: "create", + x: 0, + }); + + await waitForCreatePageLoaded(page); + await page.click("#create-thread"); + await waitForJson(page, "#errorContextType", "CREATE_THREAD_ERROR", SLOW); + await expect(page.locator("#errorCause")).toContainText( + /forbidden|permission|unauthorized|not allowed|403/i, + SLOW + ); + await page.close(); + + const verifierPage = await openPage({ + room, + run, + user: 1, + visibility: "all", + x: 0, + }); + + await waitForPageLoaded(verifierPage); + await waitForJson(verifierPage, "#threadCount", 0, SLOW); + await verifierPage.close(); + } + ); + + test( + "applies explicit permission tokens with a private visibility override", + async ({}, testInfo) => { + const run = [ + Date.now().toString(36), + testInfo.workerIndex, + testInfo.retry, + Math.random().toString(16).slice(2, 8), + ].join("-"); + const publicRoom = getRoomId(testInfo, run, "public"); + const privateRoom = getRoomId(testInfo, run, "private"); + + await createThreadWithExplicitPermissions({ + room: publicRoom, + run, + user: 1, + visibility: "public", + permissions: PUBLIC_COMMENTS_WRITE_PRIVATE_NONE_PERMISSIONS, + x: 0, + }); + await verifyPersistedVisibility({ + room: publicRoom, + run, + user: 1, + visibility: "public", + x: 0, + }); + + const page = await openPage({ + room: privateRoom, + run, + user: 1, + visibility: "private", + permissions: PUBLIC_COMMENTS_WRITE_PRIVATE_NONE_PERMISSIONS, + mode: "create", + x: 0, + }); + + await waitForCreatePageLoaded(page); + await page.click("#create-thread"); + await waitForJson(page, "#errorContextType", "CREATE_THREAD_ERROR", SLOW); + await expect(page.locator("#errorCause")).toContainText( + /forbidden|permission|unauthorized|not allowed|403/i, + SLOW + ); + await page.close(); + + const verifierPage = await openPage({ + room: privateRoom, + run, + user: 1, + visibility: "all", + x: 0, + }); + + await waitForPageLoaded(verifierPage); + await waitForJson(verifierPage, "#threadCount", 0, SLOW); + await verifierPage.close(); + } + ); + + async function openPage({ + room, + run, + user, + visibility, + permissions, + mode, + x, + }: { + room: string; + run: string; + user: number; + visibility: "all" | "public" | "private"; + permissions?: readonly string[]; + mode?: "read" | "create"; + x: number; + }) { + const page = await preparePage( + getPageUrl({ + room, + run, + user, + visibility, + permissions, + mode, + }), + { x } + ); + pages.push(page); + return page; + } + + async function createThreadWithVisibilityPermissions({ + room, + run, + user, + visibility, + x, + }: { + room: string; + run: string; + user: number; + visibility: "public" | "private"; + x: number; + }) { + const page = await openPage({ + room, + run, + user, + visibility, + permissions: VISIBILITY_PERMISSIONS[visibility], + mode: "create", + x, + }); + + await waitForCreatePageLoaded(page); + + const createThreadResponse$ = waitForCreateThreadResponse(page); + await page.click("#create-thread"); + await createThreadResponse$; + await page.close(); + } + + async function createThreadWithExplicitPermissions({ + room, + run, + user, + visibility, + permissions, + x, + }: { + room: string; + run: string; + user: number; + visibility: "public" | "private"; + permissions: readonly string[]; + x: number; + }) { + const page = await openPage({ + room, + run, + user, + visibility, + permissions, + mode: "create", + x, + }); + + await waitForCreatePageLoaded(page); + + const createThreadResponse$ = waitForCreateThreadResponse(page); + await page.click("#create-thread"); + await createThreadResponse$; + await page.close(); + } + + async function verifyPersistedVisibility({ + room, + run, + user, + visibility, + x, + }: { + room: string; + run: string; + user: number; + visibility: "public" | "private"; + x: number; + }) { + const page = await openPage({ + room, + run, + user, + visibility: "all", + x, + }); + + await waitForPageLoaded(page); + await waitForJson(page, "#threadCount", 1, SLOW); + await waitForJson( + page, + visibility === "public" ? "#publicThreadCount" : "#privateThreadCount", + 1, + SLOW + ); + await waitForJson(page, "#threadVisibilities", [visibility], SLOW); + await page.close(); + } +}); + +function getRoomId(testInfo: TestInfo, run: string, suffix: string) { + return genRoomId(testInfo, `:${run}:${suffix}`); +} + +function getPageUrl({ + room, + run, + user, + visibility, + permissions, + mode, +}: { + room: string; + run: string; + user: number; + visibility: "all" | "public" | "private"; + permissions?: readonly string[]; + mode?: "read" | "create"; +}) { + const url = new URL(TEST_URL); + url.searchParams.set("room", room); + url.searchParams.set("run", run); + url.searchParams.set("user", String(user)); + url.searchParams.set("visibility", visibility); + for (const permission of permissions ?? []) { + url.searchParams.append("permissions", permission); + } + if (mode !== undefined) { + url.searchParams.set("mode", mode); + } + return url.toString(); +} + +async function waitForPageLoaded(page: Page) { + await waitForJson(page, "#isLoading", false, SLOW); + await waitForJson(page, "#error", undefined, SLOW); +} + +async function waitForCreatePageLoaded(page: Page) { + await waitForJson(page, "#error", undefined, SLOW); + await expect(page.locator("#create-thread")).toBeVisible(SLOW); +} + +async function waitForCreateThreadResponse(page: Page) { + const response = await page.waitForResponse((candidate) => { + if (candidate.request().method() !== "POST") { + return false; + } + + return new URL(candidate.url()).pathname.endsWith("/threads"); + }, SLOW); + + expect(response.ok()).toBe(true); +} diff --git a/e2e/next-sandbox/test/utils.ts b/e2e/next-sandbox/test/utils.ts index 071eb55c8ad..7580b138c4c 100644 --- a/e2e/next-sandbox/test/utils.ts +++ b/e2e/next-sandbox/test/utils.ts @@ -10,6 +10,8 @@ export type IDSelector = `#${string}`; const WIDTH = 640; const HEIGHT = 800; +const DEFAULT_ROOM_ID_MAX_LENGTH = 100; +const ABSOLUTE_ROOM_ID_MAX_LENGTH = 128; function getTestFilename(fullPath: string): string { const parts = fullPath.split("/"); @@ -25,7 +27,7 @@ function getTestFilename(fullPath: string): string { * filename and the full test name. Additionally, will prepend the Git SHA if * available (e.g. when running in CI). */ -export function genRoomId(testInfo: TestInfo) { +export function genRoomId(testInfo: TestInfo, suffix = "") { const prefix = process.env.NEXT_PUBLIC_GITHUB_SHA ? process.env.NEXT_PUBLIC_GITHUB_SHA.slice(0, 2) : null; @@ -38,16 +40,25 @@ export function genRoomId(testInfo: TestInfo) { .replace(/^-+/, "") .replace(/-+$/, ""); let roomId = `e2e:${title}`; - if (roomId.length > 100) { + const maxLength = + suffix.length === 0 + ? DEFAULT_ROOM_ID_MAX_LENGTH + : ABSOLUTE_ROOM_ID_MAX_LENGTH; + const maxBaseLength = maxLength - suffix.length; + if (maxBaseLength < 8) { + throw new Error(`Room ID suffix is too long: ${suffix.length}`); + } + + if (roomId.length > maxBaseLength) { // Room IDs cannot be longer than 128 chars. If this happens, take a short // hash from the full room ID, then cut it off and attach the hash. This // way, test names can still be arbitrarily long, human-readable (at least // the first part of it), and yet still stable for reuse, so we don't have // an ever-growing set of rooms when running against DEV or PROD. const hash = hash7(roomId); - roomId = roomId.slice(0, 100 - hash.length) + hash; + roomId = roomId.slice(0, maxBaseLength - hash.length) + hash; } - return roomId; + return `${roomId}${suffix}`; } /** diff --git a/examples/nextjs-ai-app-builder/README.md b/examples/nextjs-ai-app-builder/README.md index 20099263be1..ac89f5ee366 100644 --- a/examples/nextjs-ai-app-builder/README.md +++ b/examples/nextjs-ai-app-builder/README.md @@ -10,7 +10,7 @@ # AI App Builder

- + Live Preview diff --git a/examples/nextjs-ai-dashboard-reports/README.md b/examples/nextjs-ai-dashboard-reports/README.md index 0cebb5e6f25..8a9cc235808 100644 --- a/examples/nextjs-ai-dashboard-reports/README.md +++ b/examples/nextjs-ai-dashboard-reports/README.md @@ -10,7 +10,7 @@ # AI chat in reports dashboard

- + Live Preview diff --git a/examples/nextjs-ai-elements-realtime/README.md b/examples/nextjs-ai-elements-realtime/README.md index 4a4a26850c1..b8f34be90f3 100644 --- a/examples/nextjs-ai-elements-realtime/README.md +++ b/examples/nextjs-ai-elements-realtime/README.md @@ -10,7 +10,7 @@ # Realtime AI chat with AI Elements

- + Live Preview diff --git a/examples/nextjs-ai-elements-realtime/components/HelpButton.tsx b/examples/nextjs-ai-elements-realtime/components/HelpButton.tsx index 4e796010568..65dcf39b8e6 100644 --- a/examples/nextjs-ai-elements-realtime/components/HelpButton.tsx +++ b/examples/nextjs-ai-elements-realtime/components/HelpButton.tsx @@ -6,7 +6,7 @@ import { Button } from "./ui/button"; const EXAMPLE_NAME = "Realtime AI chat with AI Elements"; const EXAMPLE_URL = - "https://liveblocks.io/examples/nextjs-ai-elements-realtime"; + "https://liveblocks.io/examples/ai-elements-realtime/nextjs-ai-elements-realtime"; type Feature = { icon: ReactNode; diff --git a/examples/nextjs-ai-spreadsheet/README.md b/examples/nextjs-ai-spreadsheet/README.md index 3a5fd776a52..3cb4c224e98 100644 --- a/examples/nextjs-ai-spreadsheet/README.md +++ b/examples/nextjs-ai-spreadsheet/README.md @@ -10,7 +10,7 @@ # Realtime AI spreadsheet

- + Live Preview diff --git a/examples/nextjs-ai-spreadsheet/components/HelpButton.tsx b/examples/nextjs-ai-spreadsheet/components/HelpButton.tsx index d38de069aaf..242ab8ce9fa 100644 --- a/examples/nextjs-ai-spreadsheet/components/HelpButton.tsx +++ b/examples/nextjs-ai-spreadsheet/components/HelpButton.tsx @@ -5,7 +5,8 @@ import { createPortal } from "react-dom"; import { Button } from "./ui/button"; const EXAMPLE_NAME = "Realtime AI spreadsheet"; -const EXAMPLE_URL = "https://liveblocks.io/examples/nextjs-ai-spreadsheet"; +const EXAMPLE_URL = + "https://liveblocks.io/examples/ai-spreadsheet/nextjs-ai-spreadsheet"; type Feature = { icon: ReactNode; diff --git a/examples/nextjs-comments-ai/README.md b/examples/nextjs-comments-ai/README.md index 69703b581c0..24c032c9d22 100644 --- a/examples/nextjs-comments-ai/README.md +++ b/examples/nextjs-comments-ai/README.md @@ -10,7 +10,7 @@ # Comments with AI replies

- + Live Preview diff --git a/examples/nextjs-comments-audio/README.md b/examples/nextjs-comments-audio/README.md index fc52055f184..fabe594fc62 100644 --- a/examples/nextjs-comments-audio/README.md +++ b/examples/nextjs-comments-audio/README.md @@ -10,7 +10,7 @@ # Audio Comments

- + Live Preview diff --git a/examples/nextjs-comments-canvas/README.md b/examples/nextjs-comments-canvas/README.md index 4c33b750017..b9d945b73f3 100644 --- a/examples/nextjs-comments-canvas/README.md +++ b/examples/nextjs-comments-canvas/README.md @@ -10,7 +10,7 @@ # Canvas Comments

- + Live Preview diff --git a/examples/nextjs-comments-emails-resend/README.md b/examples/nextjs-comments-emails-resend/README.md index a132b4625dc..d52f9c06f38 100644 --- a/examples/nextjs-comments-emails-resend/README.md +++ b/examples/nextjs-comments-emails-resend/README.md @@ -10,7 +10,7 @@ # Comments Notification Emails (Resend)

- + Live Preview diff --git a/examples/nextjs-comments-emails-sendgrid/README.md b/examples/nextjs-comments-emails-sendgrid/README.md index b546848db2b..adfbe95c6c3 100644 --- a/examples/nextjs-comments-emails-sendgrid/README.md +++ b/examples/nextjs-comments-emails-sendgrid/README.md @@ -10,7 +10,7 @@ # Comments Notification Emails (SendGrid)

- + Live Preview diff --git a/examples/nextjs-comments-notifications/README.md b/examples/nextjs-comments-notifications/README.md index 9436433bea5..936428181f7 100644 --- a/examples/nextjs-comments-notifications/README.md +++ b/examples/nextjs-comments-notifications/README.md @@ -10,7 +10,7 @@ # Comments Notifications

- + Live Preview diff --git a/examples/nextjs-comments-overlay/README.md b/examples/nextjs-comments-overlay/README.md index 3d35034143a..8ed39e2689e 100644 --- a/examples/nextjs-comments-overlay/README.md +++ b/examples/nextjs-comments-overlay/README.md @@ -10,7 +10,7 @@ # Overlay Comments

- + Live Preview diff --git a/examples/nextjs-comments-primitives/README.md b/examples/nextjs-comments-primitives/README.md index b8627037b69..b851ecd8760 100644 --- a/examples/nextjs-comments-primitives/README.md +++ b/examples/nextjs-comments-primitives/README.md @@ -10,7 +10,7 @@ # Comments Primitives

- + Live Preview diff --git a/examples/nextjs-comments-private/.env.example b/examples/nextjs-comments-private/.env.example new file mode 100644 index 00000000000..9c176850c85 --- /dev/null +++ b/examples/nextjs-comments-private/.env.example @@ -0,0 +1,2 @@ +# https://liveblocks.io/dashboard/apikeys +LIVEBLOCKS_SECRET_KEY= diff --git a/examples/nextjs-comments-private/.gitignore b/examples/nextjs-comments-private/.gitignore new file mode 100644 index 00000000000..3a68e0cfc9d --- /dev/null +++ b/examples/nextjs-comments-private/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +node_modules +.env +.env.* +!.env.example +*.tsbuildinfo +.vercel +.next +out +next-env.d.ts +# Turborepo +.turbo diff --git a/examples/nextjs-comments-private/.prettierrc b/examples/nextjs-comments-private/.prettierrc new file mode 100644 index 00000000000..06998724304 --- /dev/null +++ b/examples/nextjs-comments-private/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": true, + "tabWidth": 2, + "useTabs": false, + "singleQuote": false, + "jsxSingleQuote": false, + "arrowParens": "always", + "bracketSpacing": true, + "bracketSameLine": false, + "trailingComma": "es5" +} diff --git a/examples/nextjs-comments-private/README.md b/examples/nextjs-comments-private/README.md new file mode 100644 index 00000000000..5e424b6e82c --- /dev/null +++ b/examples/nextjs-comments-private/README.md @@ -0,0 +1,87 @@ +

+ + Liveblocks + + + Liveblocks + +

+ +# Private Commenting + +

+ + Live Preview + + + Open in CodeSandbox + + React + Next.js +

+ +This example shows how to add private commenting to your app with +[Liveblocks](https://liveblocks.io) and [Next.js](https://nextjs.org/). + +Private Commenting + +## Getting started + +Run the following command to try this example locally: + +```bash +npx create-liveblocks-app@latest --example nextjs-comments-private --api-key +``` + +This will download the example and ask permission to open your browser, enabling +you to automatically get your API key from your +[liveblocks.io](https://liveblocks.io) account. + +### Manual setup + +
Read more + +

+ +Alternatively, you can set up your project manually: + +- Install all dependencies with `npm install` +- Create an account on [liveblocks.io](https://liveblocks.io/dashboard) +- Copy your **secret** key from the + [dashboard](https://liveblocks.io/dashboard/apikeys) +- Create an `.env.local` file and add your **secret** key as the + `LIVEBLOCKS_SECRET_KEY` environment variable +- Run `npm run dev` and go to [http://localhost:3000](http://localhost:3000) + +
+ +### Deploy on Vercel + +
Read more + +

+ +To both deploy on [Vercel](https://vercel.com), and run the example locally, use +the following command: + +```bash +npx create-liveblocks-app@latest --example nextjs-comments-private --vercel +``` + +This will download the example and ask permission to open your browser, enabling +you to deploy to Vercel. + +
+ +### Develop on CodeSandbox + +
Read more + +

+ +After forking +[this example](https://codesandbox.io/s/github/liveblocks/liveblocks/tree/main/examples/nextjs-comments-private) +on CodeSandbox, create the `LIVEBLOCKS_SECRET_KEY` environment variable as a +[secret](https://codesandbox.io/docs/secrets). + +
diff --git a/examples/nextjs-comments-private/liveblocks.config.ts b/examples/nextjs-comments-private/liveblocks.config.ts new file mode 100644 index 00000000000..7ecf28928f8 --- /dev/null +++ b/examples/nextjs-comments-private/liveblocks.config.ts @@ -0,0 +1,16 @@ +declare global { + interface Liveblocks { + // Custom user info set when authenticating with a secret key + UserMeta: { + id: string; + info: { + // Example properties, for useSelf, useUser, useOthers, etc. + name: string; + avatar: string; + color: string; + }; + }; + } +} + +export {}; diff --git a/examples/nextjs-comments-private/next.config.js b/examples/nextjs-comments-private/next.config.js new file mode 100644 index 00000000000..b49b11cbb39 --- /dev/null +++ b/examples/nextjs-comments-private/next.config.js @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + turbopack: { root: __dirname }, + reactStrictMode: true, +}; + +module.exports = nextConfig; diff --git a/examples/nextjs-comments-private/package-lock.json b/examples/nextjs-comments-private/package-lock.json new file mode 100644 index 00000000000..0f9477606ea --- /dev/null +++ b/examples/nextjs-comments-private/package-lock.json @@ -0,0 +1,3293 @@ +{ + "name": "@liveblocks-examples/nextjs-comments-private", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@liveblocks-examples/nextjs-comments-private", + "license": "Apache-2.0", + "dependencies": { + "@liveblocks/client": "3.21.0-rc1", + "@liveblocks/node": "3.21.0-rc1", + "@liveblocks/react": "3.21.0-rc1", + "@liveblocks/react-ui": "3.21.0-rc1", + "lucide-react": "^1.21.0", + "next": "^16.1.6", + "radix-ui": "^1.4.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.13" + }, + "devDependencies": { + "@types/node": "^20.4.10", + "@types/react": "^18.3.27", + "prettier": "^3.3.3", + "typescript": "^5.4.5" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, + "node_modules/@liveblocks/client": { + "version": "3.21.0-rc1", + "resolved": "https://registry.npmjs.org/@liveblocks/client/-/client-3.21.0-rc1.tgz", + "integrity": "sha512-HPkM4hk3nF5B9DXxYnD9moPoHKXdicE0Q42vjVLx+bhmTfqAzJWDo3oaXO8MxUd402/wsPnQrK7tGMtvwHzKaw==", + "license": "Apache-2.0", + "dependencies": { + "@liveblocks/core": "3.21.0-rc1" + } + }, + "node_modules/@liveblocks/core": { + "version": "3.21.0-rc1", + "resolved": "https://registry.npmjs.org/@liveblocks/core/-/core-3.21.0-rc1.tgz", + "integrity": "sha512-cQLc1h0u2HwW6DWSIoOcw7liMU+OX+4W8tXQ0nuoFjT4FmyrGvUkqR5z4Ffz2DqXSxG+suSi2Ib95xrJMD3/lw==", + "license": "Apache-2.0", + "peerDependencies": { + "@types/json-schema": "^7" + } + }, + "node_modules/@liveblocks/node": { + "version": "3.21.0-rc1", + "resolved": "https://registry.npmjs.org/@liveblocks/node/-/node-3.21.0-rc1.tgz", + "integrity": "sha512-bIyWUMH0ecPrfKQHvJOVY9ty573F+SoN8ojMMiO1nHJNXcj7aivMsX8buQezuYGI467YkR5KpBlCG2QWBkHHcA==", + "license": "Apache-2.0", + "dependencies": { + "@liveblocks/core": "3.21.0-rc1", + "@stablelib/base64": "^1.0.1", + "fast-sha256": "^1.3.0", + "marked": "^15.0.11", + "node-fetch": "^2.6.1" + } + }, + "node_modules/@liveblocks/react": { + "version": "3.21.0-rc1", + "resolved": "https://registry.npmjs.org/@liveblocks/react/-/react-3.21.0-rc1.tgz", + "integrity": "sha512-ei5rpMEAJTzHOy/K04DWuxu5cMeFKDmsvXhEu3XSfLy0B60Vxtu1o41UiS+bOh/dbaeMHvsJUY+cRE6XYCO5Lg==", + "license": "Apache-2.0", + "dependencies": { + "@liveblocks/client": "3.21.0-rc1", + "@liveblocks/core": "3.21.0-rc1" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@liveblocks/react-ui": { + "version": "3.21.0-rc1", + "resolved": "https://registry.npmjs.org/@liveblocks/react-ui/-/react-ui-3.21.0-rc1.tgz", + "integrity": "sha512-s+ZJ0cpCeTMVlzxtQRlakh9X3plwvoIZl32VB3DjC+q6Jx0jCDSAlWv1YURdFyjXHsBLunx7HcRRKH92k3zhlA==", + "license": "Apache-2.0", + "dependencies": { + "@floating-ui/react-dom": "^2.1.0", + "@liveblocks/client": "3.21.0-rc1", + "@liveblocks/core": "3.21.0-rc1", + "@liveblocks/react": "3.21.0-rc1", + "frimousse": "^0.2.0", + "marked": "^15.0.11", + "radix-ui": "^1.4.0", + "slate": "^0.110.2", + "slate-history": "^0.110.3", + "slate-hyperscript": "^0.100.0", + "slate-react": "^0.110.3" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "@types/react-dom": "^18 || ^19", + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@next/env": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.0.tgz", + "integrity": "sha512-OZIbODWWAi0epQRCRjNe1VO45LOFBzgiyqmTLzIqWq6u1wrxKnAyz1HH6tgY/Mc81YzIjRPoYsPAEr4QV4l9TA==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.0.tgz", + "integrity": "sha512-/JZsqKzKt01IFoiLLAzlNqys7qk2F3JkcUhj50zuRhKDQkZNOz9E5N6wAQWprXdsvjRP4lTFj+/+36NSv5AwhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.0.tgz", + "integrity": "sha512-/hV8erWq4SNlVgglUiW5UmQ5Hwy5EW/AbbXlJCn6zkfKxTy/E/U3V8U1Ocm2YCTUoFgQdoMxRyRMOW5jYy4ygg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.0.tgz", + "integrity": "sha512-GkjL/Q7MWOwqWR9zoxu1TIHzkOI2l2BHCf7FzeQG87zPgs+6WDh+oC9Sw9ARuuL/FUk6JNCgKRkA6rEQYadUaw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.0.tgz", + "integrity": "sha512-1ffhC6KY5qWLg5miMlKJp3dZbXelEfjuXt1qcp5WzSCQy36CV3y+JT7OC1WSFKizGQCDOcQbfkH/IjZP3cdRNA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.0.tgz", + "integrity": "sha512-FmbDcZQ8yJRq93EJSL6xaE0KK/Rslraf8fj1uViGxg7K4CKBCRYSubILJPEhjSgZurpcPQq12QNOJQ0DRJl6Hg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.0.tgz", + "integrity": "sha512-HzjIHVkmGAwRbh/vzvoBWWEbb8BBZPxBvVbDQDvzHSf3D8RP/4vjw7MNLDXFF9Q1WEzeQyEj2zdxBtVAHu5Oyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.0.tgz", + "integrity": "sha512-UMiFNQf5H7+1ZsZPxEsA064WEuFbRNq/kEXyepbCnSErp4f5iut75dBA8UeerFIG3vDaQNOfCpevnERPp2V+nA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.0.tgz", + "integrity": "sha512-DRrNJKW+/eimrZgdhVN1uvkN1OI4j6Lpefwr44jKQ0YQzztlmOBUUzHuV5GxOMPK3nmodAYElUVCY8ZXo/IWeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", + "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.17.tgz", + "integrity": "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", + "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", + "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", + "integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-arrow": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz", + "integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", + "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.1.tgz", + "integrity": "sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw==", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", + "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "license": "MIT" + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/direction": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", + "integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==", + "license": "MIT", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/frimousse": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/frimousse/-/frimousse-0.2.0.tgz", + "integrity": "sha512-viSrsVQWKR4Q7xzC0lkx3Wu9i1+IHrth0QXn0nlIIJXpltwUnjkGXSTuoW7WHI5aJ4z49WR8E/pyQizFjlNtTA==", + "license": "MIT", + "workspaces": [ + ".", + "site" + ], + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/is-hotkey": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz", + "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==", + "license": "MIT" + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.0.tgz", + "integrity": "sha512-NLBVrJy1pbV1Yn00L5sU4vFyAHt5XuSjzrNyFnxo6Com0M0KrL6hHM5B99dbqXb2bE9pm4Ow3Zl1xp6HVY9edQ==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.0", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.0", + "@next/swc-darwin-x64": "16.2.0", + "@next/swc-linux-arm64-gnu": "16.2.0", + "@next/swc-linux-arm64-musl": "16.2.0", + "@next/swc-linux-x64-gnu": "16.2.0", + "@next/swc-linux-x64-musl": "16.2.0", + "@next/swc-win32-arm64-msvc": "16.2.0", + "@next/swc-win32-x64-msvc": "16.2.0", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/radix-ui": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.0.tgz", + "integrity": "sha512-EUEC70O03EgxWMP5aoqfBZ6iLC5bczFagGy7zhSYRt8o5DP7IWNiP3ywetse3L9b8843ExB0OGWZvgbYVJuNeg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-accessible-icon": "1.1.10", + "@radix-ui/react-accordion": "1.2.14", + "@radix-ui/react-alert-dialog": "1.1.17", + "@radix-ui/react-arrow": "1.1.10", + "@radix-ui/react-aspect-ratio": "1.1.10", + "@radix-ui/react-avatar": "1.2.0", + "@radix-ui/react-checkbox": "1.3.5", + "@radix-ui/react-collapsible": "1.1.14", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-context-menu": "2.3.1", + "@radix-ui/react-dialog": "1.1.17", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-dropdown-menu": "2.1.18", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-form": "0.1.10", + "@radix-ui/react-hover-card": "1.1.17", + "@radix-ui/react-label": "2.1.10", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-menubar": "1.1.18", + "@radix-ui/react-navigation-menu": "1.2.16", + "@radix-ui/react-one-time-password-field": "0.1.10", + "@radix-ui/react-password-toggle-field": "0.1.5", + "@radix-ui/react-popover": "1.1.17", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-progress": "1.1.10", + "@radix-ui/react-radio-group": "1.4.1", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-scroll-area": "1.2.12", + "@radix-ui/react-select": "2.3.1", + "@radix-ui/react-separator": "1.1.10", + "@radix-ui/react-slider": "1.4.1", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-switch": "1.3.1", + "@radix-ui/react-tabs": "1.1.15", + "@radix-ui/react-toast": "1.2.17", + "@radix-ui/react-toggle": "1.1.12", + "@radix-ui/react-toggle-group": "1.1.13", + "@radix-ui/react-toolbar": "1.1.13", + "@radix-ui/react-tooltip": "1.2.10", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-escape-keydown": "1.1.2", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.10.tgz", + "integrity": "sha512-TraSwZUqTcVbiDV2/RXzAXC7aeVVXchq0daPFZE7zAxYFaMzjOUggLOfQH9KFLgRizuwVKZO/crveV1eeO3/ZQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-accordion": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.14.tgz", + "integrity": "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collapsible": "1.1.14", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.17.tgz", + "integrity": "sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dialog": "1.1.17", + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-arrow": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz", + "integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.10.tgz", + "integrity": "sha512-kbI7NrqhDeuytYrq7JjAsoXczvL8wgj2tc1MyaYWm+50bMKHCHQtVWCryslx4cCpmCTTkBcwQckE4CmmGV2haQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-avatar": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.0.tgz", + "integrity": "sha512-am/CwltXtmtdtP+5FbYblYDnMa/zuKcMJP1i3/SJMDXXfj2mG+BTqLH2wucqeyyiQMursUtg/5cK+Nh2pCaSOA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-checkbox": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.5.tgz", + "integrity": "sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-collapsible": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.14.tgz", + "integrity": "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-collection": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz", + "integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-context-menu": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.1.tgz", + "integrity": "sha512-XbrxS68W5dyiE4fAb96yvJwSVU5x66B20A99sD5Mk3xSWK/LqeOnx6TZnim1KieMjXS/CTFq8reOAjWxas2G8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-dialog": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", + "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", + "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.18.tgz", + "integrity": "sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", + "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-form": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.10.tgz", + "integrity": "sha512-1NfuvctVtX4sU3Mmq/IdrR8UunxiCMiVg3A5UENKhFzxUBeOyaQQ+lmaQaV7Tc8cqvBKsJL3/KGBsixK0D8WFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-label": "2.1.10", + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-hover-card": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.17.tgz", + "integrity": "sha512-GjZQIEANVkuuWeztlKz6QEHe31ZX2iDfHzcTMCQVZXC0JyQrgfKWSC+LOOEw6aVV64zyjzobIzSA4AU4eKWrHA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-label": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.10.tgz", + "integrity": "sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-menu": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.18.tgz", + "integrity": "sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-menubar": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.18.tgz", + "integrity": "sha512-hX7EGx/oFq6DPY27GQuP/2wP48GHf5LG6r06VgNJlG+znmDS8OfopZcRcGly3L4lsB9FqpmLx6JQSE9P3BUpyw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.16.tgz", + "integrity": "sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.10.tgz", + "integrity": "sha512-GHkcJ+WVj91At+OvUVTD4R3W0/wxw9t/sG5xFUBYXaCbtWiooZX5Md376QjJqgH4VsVyXrbVNHO2O4NYcmjfVg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.5.tgz", + "integrity": "sha512-fVuA82u0b/fClpbEJv8yp1nU9eSvoSEOERsU/hhf3FXGPIvkmE7oEaHEu8poowoXO39/Va7zq2E0TUcYr1dBRg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-popper": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", + "integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-portal": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", + "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-progress": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.10.tgz", + "integrity": "sha512-JYzEg60lk79PwKM27WZyKd7PW8O4OM5jOaFfRPfOyeXmMw7tLJh5kSj+CEjVTehszuwml/AdCzPGMXBTGf4BBw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-radio-group": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.1.tgz", + "integrity": "sha512-/SSxZdKEo2Eo29FFRKd06EfFDYp8HryKg0WYg7QLXaydPzl52YfSvCH2a3QDBRdtcuwACroJT8UVjQVgOJ7P9A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz", + "integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.12.tgz", + "integrity": "sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-select": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.1.tgz", + "integrity": "sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.6", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-separator": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.10.tgz", + "integrity": "sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-slider": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.1.tgz", + "integrity": "sha512-r91WSpQucNGFKAIxT8FT0H0zyjd5tJlqObLp7LOMV4z49KoDCwjy01w3vDOU4e1wxhF9IgjYco7SB6byOW7Buw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-tabs": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.15.tgz", + "integrity": "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-toast": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.17.tgz", + "integrity": "sha512-uL4kyyWy000pPL43fGGCV5qT6ZchCWEQZOSlkYiPwPt8Hy1iW38RjeptIvz1/SZesrW6Vn58Ct3sV7tfEfiAbw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-toggle": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.12.tgz", + "integrity": "sha512-AsAVsYNZIlRBsci7BhE+QyQeKd1h6TffJYt+lF0QQkd5OpQ3klfIByPsCb4G0h/Fq6PJwh1FYNluzBFYzhk4+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.13.tgz", + "integrity": "sha512-Xb9PLtlvU66F36LiKba6dFswu6V2mDkgidO4fNSbQHQwmZ9ObxMIO17MN/LJ4aWJecVuSVLAHPZjyeMzJrgeiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-toggle": "1.1.12", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-toolbar": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.13.tgz", + "integrity": "sha512-Za1l4f6fzTkGgz/iynAMN8iaqiKff2wm2/QwiLmHPtDQreWEBrvSimgQFIekxMUdRPhILM7xdIXxuS/o/DGZag==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-separator": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.13" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-tooltip": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.10.tgz", + "integrity": "sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-visually-hidden": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.6.tgz", + "integrity": "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-error-boundary": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.1.2.tgz", + "integrity": "sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/slate": { + "version": "0.110.2", + "resolved": "https://registry.npmjs.org/slate/-/slate-0.110.2.tgz", + "integrity": "sha512-4xGULnyMCiEQ0Ml7JAC1A6HVE6MNpPJU7Eq4cXh1LxlrR0dFXC3XC+rNfQtUJ7chHoPkws57x7DDiWiZAt+PBA==", + "license": "MIT", + "dependencies": { + "immer": "^10.0.3", + "is-plain-object": "^5.0.0", + "tiny-warning": "^1.0.3" + } + }, + "node_modules/slate-history": { + "version": "0.110.3", + "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.110.3.tgz", + "integrity": "sha512-sgdff4Usdflmw5ZUbhDkxFwCBQ2qlDKMMkF93w66KdV48vHOgN2BmLrf+2H8SdX8PYIpP/cTB0w8qWC2GwhDVA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^5.0.0" + }, + "peerDependencies": { + "slate": ">=0.65.3" + } + }, + "node_modules/slate-hyperscript": { + "version": "0.100.0", + "resolved": "https://registry.npmjs.org/slate-hyperscript/-/slate-hyperscript-0.100.0.tgz", + "integrity": "sha512-fb2KdAYg6RkrQGlqaIi4wdqz3oa0S4zKNBJlbnJbNOwa23+9FLD6oPVx9zUGqCSIpy+HIpOeqXrg0Kzwh/Ii4A==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^5.0.0" + }, + "peerDependencies": { + "slate": ">=0.65.3" + } + }, + "node_modules/slate-react": { + "version": "0.110.3", + "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.110.3.tgz", + "integrity": "sha512-AS8PPjwmsFS3Lq0MOEegLVlFoxhyos68G6zz2nW4sh3WeTXV7pX0exnwtY1a/docn+J3LGQO11aZXTenPXA/kg==", + "license": "MIT", + "dependencies": { + "@juggle/resize-observer": "^3.4.0", + "direction": "^1.0.4", + "is-hotkey": "^0.2.0", + "is-plain-object": "^5.0.0", + "lodash": "^4.17.21", + "scroll-into-view-if-needed": "^3.1.0", + "tiny-invariant": "1.3.1" + }, + "peerDependencies": { + "react": ">=18.2.0", + "react-dom": ">=18.2.0", + "slate": ">=0.99.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", + "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/examples/nextjs-comments-private/package.json b/examples/nextjs-comments-private/package.json new file mode 100644 index 00000000000..9e2e0099f7d --- /dev/null +++ b/examples/nextjs-comments-private/package.json @@ -0,0 +1,29 @@ +{ + "name": "@liveblocks-examples/nextjs-comments-private", + "description": "This example shows how to add private commenting to your app with Liveblocks and Next.js.", + "license": "Apache-2.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@liveblocks/client": "3.21.0-rc1", + "@liveblocks/node": "3.21.0-rc1", + "@liveblocks/react": "3.21.0-rc1", + "@liveblocks/react-ui": "3.21.0-rc1", + "lucide-react": "^1.21.0", + "next": "^16.1.6", + "radix-ui": "^1.4.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.13" + }, + "devDependencies": { + "@types/node": "^20.4.10", + "@types/react": "^18.3.27", + "prettier": "^3.3.3", + "typescript": "^5.4.5" + } +} diff --git a/examples/nextjs-comments-private/src/app/Providers.tsx b/examples/nextjs-comments-private/src/app/Providers.tsx new file mode 100644 index 00000000000..38802f12572 --- /dev/null +++ b/examples/nextjs-comments-private/src/app/Providers.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { LiveblocksProvider } from "@liveblocks/react"; +import { PropsWithChildren, Suspense } from "react"; +import { getRandomUser, getUser } from "@/database"; +import { + getUserType, + USER_ID_SEARCH_PARAM, + USER_SEARCH_PARAM, + type UserType, +} from "@/user"; + +const USER_ID_STORAGE_KEY = "liveblocks:examples:nextjs-comments-private:user"; + +async function authEndpoint(room?: string) { + const searchParams = new URLSearchParams(); + + if (typeof window !== "undefined") { + const userType = getUserType(new URLSearchParams(window.location.search)); + searchParams.set(USER_SEARCH_PARAM, userType); + searchParams.set(USER_ID_SEARCH_PARAM, getCurrentUserId(userType)); + } + + const queryString = searchParams.toString(); + const response = await fetch( + `/api/liveblocks-auth${queryString ? `?${queryString}` : ""}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ room }), + } + ); + + if (!response.ok) { + throw new Error("Problem authenticating"); + } + + return await response.json(); +} + +function getCurrentUserId(userType: UserType) { + const storageKey = `${USER_ID_STORAGE_KEY}:${userType}`; + const storedUserId = window.localStorage.getItem(storageKey); + const storedUser = storedUserId ? getUser(storedUserId) : null; + + if (storedUser?.type === userType) { + return storedUser.id; + } + + const user = getRandomUser(userType); + window.localStorage.setItem(storageKey, user.id); + return user.id; +} + +export function Providers({ children }: PropsWithChildren) { + return ( + { + const searchParams = new URLSearchParams( + userIds.map((userId) => ["userIds", userId]) + ); + const response = await fetch(`/api/users?${searchParams}`); + + if (!response.ok) { + throw new Error("Problem resolving users"); + } + + const users = await response.json(); + return users; + }} + // Find a list of users that match the current search term + resolveMentionSuggestions={async ({ text }) => { + const response = await fetch( + `/api/users/search?text=${encodeURIComponent(text)}` + ); + + if (!response.ok) { + throw new Error("Problem resolving mention suggestions"); + } + + const userIds = await response.json(); + return userIds; + }} + > + {children} + + ); +} diff --git a/examples/nextjs-comments-private/src/app/api/liveblocks-auth/route.ts b/examples/nextjs-comments-private/src/app/api/liveblocks-auth/route.ts new file mode 100644 index 00000000000..3ea248affe9 --- /dev/null +++ b/examples/nextjs-comments-private/src/app/api/liveblocks-auth/route.ts @@ -0,0 +1,47 @@ +import { Liveblocks } from "@liveblocks/node"; +import { getRandomUser, getUser } from "@/database"; +import { NextRequest, NextResponse } from "next/server"; +import { EXTERNAL_USER_TYPE, getUserType, USER_ID_SEARCH_PARAM } from "@/user"; + +/** + * Authenticating your Liveblocks application + * https://liveblocks.io/docs/authentication + */ + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, +}); + +export async function POST(request: NextRequest) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + const userType = getUserType(request.nextUrl.searchParams); + + // Get the current user's unique id and info from your database + const requestedUserId = + request.nextUrl.searchParams.get(USER_ID_SEARCH_PARAM); + const requestedUser = requestedUserId ? getUser(requestedUserId) : null; + const user = + requestedUser?.type === userType ? requestedUser : getRandomUser(userType); + + // Create a session for the current user (access token auth) + // userInfo is made available in Liveblocks user hooks, e.g. useSelf + const session = liveblocks.prepareSession(user.id, { + userInfo: user.info, + }); + + // Use a naming pattern to allow access to rooms with a wildcard + session.allow( + `liveblocks:examples:*`, + // External users don't have access to private comments + userType === EXTERNAL_USER_TYPE + ? ["*:write", "comments:private:none"] + : ["*:write"] + ); + + // Authorize the user and return the result + const { status, body } = await session.authorize(); + return new NextResponse(body, { status }); +} diff --git a/examples/nextjs-comments-private/src/app/api/users/route.ts b/examples/nextjs-comments-private/src/app/api/users/route.ts new file mode 100644 index 00000000000..77c2fd40e7f --- /dev/null +++ b/examples/nextjs-comments-private/src/app/api/users/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUser } from "@/database"; + +/** + * Get users' info from their ID + * For `resolveUsers` in liveblocks.config.ts + */ + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const userIds = searchParams.getAll("userIds"); + + if (!userIds || !Array.isArray(userIds)) { + return new NextResponse("Missing or invalid userIds", { status: 400 }); + } + + return NextResponse.json( + userIds.map((userId) => getUser(userId)?.info || null) + ); +} diff --git a/examples/nextjs-comments-private/src/app/api/users/search/route.ts b/examples/nextjs-comments-private/src/app/api/users/search/route.ts new file mode 100644 index 00000000000..07bfc3ba726 --- /dev/null +++ b/examples/nextjs-comments-private/src/app/api/users/search/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUsers } from "@/database"; + +/** + * Returns a list of user IDs from a partial search input + * For `resolveMentionSuggestions` in liveblocks.config.ts + */ + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const text = searchParams.get("text"); + + const filteredUserIds = getUsers() + .filter((user) => + text ? user.info.name.toLowerCase().includes(text.toLowerCase()) : true + ) + .map((user) => user.id); + + return NextResponse.json(filteredUserIds); +} diff --git a/examples/nextjs-comments-private/src/app/layout.tsx b/examples/nextjs-comments-private/src/app/layout.tsx new file mode 100644 index 00000000000..2919d34ea87 --- /dev/null +++ b/examples/nextjs-comments-private/src/app/layout.tsx @@ -0,0 +1,33 @@ +import "../styles/globals.css"; +import { Providers } from "./Providers"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + Liveblocks + + + + + + + {children} + + + ); +} diff --git a/examples/nextjs-comments-private/src/app/page.tsx b/examples/nextjs-comments-private/src/app/page.tsx new file mode 100644 index 00000000000..031c1424c5b --- /dev/null +++ b/examples/nextjs-comments-private/src/app/page.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { EyeOffIcon } from "lucide-react"; +import { Switch } from "radix-ui"; +import { + RoomProvider, + useSelf, + useThreads, + useUser, +} from "@liveblocks/react/suspense"; +import { Loading } from "../components/Loading"; +import { Composer, Thread } from "@liveblocks/react-ui"; +import { ClientSideSuspense } from "@liveblocks/react"; +import { ErrorBoundary } from "react-error-boundary"; +import { + EXTERNAL_USER_TYPE, + getUserType, + INTERNAL_USER_TYPE, + USER_SEARCH_PARAM, + type UserType, +} from "@/user"; + +function Example({ + userType, + onUserTypeChange, +}: { + userType: UserType; + onUserTypeChange: (userType: UserType) => void; +}) { + const isInternalUser = userType === INTERNAL_USER_TYPE; + const { threads } = useThreads(); + const [isPrivateThread, setPrivateThread] = useState(false); + const isComposerPrivate = isInternalUser && isPrivateThread; + + return ( + <> +
+ + + {threads.map((thread) => { + return ( +
+ {thread.visibility === "private" ? ( +
+ + Not visible to external users +
+ ) : null} + +
+ ); + })} + +
+ + {isInternalUser ? ( +
+ + + + + + It won't be visible to external users + +
+ ) : null} +
+
+ + ); +} + +function UserCard({ + userType, + onUserTypeChange, +}: { + userType: UserType; + onUserTypeChange: (userType: UserType) => void; +}) { + const userId = useSelf((me) => me.id); + const { user } = useUser(userId); + const isInternalUser = userType === INTERNAL_USER_TYPE; + + return ( +
+
+ +
+
{user.name}
+
+ {isInternalUser ? "Internal user" : "External user"} +
+
+
+ +
+ ); +} + +export default function Page() { + const params = useSearchParams(); + const userType = getUserType(params); + const roomId = useExampleRoomId( + "liveblocks:examples:nextjs-comments-private" + ); + const setUserType = useCallback( + (nextUserType: UserType) => { + if (nextUserType === userType) { + return; + } + + const url = new URL(window.location.href); + url.searchParams.set(USER_SEARCH_PARAM, nextUserType); + window.location.assign(url.toString()); + }, + [userType] + ); + + return ( + + There was an error while getting threads. + } + > + }> + + + + + ); +} + +/** + * This function is used when deploying an example on liveblocks.io. + * You can ignore it completely if you run the example locally. + */ +function useExampleRoomId(roomId: string) { + const params = useSearchParams(); + const exampleId = params?.get("exampleId"); + + const exampleRoomId = useMemo(() => { + return exampleId ? `${roomId}-${exampleId}` : roomId; + }, [roomId, exampleId]); + + return exampleRoomId; +} diff --git a/examples/nextjs-comments-private/src/components/Loading.tsx b/examples/nextjs-comments-private/src/components/Loading.tsx new file mode 100644 index 00000000000..1d604ac02d5 --- /dev/null +++ b/examples/nextjs-comments-private/src/components/Loading.tsx @@ -0,0 +1,7 @@ +export function Loading() { + return ( +
+ Loading +
+ ); +} diff --git a/examples/nextjs-comments-private/src/database.ts b/examples/nextjs-comments-private/src/database.ts new file mode 100644 index 00000000000..349c2f2eb0b --- /dev/null +++ b/examples/nextjs-comments-private/src/database.ts @@ -0,0 +1,93 @@ +import { EXTERNAL_USER_TYPE, INTERNAL_USER_TYPE, type UserType } from "@/user"; + +type ExampleUser = Liveblocks["UserMeta"] & { + type: UserType; +}; + +const USER_INFO: ExampleUser[] = [ + { + id: "charlie.layne@example.com", + type: INTERNAL_USER_TYPE, + info: { + name: "Charlie Layne", + color: "#D583F0", + avatar: "https://liveblocks.io/avatars/avatar-1.png", + }, + }, + { + id: "mislav.abha@example.com", + type: INTERNAL_USER_TYPE, + info: { + name: "Mislav Abha", + color: "#F08385", + avatar: "https://liveblocks.io/avatars/avatar-2.png", + }, + }, + { + id: "tatum.paolo@example.com", + type: INTERNAL_USER_TYPE, + info: { + name: "Tatum Paolo", + color: "#F0D885", + avatar: "https://liveblocks.io/avatars/avatar-3.png", + }, + }, + { + id: "anjali.wanda@example.com", + type: INTERNAL_USER_TYPE, + info: { + name: "Anjali Wanda", + color: "#85EED6", + avatar: "https://liveblocks.io/avatars/avatar-4.png", + }, + }, + { + id: "jody.hekla@example.com", + type: EXTERNAL_USER_TYPE, + info: { + name: "Jody Hekla", + color: "#85BBF0", + avatar: "https://liveblocks.io/avatars/avatar-5.png", + }, + }, + { + id: "emil.joyce@example.com", + type: EXTERNAL_USER_TYPE, + info: { + name: "Emil Joyce", + color: "#8594F0", + avatar: "https://liveblocks.io/avatars/avatar-6.png", + }, + }, + { + id: "jory.quispe@example.com", + type: EXTERNAL_USER_TYPE, + info: { + name: "Jory Quispe", + color: "#85DBF0", + avatar: "https://liveblocks.io/avatars/avatar-7.png", + }, + }, + { + id: "quinn.elton@example.com", + type: EXTERNAL_USER_TYPE, + info: { + name: "Quinn Elton", + color: "#87EE85", + avatar: "https://liveblocks.io/avatars/avatar-8.png", + }, + }, +]; + +export function getRandomUser(type: UserType) { + const users = USER_INFO.filter((user) => user.type === type); + return users[Math.floor(Math.random() * users.length)]; +} + +export function getUser(id: string) { + return USER_INFO.find((u) => u.id === id) || null; +} + +export function getUsers() { + return USER_INFO; +} diff --git a/examples/nextjs-comments-private/src/styles/globals.css b/examples/nextjs-comments-private/src/styles/globals.css new file mode 100644 index 00000000000..0b795d9f626 --- /dev/null +++ b/examples/nextjs-comments-private/src/styles/globals.css @@ -0,0 +1,264 @@ +@import "@liveblocks/react-ui/styles.css"; +@import "@liveblocks/react-ui/styles/dark/media-query.css"; + +html, +body { + background: #f3f3f3; + padding: 0; + margin: 0; + font-family: + -apple-system, + BlinkMacSystemFont, + Segoe UI, + Roboto, + Oxygen, + Ubuntu, + Cantarell, + Fira Sans, + Droid Sans, + Helvetica Neue, + sans-serif; +} + +* { + box-sizing: border-box; +} + +.lb-root { + --lb-accent: #44f; +} + +main { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 4rem 1rem; + margin: 0 auto; + max-width: 680px; +} + +.loading, +.error { + position: absolute; + width: 100vw; + height: 100vh; + display: flex; + place-content: center; + place-items: center; +} + +.loading img { + width: 64px; + height: 64px; + opacity: 0.2; +} + +.thread, +.composer, +.user-card { + position: relative; + border-radius: 0.75rem; + overflow: hidden; + background: var(--lb-background); + box-shadow: + 0 0 0 1px rgb(0 0 0 / 4%), + 0 2px 6px rgb(0 0 0 / 4%), + 0 8px 26px rgb(0 0 0 / 6%); + transition: background var(--lb-transition-duration) + var(--lb-transition-easing); +} + +.thread[data-visibility="private"], +.composer[data-visibility="private"] { + --lb-accent: #ffba00; + --lb-background: #fff8ea; + + background: var(--lb-background); +} + +.user-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.875rem; +} + +.user-card-profile { + display: flex; + align-items: center; + min-width: 0; + gap: 0.75rem; +} + +.user-card-avatar { + flex: none; + width: 28px; + height: 28px; + border-radius: var(--lb-avatar-radius); +} + +.user-card-text { + min-width: 0; +} + +.user-card-name { + overflow: hidden; + font-size: 0.8125rem; + color: var(--lb-foreground); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-card-access { + color: var(--lb-foreground-moderate); + font-size: 0.8125rem; +} + +.user-card-button { + flex: none; + margin-left: auto; + padding: 0.375rem 0.625rem; + border: 0; + border-radius: var(--lb-button-radius); + background: var(--lb-accent); + color: var(--lb-accent-foreground); + font: inherit; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; +} + +.thread-visibility, +.composer-visibility { + display: flex; + align-items: center; + gap: 0.625rem; + font-size: 0.875rem; +} + +.thread-visibility { + padding: 0.625rem 0.875rem; + border-bottom: 1px solid var(--lb-foreground-subtle); + color: var(--lb-foreground-tertiary); +} + +.thread-visibility-icon { + flex: none; + width: 16px; + height: 16px; + color: var(--lb-foreground-moderate); +} + +.composer-visibility { + justify-content: flex-start; + padding: 0.75rem 0.875rem; + border-top: 1px solid var(--lb-foreground-subtle); + color: var(--lb-foreground-secondary); + user-select: none; +} + +.composer-visibility label, +.switch { + cursor: pointer; +} + +.composer-visibility-label { + display: flex; + align-items: baseline; + flex: none; + line-height: 1.25rem; + white-space: nowrap; +} + +.composer-visibility-label-title { + font-weight: 500; + color: var(--lb-foreground-tertiary); +} + +.composer-visibility-description { + margin-left: auto; + min-width: 0; + overflow: hidden; + color: var(--lb-foreground-moderate); + line-height: 1.25rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.switch { + display: flex; + align-items: center; + width: 2.25rem; + height: 1.25rem; + padding: 0.125rem; + border: 0; + border-radius: 999px; + background: var(--lb-foreground-subtle); +} + +.composer-visibility-switch { + flex: none; +} + +.switch[data-state="checked"] { + background: var(--lb-accent); +} + +.switch[data-disabled] { + cursor: not-allowed; + opacity: 0.55; +} + +.switch-thumb { + display: block; + width: 1rem; + height: 1rem; + border-radius: 999px; + background: var(--lb-accent-foreground); + transition: transform var(--lb-transition-duration) + var(--lb-transition-easing); +} + +.switch-thumb[data-state="checked"] { + transform: translateX(1rem); +} + +@media (prefers-color-scheme: dark) { + html, + body { + background: #111; + } + + .lb-root { + --lb-accent: #77f; + } + + .loading img { + filter: invert(1); + } + + .error { + color: #fff; + } + + .thread[data-visibility="private"], + .composer[data-visibility="private"] { + --lb-accent: #ffba00; + --lb-background: #2a2316; + background: var(--lb-background); + } + + .thread::after, + .composer::after, + .user-card::after { + content: ""; + position: absolute; + width: 100%; + height: 100%; + inset: 0; + border-radius: inherit; + pointer-events: none; + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 6%); + } +} diff --git a/examples/nextjs-comments-private/src/user.ts b/examples/nextjs-comments-private/src/user.ts new file mode 100644 index 00000000000..e8cb66ca96a --- /dev/null +++ b/examples/nextjs-comments-private/src/user.ts @@ -0,0 +1,19 @@ +export const USER_SEARCH_PARAM = "userType"; +export const USER_ID_SEARCH_PARAM = "userId"; + +export const INTERNAL_USER_TYPE = "internal"; +export const EXTERNAL_USER_TYPE = "external"; + +export type UserType = typeof INTERNAL_USER_TYPE | typeof EXTERNAL_USER_TYPE; + +type SearchParams = { + get(name: string): string | null; +}; + +export function getUserType( + searchParams: SearchParams | null | undefined +): UserType { + return searchParams?.get(USER_SEARCH_PARAM) === EXTERNAL_USER_TYPE + ? EXTERNAL_USER_TYPE + : INTERNAL_USER_TYPE; +} diff --git a/examples/nextjs-comments-private/tsconfig.json b/examples/nextjs-comments-private/tsconfig.json new file mode 100644 index 00000000000..d9fed622cc5 --- /dev/null +++ b/examples/nextjs-comments-private/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "baseUrl": ".", + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + "**/*.ts", + "**/*.tsx", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/examples/nextjs-comments-private/vercel.json b/examples/nextjs-comments-private/vercel.json new file mode 100644 index 00000000000..5d7edc91130 --- /dev/null +++ b/examples/nextjs-comments-private/vercel.json @@ -0,0 +1,4 @@ +{ + "installCommand": "npm install", + "buildCommand": "npm run build" +} diff --git a/examples/nextjs-comments-search/README.md b/examples/nextjs-comments-search/README.md index 919e36ba8ee..663a241e473 100644 --- a/examples/nextjs-comments-search/README.md +++ b/examples/nextjs-comments-search/README.md @@ -10,7 +10,7 @@ # Comments search

- + Live Preview diff --git a/examples/nextjs-comments-tiptap/README.md b/examples/nextjs-comments-tiptap/README.md index 876dc3c8120..fc76f478106 100644 --- a/examples/nextjs-comments-tiptap/README.md +++ b/examples/nextjs-comments-tiptap/README.md @@ -10,7 +10,7 @@ # Text Editor Comments (Tiptap)

- + Live Preview diff --git a/examples/nextjs-comments-video/README.md b/examples/nextjs-comments-video/README.md index 18c11ca5927..c1bfcd4bd6e 100644 --- a/examples/nextjs-comments-video/README.md +++ b/examples/nextjs-comments-video/README.md @@ -10,7 +10,7 @@ # Video Comments

- + Live Preview diff --git a/examples/nextjs-lexical-emails-resend/README.md b/examples/nextjs-lexical-emails-resend/README.md index 9548559464e..f52a898693b 100644 --- a/examples/nextjs-lexical-emails-resend/README.md +++ b/examples/nextjs-lexical-emails-resend/README.md @@ -10,7 +10,7 @@ # Lexical Notification Emails (Resend)

- + Live Preview diff --git a/examples/nextjs-lexical/README.md b/examples/nextjs-lexical/README.md index f8c882ecc79..e40d03c24c5 100644 --- a/examples/nextjs-lexical/README.md +++ b/examples/nextjs-lexical/README.md @@ -10,7 +10,7 @@ # Collaborative Rich Text Editor (Lexical)

- + Live Preview diff --git a/examples/nextjs-multiplayer-handsontable/README.md b/examples/nextjs-multiplayer-handsontable/README.md index 270965e531f..7a8adc280e9 100644 --- a/examples/nextjs-multiplayer-handsontable/README.md +++ b/examples/nextjs-multiplayer-handsontable/README.md @@ -10,7 +10,7 @@ # Multiplayer Handsontable

- + Live Preview diff --git a/examples/nextjs-notifications-custom/README.md b/examples/nextjs-notifications-custom/README.md index 3fcddd205e9..c578a3f53ee 100644 --- a/examples/nextjs-notifications-custom/README.md +++ b/examples/nextjs-notifications-custom/README.md @@ -10,7 +10,7 @@ # Custom Notifications

- + Live Preview diff --git a/examples/nextjs-react-flow-ai/README.md b/examples/nextjs-react-flow-ai/README.md index 5324e6f9803..55443df35d3 100644 --- a/examples/nextjs-react-flow-ai/README.md +++ b/examples/nextjs-react-flow-ai/README.md @@ -10,7 +10,7 @@ # Collaborative React Flow with AI

- + Live Preview diff --git a/examples/nextjs-tiptap-ai/README.md b/examples/nextjs-tiptap-ai/README.md index a1cdc62013c..1915856152a 100644 --- a/examples/nextjs-tiptap-ai/README.md +++ b/examples/nextjs-tiptap-ai/README.md @@ -10,9 +10,6 @@ # Collaborative Rich Text Editor with AI (Tiptap)

- - Live Preview - Open in CodeSandbox diff --git a/examples/nextjs-tiptap-emails-resend/README.md b/examples/nextjs-tiptap-emails-resend/README.md index fe56549a5ae..909edf5f913 100644 --- a/examples/nextjs-tiptap-emails-resend/README.md +++ b/examples/nextjs-tiptap-emails-resend/README.md @@ -10,7 +10,7 @@ # TipTap Notifications Emails (Resend)

- + Live Preview diff --git a/examples/nextjs-yjs-blocknote-advanced/README.md b/examples/nextjs-yjs-blocknote-advanced/README.md index 4b73d148d74..1adb8120496 100644 --- a/examples/nextjs-yjs-blocknote-advanced/README.md +++ b/examples/nextjs-yjs-blocknote-advanced/README.md @@ -10,9 +10,6 @@ # Advanced Collaborative Rich Text Editor (BlockNote)

- - Live Preview - Open in CodeSandbox diff --git a/examples/nextjs-yjs-blocknote/README.md b/examples/nextjs-yjs-blocknote/README.md index 9d9edc55acd..a066bf6e3a1 100644 --- a/examples/nextjs-yjs-blocknote/README.md +++ b/examples/nextjs-yjs-blocknote/README.md @@ -10,9 +10,6 @@ # Collaborative Rich Text Editor (BlockNote)

- - Live Preview - Open in CodeSandbox diff --git a/examples/nextjs-yjs-lexical/README.md b/examples/nextjs-yjs-lexical/README.md index ca79a3d7bb3..ceabcde6895 100644 --- a/examples/nextjs-yjs-lexical/README.md +++ b/examples/nextjs-yjs-lexical/README.md @@ -10,9 +10,6 @@ # Collaborative Rich Text Editor (Lexical)

- - Live Preview - Open in CodeSandbox diff --git a/examples/nextjs-yjs-monaco/README.md b/examples/nextjs-yjs-monaco/README.md index 20ef9defcd0..7e8cc935aa3 100644 --- a/examples/nextjs-yjs-monaco/README.md +++ b/examples/nextjs-yjs-monaco/README.md @@ -10,7 +10,7 @@ # Collaborative Code Editor (Monaco)

- + Live Preview diff --git a/examples/nextjs-yjs-tiptap/README.md b/examples/nextjs-yjs-tiptap/README.md index 0eaccfd1022..7c089abc95f 100644 --- a/examples/nextjs-yjs-tiptap/README.md +++ b/examples/nextjs-yjs-tiptap/README.md @@ -10,9 +10,6 @@ # Collaborative Rich Text Editor (Tiptap)

- - Live Preview - Open in CodeSandbox diff --git a/guides/guides.json b/guides/guides.json index c7580046415..fa37bb26011 100644 --- a/guides/guides.json +++ b/guides/guides.json @@ -511,5 +511,19 @@ "topics": ["presence", "tutorials", "data-fetching"], "technologies": ["react-ui", "react"], "date": "2026-03-04" + }, + { + "title": "How to use public and private threads", + "path": "/how-to-use-public-and-private-threads", + "topics": ["comments", "authentication"], + "technologies": ["react", "nodejs"], + "date": "2026-06-23" + }, + { + "title": "How to add private commenting to your app", + "path": "/how-to-add-private-commenting-to-your-app", + "topics": ["comments", "authentication"], + "technologies": ["react", "nextjs"], + "date": "2026-06-24" } ] diff --git a/guides/pages/how-to-add-private-commenting-to-your-app.mdx b/guides/pages/how-to-add-private-commenting-to-your-app.mdx new file mode 100644 index 00000000000..d97e95b3783 --- /dev/null +++ b/guides/pages/how-to-add-private-commenting-to-your-app.mdx @@ -0,0 +1,195 @@ +--- +meta: + title: "How to add private commenting to your app" + description: + "Learn how to add private comments for admins and team members, alongside + your normal public comments." +--- + +Some apps have comments that only certain people should see—internal notes, +moderation discussions, or team-only annotations. With Liveblocks Comments you +can add these alongside your normal public comments, for example with a separate +“Leave note” button next to the regular comment button. + + + +Private threads are only available on Team and Enterprise plans. + + + +## What we’re building + +This guide shows how to give two different thread permissions to two different +groups of users, **admins** and **regular users**. Admins will be able to view +both public and private threads, whereas regular users will only be able to view +public threads. + + + +This guide focuses on building a private commenting UI. For the complete +visibility and permissions reference, see +[How to use public and private threads](/docs/guides/how-to-use-public-and-private-threads) +and [Permissions](/docs/authentication/permissions). + + + +## How private threads work + +A thread’s _visibility_ is set when it’s created, and every comment inside it +shares the same visibility. Thread visibility is public by default, but a +private thread can be created by passing the `visibility="private"` option. +Liveblocks then only delivers private threads to users who have permission to +read them. + +There are three pieces to building this: + +1. [Authenticate users](/docs/authentication), and give admins and regular users + different room permissions. +2. Create private threads with + [`Composer`](/docs/api-reference/liveblocks-react-ui#Creating-private-threads) + or [`useCreateThread`](/docs/api-reference/liveblocks-react#useCreateThread). +3. Render threads differently depending on their `visibility` property. + +## Authenticate users + +Before we get started, we need to decide on +[permissions](/docs/authentication/permissions) for each group. Admin users will +have full access to all threads, whereas regular users will only be able to see +public threads. + + + +| Group | Permissions | Description | +| ----------- | -------------------------------------- | -------------------------------------- | +| `"admin"` | `["*:write"]` | Full access, including all threads. | +| `"regular"` | `["*:write", "comments:private:none"]` | Full access, but hide private threads. | + +
+ +When creating a room, you can set these permissions for each group under +`groupAccesses`. + +```ts +const room = await liveblocks.createRoom("my-room-id", { + groupAccesses: { + // +++ + admin: ["*:write"], + regular: ["*:write", "comments:private:none"], + //+++ + }, +}); +``` + +To assign users to these groups, use `groupIds` in +[identifyUser](/docs/api-reference/liveblocks-node#id-tokens), inside your +authentication endpoint. + +```ts +// `marc` is an admin user +const { body, status } = await liveblocks.identifyUser({ + userId: "marc", + // +++ + groupIds: ["admin"], + // +++ +}); + +// `olivier` is a regular user +const { body, status } = await liveblocks.identifyUser({ + userId: "olivier", + // +++ + groupIds: ["regular"], + // +++ +}); +``` + +Permissions and authentication are now set up! + + + +Liveblocks recommends [ID token authentication](/docs/authentication) by +default, which we’ve used in these code snippets. If you’re using +[access tokens](/docs/authentication/access-token), you must set permissions in +[`prepareSession`](/docs/api-reference/liveblocks-node#access-tokens) instead of +on the room. + + + +## Create a private thread + +To create a private thread, pass the `visibility="private"` option to the +[`Composer`](/docs/api-reference/liveblocks-react-ui#Composer). In your app, you +may wish to hide this behind a button that only admins can see. + +```tsx +import { Composer } from "@liveblocks/react-ui"; + +function PrivateCommentComposer() { + return ( + // +++ + + // +++ + ); +} +``` + +For more complex use cases, you can use the +[`useCreateThread`](/docs/api-reference/liveblocks-react#useCreateThread) hook +to create a private thread. + +## Render threads in your app + +To render threads in your app, use +[`useThreads`](/docs/api-reference/liveblocks-react#useThreads) as usual, and +each will only see the threads they are allowed to read. Check for the +`visibility` property to render different UI for public and private threads. + +```tsx title="Public threads only" +import { Thread } from "@liveblocks/react-ui"; +import { useThreads } from "@liveblocks/react/suspense"; + +function ThreadList() { + // +++ + const { threads } = useThreads(); + // +++ + + return ( + <> + {threads.map((thread) => ( +

+ // +++ + {thread.visibility === "private" && ( +
Private, only your team can see this
+ )} + // +++ + +
+ ))} + + ); +} +``` + +### Filtering private threads + +If you’d like for admin users to _only_ see public or private threads, you can +use the `query` option to filter threads by visibility. + +```tsx title="Public threads only" +const { threads } = useThreads({ + query: { + // +++ + visibility: "private", + // +++ + }, +}); +``` + +## Next steps + +You now have public and private comments living side by side in the same room. +Here’s where to learn more: + +- [How to use public and private threads](/docs/guides/how-to-use-public-and-private-threads) +- [Permissions](/docs/authentication/permissions) +- [Comments API reference](/docs/api-reference/liveblocks-react#Comments) +- [Comments component reference](/docs/api-reference/liveblocks-react-ui#Comments) diff --git a/guides/pages/how-to-filter-threads-using-query-language.mdx b/guides/pages/how-to-filter-threads-using-query-language.mdx index c33cdd0fd2a..b7554eaa238 100644 --- a/guides/pages/how-to-filter-threads-using-query-language.mdx +++ b/guides/pages/how-to-filter-threads-using-query-language.mdx @@ -17,9 +17,9 @@ to have filtering that works the same as with ## Query language -You can filter threads by their metadata, allowing you to select for certain -properties, values, or even for string prefixes. Filters can be combined using -`AND` logic. +You can filter threads by their metadata, resolved status, or visibility, +allowing you to select for certain properties, values, or even for string +prefixes. Filters can be combined using `AND` logic. ```js // Resolved threads @@ -40,6 +40,9 @@ metadata['pinned']:false // Threads without a `color` property metadata['color']:null +// Private threads +visibility:'private' + // Combine queries with AND resolved:true AND metadata['priority']:3 diff --git a/guides/pages/how-to-use-public-and-private-threads.mdx b/guides/pages/how-to-use-public-and-private-threads.mdx new file mode 100644 index 00000000000..f9ea5afd576 --- /dev/null +++ b/guides/pages/how-to-use-public-and-private-threads.mdx @@ -0,0 +1,304 @@ +--- +meta: + title: "How to use public and private threads" + description: + "Learn how to create public and private threads, configure permissions, and + show filtered comment views." +--- + +Public and private threads let you separate the threads users can see in the +same Liveblocks room. + +Visibility is set on a thread and all comments inside that thread use the same +visibility. Set visibility when the thread is created; existing threads keep +their current visibility. + + + +Private threads are only available on Team and Enterprise plans. + + + +## How it works + +Comment threads are public by default. To create a private thread, set +`visibility: "private"` when the thread is created. + +```tsx +import { Composer } from "@liveblocks/react-ui"; + +function PrivateComposer() { + return ; +} +``` + +Liveblocks checks permissions when threads are created and retrieved: + +- Users need `comments:public:read` or broader read access to receive public + threads. +- Users need `comments:private:read` or broader read access to receive private + threads. +- Users need `comments:private:write` or broader write access to create private + threads. + +The broader `comments:read`, `comments:write`, and `comments:none` permissions +apply to both public and private threads. Use `comments:public:*` and +`comments:private:*` to override one visibility. + +## Set up permissions + +Start by deciding which users can read or write private threads. For example, +you might allow all users to write public threads, but only reviewers to read +and write private threads. + +Every room permission list needs a base permission such as `*:read` or +`*:write`. Then add granular comments permissions to override access for public +or private threads. + +```ts +const regularUserPermissions = [ + "*:read", + "comments:public:write", + "comments:private:none", +]; + +const reviewerPermissions = [ + "*:read", + "comments:public:write", + "comments:private:write", +]; +``` + +### With ID tokens + +With [ID token authentication](/docs/authentication#id-token-room-permissions), +set permissions on the room using `defaultAccesses`, `groupsAccesses`, or +`usersAccesses`. + +```ts +import { Liveblocks } from "@liveblocks/node"; + +const liveblocks = new Liveblocks({ + secret: "{{SECRET_KEY}}", +}); + +await liveblocks.createRoom("my-room-id", { + // Everyone can read the room and create public threads + defaultAccesses: ["*:read", "comments:public:write", "comments:private:none"], + + // Reviewers can also read and create private threads + groupsAccesses: { + reviewers: ["*:read", "comments:public:write", "comments:private:write"], + }, +}); +``` + +If the room already exists, use +[`liveblocks.updateRoom`](/docs/api-reference/liveblocks-node#post-rooms-roomId) +with the same permission fields. + +### With access tokens + +With +[access token authentication](/docs/authentication/access-token#room-permissions), +grant the same permission lists when you prepare the user's session. + +```ts +import { Liveblocks } from "@liveblocks/node"; + +const liveblocks = new Liveblocks({ + secret: "{{SECRET_KEY}}", +}); + +const session = liveblocks.prepareSession("marie@example.com"); + +session.allow("my-room-id", [ + "*:read", + "comments:public:write", + "comments:private:none", +]); + +const { body, status } = await session.authorize(); +``` + +Grant private comments access only for users that should see private threads. + +```ts +session.allow("my-room-id", [ + "*:read", + "comments:public:write", + "comments:private:write", +]); +``` + +## Create private threads + +The default [`Composer`][] creates public threads unless you pass +`visibility="private"`. + +```tsx +import { Composer } from "@liveblocks/react-ui"; + +function Comments() { + return ( + <> + + + + ); +} +``` + +The private composer is disabled when the current user does not have write +access to private threads. + +### With mutation hooks + +If you're building a custom composer, pass `visibility: "private"` to +[`useCreateThread`][]. + +```tsx +import { type CommentBody } from "@liveblocks/client"; +import { useCreateThread } from "@liveblocks/react/suspense"; + +function CreatePrivateThreadButton({ body }: { body: CommentBody }) { + const createThread = useCreateThread(); + + return ( + + ); +} +``` + +### From your server + +To create private threads from your back end, pass `visibility: "private"` to +[`liveblocks.createThread`][]. + +```ts +await liveblocks.createThread({ + roomId: "my-room-id", + data: { + visibility: "private", + comment: { + userId: "marie@example.com", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "Internal note" }] }], + }, + }, + }, +}); +``` + +## Show threads in your app + +Use [`useThreads`][] as usual. Users only receive threads they have permission +to read. + +```tsx +import { Thread } from "@liveblocks/react-ui"; +import { useThreads } from "@liveblocks/react/suspense"; + +function ThreadList() { + const { threads } = useThreads(); + + return ( + <> + {threads.map((thread) => ( + + ))} + + ); +} +``` + +A user without private comments access won't receive private threads from +client-side APIs such as [`useThreads`][] or [`room.getThreads`][]. + +## Filter public and private views + +Filtering is optional. Use it when you want separate UI views for public and +private discussions. + +```tsx +const { threads: publicThreads } = useThreads({ + query: { + visibility: "public", + }, +}); + +const { threads: privateThreads } = useThreads({ + query: { + visibility: "private", + }, +}); +``` + +You can combine visibility with the other thread query options. + +```tsx +const { threads } = useThreads({ + query: { + visibility: "private", + resolved: false, + metadata: { + status: "open", + }, + }, +}); +``` + +On the server, use the same query object with [`liveblocks.getThreads`][]. + +```ts +const { data: threads } = await liveblocks.getThreads({ + roomId: "my-room-id", + query: { + visibility: "private", + metadata: { + status: "open", + }, + }, +}); +``` + +The REST API also supports visibility in the +[thread query language](/docs/guides/how-to-filter-threads-using-query-language). + +```js +visibility:'private' AND metadata['status']:'open' +``` + +## Troubleshooting + +If a user can't see private threads, check that their room permission list +includes `comments:private:read`, `comments:private:write`, or a broader +permission such as `comments:read` or `*:read`, with no more specific +`comments:private:none` override. + +If a user can't create private threads, check that their room permission list +includes `comments:private:write`, `comments:write`, or `*:write`, with no more +specific `comments:private:none` or `comments:private:read` override. + +If `useThreads({ query: { visibility: "private" } })` returns an empty list for +one user but not another, the first user probably does not have read access to +private threads. + +[`Composer`]: /docs/api-reference/liveblocks-react-ui#Composer +[`liveblocks.createThread`]: + /docs/api-reference/liveblocks-node#post-rooms-roomId-threads +[`liveblocks.getThreads`]: + /docs/api-reference/liveblocks-node#get-rooms-roomId-threads +[`room.getThreads`]: /docs/api-reference/liveblocks-client#Room.getThreads +[`useCreateThread`]: /docs/api-reference/liveblocks-react#useCreateThread +[`useThreads`]: /docs/api-reference/liveblocks-react#useThreads diff --git a/packages/liveblocks-chat-sdk-adapter/package.json b/packages/liveblocks-chat-sdk-adapter/package.json index 122cd001c0e..cef5fbcef29 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.20.1", + "version": "3.21.0", "description": "Liveblocks adapter for the Chat SDK.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-chat-sdk-adapter/src/__tests__/index.test.ts b/packages/liveblocks-chat-sdk-adapter/src/__tests__/index.test.ts index 1bd79cf2a3f..57e62a42298 100644 --- a/packages/liveblocks-chat-sdk-adapter/src/__tests__/index.test.ts +++ b/packages/liveblocks-chat-sdk-adapter/src/__tests__/index.test.ts @@ -5,6 +5,7 @@ import type { CommentData, ResolveGroupsInfoArgs, ResolveUsersArgs, + ThreadVisibility, } from "@liveblocks/core"; import type { ChatInstance, Root } from "chat"; import { beforeEach, describe, expect, test, vi } from "vitest"; @@ -109,6 +110,7 @@ function createDummyThread( createdAt: Date; updatedAt: Date; resolved: boolean; + visibility: ThreadVisibility; metadata: Record; }> ) { @@ -121,6 +123,7 @@ function createDummyThread( comments, metadata: {}, resolved: false, + visibility: "public", ...overrides, }; } @@ -2084,6 +2087,7 @@ describe("LiveblocksAdapter", () => { createdAt: new Date(), updatedAt: new Date(), resolved: false, + visibility: "public", }); const result = await adapter.postChannelMessage( @@ -2116,6 +2120,7 @@ describe("LiveblocksAdapter", () => { createdAt: new Date(), updatedAt: new Date(), resolved: false, + visibility: "public", }); await expect( diff --git a/packages/liveblocks-client/package.json b/packages/liveblocks-client/package.json index c366a011d6a..2e64cc975b2 100644 --- a/packages/liveblocks-client/package.json +++ b/packages/liveblocks-client/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/client", - "version": "3.20.1", + "version": "3.21.0", "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 eab4276b3ea..4e4a49c9005 100644 --- a/packages/liveblocks-core/package.json +++ b/packages/liveblocks-core/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/core", - "version": "3.20.1", + "version": "3.21.0", "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/__tests__/auth-manager.test.ts b/packages/liveblocks-core/src/__tests__/auth-manager.test.ts index c19b55f3777..4622b7d54aa 100644 --- a/packages/liveblocks-core/src/__tests__/auth-manager.test.ts +++ b/packages/liveblocks-core/src/__tests__/auth-manager.test.ts @@ -11,9 +11,16 @@ import { vi, } from "vitest"; -import { createAuthManager } from "../auth-manager"; +import { createApiClient } from "../api-client"; +import { type AuthRequest, createAuthManager } from "../auth-manager"; +import { DEFAULT_BASE_URL } from "../constants"; import { Permission, type RoomPermissions } from "../permissions"; import type { ParsedAuthToken } from "../protocol/AuthToken"; +import type { + BaseMetadata, + CommentBody, + ThreadDataPlain, +} from "../protocol/Comments"; const SECONDS = 1 * 1000; const MINUTES = 60 * SECONDS; @@ -284,6 +291,280 @@ describe("auth-manager - secret auth", () => { expect(requestCount).toBe(1); }); + test("should reuse broad comments token for scoped comments read requests", async () => { + let localRequestCount = 0; + const commentsReadToken = makeAccessToken({ + "org1*": [Permission.Read, Permission.CommentsRead], + }); + + server.use( + http.post("/api/access-auth-comments-read", () => { + localRequestCount++; + return HttpResponse.json({ token: commentsReadToken }); + }) + ); + + const authManager = createAuthManager({ + authEndpoint: "/api/access-auth-comments-read", + }); + + const commentsReadAuthValue = (await authManager.getAuthValue({ + resource: "comments", + access: "read", + roomId: "org1.room1", + })) as { type: "secret"; token: ParsedAuthToken }; + const publicReadAuthValue = (await authManager.getAuthValue({ + resource: "comments:public", + access: "read", + roomId: "org1.room1", + })) as { type: "secret"; token: ParsedAuthToken }; + + expect(commentsReadAuthValue.token.raw).toEqual(commentsReadToken); + expect(publicReadAuthValue.token.raw).toEqual(commentsReadToken); + expect(localRequestCount).toBe(1); + }); + + test("should reuse scoped comments read token for generic comments read requests", async () => { + let localRequestCount = 0; + const publicCommentsReadToken = makeAccessToken({ + "org1*": [ + Permission.Read, + Permission.CommentsNone, + Permission.CommentsPublicRead, + ], + }); + + server.use( + http.post("/api/access-auth-public-comments-read", () => { + localRequestCount++; + return HttpResponse.json({ token: publicCommentsReadToken }); + }) + ); + + const authManager = createAuthManager({ + authEndpoint: "/api/access-auth-public-comments-read", + }); + + const publicReadAuthValue = (await authManager.getAuthValue({ + resource: "comments:public", + access: "read", + roomId: "org1.room1", + })) as { type: "secret"; token: ParsedAuthToken }; + const commentsReadAuthValue = (await authManager.getAuthValue({ + resource: "comments", + access: "read", + roomId: "org1.room1", + })) as { type: "secret"; token: ParsedAuthToken }; + + expect(publicReadAuthValue.token.raw).toEqual(publicCommentsReadToken); + expect(commentsReadAuthValue.token.raw).toEqual(publicCommentsReadToken); + expect(localRequestCount).toBe(1); + }); + + test("should reuse scoped comments write token for generic comments write requests", async () => { + let localRequestCount = 0; + const publicCommentsWriteToken = makeAccessToken({ + "org1*": [ + Permission.Read, + Permission.CommentsNone, + Permission.CommentsPublicWrite, + ], + }); + + server.use( + http.post("/api/access-auth-public-then-comments-write", () => { + localRequestCount++; + return HttpResponse.json({ token: publicCommentsWriteToken }); + }) + ); + + const authManager = createAuthManager({ + authEndpoint: "/api/access-auth-public-then-comments-write", + }); + + const publicWriteAuthValue = (await authManager.getAuthValue({ + resource: "comments:public", + access: "write", + roomId: "org1.room1", + })) as { type: "secret"; token: ParsedAuthToken }; + const commentsWriteAuthValue = (await authManager.getAuthValue({ + resource: "comments", + access: "write", + roomId: "org1.room1", + })) as { type: "secret"; token: ParsedAuthToken }; + + expect(publicWriteAuthValue.token.raw).toEqual(publicCommentsWriteToken); + expect(commentsWriteAuthValue.token.raw).toEqual(publicCommentsWriteToken); + expect(localRequestCount).toBe(1); + }); + + test("should not reuse public comments token for private comments requests", async () => { + let localRequestCount = 0; + const publicCommentsReadToken = makeAccessToken({ + "org1*": [ + Permission.Read, + Permission.CommentsNone, + Permission.CommentsPublicRead, + ], + }); + const privateCommentsReadToken = makeAccessToken({ + "org1*": [ + Permission.Read, + Permission.CommentsNone, + Permission.CommentsPrivateRead, + ], + }); + + server.use( + http.post("/api/access-auth-public-then-private-comments-read", () => { + localRequestCount++; + return HttpResponse.json({ + token: + localRequestCount === 1 + ? publicCommentsReadToken + : privateCommentsReadToken, + }); + }) + ); + + const authManager = createAuthManager({ + authEndpoint: "/api/access-auth-public-then-private-comments-read", + }); + + const publicReadAuthValue = (await authManager.getAuthValue({ + resource: "comments:public", + access: "read", + roomId: "org1.room1", + })) as { type: "secret"; token: ParsedAuthToken }; + const privateReadAuthValue = (await authManager.getAuthValue({ + resource: "comments:private", + access: "read", + roomId: "org1.room1", + })) as { type: "secret"; token: ParsedAuthToken }; + + expect(publicReadAuthValue.token.raw).toEqual(publicCommentsReadToken); + expect(privateReadAuthValue.token.raw).toEqual(privateCommentsReadToken); + expect(localRequestCount).toBe(2); + }); + + test("api client should request concrete auth for known visibility and generic auth for unknown visibility", async () => { + const commentBody = { + version: 1, + content: [], + } satisfies CommentBody; + const thread = { + type: "thread", + id: "th_123", + roomId: "room-id", + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + comments: [], + metadata: {}, + resolved: false, + visibility: "public", + } satisfies ThreadDataPlain; + + server.use( + http.get(`${DEFAULT_BASE_URL}/v2/c/rooms/:roomId/threads`, () => { + return HttpResponse.json({ + data: [], + inboxNotifications: [], + subscriptions: [], + meta: { + requestedAt: new Date(0).toISOString(), + nextCursor: null, + permissionHints: {}, + }, + }); + }), + http.post(`${DEFAULT_BASE_URL}/v2/c/rooms/:roomId/threads`, () => + HttpResponse.json(thread) + ), + http.post( + `${DEFAULT_BASE_URL}/v2/c/rooms/:roomId/threads/:threadId/mark-as-resolved`, + () => HttpResponse.json({}) + ) + ); + + async function expectAuthRequest( + run: ( + client: ReturnType> + ) => Promise, + expected: AuthRequest + ) { + const authRequests: AuthRequest[] = []; + const client = createApiClient({ + baseUrl: DEFAULT_BASE_URL, + fetchPolyfill: globalThis.fetch?.bind(globalThis), + authManager: { + reset() {}, + getAuthValue(request) { + authRequests.push(request); + return Promise.resolve({ + type: "public", + publicApiKey: "pk_test", + }); + }, + }, + }); + + await run(client); + + expect(authRequests).toEqual([expected]); + } + + await expectAuthRequest( + (client) => + client.getThreads({ + roomId: "room-id", + query: { visibility: "private" }, + }), + { roomId: "room-id", resource: "comments:private", access: "read" } + ); + + await expectAuthRequest( + (client) => + client.createThread({ + roomId: "room-id", + metadata: {}, + commentMetadata: undefined, + body: commentBody, + }), + { roomId: "room-id", resource: "comments:public", access: "write" } + ); + + await expectAuthRequest( + (client) => + client.createThread({ + roomId: "room-id", + visibility: "private", + metadata: {}, + commentMetadata: undefined, + body: commentBody, + }), + { roomId: "room-id", resource: "comments:private", access: "write" } + ); + + await expectAuthRequest( + (client) => + client.markThreadAsResolved({ + roomId: "room-id", + threadId: "th_123", + visibility: "private", + }), + { roomId: "room-id", resource: "comments:private", access: "write" } + ); + + await expectAuthRequest( + (client) => + client.markThreadAsResolved({ + roomId: "room-id", + threadId: "th_123", + }), + { roomId: "room-id", resource: "comments", access: "write" } + ); + }); + test("should fetch a new token when cached comments read token cannot write", async () => { let localRequestCount = 0; const commentsReadToken = makeAccessToken({ diff --git a/packages/liveblocks-core/src/__tests__/permissions.test.ts b/packages/liveblocks-core/src/__tests__/permissions.test.ts index 0a6335b1610..ab7254ee10e 100644 --- a/packages/liveblocks-core/src/__tests__/permissions.test.ts +++ b/packages/liveblocks-core/src/__tests__/permissions.test.ts @@ -20,6 +20,8 @@ const PERMISSION_RESOURCES = [ "room", "storage", "comments", + "comments:public", + "comments:private", "feeds", "personal", ] as const satisfies readonly PermissionResources[]; @@ -57,13 +59,6 @@ function accessRank(resource: PermissionResources, matrix: PermissionMatrix) { return ACCESS_LEVEL_RANKS[matrix[resource]]; } -function hasExplicitFeatureScope( - scopes: readonly Permission[], - resource: "storage" | "comments" | "feeds" -): boolean { - return scopes.some((scope) => scope.startsWith(`${resource}:`)); -} - function mergeRoomPermissionMatrix({ defaultAccesses, groupsAccesses, @@ -114,7 +109,7 @@ describe("normalizeRoomPermissions", () => { describe("permissionMatrixFromScopes", () => { test("resolves read access", () => { - expect(permissionMatrixFromScopes([Permission.Read])).toEqual({ + expect(permissionMatrixFromScopes([Permission.Read])).toMatchObject({ room: "read", storage: "read", comments: "read", @@ -124,7 +119,7 @@ describe("permissionMatrixFromScopes", () => { }); test("resolves write access", () => { - expect(permissionMatrixFromScopes([Permission.Write])).toEqual({ + expect(permissionMatrixFromScopes([Permission.Write])).toMatchObject({ room: "write", storage: "write", comments: "write", @@ -133,6 +128,21 @@ describe("permissionMatrixFromScopes", () => { }); }); + test("inherits base and broad comments access to scoped comments permissions", () => { + expect(permissionMatrixFromScopes([Permission.Write])).toMatchObject({ + comments: "write", + "comments:public": "write", + "comments:private": "write", + }); + expect( + permissionMatrixFromScopes([Permission.Read, Permission.CommentsWrite]) + ).toMatchObject({ + comments: "write", + "comments:public": "write", + "comments:private": "write", + }); + }); + test("resolves room read and write as aliases", () => { expect(permissionMatrixFromScopes([Permission.RoomRead])).toEqual( permissionMatrixFromScopes([Permission.Read]) @@ -183,6 +193,50 @@ describe("permissionMatrixFromScopes", () => { expect(hasPermissionAccess(matrix, "comments", "write")).toBe(false); }); + test("resolves broad and visibility-specific comments permissions", () => { + const broad = permissionMatrixFromScopes([ + Permission.Write, + Permission.CommentsRead, + ]); + + expect(broad).toMatchObject({ + comments: "read", + "comments:public": "read", + "comments:private": "read", + }); + + const scoped = permissionMatrixFromScopes([ + Permission.Write, + Permission.CommentsNone, + Permission.CommentsPublicRead, + Permission.CommentsPrivateWrite, + ]); + + expect(scoped).toMatchObject({ + comments: "none", + "comments:public": "read", + "comments:private": "write", + }); + expect(hasPermissionAccess(scoped, "comments", "write")).toBe(true); + expect(hasPermissionAccess(scoped, "comments:public", "write")).toBe(false); + expect(hasPermissionAccess(scoped, "comments:private", "write")).toBe(true); + }); + + test("treats generic comments checks as aggregate comments access", () => { + const matrix = permissionMatrixFromScopes([ + Permission.Write, + Permission.CommentsNone, + Permission.CommentsPublicWrite, + Permission.CommentsPrivateNone, + ]); + + expect(hasPermissionAccess(matrix, "comments", "write")).toBe(true); + expect(hasPermissionAccess(matrix, "comments:public", "write")).toBe(true); + expect(hasPermissionAccess(matrix, "comments:private", "write")).toBe( + false + ); + }); + test("feature permissions require a base permission", () => { expect( hasPermissionAccess( @@ -237,7 +291,7 @@ describe("permissionMatrixFromScopes", () => { [{ pattern: "org1*", scopes: [Permission.RoomWrite] }], "org1.room1" ) - ).toEqual({ + ).toMatchObject({ room: "write", storage: "write", comments: "write", @@ -312,15 +366,29 @@ describe("permission matrix helpers", () => { test("serializes permission matrix to minimal scopes", () => { expect( - permissionMatrixToScopes({ - room: "read", - storage: "none", - comments: "read", - feeds: "read", - personal: "write", - }) + permissionMatrixToScopes( + permissionMatrixFromScopes([Permission.Read, Permission.StorageNone]) + ) ).toEqual([Permission.Read, Permission.StorageNone]); }); + + test("serializes split comment scopes when public and private differ", () => { + expect( + permissionMatrixToScopes( + permissionMatrixFromScopes([ + Permission.Read, + Permission.CommentsWrite, + Permission.CommentsPublicRead, + Permission.CommentsPrivateNone, + ]) + ) + ).toEqual([ + Permission.Read, + Permission.CommentsWrite, + Permission.CommentsPublicRead, + Permission.CommentsPrivateNone, + ]); + }); }); describe("mergeRoomPermissionScopes", () => { @@ -346,7 +414,7 @@ describe("mergeRoomPermissionScopes", () => { userAccesses: [Permission.Read, Permission.StorageWrite], }) ) - ).toEqual({ + ).toMatchObject({ room: "read", storage: "write", comments: "read", @@ -388,7 +456,7 @@ describe("mergeRoomPermissionScopes", () => { userAccesses: [], }) ) - ).toEqual({ + ).toMatchObject({ room: "read", storage: "read", comments: "read", @@ -476,6 +544,16 @@ describe("validatePermissionsSet", () => { ).toBe(true); }); + test("accepts scoped comments permissions together", () => { + expect( + validatePermissionsSet([ + Permission.Read, + Permission.CommentsPublicWrite, + Permission.CommentsPrivateNone, + ]) + ).toBe(true); + }); + test("accepts the legacy presence scope as an extra room scope", () => { expect( validatePermissionsSet([ @@ -517,6 +595,18 @@ describe("validatePermissionsSet", () => { 'Permissions can include at most one scope per feature, got multiple "comments" scopes' ); }); + + test("rejects multiple scopes for the same scoped comments feature", () => { + expect( + validatePermissionsSet([ + Permission.Read, + Permission.CommentsPublicRead, + Permission.CommentsPublicWrite, + ]) + ).toBe( + 'Permissions can include at most one scope per feature, got multiple "comments:public" scopes' + ); + }); }); describe("property tests", () => { @@ -611,6 +701,22 @@ describe("property tests", () => { const matrix = permissionMatrixFromScopes(scopes); for (const resource of PERMISSION_RESOURCES) { + if (resource === "comments") { + const commentsAccessRank = Math.max( + accessRank("comments", matrix), + accessRank("comments:public", matrix), + accessRank("comments:private", matrix) + ); + + expect(hasPermissionAccess(matrix, resource, "read")).toBe( + commentsAccessRank >= ACCESS_LEVEL_RANKS.read + ); + expect(hasPermissionAccess(matrix, resource, "write")).toBe( + commentsAccessRank >= ACCESS_LEVEL_RANKS.write + ); + continue; + } + expect(hasPermissionAccess(matrix, resource, "read")).toBe( accessRank(resource, matrix) >= ACCESS_LEVEL_RANKS.read ); @@ -703,49 +809,6 @@ describe("property tests", () => { ); }); - test("mergeRoomPermissionScopes takes the highest explicit access per feature across groups", () => { - fc.assert( - fc.property(validScopeSet, validScopeSet, (left, right) => { - const merged = mergeRoomPermissionMatrix({ - defaultAccesses: [], - groupsAccesses: [left, right], - userAccesses: [], - }); - const leftMatrix = permissionMatrixFromScopes(left); - const rightMatrix = permissionMatrixFromScopes(right); - - if (!hasBasePermission(left) && !hasBasePermission(right)) { - expect(merged).toEqual(permissionMatrixFromScopes([])); - return; - } - - const expectedRoom = Math.max( - hasBasePermission(left) ? accessRank("room", leftMatrix) : 0, - hasBasePermission(right) ? accessRank("room", rightMatrix) : 0 - ); - expect(accessRank("room", merged)).toBe(expectedRoom); - - for (const resource of ["storage", "comments", "feeds"] as const) { - const explicitRanks = [left, right] - .filter((scopes) => hasExplicitFeatureScope(scopes, resource)) - .map((scopes) => - accessRank(resource, permissionMatrixFromScopes(scopes)) - ); - - if (explicitRanks.length === 0) { - expect(accessRank(resource, merged)).toBe( - accessRank("room", merged) - ); - } else { - expect(accessRank(resource, merged)).toBe( - Math.max(...explicitRanks) - ); - } - } - }) - ); - }); - test("mergeRoomPermissionScopes lets user base permissions replace lower layers", () => { fc.assert( fc.property(mergeRoomPermissionInputs, (inputs) => { @@ -760,23 +823,4 @@ describe("property tests", () => { }) ); }); - - test("mergeRoomPermissionScopes lets explicit user feature scopes override lower layers", () => { - fc.assert( - fc.property(mergeRoomPermissionInputs, (inputs) => { - if (!hasBasePermission(inputs.userAccesses)) { - return; - } - - const matrix = mergeRoomPermissionMatrix(inputs); - const userMatrix = permissionMatrixFromScopes(inputs.userAccesses); - - for (const resource of ["storage", "comments", "feeds"] as const) { - if (hasExplicitFeatureScope(inputs.userAccesses, resource)) { - expect(matrix[resource]).toBe(userMatrix[resource]); - } - } - }) - ); - }); }); diff --git a/packages/liveblocks-core/src/api-client.ts b/packages/liveblocks-core/src/api-client.ts index f366abf7e34..bd6ef4c4345 100644 --- a/packages/liveblocks-core/src/api-client.ts +++ b/packages/liveblocks-core/src/api-client.ts @@ -25,7 +25,7 @@ import { stringifyOrLog as stringify } from "./lib/stringify"; import type { QueryParams, URLSafeString } from "./lib/url"; import { url, urljoin } from "./lib/url"; import { raise } from "./lib/utils"; -import type { RoomPermissions } from "./permissions"; +import type { RoomPermissions, RoomPermissionsResource } from "./permissions"; import type { ContextualPromptContext, ContextualPromptResponse, @@ -45,6 +45,7 @@ import type { ThreadDataPlain, ThreadDeleteInfo, ThreadDeleteInfoPlain, + ThreadVisibility, } from "./protocol/Comments"; import type { GroupData, GroupDataPlain } from "./protocol/Groups"; import type { @@ -78,6 +79,7 @@ export interface RoomHttpApi { cursor?: string; query?: { resolved?: boolean; + visibility?: ThreadVisibility; subscribed?: boolean; metadata?: Partial>; }; @@ -141,6 +143,7 @@ export interface RoomHttpApi { roomId: string; threadId?: string; commentId?: string; + visibility?: ThreadVisibility; metadata: TM | undefined; commentMetadata: CM | undefined; body: CommentBody; @@ -159,6 +162,7 @@ export interface RoomHttpApi { }: { roomId: string; threadId: string; + visibility?: ThreadVisibility; }): Promise; editThreadMetadata({ @@ -169,6 +173,7 @@ export interface RoomHttpApi { roomId: string; metadata: Patchable; threadId: string; + visibility?: ThreadVisibility; }): Promise; editCommentMetadata({ @@ -181,6 +186,7 @@ export interface RoomHttpApi { threadId: string; commentId: string; metadata: Patchable; + visibility?: ThreadVisibility; }): Promise; createComment({ @@ -197,6 +203,7 @@ export interface RoomHttpApi { body: CommentBody; metadata?: CM; attachmentIds?: string[]; + visibility?: ThreadVisibility; }): Promise>; editComment({ @@ -213,6 +220,7 @@ export interface RoomHttpApi { body: CommentBody; attachmentIds?: string[]; metadata?: Patchable; + visibility?: ThreadVisibility; }): Promise>; deleteComment({ @@ -223,6 +231,7 @@ export interface RoomHttpApi { roomId: string; threadId: string; commentId: string; + visibility?: ThreadVisibility; }): Promise; addReaction({ @@ -235,6 +244,7 @@ export interface RoomHttpApi { threadId: string; commentId: string; emoji: string; + visibility?: ThreadVisibility; }): Promise; removeReaction({ @@ -247,6 +257,7 @@ export interface RoomHttpApi { threadId: string; commentId: string; emoji: string; + visibility?: ThreadVisibility; }): Promise; markThreadAsResolved({ @@ -255,6 +266,7 @@ export interface RoomHttpApi { }: { roomId: string; threadId: string; + visibility?: ThreadVisibility; }): Promise; markThreadAsUnresolved({ @@ -263,6 +275,7 @@ export interface RoomHttpApi { }: { roomId: string; threadId: string; + visibility?: ThreadVisibility; }): Promise; subscribeToThread({ @@ -343,7 +356,7 @@ export interface RoomHttpApi { mentionId: string; }): Promise; - getTextVersion({ + getYjsHistoryVersion({ roomId, versionId, }: { @@ -351,7 +364,7 @@ export interface RoomHttpApi { versionId: string; }): Promise; - createTextVersion({ roomId }: { roomId: string }): Promise; + createVersionHistorySnapshot({ roomId }: { roomId: string }): Promise; reportTextEditor({ roomId, @@ -363,20 +376,12 @@ export interface RoomHttpApi { rootKey: string; }): Promise; - listTextVersions({ roomId }: { roomId: string }): Promise<{ - versions: { - type: "historyVersion"; - kind: "yjs"; - id: string; - authors: { - id: string; - }[]; - createdAt: Date; - }[]; + listHistoryVersions({ roomId }: { roomId: string }): Promise<{ + versions: HistoryVersion[]; requestedAt: Date; }>; - listTextVersionsSince({ + listHistoryVersionsSince({ roomId, since, signal, @@ -385,15 +390,7 @@ export interface RoomHttpApi { since: Date; signal?: AbortSignal; }): Promise<{ - versions: { - type: "historyVersion"; - kind: "yjs"; - id: string; - authors: { - id: string; - }[]; - createdAt: Date; - }[]; + versions: HistoryVersion[]; requestedAt: Date; }>; @@ -487,6 +484,7 @@ export interface LiveblocksHttpApi< cursor?: string; query?: { resolved?: boolean; + visibility?: ThreadVisibility; metadata?: Partial>; }; }): Promise<{ @@ -523,6 +521,20 @@ export interface LiveblocksHttpApi< getGroup(groupId: string): Promise; } +function commentsResourceForVisibility( + visibility: ThreadVisibility | undefined +): RoomPermissionsResource { + if (visibility === "private") { + return "comments:private"; + } + + if (visibility === "public") { + return "comments:public"; + } + + return "comments"; +} + export function createApiClient< TM extends BaseMetadata, CM extends BaseMetadata, @@ -596,6 +608,7 @@ export function createApiClient< cursor?: string; query?: { resolved?: boolean; + visibility?: ThreadVisibility; subscribed?: boolean; metadata?: Partial>; }; @@ -625,7 +638,7 @@ export function createApiClient< url`/v2/c/rooms/${options.roomId}/threads`, await authManager.getAuthValue({ roomId: options.roomId, - resource: "comments", + resource: commentsResourceForVisibility(options.query?.visibility), access: "read", }), { @@ -713,6 +726,7 @@ export function createApiClient< roomId: string; threadId?: string; commentId?: string; + visibility?: ThreadVisibility; metadata: TM | undefined; body: CommentBody; commentMetadata?: CM; @@ -725,11 +739,12 @@ export function createApiClient< url`/v2/c/rooms/${options.roomId}/threads`, await authManager.getAuthValue({ roomId: options.roomId, - resource: "comments", + resource: commentsResourceForVisibility(options.visibility ?? "public"), access: "write", }), { id: threadId, + visibility: options.visibility, comment: { id: commentId, body: options.body, @@ -743,12 +758,16 @@ export function createApiClient< return convertToThreadData(thread); } - async function deleteThread(options: { roomId: string; threadId: string }) { + async function deleteThread(options: { + roomId: string; + threadId: string; + visibility?: ThreadVisibility; + }) { await httpClient.delete( url`/v2/c/rooms/${options.roomId}/threads/${options.threadId}`, await authManager.getAuthValue({ roomId: options.roomId, - resource: "comments", + resource: commentsResourceForVisibility(options.visibility), access: "write", }) ); @@ -797,12 +816,13 @@ export function createApiClient< roomId: string; metadata: Patchable; threadId: string; + visibility?: ThreadVisibility; }) { return await httpClient.post( url`/v2/c/rooms/${options.roomId}/threads/${options.threadId}/metadata`, await authManager.getAuthValue({ roomId: options.roomId, - resource: "comments", + resource: commentsResourceForVisibility(options.visibility), access: "write", }), options.metadata @@ -814,12 +834,13 @@ export function createApiClient< threadId: string; commentId: string; metadata: Patchable; + visibility?: ThreadVisibility; }) { return await httpClient.post( url`/v2/c/rooms/${options.roomId}/threads/${options.threadId}/comments/${options.commentId}/metadata`, await authManager.getAuthValue({ roomId: options.roomId, - resource: "comments", + resource: commentsResourceForVisibility(options.visibility), access: "write", }), options.metadata @@ -833,13 +854,14 @@ export function createApiClient< body: CommentBody; metadata?: CM; attachmentIds?: string[]; + visibility?: ThreadVisibility; }) { const commentId = options.commentId ?? createCommentId(); const comment = await httpClient.post>( url`/v2/c/rooms/${options.roomId}/threads/${options.threadId}/comments`, await authManager.getAuthValue({ roomId: options.roomId, - resource: "comments", + resource: commentsResourceForVisibility(options.visibility), access: "write", }), { @@ -859,12 +881,13 @@ export function createApiClient< body: CommentBody; attachmentIds?: string[]; metadata?: Patchable; + visibility?: ThreadVisibility; }) { const comment = await httpClient.post>( url`/v2/c/rooms/${options.roomId}/threads/${options.threadId}/comments/${options.commentId}`, await authManager.getAuthValue({ roomId: options.roomId, - resource: "comments", + resource: commentsResourceForVisibility(options.visibility), access: "write", }), { @@ -881,12 +904,13 @@ export function createApiClient< roomId: string; threadId: string; commentId: string; + visibility?: ThreadVisibility; }) { await httpClient.delete( url`/v2/c/rooms/${options.roomId}/threads/${options.threadId}/comments/${options.commentId}`, await authManager.getAuthValue({ roomId: options.roomId, - resource: "comments", + resource: commentsResourceForVisibility(options.visibility), access: "write", }) ); @@ -897,12 +921,13 @@ export function createApiClient< threadId: string; commentId: string; emoji: string; + visibility?: ThreadVisibility; }) { const reaction = await httpClient.post( url`/v2/c/rooms/${options.roomId}/threads/${options.threadId}/comments/${options.commentId}/reactions`, await authManager.getAuthValue({ roomId: options.roomId, - resource: "comments", + resource: commentsResourceForVisibility(options.visibility), access: "write", }), { emoji: options.emoji } @@ -916,12 +941,13 @@ export function createApiClient< threadId: string; commentId: string; emoji: string; + visibility?: ThreadVisibility; }) { await httpClient.delete>( url`/v2/c/rooms/${options.roomId}/threads/${options.threadId}/comments/${options.commentId}/reactions/${options.emoji}`, await authManager.getAuthValue({ roomId: options.roomId, - resource: "comments", + resource: commentsResourceForVisibility(options.visibility), access: "write", }) ); @@ -930,12 +956,13 @@ export function createApiClient< async function markThreadAsResolved(options: { roomId: string; threadId: string; + visibility?: ThreadVisibility; }) { await httpClient.post( url`/v2/c/rooms/${options.roomId}/threads/${options.threadId}/mark-as-resolved`, await authManager.getAuthValue({ roomId: options.roomId, - resource: "comments", + resource: commentsResourceForVisibility(options.visibility), access: "write", }) ); @@ -944,12 +971,13 @@ export function createApiClient< async function markThreadAsUnresolved(options: { roomId: string; threadId: string; + visibility?: ThreadVisibility; }) { await httpClient.post( url`/v2/c/rooms/${options.roomId}/threads/${options.threadId}/mark-as-unresolved`, await authManager.getAuthValue({ roomId: options.roomId, - resource: "comments", + resource: commentsResourceForVisibility(options.visibility), access: "write", }) ); @@ -1346,12 +1374,12 @@ export function createApiClient< ); } - async function getTextVersion(options: { + async function getYjsHistoryVersion(options: { roomId: string; versionId: string; }) { return httpClient.rawGet( - url`/v2/c/rooms/${options.roomId}/y-version/${options.versionId}`, + url`/v2/c/rooms/${options.roomId}/versions/${options.versionId}/yjs`, await authManager.getAuthValue({ roomId: options.roomId, resource: "storage", @@ -1360,9 +1388,9 @@ export function createApiClient< ); } - async function createTextVersion(options: { roomId: string }) { + async function createVersionHistorySnapshot(options: { roomId: string }) { await httpClient.rawPost( - url`/v2/c/rooms/${options.roomId}/version`, + url`/v2/c/rooms/${options.roomId}/versions`, await authManager.getAuthValue({ roomId: options.roomId, resource: "storage", @@ -1426,7 +1454,7 @@ export function createApiClient< return result.content[0].text; } - async function listTextVersions(options: { roomId: string }) { + async function listHistoryVersions(options: { roomId: string }) { const result = await httpClient.get<{ versions: DateToString[]; meta: { @@ -1452,7 +1480,7 @@ export function createApiClient< }; } - async function listTextVersionsSince(options: { + async function listHistoryVersionsSince(options: { roomId: string; since: Date; signal?: AbortSignal; @@ -1697,6 +1725,7 @@ export function createApiClient< cursor?: string; query?: { resolved?: boolean; + visibility?: ThreadVisibility; metadata?: Partial>; }; }) { @@ -1859,11 +1888,11 @@ export function createApiClient< // Room text editor createTextMention, deleteTextMention, - getTextVersion, - createTextVersion, + getYjsHistoryVersion, + createVersionHistorySnapshot, reportTextEditor, - listTextVersions, - listTextVersionsSince, + listHistoryVersions, + listHistoryVersionsSince, // Room attachments getAttachmentUrl, uploadAttachment, diff --git a/packages/liveblocks-core/src/auth-manager.ts b/packages/liveblocks-core/src/auth-manager.ts index 6e53f5b7034..220537d4a7b 100644 --- a/packages/liveblocks-core/src/auth-manager.ts +++ b/packages/liveblocks-core/src/auth-manager.ts @@ -25,9 +25,11 @@ export type AuthValue = | { type: "secret"; token: ParsedAuthToken } | { type: "public"; publicApiKey: string }; +type RoomAuthResource = Exclude; + export type AuthRequest = Relax< | { - resource: Exclude; + resource: RoomAuthResource; roomId: string; access: RequiredAccessLevel; } @@ -277,10 +279,11 @@ function cachedTokenSatisfiesRequest( request.roomId ); - return ( - matrix !== undefined && - hasPermissionAccess(matrix, request.resource, request.access) - ); + if (matrix === undefined) { + return false; + } + + return hasPermissionAccess(matrix, request.resource, request.access); } function prepareAuthentication( diff --git a/packages/liveblocks-core/src/index.ts b/packages/liveblocks-core/src/index.ts index a5fe0710b43..9fec8239ef9 100644 --- a/packages/liveblocks-core/src/index.ts +++ b/packages/liveblocks-core/src/index.ts @@ -264,6 +264,7 @@ export type { ThreadData, ThreadDataPlain, ThreadDataWithDeleteInfo, + ThreadVisibility, } from "./protocol/Comments"; export type { ThreadDeleteInfo } from "./protocol/Comments"; export type { Feed, FeedMessage } from "./protocol/Feeds"; diff --git a/packages/liveblocks-core/src/permissions.ts b/packages/liveblocks-core/src/permissions.ts index 3bfddcab530..0498ac8f9ec 100644 --- a/packages/liveblocks-core/src/permissions.ts +++ b/packages/liveblocks-core/src/permissions.ts @@ -24,6 +24,12 @@ export const Permission = { CommentsWrite: "comments:write", CommentsRead: "comments:read", CommentsNone: "comments:none", + CommentsPublicWrite: "comments:public:write", + CommentsPublicRead: "comments:public:read", + CommentsPublicNone: "comments:public:none", + CommentsPrivateWrite: "comments:private:write", + CommentsPrivateRead: "comments:private:read", + CommentsPrivateNone: "comments:private:none", /** * Feeds @@ -50,43 +56,38 @@ export type PermissionMatrix = { room: AccessLevel; storage: AccessLevel; comments: AccessLevel; + "comments:public": AccessLevel; + "comments:private": AccessLevel; feeds: AccessLevel; personal: AccessLevel; }; export type PermissionResources = keyof PermissionMatrix; -const basePermissionScopes = new Set([ - Permission.Read, - Permission.Write, - Permission.RoomRead, - Permission.RoomWrite, -]); - -type ResolvedPermissionScopes = { - hasDefaultPermission: boolean; - baseAccess: AccessLevel; - matrix: Partial; -}; - -export type RoomPatternPermissions = { - pattern: string; - scopes: RoomPermissions; -}; - export type RoomPermissions = Permission[]; export type RoomAccesses = Record; export type UpdateRoomAccesses = Record; -type RoomPermissionsResource = Exclude< +export type RoomPermissionsResource = Exclude< PermissionResources, "room" | "personal" >; +type PermissionScopeResource = Exclude; + +type ExplicitPermissionMatrix = Partial< + Record +>; + +export type RoomPatternPermissions = { + pattern: string; + scopes: RoomPermissions; +}; + type ResourcePermissionsMap = Record< - PermissionResources, + PermissionScopeResource, Partial> >; @@ -101,9 +102,6 @@ const PERMISSIONS_BY_RESOURCE: ResourcePermissionsMap = { read: [Permission.Read, Permission.RoomRead], write: [Permission.Write, Permission.RoomWrite], }, - personal: { - write: [], - }, storage: { write: [Permission.StorageWrite], read: [Permission.StorageRead], @@ -114,6 +112,16 @@ const PERMISSIONS_BY_RESOURCE: ResourcePermissionsMap = { read: [Permission.CommentsRead], none: [Permission.CommentsNone], }, + "comments:public": { + write: [Permission.CommentsPublicWrite], + read: [Permission.CommentsPublicRead], + none: [Permission.CommentsPublicNone], + }, + "comments:private": { + write: [Permission.CommentsPrivateWrite], + read: [Permission.CommentsPrivateRead], + none: [Permission.CommentsPrivateNone], + }, feeds: { write: [Permission.FeedsWrite], read: [Permission.FeedsRead], @@ -125,17 +133,33 @@ const NO_PERMISSION_MATRIX: PermissionMatrix = { room: "none", storage: "none", comments: "none", + "comments:public": "none", + "comments:private": "none", feeds: "none", personal: "none", }; -const BASE_PERMISSION_RESOURCE = "room" satisfies PermissionResources; +const BASE_PERMISSION_RESOURCE = "room" satisfies PermissionScopeResource; const ROOM_PERMISSION_RESOURCES = [ "storage", "comments", + "comments:public", + "comments:private", "feeds", -] as const satisfies RoomPermissionsResource[]; +] as const satisfies readonly RoomPermissionsResource[]; + +const COMMENT_VISIBILITY_RESOURCES = [ + "comments:public", + "comments:private", +] as const satisfies readonly RoomPermissionsResource[]; + +const basePermissionScopes = new Set([ + Permission.Read, + Permission.Write, + Permission.RoomRead, + Permission.RoomWrite, +]); const VALID_PERMISSIONS = new Set(Object.values(Permission)); @@ -145,10 +169,9 @@ function isPermission(permission: string): permission is Permission { function resolveResourceAccess( scopes: RoomPermissions, - resource: RoomPermissionsResource + resource: PermissionScopeResource ): AccessLevel | undefined { - const permissions: Partial> = - PERMISSIONS_BY_RESOURCE[resource]; + const permissions = PERMISSIONS_BY_RESOURCE[resource]; let resourceAccess: AccessLevel | undefined; for (const access of ACCESS_LEVELS) { @@ -164,58 +187,53 @@ function resolveResourceAccess( return resourceAccess; } -function permissionMatrixFromResolvedScopes( - resolved: ResolvedPermissionScopes -): PermissionMatrix { - if (!resolved.hasDefaultPermission) { - return { ...NO_PERMISSION_MATRIX }; - } +function explicitPermissionMatrixFromScopes( + scopes: RoomPermissions +): ExplicitPermissionMatrix { + const matrix: ExplicitPermissionMatrix = {}; - const matrix: PermissionMatrix = { - ...NO_PERMISSION_MATRIX, - [BASE_PERMISSION_RESOURCE]: resolved.baseAccess, - personal: "write", - }; + const baseAccess = resolveResourceAccess(scopes, BASE_PERMISSION_RESOURCE); + if (baseAccess !== undefined) { + matrix.room = baseAccess; + } for (const resource of ROOM_PERMISSION_RESOURCES) { - matrix[resource] = resolved.matrix[resource] ?? resolved.baseAccess; + const access = resolveResourceAccess(scopes, resource); + if (access !== undefined) { + matrix[resource] = access; + } } return matrix; } -export function permissionMatrixFromScopes( - scopes: RoomPermissions +function permissionMatrixFromExplicitPermissions( + explicitMatrix: ExplicitPermissionMatrix ): PermissionMatrix { - return permissionMatrixFromResolvedScopes(resolvePermissionScopes(scopes)); -} + const baseAccess = explicitMatrix.room; + if (baseAccess === undefined) { + return { ...NO_PERMISSION_MATRIX }; + } -function resolvePermissionScopes( - scopes: RoomPermissions -): ResolvedPermissionScopes { - const hasDefaultPermission = - scopes.includes(Permission.Write) || - scopes.includes(Permission.Read) || - scopes.includes(Permission.RoomWrite) || - scopes.includes(Permission.RoomRead); - - const baseAccess: AccessLevel = - scopes.includes(Permission.Write) || scopes.includes(Permission.RoomWrite) - ? "write" - : scopes.includes(Permission.Read) || scopes.includes(Permission.RoomRead) - ? "read" - : "none"; - - const matrix: Partial = {}; + const commentsAccess = explicitMatrix.comments ?? baseAccess; - for (const resource of ROOM_PERMISSION_RESOURCES) { - const access = resolveResourceAccess(scopes, resource); - if (access !== undefined) { - matrix[resource] = access; - } - } + return { + room: baseAccess, + storage: explicitMatrix.storage ?? baseAccess, + comments: commentsAccess, + "comments:public": explicitMatrix["comments:public"] ?? commentsAccess, + "comments:private": explicitMatrix["comments:private"] ?? commentsAccess, + feeds: explicitMatrix.feeds ?? baseAccess, + personal: "write", + }; +} - return { hasDefaultPermission, baseAccess, matrix }; +export function permissionMatrixFromScopes( + scopes: RoomPermissions +): PermissionMatrix { + return permissionMatrixFromExplicitPermissions( + explicitPermissionMatrixFromScopes(scopes) + ); } export function hasPermissionAccess( @@ -223,8 +241,19 @@ export function hasPermissionAccess( resource: PermissionResources, requiredAccess: RequiredAccessLevel ): boolean { + const requiredRank = ACCESS_LEVEL_RANKS[requiredAccess]; + + if (resource === "comments") { + const commentsRank = Math.max( + ACCESS_LEVEL_RANKS[matrix.comments ?? "none"], + ACCESS_LEVEL_RANKS[matrix["comments:public"] ?? "none"], + ACCESS_LEVEL_RANKS[matrix["comments:private"] ?? "none"] + ); + return commentsRank >= requiredRank; + } + const access = matrix[resource] ?? "none"; - return ACCESS_LEVEL_RANKS[access] >= ACCESS_LEVEL_RANKS[requiredAccess]; + return ACCESS_LEVEL_RANKS[access] >= requiredRank; } export function resolveRoomPermissionMatrix( @@ -239,45 +268,38 @@ export function resolveRoomPermissionMatrix( return undefined; } - let hasDefaultPermission = false; - let baseAccess: AccessLevel = "none"; - const explicitMatrix: Partial = {}; - const explicitSpecificity: Partial> = {}; + const matrix: ExplicitPermissionMatrix = {}; + const specificityByResource: Partial< + Record + > = {}; for (const entry of matchedPermissions) { - const resolved = resolvePermissionScopes(entry.scopes); + const explicitMatrix = explicitPermissionMatrixFromScopes(entry.scopes); const specificity = roomPatternSpecificity(entry.pattern); - if (resolved.hasDefaultPermission) { - hasDefaultPermission = true; + if (explicitMatrix.room !== undefined) { // Base access is additive across all matching patterns (highest wins), // unlike resource-specific overrides which use most-specific-wins. - baseAccess = strongestAccess(baseAccess, resolved.baseAccess); + matrix.room = strongestAccess(matrix.room ?? "none", explicitMatrix.room); } for (const resource of ROOM_PERMISSION_RESOURCES) { - const access = resolved.matrix[resource]; - if (access !== undefined) { - const currentSpecificity = explicitSpecificity[resource] ?? -1; - - if (specificity > currentSpecificity) { - explicitMatrix[resource] = access; - explicitSpecificity[resource] = specificity; - } else if (specificity === currentSpecificity) { - explicitMatrix[resource] = strongestAccess( - explicitMatrix[resource] ?? "none", - access - ); - } + const access = explicitAccessForResource(explicitMatrix, resource); + if (access === undefined) { + continue; + } + + const currentSpecificity = specificityByResource[resource] ?? -1; + if (specificity > currentSpecificity) { + matrix[resource] = access; + specificityByResource[resource] = specificity; + } else if (specificity === currentSpecificity) { + matrix[resource] = strongestAccess(matrix[resource] ?? "none", access); } } } - return permissionMatrixFromResolvedScopes({ - hasDefaultPermission, - baseAccess, - matrix: explicitMatrix, - }); + return permissionMatrixFromExplicitPermissions(matrix); } export function normalizeRoomPermissions( @@ -339,13 +361,25 @@ export function permissionMatrixToScopes( scopes.push(permissionForAccessLevel(BASE_PERMISSION_RESOURCE, baseAccess)); } - for (const resource of ROOM_PERMISSION_RESOURCES) { - const access = matrix[resource]; - if (access !== baseAccess) { - scopes.push(permissionForAccessLevel(resource, access)); + if (matrix.storage !== baseAccess) { + scopes.push(permissionForAccessLevel("storage", matrix.storage)); + } + + const commentsAccess = matrix.comments; + if (commentsAccess !== baseAccess) { + scopes.push(permissionForAccessLevel("comments", commentsAccess)); + } + + for (const resource of COMMENT_VISIBILITY_RESOURCES) { + if (matrix[resource] !== commentsAccess) { + scopes.push(permissionForAccessLevel(resource, matrix[resource])); } } + if (matrix.feeds !== baseAccess) { + scopes.push(permissionForAccessLevel("feeds", matrix.feeds)); + } + return scopes; } @@ -366,58 +400,47 @@ export function mergeRoomPermissionScopes({ }): RoomPermissions { // Ordered from lowest to highest priority const sources = [ - resolvePermissionScopes(defaultAccesses), - mergeResolvedScopesByHighestAccess( - groupsAccesses.map(resolvePermissionScopes) + explicitPermissionMatrixFromScopes(defaultAccesses), + mergeExplicitPermissionMatricesByHighestAccess( + groupsAccesses.map(explicitPermissionMatrixFromScopes) ), - resolvePermissionScopes(userAccesses), + explicitPermissionMatrixFromScopes(userAccesses), ]; - const merged: ResolvedPermissionScopes = { - hasDefaultPermission: false, - baseAccess: "none", - matrix: {}, - }; + const merged: ExplicitPermissionMatrix = {}; for (const source of sources) { - if (source.hasDefaultPermission) { - merged.hasDefaultPermission = true; - merged.baseAccess = source.baseAccess; + if (source.room !== undefined) { + merged.room = source.room; } for (const resource of ROOM_PERMISSION_RESOURCES) { - const access = source.matrix[resource]; + const access = explicitAccessForResource(source, resource); if (access !== undefined) { - merged.matrix[resource] = access; + merged[resource] = access; } } } - return permissionMatrixToScopes(permissionMatrixFromResolvedScopes(merged)); + return permissionMatrixToScopes( + permissionMatrixFromExplicitPermissions(merged) + ); } -function mergeResolvedScopesByHighestAccess( - sources: ResolvedPermissionScopes[] -): ResolvedPermissionScopes { - const merged: ResolvedPermissionScopes = { - hasDefaultPermission: false, - baseAccess: "none", - matrix: {}, - }; +function mergeExplicitPermissionMatricesByHighestAccess( + sources: ExplicitPermissionMatrix[] +): ExplicitPermissionMatrix { + const merged: ExplicitPermissionMatrix = {}; for (const source of sources) { - if (source.hasDefaultPermission) { - merged.hasDefaultPermission = true; - merged.baseAccess = strongestAccess(merged.baseAccess, source.baseAccess); + if (source.room !== undefined) { + merged.room = strongestAccess(merged.room ?? "none", source.room); } for (const resource of ROOM_PERMISSION_RESOURCES) { - const access = source.matrix[resource]; + const access = explicitAccessForResource(source, resource); if (access !== undefined) { - merged.matrix[resource] = strongestAccess( - merged.matrix[resource] ?? "none", - access - ); + merged[resource] = strongestAccess(merged[resource] ?? "none", access); } } } @@ -425,20 +448,30 @@ function mergeResolvedScopesByHighestAccess( return merged; } +function explicitAccessForResource( + source: ExplicitPermissionMatrix, + resource: RoomPermissionsResource +): AccessLevel | undefined { + return ( + source[resource] ?? + (isCommentVisibilityResource(resource) ? source.comments : undefined) + ); +} + function permissionForAccessLevel( - resource: PermissionResources, + resource: PermissionScopeResource, access: AccessLevel, field: string = resource ): Permission { - const levels: Partial> = - PERMISSIONS_BY_RESOURCE[resource]; - const permissions = levels[access]; - if (permissions === undefined || permissions.length === 0) { - throw new Error( - `Invalid permission level for ${field}: ${JSON.stringify(access) ?? String(access)}` - ); + const permissions = PERMISSIONS_BY_RESOURCE[resource][access]; + const permission = permissions?.[0]; + if (permission !== undefined) { + return permission; } - return permissions[0]; + + throw new Error( + `Invalid permission level for ${field}: ${JSON.stringify(access) ?? String(access)}` + ); } function strongestAccess(left: AccessLevel, right: AccessLevel): AccessLevel { @@ -493,7 +526,7 @@ export function validatePermissionsSet( continue; } - const feature = scope.slice(0, scope.indexOf(":")); + const feature = permissionFeature(scope); if (seenFeatures.has(feature)) { return `Permissions can include at most one scope per feature, got multiple "${feature}" scopes`; } @@ -502,3 +535,16 @@ export function validatePermissionsSet( return true; } + +function permissionFeature(scope: string): string { + const accessSeparatorIndex = scope.lastIndexOf(":"); + return accessSeparatorIndex === -1 + ? scope + : scope.slice(0, accessSeparatorIndex); +} + +function isCommentVisibilityResource( + resource: RoomPermissionsResource +): resource is (typeof COMMENT_VISIBILITY_RESOURCES)[number] { + return resource.startsWith("comments:"); +} diff --git a/packages/liveblocks-core/src/protocol/Comments.ts b/packages/liveblocks-core/src/protocol/Comments.ts index 23392cb5e61..c7c98d12089 100644 --- a/packages/liveblocks-core/src/protocol/Comments.ts +++ b/packages/liveblocks-core/src/protocol/Comments.ts @@ -172,6 +172,8 @@ export type SearchCommentsResult = { content: string; }; +export type ThreadVisibility = "public" | "private"; + /** * Represents a thread of comments. */ @@ -187,6 +189,7 @@ export type ThreadData< comments: CommentData[]; metadata: TM; resolved: boolean; + visibility: ThreadVisibility; }; export interface ThreadDataWithDeleteInfo< diff --git a/packages/liveblocks-core/src/protocol/VersionHistory.ts b/packages/liveblocks-core/src/protocol/VersionHistory.ts index f23e947793d..741572c19e7 100644 --- a/packages/liveblocks-core/src/protocol/VersionHistory.ts +++ b/packages/liveblocks-core/src/protocol/VersionHistory.ts @@ -1,9 +1,5 @@ export type HistoryVersion = { - type: "historyVersion"; - kind: "yjs"; + id: `vh_${string}`; createdAt: Date; - id: string; - authors: { - id: string; - }[]; + authors: { id: string }[]; }; diff --git a/packages/liveblocks-core/src/room.ts b/packages/liveblocks-core/src/room.ts index ce8ad1d634f..f0fd87e22ac 100644 --- a/packages/liveblocks-core/src/room.ts +++ b/packages/liveblocks-core/src/room.ts @@ -90,6 +90,7 @@ import type { QueryMetadata, ThreadData, ThreadDeleteInfo, + ThreadVisibility, } from "./protocol/Comments"; import type { Feed, FeedMessage } from "./protocol/Feeds"; import type { @@ -535,6 +536,7 @@ export type GetThreadsOptions = { cursor?: string; query?: { resolved?: boolean; + visibility?: ThreadVisibility; subscribed?: boolean; metadata?: Partial>; }; @@ -986,6 +988,7 @@ export type Room< createThread(options: { threadId?: string; commentId?: string; + visibility?: ThreadVisibility; metadata: TM | undefined; body: CommentBody; commentMetadata?: CM; @@ -1248,19 +1251,23 @@ export type PrivateRoomApi = { // For reporting editor metadata reportTextEditor(editor: TextEditorType, rootKey: string): Promise; + getPermissionMatrix(): PermissionMatrix | undefined; + createTextMention(mentionId: string, mention: MentionData): Promise; deleteTextMention(mentionId: string): Promise; - listTextVersions(): Promise<{ + + // Version History APIs + listHistoryVersions(): Promise<{ versions: HistoryVersion[]; requestedAt: Date; }>; - listTextVersionsSince(options: ListTextVersionsSinceOptions): Promise<{ + listHistoryVersionsSince(options: ListTextVersionsSinceOptions): Promise<{ versions: HistoryVersion[]; requestedAt: Date; }>; - getTextVersion(versionId: string): Promise; - createTextVersion(): Promise; + getYjsHistoryVersion(versionId: string): Promise; + createVersionHistorySnapshot(): Promise; executeContextualPrompt(options: { prompt: string; @@ -1852,24 +1859,24 @@ export function createRoom< await httpClient.reportTextEditor({ roomId, type, rootKey }); } - async function listTextVersions() { - return httpClient.listTextVersions({ roomId }); + async function listHistoryVersions() { + return httpClient.listHistoryVersions({ roomId }); } - async function listTextVersionsSince(options: ListTextVersionsSinceOptions) { - return httpClient.listTextVersionsSince({ + async function listHistoryVersionsSince(options: ListTextVersionsSinceOptions) { + return httpClient.listHistoryVersionsSince({ roomId, since: options.since, signal: options.signal, }); } - async function getTextVersion(versionId: string) { - return httpClient.getTextVersion({ roomId, versionId }); + async function getYjsHistoryVersion(versionId: string) { + return httpClient.getYjsHistoryVersion({ roomId, versionId }); } - async function createTextVersion() { - return httpClient.createTextVersion({ roomId }); + async function createVersionHistorySnapshot() { + return httpClient.createVersionHistorySnapshot({ roomId }); } async function executeContextualPrompt(options: { @@ -3565,6 +3572,7 @@ export function createRoom< roomId: string; threadId?: string; commentId?: string; + visibility?: ThreadVisibility; metadata: TM | undefined; commentMetadata: CM | undefined; body: CommentBody; @@ -3574,6 +3582,7 @@ export function createRoom< roomId, threadId: options.threadId, commentId: options.commentId, + visibility: options.visibility, metadata: options.metadata, body: options.body, commentMetadata: options.commentMetadata, @@ -3792,18 +3801,20 @@ export function createRoom< // send metadata when using a text editor reportTextEditor, + getPermissionMatrix: () => + context.dynamicSessionInfoSig.get()?.permissionMatrix, // create a text mention when using a text editor createTextMention, // delete a text mention when using a text editor deleteTextMention, // list versions of the document - listTextVersions, + listHistoryVersions, // List versions of the document since the specified date - listTextVersionsSince, + listHistoryVersionsSince, // get a specific version - getTextVersion, + getYjsHistoryVersion, // create a version - createTextVersion, + createVersionHistorySnapshot, // execute a contextual prompt executeContextualPrompt, diff --git a/packages/liveblocks-core/src/types/LiveblocksError.ts b/packages/liveblocks-core/src/types/LiveblocksError.ts index 0fc0bcbeca4..0493dd288da 100644 --- a/packages/liveblocks-core/src/types/LiveblocksError.ts +++ b/packages/liveblocks-core/src/types/LiveblocksError.ts @@ -1,6 +1,10 @@ import { assertNever } from "../lib/assert"; import type { Relax } from "../lib/Relax"; -import type { BaseMetadata, CommentBody } from "../protocol/Comments"; +import type { + BaseMetadata, + CommentBody, + ThreadVisibility, +} from "../protocol/Comments"; import type { Patchable } from "./Patchable"; // All possible error originating from using Presence, Storage, or Yjs @@ -36,6 +40,7 @@ type CommentsOrNotificationsErrorContext = threadId: string; commentId: string; body: CommentBody; + visibility: ThreadVisibility; metadata: BaseMetadata; commentMetadata: BaseMetadata; } diff --git a/packages/liveblocks-emails/package.json b/packages/liveblocks-emails/package.json index 3b0aa0e40f2..0fb9fb7f71b 100644 --- a/packages/liveblocks-emails/package.json +++ b/packages/liveblocks-emails/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/emails", - "version": "3.20.1", + "version": "3.21.0", "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-emails/src/__tests__/_helpers.ts b/packages/liveblocks-emails/src/__tests__/_helpers.ts index 676a00b6fa7..22ed1dba766 100644 --- a/packages/liveblocks-emails/src/__tests__/_helpers.ts +++ b/packages/liveblocks-emails/src/__tests__/_helpers.ts @@ -303,6 +303,7 @@ export const makeThread = ({ roomId: ROOM_ID_TEST, metadata: {}, resolved: false, + visibility: "public", createdAt: at, updatedAt: at, comments, diff --git a/packages/liveblocks-node-lexical/package.json b/packages/liveblocks-node-lexical/package.json index d7ec9b3acc6..3f4cb6351f6 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.20.1", + "version": "3.21.0", "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 106d920dbf3..e462cfed724 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.20.1", + "version": "3.21.0", "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 a6fbf9077d4..2d6d8c57703 100644 --- a/packages/liveblocks-node/package.json +++ b/packages/liveblocks-node/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node", - "version": "3.20.1", + "version": "3.21.0", "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-node/src/__tests__/Session.test.ts b/packages/liveblocks-node/src/__tests__/Session.test.ts index 61c813d1631..86cfe60ac4f 100644 --- a/packages/liveblocks-node/src/__tests__/Session.test.ts +++ b/packages/liveblocks-node/src/__tests__/Session.test.ts @@ -5,7 +5,8 @@ import { Liveblocks } from "../client"; const P1 = "*:read"; const P2 = "*:write"; const P3 = "comments:read"; -// const P4 = "comments:write"; +const P4 = "comments:public:write"; +const P5 = "comments:private:none"; function makeSession(options?: { secret?: string; @@ -180,6 +181,14 @@ describe("authorization (new API)", () => { }); }); + test("accepts scoped comments permissions", () => { + expect( + makeSession().allow("foo", [P1, P4, P5]).serializePermissions() + ).toEqual({ + foo: [P1, P4, P5], + }); + }); + test("permissions are preserved when adding defaults and resource-specific values", () => { expect( makeSession() diff --git a/packages/liveblocks-node/src/__tests__/client.test.ts b/packages/liveblocks-node/src/__tests__/client.test.ts index 854a42f85ca..dd7e3829b95 100644 --- a/packages/liveblocks-node/src/__tests__/client.test.ts +++ b/packages/liveblocks-node/src/__tests__/client.test.ts @@ -91,6 +91,7 @@ describe("client", () => { updatedAt: new Date("2022-07-13T14:32:50.697Z"), comments: [comment], resolved: false, + visibility: "public", }; const reaction: CommentUserReaction = { @@ -942,6 +943,7 @@ describe("client", () => { metadata: { color: "blue", }, + visibility: "private", }; server.use( @@ -1199,9 +1201,9 @@ describe("client", () => { }); }); - test("should return a filtered list of threads when a query parameter is used for getThreads with a metadata object", async () => { + test("should return a filtered list of threads when a query parameter is used for getThreads with an object", async () => { const expectedQuery = - "metadata['status']:'open' metadata['priority']:3 metadata['organization']^'liveblocks:'"; + "resolved:false visibility:'private' metadata['status']:'open' metadata['priority']:3 metadata['organization']^'liveblocks:'"; server.use( http.get( @@ -1227,6 +1229,8 @@ describe("client", () => { startsWith: "liveblocks:", }, }, + resolved: false, + visibility: "private", }, }) ).resolves.toEqual({ diff --git a/packages/liveblocks-node/src/client.ts b/packages/liveblocks-node/src/client.ts index c07ebca7193..998d5cb7276 100644 --- a/packages/liveblocks-node/src/client.ts +++ b/packages/liveblocks-node/src/client.ts @@ -53,6 +53,7 @@ import type { SubscriptionDataPlain, ThreadData, ThreadDataPlain, + ThreadVisibility, ToJson, UpdateRoomAccesses, URLSafeString, @@ -161,6 +162,7 @@ export type CreateThreadOptions< > = { roomId: string; data: { + visibility?: ThreadVisibility; comment: { userId: string; createdAt?: Date; @@ -1648,7 +1650,7 @@ export class Liveblocks { * Gets all the threads in a room. * * @param params.roomId The room ID to get the threads from. - * @param params.query The query to filter threads by. It is based on our query language and can filter by metadata. + * @param params.query The query to filter threads by. It is based on our query language and can filter by visibility, metadata, and resolved status. * @param options.signal (optional) An abort signal to cancel the request. * @returns A list of threads. */ @@ -1661,7 +1663,7 @@ export class Liveblocks { * @example * ``` * { - * query: "metadata['organization']^'liveblocks:' AND metadata['status']:'open' AND metadata['pinned']:false AND metadata['priority']:3 AND resolved:true" + * query: "metadata['organization']^'liveblocks:' AND metadata['status']:'open' AND metadata['pinned']:false AND metadata['priority']:3 AND resolved:true AND visibility:'private'" * } * ``` * @example @@ -1676,7 +1678,8 @@ export class Liveblocks { * startsWith: "liveblocks:" * } * }, - * resolved: true + * resolved: true, + * visibility: "private" * } * } * ``` @@ -1686,6 +1689,7 @@ export class Liveblocks { | { metadata?: Partial>; resolved?: boolean; + visibility?: ThreadVisibility; }; }, options?: RequestOptions diff --git a/packages/liveblocks-node/test-d/augmentation.test-d.ts b/packages/liveblocks-node/test-d/augmentation.test-d.ts index bba27f920ff..98b52dc7159 100644 --- a/packages/liveblocks-node/test-d/augmentation.test-d.ts +++ b/packages/liveblocks-node/test-d/augmentation.test-d.ts @@ -266,6 +266,7 @@ describe("Liveblocks client with Liveblocks augmentation", () => { metadata: { priority: 1 }, }, metadata: { color: "red" }, + visibility: "private", }, }); diff --git a/packages/liveblocks-node/test-d/no-augmentation.test-d.ts b/packages/liveblocks-node/test-d/no-augmentation.test-d.ts index 77447fd9714..5f7846bc60f 100644 --- a/packages/liveblocks-node/test-d/no-augmentation.test-d.ts +++ b/packages/liveblocks-node/test-d/no-augmentation.test-d.ts @@ -186,6 +186,7 @@ describe("Liveblocks client without Liveblocks augmentation", () => { body: { version: 1, content: [] }, }, metadata: { color: "red" }, + visibility: "private", }, }); diff --git a/packages/liveblocks-python-codegen/config.yaml b/packages/liveblocks-python-codegen/config.yaml index 985db9c0b8c..36b783af98f 100644 --- a/packages/liveblocks-python-codegen/config.yaml +++ b/packages/liveblocks-python-codegen/config.yaml @@ -1,7 +1,7 @@ project_name_override: liveblocks package_name_override: liveblocks -package_version_override: 3.20.0 +package_version_override: 3.21.0 post_hooks: - "uvx ruff check --fix-only ." diff --git a/packages/liveblocks-python/README.md b/packages/liveblocks-python/README.md index 2066e21680f..5175305e477 100644 --- a/packages/liveblocks-python/README.md +++ b/packages/liveblocks-python/README.md @@ -86,10 +86,10 @@ print(result) #### `create_room` This endpoint creates a new room. `id` and `defaultAccesses` are required. When provided with a `?idempotent` query argument, will not return a 409 when the room already exists, but instead return the existing room as-is. Corresponds to [`liveblocks.createRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-rooms), or to [`liveblocks.getOrCreateRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-or-create-rooms-roomId) when `?idempotent` is provided. -- `defaultAccesses` could be `[]` or `["*:write"]` (private or public). +- `defaultAccesses` is the default room permission list, for example `[]`, `["*:read"]`, `["*:write"]`, or a more granular permission list. - `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field. -- `usersAccesses` could be `[]` or `["*:write"]` for every records. `usersAccesses` can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. -- `groupsAccesses` are optional fields. +- `usersAccesses` contains user-specific permission lists. It can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. +- `groupsAccesses` contains group-specific permission lists and is optional. **Example** @@ -151,10 +151,10 @@ Setting a property to `null` means to delete this property. For example, if you }`` `defaultAccesses`, `metadata`, `usersAccesses`, `groupsAccesses` can be updated. -- `defaultAccesses` could be `[]` or `["*:write"]` (private or public). +- `defaultAccesses` is the default room permission list, for example `[]`, `["*:read"]`, `["*:write"]`, or a more granular permission list. - `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field. -- `usersAccesses` could be `[]` or `["*:write"]` for every records. `usersAccesses` can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. -- `groupsAccesses` could be `[]` or `["*:write"]` for every records. `groupsAccesses` can contain 1000 ids maximum. Id length has a limit of 256 characters. `groupsAccesses` is optional field. +- `usersAccesses` contains user-specific permission lists. It can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. +- `groupsAccesses` contains group-specific permission lists and is optional. **Example** ```python @@ -231,10 +231,10 @@ Setting a property to `null` means to delete this property. For example, if you }`` `defaultAccesses`, `metadata`, `usersAccesses`, `groupsAccesses` can be updated. -- `defaultAccesses` could be `[]` or `["*:write"]` (private or public). +- `defaultAccesses` is the default room permission list, for example `[]`, `["*:read"]`, `["*:write"]`, or a more granular permission list. - `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field. -- `usersAccesses` could be `[]` or `["*:write"]` for every records. `usersAccesses` can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. -- `groupsAccesses` could be `[]` or `["*:write"]` for every records. `groupsAccesses` can contain 1000 ids maximum. Id length has a limit of 256 characters. `groupsAccesses` is optional field. +- `usersAccesses` contains user-specific permission lists. It can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. +- `groupsAccesses` contains group-specific permission lists and is optional. **Example** ```python @@ -578,13 +578,13 @@ print(result) --- -#### `get_yjs_versions` +#### `get_version_history` This endpoint returns a list of version history snapshots for the room's Yjs document. The versions are returned sorted by creation date, from newest to oldest. **Example** ```python -result = client.get_yjs_versions( +result = client.get_version_history( room_id="my-room-id", # limit=20, # cursor="eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9", @@ -602,15 +602,14 @@ print(result) --- -#### `get_yjs_version` +#### `create_version_history_snapshot` -This endpoint returns a specific version of the room's Yjs document encoded as a binary Yjs update. +This endpoint creates a new version history snapshot for the room. Currently only works for Yjs. **Example** ```python -result = client.get_yjs_version( +result = client.create_version_history_snapshot( room_id="my-room-id", - version_id="vh_abc123", ) print(result) ``` @@ -619,19 +618,19 @@ print(result) | Name | Type | Required | Description | |------|------|----------|-------------| | `room_id` | `str` | Yes | ID of the room | -| `version_id` | `str` | Yes | ID of the version | --- -#### `create_yjs_version` +#### `get_yjs_version` -This endpoint creates a new version history snapshot for the room's Yjs document. +This endpoint returns a specific version of the room's Yjs document encoded as a binary Yjs update. **Example** ```python -result = client.create_yjs_version( +result = client.get_yjs_version( room_id="my-room-id", + version_id="vh_abc123", ) print(result) ``` @@ -640,6 +639,7 @@ print(result) | Name | Type | Required | Description | |------|------|----------|-------------| | `room_id` | `str` | Yes | ID of the room | +| `version_id` | `str` | Yes | ID of the version | --- @@ -663,7 +663,7 @@ print(result) | Name | Type | Required | Description | |------|------|----------|-------------| | `room_id` | `str` | Yes | ID of the room | -| `query` | `str \| Unset` | No | Query to filter threads. You can filter by `metadata` and `resolved`, for example, `metadata["status"]:"open" AND metadata["color"]:"red" AND resolved:true`. Learn more about [filtering threads with query language](https://liveblocks.io/docs/guides/how-to-filter-threads-using-query-language). | +| `query` | `str \| Unset` | No | Query to filter threads. You can filter by `metadata`, `resolved`, and `visibility`, for example, `metadata["status"]:"open" AND metadata["color"]:"red" AND resolved:true AND visibility:"private"`. Learn more about [filtering threads with query language](https://liveblocks.io/docs/guides/how-to-filter-threads-using-query-language). | --- @@ -697,6 +697,7 @@ result = client.create_thread( body=CreateThreadRequestBody( comment=..., # metadata=..., + # visibility=..., ), ) print(result) diff --git a/packages/liveblocks-python/README.mdx b/packages/liveblocks-python/README.mdx index 8cd1a7baacd..336d4e634fc 100644 --- a/packages/liveblocks-python/README.mdx +++ b/packages/liveblocks-python/README.mdx @@ -110,10 +110,10 @@ print(result) ### create_room This endpoint creates a new room. `id` and `defaultAccesses` are required. When provided with a `?idempotent` query argument, will not return a 409 when the room already exists, but instead return the existing room as-is. Corresponds to [`liveblocks.createRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-rooms), or to [`liveblocks.getOrCreateRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-or-create-rooms-roomId) when `?idempotent` is provided. -- `defaultAccesses` could be `[]` or `["*:write"]` (private or public). +- `defaultAccesses` is the default room permission list, for example `[]`, `["*:read"]`, `["*:write"]`, or a more granular permission list. - `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field. -- `usersAccesses` could be `[]` or `["*:write"]` for every records. `usersAccesses` can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. -- `groupsAccesses` are optional fields. +- `usersAccesses` contains user-specific permission lists. It can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. +- `groupsAccesses` contains group-specific permission lists and is optional. ```python @@ -184,10 +184,10 @@ Setting a property to `null` means to delete this property. For example, if you }`` `defaultAccesses`, `metadata`, `usersAccesses`, `groupsAccesses` can be updated. -- `defaultAccesses` could be `[]` or `["*:write"]` (private or public). +- `defaultAccesses` is the default room permission list, for example `[]`, `["*:read"]`, `["*:write"]`, or a more granular permission list. - `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field. -- `usersAccesses` could be `[]` or `["*:write"]` for every records. `usersAccesses` can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. -- `groupsAccesses` could be `[]` or `["*:write"]` for every records. `groupsAccesses` can contain 1000 ids maximum. Id length has a limit of 256 characters. `groupsAccesses` is optional field. +- `usersAccesses` contains user-specific permission lists. It can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. +- `groupsAccesses` contains group-specific permission lists and is optional. ```python from liveblocks.models import UpdateRoomRequestBody @@ -276,10 +276,10 @@ Setting a property to `null` means to delete this property. For example, if you }`` `defaultAccesses`, `metadata`, `usersAccesses`, `groupsAccesses` can be updated. -- `defaultAccesses` could be `[]` or `["*:write"]` (private or public). +- `defaultAccesses` is the default room permission list, for example `[]`, `["*:read"]`, `["*:write"]`, or a more granular permission list. - `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field. -- `usersAccesses` could be `[]` or `["*:write"]` for every records. `usersAccesses` can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. -- `groupsAccesses` could be `[]` or `["*:write"]` for every records. `groupsAccesses` can contain 1000 ids maximum. Id length has a limit of 256 characters. `groupsAccesses` is optional field. +- `usersAccesses` contains user-specific permission lists. It can contain 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. +- `groupsAccesses` contains group-specific permission lists and is optional. ```python from liveblocks.models import UpsertRoomRequestBody @@ -727,12 +727,12 @@ print(result) -### get_yjs_versions +### get_version_history This endpoint returns a list of version history snapshots for the room's Yjs document. The versions are returned sorted by creation date, from newest to oldest. ```python -result = client.get_yjs_versions( +result = client.get_version_history( room_id="my-room-id", # limit=20, # cursor="eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9", @@ -763,14 +763,13 @@ print(result) -### get_yjs_version +### create_version_history_snapshot -This endpoint returns a specific version of the room's Yjs document encoded as a binary Yjs update. +This endpoint creates a new version history snapshot for the room. Currently only works for Yjs. ```python -result = client.get_yjs_version( +result = client.create_version_history_snapshot( room_id="my-room-id", - version_id="vh_abc123", ) print(result) ``` @@ -783,23 +782,17 @@ print(result) ID of the room - - ID of the version - - -### create_yjs_version +### get_yjs_version -This endpoint creates a new version history snapshot for the room's Yjs document. +This endpoint returns a specific version of the room's Yjs document encoded as a binary Yjs update. ```python -result = client.create_yjs_version( +result = client.get_yjs_version( room_id="my-room-id", + version_id="vh_abc123", ) print(result) ``` @@ -812,6 +805,13 @@ print(result) ID of the room + + ID of the version + + @@ -841,7 +841,7 @@ print(result) name="query" type="str | Unset" > - Query to filter threads. You can filter by `metadata` and `resolved`, for example, `metadata["status"]:"open" AND metadata["color"]:"red" AND resolved:true`. Learn more about [filtering threads with query language](https://liveblocks.io/docs/guides/how-to-filter-threads-using-query-language). + Query to filter threads. You can filter by `metadata`, `resolved`, and `visibility`, for example, `metadata["status"]:"open" AND metadata["color"]:"red" AND resolved:true AND visibility:"private"`. Learn more about [filtering threads with query language](https://liveblocks.io/docs/guides/how-to-filter-threads-using-query-language). @@ -874,6 +874,7 @@ result = client.create_thread( body=CreateThreadRequestBody( comment=..., # metadata=..., + # visibility=..., ), ) print(result) diff --git a/packages/liveblocks-python/liveblocks/api/yjs/create_yjs_version.py b/packages/liveblocks-python/liveblocks/api/yjs/create_version_history_snapshot.py similarity index 68% rename from packages/liveblocks-python/liveblocks/api/yjs/create_yjs_version.py rename to packages/liveblocks-python/liveblocks/api/yjs/create_version_history_snapshot.py index d90ad299531..612f1936f35 100644 --- a/packages/liveblocks-python/liveblocks/api/yjs/create_yjs_version.py +++ b/packages/liveblocks-python/liveblocks/api/yjs/create_version_history_snapshot.py @@ -4,7 +4,7 @@ import httpx from ... import errors -from ...models.create_yjs_version_response import CreateYjsVersionResponse +from ...models.create_version_history_snapshot_response import CreateVersionHistorySnapshotResponse def _get_kwargs( @@ -13,7 +13,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "post", - "url": "/v2/rooms/{room_id}/version".format( + "url": "/v2/rooms/{room_id}/versions".format( room_id=quote(str(room_id), safe=""), ), } @@ -21,9 +21,9 @@ def _get_kwargs( return _kwargs -def _parse_response(*, response: httpx.Response) -> CreateYjsVersionResponse: +def _parse_response(*, response: httpx.Response) -> CreateVersionHistorySnapshotResponse: if response.status_code == 200: - response_200 = CreateYjsVersionResponse.from_dict(response.json()) + response_200 = CreateVersionHistorySnapshotResponse.from_dict(response.json()) return response_200 @@ -34,7 +34,7 @@ def _sync( room_id: str, *, client: httpx.Client, -) -> CreateYjsVersionResponse: +) -> CreateVersionHistorySnapshotResponse: kwargs = _get_kwargs( room_id=room_id, ) @@ -49,7 +49,7 @@ async def _asyncio( room_id: str, *, client: httpx.AsyncClient, -) -> CreateYjsVersionResponse: +) -> CreateVersionHistorySnapshotResponse: kwargs = _get_kwargs( room_id=room_id, ) diff --git a/packages/liveblocks-python/liveblocks/api/yjs/get_yjs_versions.py b/packages/liveblocks-python/liveblocks/api/yjs/get_version_history.py similarity index 83% rename from packages/liveblocks-python/liveblocks/api/yjs/get_yjs_versions.py rename to packages/liveblocks-python/liveblocks/api/yjs/get_version_history.py index f6301771d6c..23b0ee4df97 100644 --- a/packages/liveblocks-python/liveblocks/api/yjs/get_yjs_versions.py +++ b/packages/liveblocks-python/liveblocks/api/yjs/get_version_history.py @@ -4,7 +4,7 @@ import httpx from ... import errors -from ...models.get_yjs_versions_response import GetYjsVersionsResponse +from ...models.get_version_history_response import GetVersionHistoryResponse from ...types import UNSET, Unset @@ -34,9 +34,9 @@ def _get_kwargs( return _kwargs -def _parse_response(*, response: httpx.Response) -> GetYjsVersionsResponse: +def _parse_response(*, response: httpx.Response) -> GetVersionHistoryResponse: if response.status_code == 200: - response_200 = GetYjsVersionsResponse.from_dict(response.json()) + response_200 = GetVersionHistoryResponse.from_dict(response.json()) return response_200 @@ -49,7 +49,7 @@ def _sync( client: httpx.Client, limit: int | Unset = 20, cursor: str | Unset = UNSET, -) -> GetYjsVersionsResponse: +) -> GetVersionHistoryResponse: kwargs = _get_kwargs( room_id=room_id, limit=limit, @@ -68,7 +68,7 @@ async def _asyncio( client: httpx.AsyncClient, limit: int | Unset = 20, cursor: str | Unset = UNSET, -) -> GetYjsVersionsResponse: +) -> GetVersionHistoryResponse: kwargs = _get_kwargs( room_id=room_id, limit=limit, diff --git a/packages/liveblocks-python/liveblocks/api/yjs/get_yjs_version.py b/packages/liveblocks-python/liveblocks/api/yjs/get_yjs_version.py index 089b6b6a59d..a6ed26c4c9e 100644 --- a/packages/liveblocks-python/liveblocks/api/yjs/get_yjs_version.py +++ b/packages/liveblocks-python/liveblocks/api/yjs/get_yjs_version.py @@ -15,7 +15,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "get", - "url": "/v2/rooms/{room_id}/version/{version_id}".format( + "url": "/v2/rooms/{room_id}/versions/{version_id}/yjs".format( room_id=quote(str(room_id), safe=""), version_id=quote(str(version_id), safe=""), ), diff --git a/packages/liveblocks-python/liveblocks/client.py b/packages/liveblocks-python/liveblocks/client.py index cf846533b6b..31a0c520df5 100644 --- a/packages/liveblocks-python/liveblocks/client.py +++ b/packages/liveblocks-python/liveblocks/client.py @@ -34,9 +34,9 @@ from .models.create_group_request_body import CreateGroupRequestBody from .models.create_room_request_body import CreateRoomRequestBody from .models.create_thread_request_body import CreateThreadRequestBody + from .models.create_version_history_snapshot_response import CreateVersionHistorySnapshotResponse from .models.create_web_knowledge_source_request_body import CreateWebKnowledgeSourceRequestBody from .models.create_web_knowledge_source_response import CreateWebKnowledgeSourceResponse - from .models.create_yjs_version_response import CreateYjsVersionResponse from .models.edit_comment_metadata_request_body import EditCommentMetadataRequestBody from .models.edit_comment_request_body import EditCommentRequestBody from .models.edit_thread_metadata_request_body import EditThreadMetadataRequestBody @@ -57,10 +57,10 @@ from .models.get_thread_subscriptions_response import GetThreadSubscriptionsResponse from .models.get_threads_response import GetThreadsResponse from .models.get_user_groups_response import GetUserGroupsResponse + from .models.get_version_history_response import GetVersionHistoryResponse from .models.get_web_knowledge_source_links_response import GetWebKnowledgeSourceLinksResponse from .models.get_yjs_document_response import GetYjsDocumentResponse from .models.get_yjs_document_type import GetYjsDocumentType - from .models.get_yjs_versions_response import GetYjsVersionsResponse from .models.group import Group from .models.identify_user_request_body import IdentifyUserRequestBody from .models.identify_user_response import IdentifyUserResponse @@ -239,13 +239,14 @@ def create_room( reference/liveblocks-node#post-rooms), or to [`liveblocks.getOrCreateRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-or- create-rooms-roomId) when `?idempotent` is provided. - - `defaultAccesses` could be `[]` or `[\"*:write\"]` (private or public). + - `defaultAccesses` is the default room permission list, for example `[]`, `[\"*:read\"]`, + `[\"*:write\"]`, or a more granular permission list. - `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field. - - `usersAccesses` could be `[]` or `[\"*:write\"]` for every records. `usersAccesses` can contain - 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. - - `groupsAccesses` are optional fields. + - `usersAccesses` contains user-specific permission lists. It can contain 1000 ids maximum. Id + length has a limit of 256 characters. `usersAccesses` is optional field. + - `groupsAccesses` contains group-specific permission lists and is optional. Args: idempotent (bool | Unset): When provided, will not return a 409 when the room already @@ -321,14 +322,14 @@ def update_room( }`` `defaultAccesses`, `metadata`, `usersAccesses`, `groupsAccesses` can be updated. - - `defaultAccesses` could be `[]` or `[\"*:write\"]` (private or public). + - `defaultAccesses` is the default room permission list, for example `[]`, `[\"*:read\"]`, + `[\"*:write\"]`, or a more granular permission list. - `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field. - - `usersAccesses` could be `[]` or `[\"*:write\"]` for every records. `usersAccesses` can contain - 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. - - `groupsAccesses` could be `[]` or `[\"*:write\"]` for every records. `groupsAccesses` can contain - 1000 ids maximum. Id length has a limit of 256 characters. `groupsAccesses` is optional field. + - `usersAccesses` contains user-specific permission lists. It can contain 1000 ids maximum. Id + length has a limit of 256 characters. `usersAccesses` is optional field. + - `groupsAccesses` contains group-specific permission lists and is optional. Args: room_id (str): ID of the room Example: my-room-id. @@ -433,14 +434,14 @@ def upsert_room( }`` `defaultAccesses`, `metadata`, `usersAccesses`, `groupsAccesses` can be updated. - - `defaultAccesses` could be `[]` or `[\"*:write\"]` (private or public). + - `defaultAccesses` is the default room permission list, for example `[]`, `[\"*:read\"]`, + `[\"*:write\"]`, or a more granular permission list. - `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field. - - `usersAccesses` could be `[]` or `[\"*:write\"]` for every records. `usersAccesses` can contain - 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. - - `groupsAccesses` could be `[]` or `[\"*:write\"]` for every records. `groupsAccesses` can contain - 1000 ids maximum. Id length has a limit of 256 characters. `groupsAccesses` is optional field. + - `usersAccesses` contains user-specific permission lists. It can contain 1000 ids maximum. Id + length has a limit of 256 characters. `usersAccesses` is optional field. + - `groupsAccesses` contains group-specific permission lists and is optional. Args: room_id (str): ID of the room Example: my-room-id. @@ -925,14 +926,14 @@ def get_yjs_document_as_binary_update( client=self._client, ) - def get_yjs_versions( + def get_version_history( self, room_id: str, *, limit: int | Unset = 20, cursor: str | Unset = UNSET, - ) -> GetYjsVersionsResponse: - """Get Yjs version history + ) -> GetVersionHistoryResponse: + """Get Version History This endpoint returns a list of version history snapshots for the room's Yjs document. The versions are returned sorted by creation date, from newest to oldest. @@ -949,70 +950,70 @@ def get_yjs_versions( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - GetYjsVersionsResponse + GetVersionHistoryResponse """ - from .api.yjs import get_yjs_versions + from .api.yjs import get_version_history - return get_yjs_versions._sync( + return get_version_history._sync( room_id=room_id, limit=limit, cursor=cursor, client=self._client, ) - def get_yjs_version( + def create_version_history_snapshot( self, room_id: str, - version_id: str, - ) -> File: - """Get Yjs document version + ) -> CreateVersionHistorySnapshotResponse: + """Create version history snapshot - This endpoint returns a specific version of the room's Yjs document encoded as a binary Yjs update. + This endpoint creates a new version history snapshot for the room. Currently only works for Yjs. Args: room_id (str): ID of the room Example: my-room-id. - version_id (str): ID of the version Example: vh_abc123. Raises: errors.LiveblocksError: If the server returns a response with non-2xx status code. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - File + CreateVersionHistorySnapshotResponse """ - from .api.yjs import get_yjs_version + from .api.yjs import create_version_history_snapshot - return get_yjs_version._sync( + return create_version_history_snapshot._sync( room_id=room_id, - version_id=version_id, client=self._client, ) - def create_yjs_version( + def get_yjs_version( self, room_id: str, - ) -> CreateYjsVersionResponse: - """Create Yjs version snapshot + version_id: str, + ) -> File: + """Get Yjs document version - This endpoint creates a new version history snapshot for the room's Yjs document. + This endpoint returns a specific version of the room's Yjs document encoded as a binary Yjs update. Args: room_id (str): ID of the room Example: my-room-id. + version_id (str): ID of the version Example: vh_abc123. Raises: errors.LiveblocksError: If the server returns a response with non-2xx status code. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - CreateYjsVersionResponse + File """ - from .api.yjs import create_yjs_version + from .api.yjs import get_yjs_version - return create_yjs_version._sync( + return get_yjs_version._sync( room_id=room_id, + version_id=version_id, client=self._client, ) @@ -1030,9 +1031,9 @@ def get_threads( Args: room_id (str): ID of the room Example: my-room-id. - query (str | Unset): Query to filter threads. You can filter by `metadata` and `resolved`, - for example, `metadata["status"]:"open" AND metadata["color"]:"red" AND resolved:true`. - Learn more about [filtering threads with query + query (str | Unset): Query to filter threads. You can filter by `metadata`, `resolved`, + and `visibility`, for example, `metadata["status"]:"open" AND metadata["color"]:"red" AND + resolved:true AND visibility:"private"`. Learn more about [filtering threads with query language](https://liveblocks.io/docs/guides/how-to-filter-threads-using-query-language). Example: metadata["color"]:"blue". @@ -3373,13 +3374,14 @@ async def create_room( reference/liveblocks-node#post-rooms), or to [`liveblocks.getOrCreateRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-or- create-rooms-roomId) when `?idempotent` is provided. - - `defaultAccesses` could be `[]` or `[\"*:write\"]` (private or public). + - `defaultAccesses` is the default room permission list, for example `[]`, `[\"*:read\"]`, + `[\"*:write\"]`, or a more granular permission list. - `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field. - - `usersAccesses` could be `[]` or `[\"*:write\"]` for every records. `usersAccesses` can contain - 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. - - `groupsAccesses` are optional fields. + - `usersAccesses` contains user-specific permission lists. It can contain 1000 ids maximum. Id + length has a limit of 256 characters. `usersAccesses` is optional field. + - `groupsAccesses` contains group-specific permission lists and is optional. Args: idempotent (bool | Unset): When provided, will not return a 409 when the room already @@ -3455,14 +3457,14 @@ async def update_room( }`` `defaultAccesses`, `metadata`, `usersAccesses`, `groupsAccesses` can be updated. - - `defaultAccesses` could be `[]` or `[\"*:write\"]` (private or public). + - `defaultAccesses` is the default room permission list, for example `[]`, `[\"*:read\"]`, + `[\"*:write\"]`, or a more granular permission list. - `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field. - - `usersAccesses` could be `[]` or `[\"*:write\"]` for every records. `usersAccesses` can contain - 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. - - `groupsAccesses` could be `[]` or `[\"*:write\"]` for every records. `groupsAccesses` can contain - 1000 ids maximum. Id length has a limit of 256 characters. `groupsAccesses` is optional field. + - `usersAccesses` contains user-specific permission lists. It can contain 1000 ids maximum. Id + length has a limit of 256 characters. `usersAccesses` is optional field. + - `groupsAccesses` contains group-specific permission lists and is optional. Args: room_id (str): ID of the room Example: my-room-id. @@ -3567,14 +3569,14 @@ async def upsert_room( }`` `defaultAccesses`, `metadata`, `usersAccesses`, `groupsAccesses` can be updated. - - `defaultAccesses` could be `[]` or `[\"*:write\"]` (private or public). + - `defaultAccesses` is the default room permission list, for example `[]`, `[\"*:read\"]`, + `[\"*:write\"]`, or a more granular permission list. - `metadata` could be key/value as `string` or `string[]`. `metadata` supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 256 characters maximum. `metadata` is optional field. - - `usersAccesses` could be `[]` or `[\"*:write\"]` for every records. `usersAccesses` can contain - 1000 ids maximum. Id length has a limit of 256 characters. `usersAccesses` is optional field. - - `groupsAccesses` could be `[]` or `[\"*:write\"]` for every records. `groupsAccesses` can contain - 1000 ids maximum. Id length has a limit of 256 characters. `groupsAccesses` is optional field. + - `usersAccesses` contains user-specific permission lists. It can contain 1000 ids maximum. Id + length has a limit of 256 characters. `usersAccesses` is optional field. + - `groupsAccesses` contains group-specific permission lists and is optional. Args: room_id (str): ID of the room Example: my-room-id. @@ -4059,14 +4061,14 @@ async def get_yjs_document_as_binary_update( client=self._client, ) - async def get_yjs_versions( + async def get_version_history( self, room_id: str, *, limit: int | Unset = 20, cursor: str | Unset = UNSET, - ) -> GetYjsVersionsResponse: - """Get Yjs version history + ) -> GetVersionHistoryResponse: + """Get Version History This endpoint returns a list of version history snapshots for the room's Yjs document. The versions are returned sorted by creation date, from newest to oldest. @@ -4083,70 +4085,70 @@ async def get_yjs_versions( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - GetYjsVersionsResponse + GetVersionHistoryResponse """ - from .api.yjs import get_yjs_versions + from .api.yjs import get_version_history - return await get_yjs_versions._asyncio( + return await get_version_history._asyncio( room_id=room_id, limit=limit, cursor=cursor, client=self._client, ) - async def get_yjs_version( + async def create_version_history_snapshot( self, room_id: str, - version_id: str, - ) -> File: - """Get Yjs document version + ) -> CreateVersionHistorySnapshotResponse: + """Create version history snapshot - This endpoint returns a specific version of the room's Yjs document encoded as a binary Yjs update. + This endpoint creates a new version history snapshot for the room. Currently only works for Yjs. Args: room_id (str): ID of the room Example: my-room-id. - version_id (str): ID of the version Example: vh_abc123. Raises: errors.LiveblocksError: If the server returns a response with non-2xx status code. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - File + CreateVersionHistorySnapshotResponse """ - from .api.yjs import get_yjs_version + from .api.yjs import create_version_history_snapshot - return await get_yjs_version._asyncio( + return await create_version_history_snapshot._asyncio( room_id=room_id, - version_id=version_id, client=self._client, ) - async def create_yjs_version( + async def get_yjs_version( self, room_id: str, - ) -> CreateYjsVersionResponse: - """Create Yjs version snapshot + version_id: str, + ) -> File: + """Get Yjs document version - This endpoint creates a new version history snapshot for the room's Yjs document. + This endpoint returns a specific version of the room's Yjs document encoded as a binary Yjs update. Args: room_id (str): ID of the room Example: my-room-id. + version_id (str): ID of the version Example: vh_abc123. Raises: errors.LiveblocksError: If the server returns a response with non-2xx status code. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - CreateYjsVersionResponse + File """ - from .api.yjs import create_yjs_version + from .api.yjs import get_yjs_version - return await create_yjs_version._asyncio( + return await get_yjs_version._asyncio( room_id=room_id, + version_id=version_id, client=self._client, ) @@ -4164,9 +4166,9 @@ async def get_threads( Args: room_id (str): ID of the room Example: my-room-id. - query (str | Unset): Query to filter threads. You can filter by `metadata` and `resolved`, - for example, `metadata["status"]:"open" AND metadata["color"]:"red" AND resolved:true`. - Learn more about [filtering threads with query + query (str | Unset): Query to filter threads. You can filter by `metadata`, `resolved`, + and `visibility`, for example, `metadata["status"]:"open" AND metadata["color"]:"red" AND + resolved:true AND visibility:"private"`. Learn more about [filtering threads with query language](https://liveblocks.io/docs/guides/how-to-filter-threads-using-query-language). Example: metadata["color"]:"blue". diff --git a/packages/liveblocks-python/liveblocks/models/__init__.py b/packages/liveblocks-python/liveblocks/models/__init__.py index 7c9ed5dddc0..57fae14c4f6 100644 --- a/packages/liveblocks-python/liveblocks/models/__init__.py +++ b/packages/liveblocks-python/liveblocks/models/__init__.py @@ -54,11 +54,12 @@ from .create_room_request_body_engine import CreateRoomRequestBodyEngine from .create_thread_request_body import CreateThreadRequestBody from .create_thread_request_body_comment import CreateThreadRequestBodyComment +from .create_thread_request_body_visibility import CreateThreadRequestBodyVisibility +from .create_version_history_snapshot_response import CreateVersionHistorySnapshotResponse +from .create_version_history_snapshot_response_data import CreateVersionHistorySnapshotResponseData from .create_web_knowledge_source_request_body import CreateWebKnowledgeSourceRequestBody from .create_web_knowledge_source_request_body_type import CreateWebKnowledgeSourceRequestBodyType from .create_web_knowledge_source_response import CreateWebKnowledgeSourceResponse -from .create_yjs_version_response import CreateYjsVersionResponse -from .create_yjs_version_response_data import CreateYjsVersionResponseData from .edit_comment_metadata_request_body import EditCommentMetadataRequestBody from .edit_comment_metadata_request_body_metadata import EditCommentMetadataRequestBodyMetadata from .edit_comment_request_body import EditCommentRequestBody @@ -85,10 +86,10 @@ from .get_thread_subscriptions_response import GetThreadSubscriptionsResponse from .get_threads_response import GetThreadsResponse from .get_user_groups_response import GetUserGroupsResponse +from .get_version_history_response import GetVersionHistoryResponse from .get_web_knowledge_source_links_response import GetWebKnowledgeSourceLinksResponse from .get_yjs_document_response import GetYjsDocumentResponse from .get_yjs_document_type import GetYjsDocumentType -from .get_yjs_versions_response import GetYjsVersionsResponse from .google_model import GoogleModel from .google_provider_options import GoogleProviderOptions from .google_provider_options_google import GoogleProviderOptionsGoogle @@ -96,6 +97,8 @@ from .group import Group from .group_member import GroupMember from .group_scopes import GroupScopes +from .history_version import HistoryVersion +from .history_version_authors_item import HistoryVersionAuthorsItem from .identify_user_request_body import IdentifyUserRequestBody from .identify_user_request_body_user_info import IdentifyUserRequestBodyUserInfo from .identify_user_response import IdentifyUserResponse @@ -144,6 +147,7 @@ from .test_json_patch_operation import TestJsonPatchOperation from .thread import Thread from .thread_metadata import ThreadMetadata +from .thread_visibility import ThreadVisibility from .trigger_inbox_notification_request_body import TriggerInboxNotificationRequestBody from .trigger_inbox_notification_request_body_activity_data import TriggerInboxNotificationRequestBodyActivityData from .unsubscribe_from_thread_request_body import UnsubscribeFromThreadRequestBody @@ -173,8 +177,6 @@ from .user_subscription import UserSubscription from .web_knowledge_source_link import WebKnowledgeSourceLink from .web_knowledge_source_link_status import WebKnowledgeSourceLinkStatus -from .yjs_version import YjsVersion -from .yjs_version_authors_item import YjsVersionAuthorsItem __all__ = ( "ActiveUsersResponse", @@ -225,11 +227,12 @@ "CreateRoomRequestBodyEngine", "CreateThreadRequestBody", "CreateThreadRequestBodyComment", + "CreateThreadRequestBodyVisibility", + "CreateVersionHistorySnapshotResponse", + "CreateVersionHistorySnapshotResponseData", "CreateWebKnowledgeSourceRequestBody", "CreateWebKnowledgeSourceRequestBodyType", "CreateWebKnowledgeSourceResponse", - "CreateYjsVersionResponse", - "CreateYjsVersionResponseData", "EditCommentMetadataRequestBody", "EditCommentMetadataRequestBodyMetadata", "EditCommentRequestBody", @@ -256,10 +259,10 @@ "GetThreadsResponse", "GetThreadSubscriptionsResponse", "GetUserGroupsResponse", + "GetVersionHistoryResponse", "GetWebKnowledgeSourceLinksResponse", "GetYjsDocumentResponse", "GetYjsDocumentType", - "GetYjsVersionsResponse", "GoogleModel", "GoogleProviderOptions", "GoogleProviderOptionsGoogle", @@ -267,6 +270,8 @@ "Group", "GroupMember", "GroupScopes", + "HistoryVersion", + "HistoryVersionAuthorsItem", "IdentifyUserRequestBody", "IdentifyUserRequestBodyUserInfo", "IdentifyUserResponse", @@ -316,6 +321,7 @@ "TestJsonPatchOperation", "Thread", "ThreadMetadata", + "ThreadVisibility", "TriggerInboxNotificationRequestBody", "TriggerInboxNotificationRequestBodyActivityData", "UnsubscribeFromThreadRequestBody", @@ -343,6 +349,4 @@ "UserSubscription", "WebKnowledgeSourceLink", "WebKnowledgeSourceLinkStatus", - "YjsVersion", - "YjsVersionAuthorsItem", ) diff --git a/packages/liveblocks-python/liveblocks/models/create_thread_request_body.py b/packages/liveblocks-python/liveblocks/models/create_thread_request_body.py index ff8cfefa9fc..6c7622d9b05 100644 --- a/packages/liveblocks-python/liveblocks/models/create_thread_request_body.py +++ b/packages/liveblocks-python/liveblocks/models/create_thread_request_body.py @@ -6,6 +6,7 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field +from ..models.create_thread_request_body_visibility import CreateThreadRequestBodyVisibility from ..types import UNSET, Unset if TYPE_CHECKING: @@ -20,10 +21,12 @@ class CreateThreadRequestBody: comment (CreateThreadRequestBodyComment): metadata (ThreadMetadata | Unset): Custom metadata attached to a thread. Supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 4000 characters maximum for strings. + visibility (CreateThreadRequestBodyVisibility | Unset): Default: CreateThreadRequestBodyVisibility.PUBLIC. """ comment: CreateThreadRequestBodyComment metadata: ThreadMetadata | Unset = UNSET + visibility: CreateThreadRequestBodyVisibility | Unset = CreateThreadRequestBodyVisibility.PUBLIC additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -33,6 +36,10 @@ def to_dict(self) -> dict[str, Any]: if not isinstance(self.metadata, Unset): metadata = self.metadata.to_dict() + visibility: str | Unset = UNSET + if not isinstance(self.visibility, Unset): + visibility = self.visibility.value + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -42,6 +49,8 @@ def to_dict(self) -> dict[str, Any]: ) if metadata is not UNSET: field_dict["metadata"] = metadata + if visibility is not UNSET: + field_dict["visibility"] = visibility return field_dict @@ -60,9 +69,17 @@ def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: else: metadata = ThreadMetadata.from_dict(_metadata) + _visibility = d.pop("visibility", UNSET) + visibility: CreateThreadRequestBodyVisibility | Unset + if isinstance(_visibility, Unset): + visibility = UNSET + else: + visibility = CreateThreadRequestBodyVisibility(_visibility) + create_thread_request_body = cls( comment=comment, metadata=metadata, + visibility=visibility, ) create_thread_request_body.additional_properties = d diff --git a/packages/liveblocks-python/liveblocks/models/create_thread_request_body_visibility.py b/packages/liveblocks-python/liveblocks/models/create_thread_request_body_visibility.py new file mode 100644 index 00000000000..3163a4e2cfa --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/create_thread_request_body_visibility.py @@ -0,0 +1,6 @@ +from enum import StrEnum + + +class CreateThreadRequestBodyVisibility(StrEnum): + PRIVATE = "private" + PUBLIC = "public" diff --git a/packages/liveblocks-python/liveblocks/models/create_yjs_version_response.py b/packages/liveblocks-python/liveblocks/models/create_version_history_snapshot_response.py similarity index 53% rename from packages/liveblocks-python/liveblocks/models/create_yjs_version_response.py rename to packages/liveblocks-python/liveblocks/models/create_version_history_snapshot_response.py index 03f2177f89e..34c64909b87 100644 --- a/packages/liveblocks-python/liveblocks/models/create_yjs_version_response.py +++ b/packages/liveblocks-python/liveblocks/models/create_version_history_snapshot_response.py @@ -6,20 +6,20 @@ from attrs import define as _attrs_define if TYPE_CHECKING: - from ..models.create_yjs_version_response_data import CreateYjsVersionResponseData + from ..models.create_version_history_snapshot_response_data import CreateVersionHistorySnapshotResponseData @_attrs_define -class CreateYjsVersionResponse: +class CreateVersionHistorySnapshotResponse: """ Example: {'data': {'id': 'vh_abc123'}} Attributes: - data (CreateYjsVersionResponseData): + data (CreateVersionHistorySnapshotResponseData): """ - data: CreateYjsVersionResponseData + data: CreateVersionHistorySnapshotResponseData def to_dict(self) -> dict[str, Any]: data = self.data.to_dict() @@ -36,13 +36,13 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: - from ..models.create_yjs_version_response_data import CreateYjsVersionResponseData + from ..models.create_version_history_snapshot_response_data import CreateVersionHistorySnapshotResponseData d = dict(src_dict) - data = CreateYjsVersionResponseData.from_dict(d.pop("data")) + data = CreateVersionHistorySnapshotResponseData.from_dict(d.pop("data")) - create_yjs_version_response = cls( + create_version_history_snapshot_response = cls( data=data, ) - return create_yjs_version_response + return create_version_history_snapshot_response diff --git a/packages/liveblocks-python/liveblocks/models/create_yjs_version_response_data.py b/packages/liveblocks-python/liveblocks/models/create_version_history_snapshot_response_data.py similarity index 79% rename from packages/liveblocks-python/liveblocks/models/create_yjs_version_response_data.py rename to packages/liveblocks-python/liveblocks/models/create_version_history_snapshot_response_data.py index f958c26db07..9ae8a4a50f2 100644 --- a/packages/liveblocks-python/liveblocks/models/create_yjs_version_response_data.py +++ b/packages/liveblocks-python/liveblocks/models/create_version_history_snapshot_response_data.py @@ -7,7 +7,7 @@ @_attrs_define -class CreateYjsVersionResponseData: +class CreateVersionHistorySnapshotResponseData: """ Attributes: id (str): Unique identifier for the created version @@ -33,8 +33,8 @@ def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) id = d.pop("id") - create_yjs_version_response_data = cls( + create_version_history_snapshot_response_data = cls( id=id, ) - return create_yjs_version_response_data + return create_version_history_snapshot_response_data diff --git a/packages/liveblocks-python/liveblocks/models/get_threads_response.py b/packages/liveblocks-python/liveblocks/models/get_threads_response.py index eac780933c7..bf7bbb50634 100644 --- a/packages/liveblocks-python/liveblocks/models/get_threads_response.py +++ b/packages/liveblocks-python/liveblocks/models/get_threads_response.py @@ -17,7 +17,7 @@ class GetThreadsResponse: 'threadId': 'th_abc123', 'roomId': 'my-room-id', 'id': 'cm_abc123', 'userId': 'alice', 'createdAt': '2022-07-13T14:32:50.697Z', 'body': {'version': 1, 'content': []}, 'metadata': {}, 'reactions': [], 'attachments': []}], 'createdAt': '2022-07-13T14:32:50.697Z', 'updatedAt': '2022-07-13T14:32:50.697Z', - 'metadata': {}, 'resolved': False}]} + 'metadata': {}, 'resolved': False, 'visibility': 'public'}]} Attributes: data (list[Thread]): diff --git a/packages/liveblocks-python/liveblocks/models/get_yjs_versions_response.py b/packages/liveblocks-python/liveblocks/models/get_version_history_response.py similarity index 70% rename from packages/liveblocks-python/liveblocks/models/get_yjs_versions_response.py rename to packages/liveblocks-python/liveblocks/models/get_version_history_response.py index d7b338395ff..2144288c60f 100644 --- a/packages/liveblocks-python/liveblocks/models/get_yjs_versions_response.py +++ b/packages/liveblocks-python/liveblocks/models/get_version_history_response.py @@ -6,23 +6,23 @@ from attrs import define as _attrs_define if TYPE_CHECKING: - from ..models.yjs_version import YjsVersion + from ..models.history_version import HistoryVersion @_attrs_define -class GetYjsVersionsResponse: +class GetVersionHistoryResponse: """ Example: - {'data': [{'type': 'historyVersion', 'id': 'vh_abc123', 'createdAt': '2024-10-15T10:30:00.000Z', 'authors': - [{'id': 'user-123'}], 'kind': 'yjs'}], 'nextCursor': 'eyJjcmVhdGVkQXQiOiIyMDI0LTEwLTE1VDEwOjMwOjAwLjAwMFoifQ=='} + {'data': [{'id': 'vh_abc123', 'createdAt': '2024-10-15T10:30:00.000Z', 'authors': [{'id': 'user-123'}]}], + 'nextCursor': 'eyJjcmVhdGVkQXQiOiIyMDI0LTEwLTE1VDEwOjMwOjAwLjAwMFoifQ=='} Attributes: next_cursor (None | str): Cursor for pagination to get the next page of results - data (list[YjsVersion]): + data (list[HistoryVersion]): """ next_cursor: None | str - data: list[YjsVersion] + data: list[HistoryVersion] def to_dict(self) -> dict[str, Any]: next_cursor: None | str @@ -46,7 +46,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: - from ..models.yjs_version import YjsVersion + from ..models.history_version import HistoryVersion d = dict(src_dict) @@ -60,13 +60,13 @@ def _parse_next_cursor(data: object) -> None | str: data = [] _data = d.pop("data") for data_item_data in _data: - data_item = YjsVersion.from_dict(data_item_data) + data_item = HistoryVersion.from_dict(data_item_data) data.append(data_item) - get_yjs_versions_response = cls( + get_version_history_response = cls( next_cursor=next_cursor, data=data, ) - return get_yjs_versions_response + return get_version_history_response diff --git a/packages/liveblocks-python/liveblocks/models/history_version.py b/packages/liveblocks-python/liveblocks/models/history_version.py new file mode 100644 index 00000000000..3fe2b5afffe --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/history_version.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +if TYPE_CHECKING: + from ..models.history_version_authors_item import HistoryVersionAuthorsItem + + +@_attrs_define +class HistoryVersion: + """ + Example: + {'id': 'vh_abc123', 'createdAt': '2024-10-15T10:30:00.000Z', 'authors': [{'id': 'user-123'}, {'id': + 'user-456'}]} + + Attributes: + id (str): Unique identifier for the version + created_at (datetime.datetime): ISO 8601 timestamp of when the version was created + authors (list[HistoryVersionAuthorsItem]): List of users who contributed to this version + """ + + id: str + created_at: datetime.datetime + authors: list[HistoryVersionAuthorsItem] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + id = self.id + + created_at = self.created_at.isoformat() + + authors = [] + for authors_item_data in self.authors: + authors_item = authors_item_data.to_dict() + authors.append(authors_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "createdAt": created_at, + "authors": authors, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.history_version_authors_item import HistoryVersionAuthorsItem + + d = dict(src_dict) + id = d.pop("id") + + created_at = isoparse(d.pop("createdAt")) + + authors = [] + _authors = d.pop("authors") + for authors_item_data in _authors: + authors_item = HistoryVersionAuthorsItem.from_dict(authors_item_data) + + authors.append(authors_item) + + history_version = cls( + id=id, + created_at=created_at, + authors=authors, + ) + + history_version.additional_properties = d + return history_version + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/liveblocks-python/liveblocks/models/yjs_version_authors_item.py b/packages/liveblocks-python/liveblocks/models/history_version_authors_item.py similarity index 88% rename from packages/liveblocks-python/liveblocks/models/yjs_version_authors_item.py rename to packages/liveblocks-python/liveblocks/models/history_version_authors_item.py index 83d7e28c08d..78fff0f50ca 100644 --- a/packages/liveblocks-python/liveblocks/models/yjs_version_authors_item.py +++ b/packages/liveblocks-python/liveblocks/models/history_version_authors_item.py @@ -10,7 +10,7 @@ @_attrs_define -class YjsVersionAuthorsItem: +class HistoryVersionAuthorsItem: """ Attributes: id (str | Unset): User ID of the author @@ -35,12 +35,12 @@ def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) id = d.pop("id", UNSET) - yjs_version_authors_item = cls( + history_version_authors_item = cls( id=id, ) - yjs_version_authors_item.additional_properties = d - return yjs_version_authors_item + history_version_authors_item.additional_properties = d + return history_version_authors_item @property def additional_keys(self) -> list[str]: diff --git a/packages/liveblocks-python/liveblocks/models/room_permission_item.py b/packages/liveblocks-python/liveblocks/models/room_permission_item.py index 5aa7f4aeec6..d4360475bbc 100644 --- a/packages/liveblocks-python/liveblocks/models/room_permission_item.py +++ b/packages/liveblocks-python/liveblocks/models/room_permission_item.py @@ -3,6 +3,12 @@ class RoomPermissionItem(StrEnum): COMMENTSNONE = "comments:none" + COMMENTSPRIVATENONE = "comments:private:none" + COMMENTSPRIVATEREAD = "comments:private:read" + COMMENTSPRIVATEWRITE = "comments:private:write" + COMMENTSPUBLICNONE = "comments:public:none" + COMMENTSPUBLICREAD = "comments:public:read" + COMMENTSPUBLICWRITE = "comments:public:write" COMMENTSREAD = "comments:read" COMMENTSWRITE = "comments:write" FEEDSNONE = "feeds:none" diff --git a/packages/liveblocks-python/liveblocks/models/thread.py b/packages/liveblocks-python/liveblocks/models/thread.py index bbc9732aa45..6fc9629abba 100644 --- a/packages/liveblocks-python/liveblocks/models/thread.py +++ b/packages/liveblocks-python/liveblocks/models/thread.py @@ -8,6 +8,8 @@ from attrs import field as _attrs_field from dateutil.parser import isoparse +from ..models.thread_visibility import ThreadVisibility + if TYPE_CHECKING: from ..models.comment import Comment from ..models.thread_metadata import ThreadMetadata @@ -21,7 +23,7 @@ class Thread: 'th_abc123', 'roomId': 'my-room-id', 'id': 'cm_abc123', 'userId': 'alice', 'createdAt': '2022-07-13T14:32:50.697Z', 'body': {'version': 1, 'content': []}, 'metadata': {}, 'reactions': [], 'attachments': []}], 'createdAt': '2022-07-13T14:32:50.697Z', 'updatedAt': '2022-07-13T14:32:50.697Z', - 'metadata': {'color': 'blue'}, 'resolved': False} + 'metadata': {'color': 'blue'}, 'resolved': False, 'visibility': 'public'} Attributes: type_ (Literal['thread']): @@ -32,6 +34,7 @@ class Thread: metadata (ThreadMetadata): Custom metadata attached to a thread. Supports maximum 50 entries. Key length has a limit of 40 characters maximum. Value length has a limit of 4000 characters maximum for strings. resolved (bool): + visibility (ThreadVisibility): updated_at (datetime.datetime): """ @@ -42,6 +45,7 @@ class Thread: created_at: datetime.datetime metadata: ThreadMetadata resolved: bool + visibility: ThreadVisibility updated_at: datetime.datetime additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -63,6 +67,8 @@ def to_dict(self) -> dict[str, Any]: resolved = self.resolved + visibility = self.visibility.value + updated_at = self.updated_at.isoformat() field_dict: dict[str, Any] = {} @@ -76,6 +82,7 @@ def to_dict(self) -> dict[str, Any]: "createdAt": created_at, "metadata": metadata, "resolved": resolved, + "visibility": visibility, "updatedAt": updated_at, } ) @@ -109,6 +116,8 @@ def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: resolved = d.pop("resolved") + visibility = ThreadVisibility(d.pop("visibility")) + updated_at = isoparse(d.pop("updatedAt")) thread = cls( @@ -119,6 +128,7 @@ def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: created_at=created_at, metadata=metadata, resolved=resolved, + visibility=visibility, updated_at=updated_at, ) diff --git a/packages/liveblocks-python/liveblocks/models/thread_visibility.py b/packages/liveblocks-python/liveblocks/models/thread_visibility.py new file mode 100644 index 00000000000..e27517542f0 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/thread_visibility.py @@ -0,0 +1,6 @@ +from enum import StrEnum + + +class ThreadVisibility(StrEnum): + PRIVATE = "private" + PUBLIC = "public" diff --git a/packages/liveblocks-python/liveblocks/models/yjs_version.py b/packages/liveblocks-python/liveblocks/models/yjs_version.py deleted file mode 100644 index c8450bee7ce..00000000000 --- a/packages/liveblocks-python/liveblocks/models/yjs_version.py +++ /dev/null @@ -1,121 +0,0 @@ -from __future__ import annotations - -import datetime -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, Literal, Self, cast - -from attrs import define as _attrs_define -from attrs import field as _attrs_field -from dateutil.parser import isoparse - -from ..types import UNSET, Unset - -if TYPE_CHECKING: - from ..models.yjs_version_authors_item import YjsVersionAuthorsItem - - -@_attrs_define -class YjsVersion: - """ - Example: - {'id': 'vh_abc123', 'type': 'historyVersion', 'createdAt': '2024-10-15T10:30:00.000Z', 'authors': [{'id': - 'user-123'}, {'id': 'user-456'}], 'kind': 'yjs'} - - Attributes: - id (str): Unique identifier for the version - type_ (Literal['historyVersion']): - created_at (datetime.datetime): ISO 8601 timestamp of when the version was created - kind (Literal['yjs']): - authors (list[YjsVersionAuthorsItem] | Unset): List of users who contributed to this version - """ - - id: str - type_: Literal["historyVersion"] - created_at: datetime.datetime - kind: Literal["yjs"] - authors: list[YjsVersionAuthorsItem] | Unset = UNSET - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - id = self.id - - type_ = self.type_ - - created_at = self.created_at.isoformat() - - kind = self.kind - - authors: list[dict[str, Any]] | Unset = UNSET - if not isinstance(self.authors, Unset): - authors = [] - for authors_item_data in self.authors: - authors_item = authors_item_data.to_dict() - authors.append(authors_item) - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "id": id, - "type": type_, - "createdAt": created_at, - "kind": kind, - } - ) - if authors is not UNSET: - field_dict["authors"] = authors - - return field_dict - - @classmethod - def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: - from ..models.yjs_version_authors_item import YjsVersionAuthorsItem - - d = dict(src_dict) - id = d.pop("id") - - type_ = cast(Literal["historyVersion"], d.pop("type")) - if type_ != "historyVersion": - raise ValueError(f"type must match const 'historyVersion', got '{type_}'") - - created_at = isoparse(d.pop("createdAt")) - - kind = cast(Literal["yjs"], d.pop("kind")) - if kind != "yjs": - raise ValueError(f"kind must match const 'yjs', got '{kind}'") - - _authors = d.pop("authors", UNSET) - authors: list[YjsVersionAuthorsItem] | Unset = UNSET - if _authors is not UNSET: - authors = [] - for authors_item_data in _authors: - authors_item = YjsVersionAuthorsItem.from_dict(authors_item_data) - - authors.append(authors_item) - - yjs_version = cls( - id=id, - type_=type_, - created_at=created_at, - kind=kind, - authors=authors, - ) - - yjs_version.additional_properties = d - return yjs_version - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/packages/liveblocks-python/liveblocks/session.py b/packages/liveblocks-python/liveblocks/session.py index 23de587efe8..7e595d77701 100644 --- a/packages/liveblocks-python/liveblocks/session.py +++ b/packages/liveblocks-python/liveblocks/session.py @@ -22,6 +22,12 @@ "comments:read", "comments:write", "comments:none", + "comments:public:read", + "comments:public:write", + "comments:public:none", + "comments:private:read", + "comments:private:write", + "comments:private:none", "feeds:read", "feeds:write", "feeds:none", @@ -40,6 +46,12 @@ "comments:read", "comments:write", "comments:none", + "comments:public:read", + "comments:public:write", + "comments:public:none", + "comments:private:read", + "comments:private:write", + "comments:private:none", "feeds:read", "feeds:write", "feeds:none", diff --git a/packages/liveblocks-python/pyproject.toml b/packages/liveblocks-python/pyproject.toml index 6994d540bc8..d9419018413 100644 --- a/packages/liveblocks-python/pyproject.toml +++ b/packages/liveblocks-python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "liveblocks" -version = "3.20.0" +version = "3.21.0" description = "A client library for accessing Liveblocks API" authors = [] requires-python = ">=3.11" diff --git a/packages/liveblocks-python/tests/test_session.py b/packages/liveblocks-python/tests/test_session.py index 071abb1e54f..449ce8fa0ac 100644 --- a/packages/liveblocks-python/tests/test_session.py +++ b/packages/liveblocks-python/tests/test_session.py @@ -8,6 +8,8 @@ P1 = "*:read" P2 = "*:write" P3 = "comments:read" +P4 = "comments:public:write" +P5 = "comments:private:none" def make_session( @@ -83,6 +85,11 @@ def test_permissions_are_additive(self): "bar": [P2], } + def test_accepts_scoped_comments_permissions(self): + assert (make_session().allow("foo", [P1, P4, P5])._serialize_permissions()) == { + "foo": [P1, P4, P5], + } + def test_permissions_are_deduped(self): assert ( make_session() diff --git a/packages/liveblocks-react-blocknote/package.json b/packages/liveblocks-react-blocknote/package.json index ffd94c019e4..2b08ca60d6e 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.20.1", + "version": "3.21.0", "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 9b1eec21ea5..6194e4a5393 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.20.1", + "version": "3.21.0", "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 fa103380816..b8f29562481 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.20.1", + "version": "3.21.0", "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 68bdac7ae00..0bf6eb4636e 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 @@ -11,7 +11,7 @@ import { syncYjsChangesToLexical, } from "@lexical/yjs"; import type { HistoryVersion } from "@liveblocks/core"; -import { useHistoryVersionData } from "@liveblocks/react"; +import { useHistoryVersionYjsData } from "@liveblocks/react"; import { useOverrides } from "@liveblocks/react-ui"; import { Button, @@ -112,7 +112,7 @@ export const HistoryVersionPreview = forwardRef< const [parentEditor, parentContext] = useLexicalComposerContext(); const editor = useRef(); const $ = useOverrides(); - const { isLoading, data, error } = useHistoryVersionData(version.id); + const { isLoading, data, error } = useHistoryVersionYjsData(version.id); const initialConfig = useMemo(() => { const nodes = Array.from(parentEditor._nodes.values()).map((n) => n.klass); diff --git a/packages/liveblocks-react-tiptap/package.json b/packages/liveblocks-react-tiptap/package.json index 162500108ab..e20b5ba2be0 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.20.1", + "version": "3.21.0", "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 6665438e7da..2d54a97ad2c 100644 --- a/packages/liveblocks-react-tiptap/src/version-history/HistoryVersionPreview.tsx +++ b/packages/liveblocks-react-tiptap/src/version-history/HistoryVersionPreview.tsx @@ -1,5 +1,5 @@ import type { HistoryVersion } from "@liveblocks/core"; -import { useHistoryVersionData } from "@liveblocks/react"; +import { useHistoryVersionYjsData } from "@liveblocks/react"; import { useOverrides } from "@liveblocks/react-ui"; import { Button, @@ -39,7 +39,7 @@ export const HistoryVersionPreview = forwardRef< forwardedRef ) => { const $ = useOverrides(); - const { isLoading, data, error } = useHistoryVersionData(version.id); + const { isLoading, data, error } = useHistoryVersionYjsData(version.id); const previewEditor = useEditor({ // ignore extensions, only get marks/nodes diff --git a/packages/liveblocks-react-ui/package.json b/packages/liveblocks-react-ui/package.json index f25a9eaee32..eda35e477f5 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.20.1", + "version": "3.21.0", "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/__tests__/_liveblocks.config.ts b/packages/liveblocks-react-ui/src/__tests__/_liveblocks.config.ts index 0d8ea198608..5256a1a88de 100644 --- a/packages/liveblocks-react-ui/src/__tests__/_liveblocks.config.ts +++ b/packages/liveblocks-react-ui/src/__tests__/_liveblocks.config.ts @@ -1,7 +1,7 @@ import { createClient } from "@liveblocks/client"; import { createRoomContext } from "@liveblocks/react"; -const client = createClient({ +export const client = createClient({ publicApiKey: "pk_localdev", baseUrl: `http://localhost:${process.env.LIVEBLOCKS_DEV_SERVER_PORT ?? 1154}`, polyfills: { WebSocket: globalThis.WebSocket }, diff --git a/packages/liveblocks-react-ui/src/__tests__/index.test.tsx b/packages/liveblocks-react-ui/src/__tests__/index.test.tsx index e317c4caed0..5419782f3b9 100644 --- a/packages/liveblocks-react-ui/src/__tests__/index.test.tsx +++ b/packages/liveblocks-react-ui/src/__tests__/index.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, test } from "vitest"; import { Comment } from "../components/Comment"; import { Composer } from "../components/Composer"; import { Thread } from "../components/Thread"; -import { render } from "./_utils"; // Basically re-exports from @testing-library/react +import { fireEvent, render, screen } from "./_utils"; // Basically re-exports from @testing-library/react const comment: CommentData = { type: "comment", @@ -116,6 +116,7 @@ const thread: ThreadData = { comments: [comment, editedComment, deletedComment], metadata: {}, resolved: false, + visibility: "public", }; describe("Thread", () => { @@ -140,4 +141,31 @@ describe("Composer", () => { expect(container).not.toBeEmptyDOMElement(); }); + + test("should stay expanded after blurring when initially expanded", () => { + render(); + + fireEvent.focus(screen.getByLabelText("Composer editor")); + fireEvent.blur(screen.getByLabelText("Composer editor"), { + relatedTarget: document.body, + }); + + expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument(); + }); + + test("should collapse after blurring when initially collapsed", () => { + render(); + + expect(screen.queryByRole("button", { name: "Send" })).toBeNull(); + + fireEvent.focus(screen.getByLabelText("Composer editor")); + + expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument(); + + fireEvent.blur(screen.getByLabelText("Composer editor"), { + relatedTarget: document.body, + }); + + expect(screen.queryByRole("button", { name: "Send" })).toBeNull(); + }); }); diff --git a/packages/liveblocks-react-ui/src/components/Comment.tsx b/packages/liveblocks-react-ui/src/components/Comment.tsx index 6be10c80e88..54036bee090 100644 --- a/packages/liveblocks-react-ui/src/components/Comment.tsx +++ b/packages/liveblocks-react-ui/src/components/Comment.tsx @@ -10,6 +10,7 @@ import { type GroupMentionData, MENTION_CHARACTER, type MentionData, + type ThreadVisibility, } from "@liveblocks/core"; import { useAddRoomCommentReaction, @@ -66,15 +67,14 @@ import type { } from "../primitives/Comment/types"; import * as ComposerPrimitive from "../primitives/Composer"; import { Timestamp } from "../primitives/Timestamp"; -import { useCurrentUserId } from "../shared"; +import { commentsResourceForVisibility, useCurrentUserId } from "../shared"; import type { CommentAttachmentArgs } from "../types"; import { cn } from "../utils/cn"; import { download } from "../utils/download"; import { useIsGroupMentionMember } from "../utils/use-group-mention"; import { useRefs } from "../utils/use-refs"; import { UserAvatar } from "./Avatar"; -import type { ComposerProps } from "./Composer"; -import { Composer } from "./Composer"; +import { Composer, type ComposerProps } from "./Composer"; import { FileAttachment, MediaAttachment, @@ -100,6 +100,11 @@ export interface CommentProps extends Omit< */ comment: CommentData; + /** + * The visibility of the thread containing the comment. + */ + visibility?: ThreadVisibility; + /** * The comment's avatar. * Can be combined with `Comment.Avatar` to easily follow default styles. @@ -689,6 +694,7 @@ export const Comment = Object.assign( ( { comment, + visibility, indentContent = true, showDeleted, showActions = "hover", @@ -736,7 +742,7 @@ export const Comment = Object.assign( const canComment = useHasPermissionAccess( comment.roomId, - "comments", + commentsResourceForVisibility(visibility), "write" ); @@ -886,6 +892,9 @@ export const Comment = Object.assign( content = ( = { metadata?: never; + /** + * @internal + * The visibility of the parent thread. + */ + visibility?: ThreadVisibility; + /** * The metadata of the comment to create. */ @@ -140,6 +153,12 @@ export type ComposerEditCommentProps = { metadata?: never; + /** + * @internal + * The visibility of the parent thread. + */ + visibility?: ThreadVisibility; + /** * The metadata of the comment to edit. */ @@ -707,6 +726,7 @@ export const Composer = forwardRef( threadId, commentId, metadata, + visibility, commentMetadata, defaultValue, defaultAttachments, @@ -753,8 +773,19 @@ export const Composer = forwardRef( controlledCollapsed, controlledOnCollapsedChange ); - - const canComment = useHasPermissionAccess(roomId, "comments", "write"); + // Only auto-collapse composers that are explicitly meant to support a collapsed state. + const shouldCollapseWhenEmpty = + controlledCollapsed !== undefined || defaultCollapsed === true; + + const commentsResource = + threadId === undefined + ? commentsResourceForVisibility(visibility ?? "public") + : commentsResourceForVisibility(visibility); + const canComment = useHasPermissionAccess( + roomId, + commentsResource, + "write" + ); const setEmptyRef = useCallback((isEmpty: boolean) => { isEmptyRef.current = isEmpty; @@ -791,11 +822,16 @@ export const Composer = forwardRef( event.relatedTarget ?? document.activeElement ); - if (isOutside && isEmptyRef.current && !isEmojiPickerOpenRef.current) { + if ( + shouldCollapseWhenEmpty && + isOutside && + isEmptyRef.current && + !isEmojiPickerOpenRef.current + ) { onCollapsedChange?.(true); } }, - [onBlur, onCollapsedChange] + [onBlur, onCollapsedChange, shouldCollapseWhenEmpty] ); const handleEditorClick = useCallback( @@ -838,6 +874,7 @@ export const Composer = forwardRef( createThread({ body: comment.body, metadata, + visibility, commentMetadata: commentMetadata as CM | undefined, attachments: comment.attachments, }); @@ -850,6 +887,7 @@ export const Composer = forwardRef( editComment, commentMetadata, metadata, + visibility, onComposerSubmit, threadId, ] diff --git a/packages/liveblocks-react-ui/src/components/Thread.tsx b/packages/liveblocks-react-ui/src/components/Thread.tsx index 4b608fbe57c..4f2b2e9c123 100644 --- a/packages/liveblocks-react-ui/src/components/Thread.tsx +++ b/packages/liveblocks-react-ui/src/components/Thread.tsx @@ -46,6 +46,7 @@ import type { ThreadOverrides, } from "../overrides"; import { useOverrides } from "../overrides"; +import { commentsResourceForVisibility } from "../shared"; import { cn } from "../utils/cn"; import { useStableComponent } from "../utils/use-stable-component"; import { useIntersectionCallback } from "../utils/use-visible"; @@ -440,7 +441,7 @@ export const Thread = forwardRef( const canComment = useHasPermissionAccess( thread.roomId, - "comments", + commentsResourceForVisibility(thread.visibility), "write" ); @@ -555,6 +556,7 @@ export const Thread = forwardRef( className="lb-thread-comment" data-unread={isUnread ? "" : undefined} comment={comment} + visibility={thread.visibility} indentContent={indentCommentContent} showDeleted={showDeletedComments} showActions={showActions} @@ -658,6 +660,7 @@ export const Thread = forwardRef( { + test("accepts augmented thread and comment metadata", () => { + void ( + + ); + + void ( + + ); + + void ( + + ); + }); +}); + describe("InboxNotification `kinds` (with Liveblocks augmentation)", () => { test("existing kinds have the expected props types", () => { void ( diff --git a/packages/liveblocks-react-ui/test-d/no-augmentation.test-d.tsx b/packages/liveblocks-react-ui/test-d/no-augmentation.test-d.tsx index 40607d677ff..9b999c52bf0 100644 --- a/packages/liveblocks-react-ui/test-d/no-augmentation.test-d.tsx +++ b/packages/liveblocks-react-ui/test-d/no-augmentation.test-d.tsx @@ -1,10 +1,68 @@ import type { ActivityData, InboxNotificationData } from "@liveblocks/core"; import type { InboxNotificationCustomKindProps } from "@liveblocks/react-ui"; -import { InboxNotification } from "@liveblocks/react-ui"; +import { Composer, InboxNotification } from "@liveblocks/react-ui"; import { describe, expectTypeOf, test } from "vitest"; // TODO: Create type tests for all components/props +describe("Composer (no Liveblocks augmentation)", () => { + test("accepts metadata props for each mode", () => { + void ( + + ); + + void ( + + ); + + void ( + + ); + }); + + test("keeps thread-only props out of reply and edit modes", () => { + void ( + ( + // @ts-expect-error - visibility only applies when creating threads + + ) + ); + void ( + ( + // @ts-expect-error - thread metadata only applies when creating threads + + ) + ); + void ( + ( + // @ts-expect-error - commentId requires threadId + + ) + ); + void ( + ( + // @ts-expect-error - thread metadata only applies when creating threads + + ) + ); + }); +}); + describe("InboxNotification `kinds` (no Liveblocks augmentation)", () => { test("existing kinds have the expected props types", () => { void ( diff --git a/packages/liveblocks-react/package.json b/packages/liveblocks-react/package.json index 44cd0bd934d..4dfcb630893 100644 --- a/packages/liveblocks-react/package.json +++ b/packages/liveblocks-react/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react", - "version": "3.20.1", + "version": "3.21.0", "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-react/scripts/check-exports.ts b/packages/liveblocks-react/scripts/check-exports.ts index 3cba834f85a..7173a2c8c3a 100755 --- a/packages/liveblocks-react/scripts/check-exports.ts +++ b/packages/liveblocks-react/scripts/check-exports.ts @@ -70,6 +70,7 @@ const CLASSIC_ONLY = [ "createLiveblocksContext", "createRoomContext", "useHistoryVersionData", + "useHistoryVersionYjsData", "useSearchComments", ]; const SUSPENSE_ONLY = []; diff --git a/packages/liveblocks-react/src/__tests__/ThreadDB.test.ts b/packages/liveblocks-react/src/__tests__/ThreadDB.test.ts index cffdcc3431a..012e2f49bd8 100644 --- a/packages/liveblocks-react/src/__tests__/ThreadDB.test.ts +++ b/packages/liveblocks-react/src/__tests__/ThreadDB.test.ts @@ -280,6 +280,7 @@ describe("ThreadDB", () => { roomId: "room1", createdAt: new Date("2024-10-09"), resolved: false, + visibility: "private", metadata: { color: "red", tag: "even" }, }); const th3 = dummyThreadData({ @@ -302,6 +303,7 @@ describe("ThreadDB", () => { roomId: "room1", createdAt: new Date("2024-10-12"), resolved: true, + visibility: "private", metadata: { color: "brown", tag: "odd" }, }); @@ -337,6 +339,16 @@ describe("ThreadDB", () => { th5, ]); + // Visibility checks + expect(db.findMany(undefined, { visibility: "private" }, "asc")).toEqual([ + th2, + th5, + ]); + expect(db.findMany(undefined, { visibility: "public" }, "asc")).toEqual([ + th1, + th3, + ]); + // Metadata checks { const query = { metadata: { color: "red" } }; diff --git a/packages/liveblocks-react/src/__tests__/_dummies.ts b/packages/liveblocks-react/src/__tests__/_dummies.ts index 4fbbea185cd..89e11ebfb39 100644 --- a/packages/liveblocks-react/src/__tests__/_dummies.ts +++ b/packages/liveblocks-react/src/__tests__/_dummies.ts @@ -30,6 +30,7 @@ export function dummyThreadData({ roomId, metadata: {}, resolved: false, + visibility: "public", ...overrides, comments: overrides.comments ? overrides.comments.map((comment) => ({ ...comment, threadId })) diff --git a/packages/liveblocks-react/src/__tests__/_restMocks.ts b/packages/liveblocks-react/src/__tests__/_restMocks.ts index 0acfce78885..f3fa6257246 100644 --- a/packages/liveblocks-react/src/__tests__/_restMocks.ts +++ b/packages/liveblocks-react/src/__tests__/_restMocks.ts @@ -11,6 +11,7 @@ import type { SubscriptionData, ThreadData, ThreadDataWithDeleteInfo, + ThreadVisibility, } from "@liveblocks/core"; import type { HttpResponseResolver } from "msw"; import { http } from "msw"; @@ -63,6 +64,7 @@ export function mockCreateThread< { roomId: string }, { id: string; + visibility?: ThreadVisibility; metadata?: TM; comment: { id: string; body: CommentBody; metadata?: CM }; }, diff --git a/packages/liveblocks-react/src/__tests__/_utils.tsx b/packages/liveblocks-react/src/__tests__/_utils.tsx index 444c0ff3561..6338d8ea629 100644 --- a/packages/liveblocks-react/src/__tests__/_utils.tsx +++ b/packages/liveblocks-react/src/__tests__/_utils.tsx @@ -131,6 +131,7 @@ const parser = new QueryParser({ fields: { resolved: "boolean", subscribed: "boolean", + visibility: "string", }, indexableFields: { metadata: "mixed", diff --git a/packages/liveblocks-react/src/__tests__/index.test.tsx b/packages/liveblocks-react/src/__tests__/index.test.tsx index d1e642ed5fc..da5666c2116 100644 --- a/packages/liveblocks-react/src/__tests__/index.test.tsx +++ b/packages/liveblocks-react/src/__tests__/index.test.tsx @@ -3,6 +3,7 @@ import { ClientMsgCode, ServerMsgCode, wait } from "@liveblocks/core"; import { render } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; +import type { ReactNode } from "react"; import { afterAll, afterEach, @@ -14,8 +15,10 @@ import { vi, } from "vitest"; +import { useHasPermissionAccess } from "../_private"; import { createRoomContext, useRoom as useRoomGlobal } from "../room"; import { + RoomProvider, useCanRedo, useCanUndo, useHistory, @@ -306,6 +309,68 @@ describe("useOthers", () => { }); }); +describe("useHasPermissionAccess", () => { + test("optimistically allows writing to scoped comments resources before permission hints arrive", () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const publicAccess = renderHook( + () => useHasPermissionAccess("room", "comments:public", "write"), + { wrapper } + ); + const privateAccess = renderHook( + () => useHasPermissionAccess("room", "comments:private", "write"), + { wrapper } + ); + + expect(publicAccess.result.current).toBe(true); + expect(privateAccess.result.current).toBe(true); + }); + + test("uses aggregate and scoped comment access from the connection before permission hints arrive", async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const access = renderHook( + () => ({ + comments: useHasPermissionAccess("room", "comments", "write"), + public: useHasPermissionAccess("room", "comments:public", "write"), + private: useHasPermissionAccess("room", "comments:private", "write"), + }), + { wrapper } + ); + + const sim = await websocketSimulator(); + act(() => + sim.simulateIncomingMessage({ + type: ServerMsgCode.ROOM_STATE, + actor: 0, + nonce: "nonce-for-actor-0", + scopes: [ + "room:write", + "comments:none", + "comments:public:write", + "comments:private:none", + ], + users: {}, + meta: {}, + }) + ); + + expect(access.result.current).toEqual({ + comments: true, + public: true, + private: false, + }); + }); +}); + describe("useStorage", () => { test("return null before storage has loaded", () => { const { result } = renderHook(() => useStorage((root) => root.obj)); diff --git a/packages/liveblocks-react/src/__tests__/umbrella-store/_dummies.ts b/packages/liveblocks-react/src/__tests__/umbrella-store/_dummies.ts index 92d4c91c81f..ac974ed4107 100644 --- a/packages/liveblocks-react/src/__tests__/umbrella-store/_dummies.ts +++ b/packages/liveblocks-react/src/__tests__/umbrella-store/_dummies.ts @@ -16,6 +16,7 @@ export function createThread( comments = [], metadata = {}, resolved = false, + visibility = "public", } = overrides; const createdAt = overrides.createdAt ?? new Date(); @@ -30,6 +31,7 @@ export function createThread( comments, metadata, resolved, + visibility, }; } diff --git a/packages/liveblocks-react/src/__tests__/useCreateThread.test.tsx b/packages/liveblocks-react/src/__tests__/useCreateThread.test.tsx index e795817203f..98a05614205 100644 --- a/packages/liveblocks-react/src/__tests__/useCreateThread.test.tsx +++ b/packages/liveblocks-react/src/__tests__/useCreateThread.test.tsx @@ -38,6 +38,7 @@ describe("useCreateThread", () => { test("should create a thread optimistically and override with thread coming from server", async () => { const roomId = nanoid(); const fakeCreatedAt = addMinutes(new Date(), 5); + const visibility = "private"; server.use( mockGetThreads(() => { @@ -56,6 +57,7 @@ describe("useCreateThread", () => { }), mockCreateThread(async ({ request }) => { const json = await request.json(); + expect(json.visibility).toBe(visibility); const comment = dummyCommentData({ roomId, @@ -70,6 +72,7 @@ describe("useCreateThread", () => { id: json.id, comments: [comment], createdAt: fakeCreatedAt, + visibility: json.visibility, }); return HttpResponse.json(thread); @@ -112,10 +115,12 @@ describe("useCreateThread", () => { version: 1, content: [{ type: "paragraph", children: [{ text: "Hello" }] }], }, + visibility, }); }); expect(result.current.threadData.threads?.[0]).toEqual(thread); + expect(thread.visibility).toBe(visibility); // We're using the createdDate overriden by the server to ensure the optimistic update have been properly deleted await vi.waitFor(() => diff --git a/packages/liveblocks-react/src/__tests__/useHistoryVersions.test.tsx b/packages/liveblocks-react/src/__tests__/useHistoryVersions.test.tsx index d8730d1f9e0..508c880c63d 100644 --- a/packages/liveblocks-react/src/__tests__/useHistoryVersions.test.tsx +++ b/packages/liveblocks-react/src/__tests__/useHistoryVersions.test.tsx @@ -1,4 +1,4 @@ -import type { HistoryVersion } from "@liveblocks/core"; +import type { HistoryVersion } from "@liveblocks/client"; import { nanoid } from "@liveblocks/core"; import { fireEvent, renderHook, screen } from "@testing-library/react"; import type { HttpResponseResolver } from "msw"; @@ -86,15 +86,9 @@ describe("useHistoryVersions", () => { const roomId = nanoid(); const versions: HistoryVersion[] = [ { - type: "historyVersion", - kind: "yjs", + id: "vh_version_1", createdAt: new Date(), - id: "version_1", - authors: [ - { - id: "user-1", - }, - ], + authors: [{ id: "user-1" }], }, ]; @@ -137,15 +131,9 @@ describe("useHistoryVersions", () => { const roomId = nanoid(); const versions: HistoryVersion[] = [ { - type: "historyVersion", - kind: "yjs", + id: "vh_version_1", createdAt: new Date(), - id: "version_1", - authors: [ - { - id: "user-1", - }, - ], + authors: [{ id: "user-1" }], }, ]; @@ -167,10 +155,8 @@ describe("useHistoryVersions", () => { umbrellaStore.historyVersions.update("room-1", [ { - type: "historyVersion", - kind: "yjs", + id: "vh_version_1", createdAt: new Date(), - id: "version_1", authors: [{ id: "user-1" }], }, ]); @@ -288,15 +274,9 @@ describe("useHistoryVersions: suspense", () => { const roomId = nanoid(); const versions: HistoryVersion[] = [ { - type: "historyVersion", - kind: "yjs", + id: "vh_version_1", createdAt: new Date(), - id: "version_1", - authors: [ - { - id: "user-1", - }, - ], + authors: [{ id: "user-1" }], }, ]; @@ -446,15 +426,9 @@ describe("useHistoryVersions: polling", () => { const roomId = nanoid(); const versions: HistoryVersion[] = [ { - type: "historyVersion", - kind: "yjs", + id: "vh_version_1", createdAt: new Date(), - id: "version_1", - authors: [ - { - id: "user-1", - }, - ], + authors: [{ id: "user-1" }], }, ]; @@ -505,15 +479,9 @@ describe("useHistoryVersions: polling", () => { expect(getHistoryVersionsSinceCount).toBe(0); versions.push({ - type: "historyVersion", - kind: "yjs", + id: "vh_version_2", createdAt: new Date(), - id: "version_2", - authors: [ - { - id: "user-2", - }, - ], + authors: [{ id: "user-2" }], }); // Wait for the first polling to occur after the initial render diff --git a/packages/liveblocks-react/src/__tests__/useThreads.test.tsx b/packages/liveblocks-react/src/__tests__/useThreads.test.tsx index d2be4393d62..05340ab8db0 100644 --- a/packages/liveblocks-react/src/__tests__/useThreads.test.tsx +++ b/packages/liveblocks-react/src/__tests__/useThreads.test.tsx @@ -257,16 +257,17 @@ describe("useThreads", () => { test("should fetch threads for a given query", async () => { const roomId = nanoid(); - const pinnedThread = dummyThreadData({ + const privatePinnedThread = dummyThreadData({ roomId, + visibility: "private", metadata: { pinned: true, }, }); - const unpinnedThread = dummyThreadData({ + const publicPinnedThread = dummyThreadData({ roomId, metadata: { - pinned: false, + pinned: true, }, }); @@ -275,7 +276,10 @@ describe("useThreads", () => { const url = new URL(request.url); const query = url.searchParams.get("query"); const pred = query ? makeThreadFilter(query) : () => true; - const filteredThreads = [pinnedThread, unpinnedThread].filter(pred); + const filteredThreads = [ + privatePinnedThread, + publicPinnedThread, + ].filter(pred); const subscriptions = filteredThreads.map((thread) => dummySubscriptionData({ subjectId: thread.id }) ); @@ -302,7 +306,10 @@ describe("useThreads", () => { }>(); const { result, unmount } = renderHook( - () => useThreads({ query: { metadata: { pinned: true } } }), + () => + useThreads({ + query: { visibility: "private", metadata: { pinned: true } }, + }), { wrapper: ({ children }) => ( {children} @@ -315,7 +322,7 @@ describe("useThreads", () => { await vi.waitFor(() => expect(result.current).toEqual({ isLoading: false, - threads: [pinnedThread], + threads: [privatePinnedThread], fetchMore: expect.any(Function), isFetchingMore: false, hasFetchedAll: true, diff --git a/packages/liveblocks-react/src/__tests__/useUserThreads.test.tsx b/packages/liveblocks-react/src/__tests__/useUserThreads.test.tsx index 3f708c89728..1f90fdad564 100644 --- a/packages/liveblocks-react/src/__tests__/useUserThreads.test.tsx +++ b/packages/liveblocks-react/src/__tests__/useUserThreads.test.tsx @@ -150,21 +150,22 @@ describe("useUserThreads", () => { test("should fetch user threads for given query on mount", async () => { const roomId = nanoid(); - const pinnedThread = dummyThreadData({ + const privatePinnedThread = dummyThreadData({ roomId, + visibility: "private", metadata: { pinned: true, }, }); - const unpinnedThread = dummyThreadData({ + const publicPinnedThread = dummyThreadData({ roomId, metadata: { - pinned: false, + pinned: true, }, }); const subscriptions = [ - dummySubscriptionData({ subjectId: pinnedThread.id }), - dummySubscriptionData({ subjectId: unpinnedThread.id }), + dummySubscriptionData({ subjectId: privatePinnedThread.id }), + dummySubscriptionData({ subjectId: publicPinnedThread.id }), ]; server.use( @@ -173,7 +174,7 @@ describe("useUserThreads", () => { const query = url.searchParams.get("query"); const pred = query ? makeThreadFilter(query) : () => true; return HttpResponse.json({ - threads: [pinnedThread, unpinnedThread].filter(pred), + threads: [privatePinnedThread, publicPinnedThread].filter(pred), inboxNotifications: [], subscriptions, meta: { @@ -193,7 +194,9 @@ describe("useUserThreads", () => { const { result, unmount } = renderHook( () => - useUserThreads_experimental({ query: { metadata: { pinned: true } } }), + useUserThreads_experimental({ + query: { visibility: "private", metadata: { pinned: true } }, + }), { wrapper: ({ children }) => ( {children} @@ -208,7 +211,7 @@ describe("useUserThreads", () => { await vi.waitFor(() => expect(result.current).toEqual({ isLoading: false, - threads: [pinnedThread], + threads: [privatePinnedThread], fetchMore: expect.any(Function), isFetchingMore: false, hasFetchedAll: true, diff --git a/packages/liveblocks-react/src/index.ts b/packages/liveblocks-react/src/index.ts index c6d664e57e2..deafba986f9 100644 --- a/packages/liveblocks-react/src/index.ts +++ b/packages/liveblocks-react/src/index.ts @@ -75,6 +75,7 @@ export { useUpdateMyPresence, useUpdateRoomSubscriptionSettings, useHistoryVersionData, + useHistoryVersionYjsData, } from "./room"; // Export the classic (non-Suspense) versions of our hooks diff --git a/packages/liveblocks-react/src/lib/querying.ts b/packages/liveblocks-react/src/lib/querying.ts index 00d8d471267..208e41df90e 100644 --- a/packages/liveblocks-react/src/lib/querying.ts +++ b/packages/liveblocks-react/src/lib/querying.ts @@ -44,6 +44,7 @@ function matchesThreadsQuery( return ( (q.resolved === undefined || thread.resolved === q.resolved) && + (q.visibility === undefined || thread.visibility === q.visibility) && (q.subscribed === undefined || (q.subscribed === true && subscription !== undefined) || (q.subscribed === false && subscription === undefined)) diff --git a/packages/liveblocks-react/src/room.tsx b/packages/liveblocks-react/src/room.tsx index 7d4d06985c5..e017b34f485 100644 --- a/packages/liveblocks-react/src/room.tsx +++ b/packages/liveblocks-react/src/room.tsx @@ -102,9 +102,9 @@ import type { FeedMessagesAsyncSuccess, FeedsAsyncResult, FeedsAsyncSuccess, - HistoryVersionDataAsyncResult, HistoryVersionsAsyncResult, HistoryVersionsAsyncSuccess, + HistoryVersionYjsDataAsyncResult, MutationContext, OmitFirstArg, RoomContextBundle, @@ -225,6 +225,13 @@ function getRoomExtrasForClient< }; } +function getThreadVisibility( + store: UmbrellaStore, + threadId: string +): ThreadData["visibility"] | undefined { + return store.outputs.threads.get().getEvenIfDeleted(threadId)?.visibility; +} + function makeRoomExtrasForClient(client: OpaqueClient) { const store = getUmbrellaStoreForClient(client); @@ -1934,6 +1941,7 @@ function useCreateRoomThread( const metadata = options.metadata ?? ({} as TM); const commentMetadata = options.commentMetadata ?? ({} as CM); const attachments = options.attachments; + const visibility = options.visibility ?? "public"; const threadId = createThreadId(); const commentId = createCommentId(); @@ -1960,6 +1968,7 @@ function useCreateRoomThread( metadata, comments: [newComment], resolved: false, + visibility, }; const { store, onMutationFailure } = getRoomExtrasForClient(client); @@ -1977,6 +1986,7 @@ function useCreateRoomThread( threadId, commentId, body, + visibility, metadata, commentMetadata, attachmentIds, @@ -1995,6 +2005,7 @@ function useCreateRoomThread( threadId, commentId, body, + visibility, metadata, commentMetadata, }, @@ -2041,18 +2052,24 @@ function useDeleteRoomThread(roomId: string): (threadId: string) => void { deletedAt: new Date(), }); - client[kInternal].httpClient.deleteThread({ roomId, threadId }).then( - () => { - // Replace the optimistic update by the real thing - store.deleteThread(threadId, optimisticId); - }, - (err: Error) => - onMutationFailure( - optimisticId, - { type: "DELETE_THREAD_ERROR", roomId, threadId }, - err - ) - ); + client[kInternal].httpClient + .deleteThread({ + roomId, + threadId, + visibility: existing.visibility, + }) + .then( + () => { + // Replace the optimistic update by the real thing + store.deleteThread(threadId, optimisticId); + }, + (err: Error) => + onMutationFailure( + optimisticId, + { type: "DELETE_THREAD_ERROR", roomId, threadId }, + err + ) + ); }, [client, roomId] ); @@ -2092,7 +2109,12 @@ function useEditRoomThreadMetadata(roomId: string) { }); client[kInternal].httpClient - .editThreadMetadata({ roomId, threadId, metadata }) + .editThreadMetadata({ + roomId, + threadId, + metadata, + visibility: getThreadVisibility(store, threadId), + }) .then( (metadata) => // Replace the optimistic update by the real thing @@ -2152,7 +2174,13 @@ function useEditRoomCommentMetadata(roomId: string) { }); client[kInternal].httpClient - .editCommentMetadata({ roomId, threadId, commentId, metadata }) + .editCommentMetadata({ + roomId, + threadId, + commentId, + metadata, + visibility: getThreadVisibility(store, threadId), + }) .then( (updatedMetadata) => // Replace the optimistic update by the real thing @@ -2247,6 +2275,7 @@ function useCreateRoomComment( body, metadata, attachmentIds, + visibility: getThreadVisibility(store, threadId), }) .then( (newComment) => { @@ -2363,6 +2392,7 @@ function useEditRoomComment( body, attachmentIds, metadata, + visibility: existing.visibility, }) .then( (editedComment) => { @@ -2430,7 +2460,12 @@ function useDeleteRoomComment(roomId: string) { }); client[kInternal].httpClient - .deleteComment({ roomId, threadId, commentId }) + .deleteComment({ + roomId, + threadId, + commentId, + visibility: getThreadVisibility(store, threadId), + }) .then( () => { // Replace the optimistic update by the real thing @@ -2485,7 +2520,13 @@ function useAddRoomCommentReaction(roomId: string) { }); client[kInternal].httpClient - .addReaction({ roomId, threadId, commentId, emoji }) + .addReaction({ + roomId, + threadId, + commentId, + emoji, + visibility: getThreadVisibility(store, threadId), + }) .then( (addedReaction) => { // Replace the optimistic update by the real thing @@ -2557,7 +2598,13 @@ function useRemoveRoomCommentReaction(roomId: string) { }); client[kInternal].httpClient - .removeReaction({ roomId, threadId, commentId, emoji }) + .removeReaction({ + roomId, + threadId, + commentId, + emoji, + visibility: getThreadVisibility(store, threadId), + }) .then( () => { // Replace the optimistic update by the real thing @@ -2704,7 +2751,11 @@ function useMarkRoomThreadAsResolved(roomId: string) { }); client[kInternal].httpClient - .markThreadAsResolved({ roomId, threadId }) + .markThreadAsResolved({ + roomId, + threadId, + visibility: getThreadVisibility(store, threadId), + }) .then( () => { // Replace the optimistic update by the real thing @@ -2764,7 +2815,11 @@ function useMarkRoomThreadAsUnresolved(roomId: string) { }); client[kInternal].httpClient - .markThreadAsUnresolved({ roomId, threadId }) + .markThreadAsUnresolved({ + roomId, + threadId, + visibility: getThreadVisibility(store, threadId), + }) .then( () => { // Replace the optimistic update by the real thing @@ -3091,11 +3146,11 @@ function useRoomSubscriptionSettingsSuspense(): [ /** * @internal */ -function useHistoryVersionData_withRoomContext( +function useHistoryVersionYjsData_withRoomContext( RoomContext: Context, versionId: string -): HistoryVersionDataAsyncResult { - const [state, setState] = useState({ +): HistoryVersionYjsDataAsyncResult { + const [state, setState] = useState({ isLoading: true, }); const room = useRoom_withRoomContext(RoomContext); @@ -3103,7 +3158,7 @@ function useHistoryVersionData_withRoomContext( setState({ isLoading: true }); const load = async () => { try { - const response = await room[kInternal].getTextVersion(versionId); + const response = await room[kInternal].getYjsHistoryVersion(versionId); const buffer = await response.arrayBuffer(); const data = new Uint8Array(buffer); setState({ @@ -3128,15 +3183,29 @@ function useHistoryVersionData_withRoomContext( } /** - * Returns the version data bianry for a given version + * @deprecated Use `useHistoryVersionYjsData(versionId)` instead. + * + * Returns the version data binary for a given version * * @example * const {data} = useHistoryVersionData(versionId); */ function useHistoryVersionData( versionId: string -): HistoryVersionDataAsyncResult { - return useHistoryVersionData_withRoomContext(GlobalRoomContext, versionId); +): HistoryVersionYjsDataAsyncResult { + return useHistoryVersionYjsData_withRoomContext(GlobalRoomContext, versionId); +} + +/** + * Returns the Yjs data for a given version of the room. + * + * @example + * const { data, isLoading, error } = useHistoryVersionYjsData(versionId); + */ +function useHistoryVersionYjsData( + versionId: string +): HistoryVersionYjsDataAsyncResult { + return useHistoryVersionYjsData_withRoomContext(GlobalRoomContext, versionId); } /** @@ -3754,12 +3823,23 @@ function useSelfAccessFallback( ); const getSnapshot = useCallback(() => { - const self = room?.id === roomId ? room.getSelf() : null; - if (resource === "comments" && requiredAccess === "write") { - return self?.canComment ?? true; + const isCommentsResource = + resource === "comments" || + resource === "comments:public" || + resource === "comments:private"; + + if (room?.id === roomId) { + const permissionMatrix = room[kInternal].getPermissionMatrix(); + if (permissionMatrix !== undefined) { + return hasPermissionAccess(permissionMatrix, resource, requiredAccess); + } } - if (resource === "storage" && requiredAccess === "write") { - return self?.canWrite ?? true; + + if ( + requiredAccess === "write" && + (isCommentsResource || resource === "storage") + ) { + return true; } return false; @@ -4059,10 +4139,10 @@ export function createRoomContext< return useHistoryVersionsSuspense_withRoomContext(BoundRoomContext); } - function useHistoryVersionData_withBoundRoomContext( - ...args: Parameters + function useHistoryVersionYjsData_withBoundRoomContext( + ...args: Parameters ) { - return useHistoryVersionData_withRoomContext(BoundRoomContext, ...args); + return useHistoryVersionYjsData_withRoomContext(BoundRoomContext, ...args); } function useRoomSubscriptionSettings_withBoundRoomContext() { @@ -4233,7 +4313,9 @@ export function createRoomContext< // prettier-ignore useHistoryVersions: useHistoryVersions_withBoundRoomContext as TRoomBundle["useHistoryVersions"], // prettier-ignore - useHistoryVersionData: useHistoryVersionData_withBoundRoomContext as TRoomBundle["useHistoryVersionData"], + useHistoryVersionData: useHistoryVersionYjsData_withBoundRoomContext as TRoomBundle["useHistoryVersionData"], + // prettier-ignore + useHistoryVersionYjsData: useHistoryVersionYjsData_withBoundRoomContext as TRoomBundle["useHistoryVersionYjsData"], // prettier-ignore useRoomSubscriptionSettings: useRoomSubscriptionSettings_withBoundRoomContext as TRoomBundle["useRoomSubscriptionSettings"], @@ -5010,6 +5092,7 @@ export { useHistoryVersionData, _useHistoryVersions as useHistoryVersions, _useHistoryVersionsSuspense as useHistoryVersionsSuspense, + useHistoryVersionYjsData, _useIsInsideRoom as useIsInsideRoom, useLostConnectionListener, useMarkRoomThreadAsRead, diff --git a/packages/liveblocks-react/src/types/index.ts b/packages/liveblocks-react/src/types/index.ts index 7fd0f6eddf7..68e9be303e4 100644 --- a/packages/liveblocks-react/src/types/index.ts +++ b/packages/liveblocks-react/src/types/index.ts @@ -50,6 +50,7 @@ import type { SearchCommentsResult, SyncStatus, ThreadData, + ThreadVisibility, ToJson, UrlMetadata, WithNavigation, @@ -130,6 +131,11 @@ export type ThreadsQuery = { */ resolved?: boolean; + /** + * Whether to only return public or private threads. If not provided, all threads will be returned. + */ + visibility?: ThreadVisibility; + /** * Whether to only return threads that the user is subscribed to or not. If not provided, * all threads will be returned. @@ -269,7 +275,7 @@ export type GroupAsyncSuccess = AsyncSuccess; // prettier-ignore export type CreateThreadOptions = Resolve< - { body: CommentBody, attachments?: CommentAttachment[]; } + { body: CommentBody, attachments?: CommentAttachment[]; visibility?: ThreadVisibility; } & PartialUnless & PartialUnless >; @@ -352,7 +358,7 @@ export type NotificationSettingsAsyncSuccess = AsyncSuccess; // prettier-ignore export type RoomSubscriptionSettingsAsyncResult = AsyncResult; // prettier-ignore -export type HistoryVersionDataAsyncResult = AsyncResult; +export type HistoryVersionYjsDataAsyncResult = AsyncResult; export type HistoryVersionsAsyncSuccess = AsyncSuccess; // prettier-ignore export type HistoryVersionsAsyncResult = AsyncResult; // prettier-ignore @@ -1338,12 +1344,22 @@ export type RoomContextBundle< useHistoryVersions(): HistoryVersionsAsyncResult; /** + * Returns the Yjs data for a given version of the room. + * + * @example + * const { data, error, isLoading } = useHistoryVersionYjsData(version.id); + */ + useHistoryVersionYjsData(id: string): HistoryVersionYjsDataAsyncResult; + + /** + * @deprecated Use `useHistoryVersionYjsData(id)` instead. + * * (Private beta) Returns the data of a specific version of the current room. * * @example * const { data, error, isLoading } = useHistoryVersionData(version.id); */ - useHistoryVersionData(id: string): HistoryVersionDataAsyncResult; + useHistoryVersionData(id: string): HistoryVersionYjsDataAsyncResult; suspense: Resolve< RoomContextBundleCommon & @@ -1466,14 +1482,6 @@ export type RoomContextBundle< */ useHistoryVersions(): HistoryVersionsAsyncSuccess; - // /** - // * Returns the data of a specific version of the current room's history. - // * - // * @example - // * const { data } = useHistoryVersionData(version.id); - // */ - // useHistoryVersionData(versionId: string): HistoryVersionDataState; - /** * Returns the user's subscription settings for the current room * and a function to update them. diff --git a/packages/liveblocks-react/src/umbrella-store.ts b/packages/liveblocks-react/src/umbrella-store.ts index 652c49b5257..4c181158e95 100644 --- a/packages/liveblocks-react/src/umbrella-store.ts +++ b/packages/liveblocks-react/src/umbrella-store.ts @@ -1672,7 +1672,7 @@ export class UmbrellaStore { throw new Error(`Room '${roomId}' is not available on client`); } - const result = await room[kInternal].listTextVersions(); + const result = await room[kInternal].listHistoryVersions(); this.historyVersions.update(roomId, result.versions); const lastRequestedAt = @@ -2427,7 +2427,7 @@ export class UmbrellaStore { `Room with id ${roomId} is not available on client` ); - const updates = await room[kInternal].listTextVersionsSince({ + const updates = await room[kInternal].listHistoryVersionsSince({ since: lastRequestedAt, signal, }); diff --git a/packages/liveblocks-react/test-d/augmentation.test-d.tsx b/packages/liveblocks-react/test-d/augmentation.test-d.tsx index f94bbecedcf..3734644b283 100644 --- a/packages/liveblocks-react/test-d/augmentation.test-d.tsx +++ b/packages/liveblocks-react/test-d/augmentation.test-d.tsx @@ -802,6 +802,7 @@ describe("with Liveblocks augmentation", () => { }, metadata: { color: "red" }, commentMetadata: { priority: 1 }, + visibility: "private", }); expectTypeOf(thread.type).toEqualTypeOf<"thread">(); diff --git a/packages/liveblocks-react/test-d/no-augmentation.test-d.tsx b/packages/liveblocks-react/test-d/no-augmentation.test-d.tsx index da9e28ef1cb..a176af4494f 100644 --- a/packages/liveblocks-react/test-d/no-augmentation.test-d.tsx +++ b/packages/liveblocks-react/test-d/no-augmentation.test-d.tsx @@ -641,6 +641,7 @@ describe("without Liveblocks augmentation", () => { version: 1, content: [{ type: "paragraph", children: [{ text: "hi" }] }], }, + visibility: "private", }); expectTypeOf(thread1.type).toEqualTypeOf<"thread">(); diff --git a/packages/liveblocks-redux/package.json b/packages/liveblocks-redux/package.json index b3b391dbef1..3b0476e71d7 100644 --- a/packages/liveblocks-redux/package.json +++ b/packages/liveblocks-redux/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/redux", - "version": "3.20.1", + "version": "3.21.0", "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-server/src/index.ts b/packages/liveblocks-server/src/index.ts index 95d74512d81..7c72663b7f6 100644 --- a/packages/liveblocks-server/src/index.ts +++ b/packages/liveblocks-server/src/index.ts @@ -53,6 +53,7 @@ export * from "~/lib/tryCatch"; // YYY Maybe isolate these simple/common datastructures into a separate NPM package? export * from "~/lib/DefaultMap"; export * from "~/lib/NestedMap"; +export * from "~/lib/semver"; export { quote } from "~/lib/text"; export * from "~/lib/UniqueMap"; diff --git a/packages/liveblocks-server/src/lib/semver.ts b/packages/liveblocks-server/src/lib/semver.ts new file mode 100644 index 00000000000..0687b4bb34b --- /dev/null +++ b/packages/liveblocks-server/src/lib/semver.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) Liveblocks Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Compares two semver-ish version strings (`major.minor.patch` with an optional + * `-prerelease` suffix). Returns a negative number if `a < b`, a positive number + * if `a > b`, and `0` if they're equal. + * + * Missing numeric components are treated as `0`, so `"3.21"` and `"3.21.0"` + * compare equal. A final release outranks any of its prereleases + * (`"3.14.0" > "3.14.0-rc1"`); prereleases are otherwise compared lexically. + */ +export function cmpSemver(a: string, b: string): number { + const [coreA = "", preA] = a.split("-", 2); + const [coreB = "", preB] = b.split("-", 2); + const partsA = coreA.split(".").map(Number); + const partsB = coreB.split(".").map(Number); + for (let i = 0; i < 3; i++) { + const pa = partsA[i] ?? 0; + const pb = partsB[i] ?? 0; + if (pa !== pb) return pa - pb; + } + // A final release outranks any of its prereleases (3.14.0 > 3.14.0-rc1) + if (!preA && preB) return 1; + if (preA && !preB) return -1; + if (preA && preB) return preA < preB ? -1 : preA > preB ? 1 : 0; + return 0; +} diff --git a/packages/liveblocks-server/test/semver.test.ts b/packages/liveblocks-server/test/semver.test.ts new file mode 100644 index 00000000000..a29da4d5b7c --- /dev/null +++ b/packages/liveblocks-server/test/semver.test.ts @@ -0,0 +1,64 @@ +/** + * Copyright (c) Liveblocks Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, test } from "vitest"; + +import { cmpSemver } from "../src/lib/semver"; + +describe("cmpSemver", () => { + test("compares major versions", () => { + expect(cmpSemver("2.0.0", "3.0.0")).toBeLessThan(0); + expect(cmpSemver("3.0.0", "2.0.0")).toBeGreaterThan(0); + }); + + test("compares minor versions", () => { + expect(cmpSemver("3.13.0", "3.14.0")).toBeLessThan(0); + expect(cmpSemver("3.14.0", "3.13.0")).toBeGreaterThan(0); + }); + + test("compares patch versions", () => { + expect(cmpSemver("3.14.0", "3.14.1")).toBeLessThan(0); + expect(cmpSemver("3.14.1", "3.14.0")).toBeGreaterThan(0); + }); + + test("treats equal versions as equal", () => { + expect(cmpSemver("3.14.0", "3.14.0")).toBe(0); + }); + + test("a final release outranks any of its prereleases", () => { + expect(cmpSemver("3.14.0", "3.14.0-rc1")).toBeGreaterThan(0); + expect(cmpSemver("3.14.0-rc1", "3.14.0")).toBeLessThan(0); + }); + + test("compares prereleases lexically", () => { + expect(cmpSemver("3.14.0-rc1", "3.14.0-rc2")).toBeLessThan(0); + expect(cmpSemver("3.14.0-rc2", "3.14.0-rc1")).toBeGreaterThan(0); + expect(cmpSemver("3.14.0-rc1", "3.14.0-rc1")).toBe(0); + }); + + test("treats a missing patch component as 0", () => { + // "3.21" and "3.21.0" are equivalent thresholds + expect(cmpSemver("3.21", "3.21.0")).toBe(0); + expect(cmpSemver("3.21", "3.20.0")).toBeGreaterThan(0); + expect(cmpSemver("3.21", "3.22.0")).toBeLessThan(0); + }); + + test("the comparison is sign-consistent when operands swap", () => { + expect(cmpSemver("3.19.5", "3.21.0")).toBeLessThan(0); + expect(cmpSemver("3.21.0", "3.19.5")).toBeGreaterThan(0); + }); +}); diff --git a/packages/liveblocks-yjs/package.json b/packages/liveblocks-yjs/package.json index 0ff5163b29a..ce71c69b8a2 100644 --- a/packages/liveblocks-yjs/package.json +++ b/packages/liveblocks-yjs/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/yjs", - "version": "3.20.1", + "version": "3.21.0", "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 132531db23a..8148548bf32 100644 --- a/packages/liveblocks-zustand/package.json +++ b/packages/liveblocks-zustand/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/zustand", - "version": "3.20.1", + "version": "3.21.0", "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.", diff --git a/tools/liveblocks-cli/package.json b/tools/liveblocks-cli/package.json index 1bfe2706a92..8d6836419e1 100644 --- a/tools/liveblocks-cli/package.json +++ b/tools/liveblocks-cli/package.json @@ -49,7 +49,7 @@ "@liveblocks/core": "v3.21.0-private3", "@liveblocks/query-parser": "workspace:^", "@liveblocks/server": "workspace:^", - "@liveblocks/zenrouter": "^1.0.18", + "@liveblocks/zenrouter": "^1.1.0", "decoders": "^2.9.0", "js-base64": "^3.7.5", "yjs": "^13.6.10" diff --git a/tools/liveblocks-cli/src/dev-server/auth.ts b/tools/liveblocks-cli/src/dev-server/auth.ts index 1e122b174cb..4ee25299c59 100644 --- a/tools/liveblocks-cli/src/dev-server/auth.ts +++ b/tools/liveblocks-cli/src/dev-server/auth.ts @@ -15,7 +15,11 @@ * along with this program. If not, see . */ -import { mergeRoomPermissionScopes, nanoid, Permission } from "@liveblocks/core"; +import { + mergeRoomPermissionScopes, + nanoid, + Permission, +} from "@liveblocks/core"; import type { CreateTicketOptions } from "@liveblocks/server"; import { ProtocolVersion } from "@liveblocks/server"; diff --git a/tools/liveblocks-cli/src/dev-server/routes/client-api.ts b/tools/liveblocks-cli/src/dev-server/routes/client-api.ts index 5ea6c9aa577..e6c2799239f 100644 --- a/tools/liveblocks-cli/src/dev-server/routes/client-api.ts +++ b/tools/liveblocks-cli/src/dev-server/routes/client-api.ts @@ -151,8 +151,12 @@ zen.route("POST /v2/c/rooms//text-metadata", () => { zen.route("POST /v2/c/rooms//attachments/presigned-urls", () => NOT_IMPLEMENTED()); zen.route("POST /v2/c/rooms//send-message", () => NOT_IMPLEMENTED()); zen.route("GET /v2/c/rooms//storage", () => NOT_IMPLEMENTED()); + zen.route("GET /v2/c/rooms//versions", () => NOT_IMPLEMENTED()); + zen.route("GET /v2/c/rooms//versions/delta", () => NOT_IMPLEMENTED()); + // zen.route("GET /v2/c/rooms//versions//storage", () => NOT_IMPLEMENTED()); + zen.route("GET /v2/c/rooms//versions//yjs", () => NOT_IMPLEMENTED()); + zen.route("GET /v2/c/rooms//y-version/", () => NOT_IMPLEMENTED()); // Deprecated, replaced by GET /v2/c/rooms//versions//yjs zen.route("POST /v2/c/rooms//version", () => NOT_IMPLEMENTED()); - zen.route("GET /v2/c/rooms//y-version/", () => NOT_IMPLEMENTED()); zen.route("POST /v2/c/rooms//ai/contextual-prompt", () => NOT_IMPLEMENTED()); zen.route("POST /v2/c/rooms//threads", () => NOT_IMPLEMENTED()); zen.route("POST /v2/c/rooms//threads/search", () => NOT_IMPLEMENTED()); @@ -185,7 +189,5 @@ zen.route("POST /v2/c/rooms//text-metadata", () => { zen.route("POST /v2/c/notification-settings", () => NOT_IMPLEMENTED()); zen.route("GET /v2/c/rooms//thread-with-notification/", () => NOT_IMPLEMENTED()); zen.route("GET /v2/c/urls/metadata", () => NOT_IMPLEMENTED()); - zen.route("GET /v2/c/rooms//versions", () => NOT_IMPLEMENTED()); - zen.route("GET /v2/c/rooms//versions/delta", () => NOT_IMPLEMENTED()); zen.route("POST /v2/c/groups/find", () => NOT_IMPLEMENTED()); } diff --git a/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts b/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts index d1746cd4f9d..f42db27a560 100644 --- a/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts +++ b/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts @@ -687,8 +687,8 @@ zen.route( zen.route("GET /v2/rooms//prewarm", () => NOT_IMPLEMENTED()); zen.route("POST /v2/rooms//broadcast_event", () => NOT_IMPLEMENTED()); zen.route("GET /v2/rooms//versions", () => NOT_IMPLEMENTED()); - zen.route("GET /v2/rooms//version/", () => NOT_IMPLEMENTED()); - zen.route("POST /v2/rooms//version", () => NOT_IMPLEMENTED()); + zen.route("GET /v2/rooms//versions//yjs", () => NOT_IMPLEMENTED()); + zen.route("POST /v2/rooms//versions", () => NOT_IMPLEMENTED()); zen.route("POST /v2/rooms//threads", () => NOT_IMPLEMENTED()); zen.route("GET /v2/rooms//threads/", () => NOT_IMPLEMENTED()); zen.route("POST /v2/rooms//threads//mark-as-resolved", () => NOT_IMPLEMENTED()); diff --git a/tools/liveblocks-cli/src/upgrade/index.ts b/tools/liveblocks-cli/src/upgrade/index.ts index be45a4dd360..1c5f28e849e 100644 --- a/tools/liveblocks-cli/src/upgrade/index.ts +++ b/tools/liveblocks-cli/src/upgrade/index.ts @@ -15,6 +15,7 @@ * along with this program. If not, see . */ +import { cmpSemver } from "@liveblocks/server"; import { execFileSync } from "child_process"; import { existsSync, readFileSync } from "fs"; import { resolve } from "path"; @@ -36,21 +37,6 @@ function findLiveblocksDependencies( ); } -export function cmpSemver(a: string, b: string): number { - const [coreA, preA] = a.split("-", 2); - const [coreB, preB] = b.split("-", 2); - const partsA = coreA.split(".").map(Number); - const partsB = coreB.split(".").map(Number); - for (let i = 0; i < 3; i++) { - if (partsA[i] !== partsB[i]) return partsA[i] - partsB[i]; - } - // No prerelease > any prerelease (3.14.0 > 3.14.0-rc1) - if (!preA && preB) return 1; - if (preA && !preB) return -1; - if (preA && preB) return preA < preB ? -1 : preA > preB ? 1 : 0; - return 0; -} - /** * Given the parsed JSON output of `npm view ... version --json`, returns the * highest version. npm returns a single string when one version matches, or diff --git a/tools/liveblocks-cli/test/upgrade.test.ts b/tools/liveblocks-cli/test/upgrade.test.ts index 72f03c6256e..829f2560a0c 100644 --- a/tools/liveblocks-cli/test/upgrade.test.ts +++ b/tools/liveblocks-cli/test/upgrade.test.ts @@ -17,39 +17,7 @@ import { describe, expect, test } from "bun:test"; -import { cmpSemver, highestVersion } from "~/upgrade/index"; - -describe("compareSemver", () => { - test("compares major versions", () => { - expect(cmpSemver("2.0.0", "3.0.0")).toBeLessThan(0); - expect(cmpSemver("3.0.0", "2.0.0")).toBeGreaterThan(0); - }); - - test("compares minor versions", () => { - expect(cmpSemver("3.13.0", "3.14.0")).toBeLessThan(0); - expect(cmpSemver("3.14.0", "3.13.0")).toBeGreaterThan(0); - }); - - test("compares patch versions", () => { - expect(cmpSemver("3.14.0", "3.14.1")).toBeLessThan(0); - expect(cmpSemver("3.14.1", "3.14.0")).toBeGreaterThan(0); - }); - - test("equal versions", () => { - expect(cmpSemver("3.14.0", "3.14.0")).toBe(0); - }); - - test("release > prerelease", () => { - expect(cmpSemver("3.14.0", "3.14.0-rc1")).toBeGreaterThan(0); - expect(cmpSemver("3.14.0-rc1", "3.14.0")).toBeLessThan(0); - }); - - test("compares prerelease strings", () => { - expect(cmpSemver("3.14.0-rc1", "3.14.0-rc2")).toBeLessThan(0); - expect(cmpSemver("3.14.0-rc2", "3.14.0-rc1")).toBeGreaterThan(0); - expect(cmpSemver("3.14.0-rc1", "3.14.0-rc1")).toBe(0); - }); -}); +import { highestVersion } from "~/upgrade/index"; describe("pickHighestVersion", () => { // Simulates: npm view @liveblocks/core@latest version --json