diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 4920f37b653..09d7ef78eda 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -84,7 +84,8 @@ jobs: run: ./.github/scripts/release.sh -V "$VERSION" -w "packages/liveblocks-core" "packages/liveblocks-client" - "packages/liveblocks-node" "packages/liveblocks-react" + "packages/liveblocks-node" "packages/liveblocks-chat-sdk-adapter" + "packages/liveblocks-react" "packages/liveblocks-redux" "packages/liveblocks-zustand" "packages/liveblocks-yjs" "packages/liveblocks-react-ui" "packages/liveblocks-react-lexical" "packages/liveblocks-node-lexical" @@ -106,7 +107,8 @@ jobs: run: ./.github/scripts/publish.sh -V "$VERSION" -t "$NPM_TAG" "packages/liveblocks-core" "packages/liveblocks-client" - "packages/liveblocks-node" "packages/liveblocks-react" + "packages/liveblocks-node" "packages/liveblocks-chat-sdk-adapter" + "packages/liveblocks-react" "packages/liveblocks-redux" "packages/liveblocks-zustand" "packages/liveblocks-yjs" "packages/liveblocks-react-lexical" "packages/liveblocks-node-lexical" "packages/liveblocks-react-ui" diff --git a/CHANGELOG.md b/CHANGELOG.md index f9b5f684eb9..5f047d83c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,50 @@ -## 3.15.5 +## vNEXT (not yet released) + +## v3.16.0 + +### `@liveblocks/chat-sdk-adapter` + +- Introduce the package. Install with + `npm install @liveblocks/chat-sdk-adapter`. Provides a + [`chat-sdk`](https://www.npmjs.com/package/chat)-compatible backend adapter + backed by Liveblocks Comments: webhooks, posting and editing messages, + reactions, paginated fetches, thread and channel helpers, and optional + `resolveUsers` / `resolveGroupsInfo` hooks. + +### `@liveblocks/react` + +- Add Feeds hooks: `useFeeds`, `useFeedMessages`, `useCreateFeed`, + `useDeleteFeed`, `useUpdateFeedMetadata`, `useCreateFeedMessage`, + `useDeleteFeedMessage`, and `useUpdateFeedMessage`. + +### `@liveblocks/node` + +- Add REST client methods for Feeds: `getFeeds`, `getFeed`, `createFeed`, + `updateFeed`, `deleteFeed`, `getFeedMessages`, `createFeedMessage`, + `updateFeedMessage`, and `deleteFeedMessage`. + +### Python SDK + +- Add Feeds REST API support on the sync and async clients (`get_feeds`, + `get_feed`, `create_feed`, `update_feed`, `delete_feed`, `get_feed_messages`, + `create_feed_message`, `update_feed_message`, `delete_feed_message`) with + matching request/response models. + +### `@liveblocks/client` + +- Add **Feeds**: room-scoped feeds with metadata and messages and APIs to list, + create, update, and delete feeds and messages (`fetchFeeds`, + `fetchFeedMessages`, `addFeed`, `updateFeed`, `deleteFeed`, `addFeedMessage`, + `updateFeedMessage`, `deleteFeedMessage`). + +### `@liveblocks/react-ui` + +- Add `body` prop to `Comment` to allow overriding only the default rich-text + comment body while still keeping attachments, reactions, and + `additionalContent` as is, unlike when using the `children` prop. +- Fix `AvatarStack` negative margin breaking alignment. + +## v3.15.5 ### `@liveblocks/tiptap` diff --git a/docs/pages/api-reference/liveblocks-chat-sdk-adapter.mdx b/docs/pages/api-reference/liveblocks-chat-sdk-adapter.mdx new file mode 100644 index 00000000000..8217e09aee5 --- /dev/null +++ b/docs/pages/api-reference/liveblocks-chat-sdk-adapter.mdx @@ -0,0 +1,289 @@ +--- +meta: + title: "@liveblocks/chat-sdk-adapter" + parentTitle: "API Reference" + description: "API Reference for the @liveblocks/chat-sdk-adapter package" +alwaysShowAllNavigationLevels: false +--- + +`@liveblocks/chat-sdk-adapter` is a [Chat SDK](https://chat-sdk.dev) platform +adapter backed by [Liveblocks Comments](/docs/products/comments). It maps +Liveblocks rooms, threads, and comments to the Chat SDK's `Channel` / `Thread` / +`Message` model, allowing you to build conversational bots that read and post in +Liveblocks comment threads. + +## Installation + +```bash +npm install @liveblocks/chat-sdk-adapter chat +``` + +## Prerequisites + +Before using this adapter, ensure you have: + +1. A [Liveblocks project](/docs/get-started) with rooms using + [Comments](/docs/products/comments). +2. A **secret key** (`sk_...`) from the Liveblocks dashboard for REST API calls. +3. A **webhook signing secret** (`whsec_...`) from the dashboard to verify + webhook payloads. +4. Webhooks configured to subscribe to `commentCreated`, `commentReactionAdded`, + and `commentReactionRemoved` events. +5. A stable `botUserId` that matches how you identify users in your app. + +## createLiveblocksAdapter [#createLiveblocksAdapter] + +Factory function that creates a new `LiveblocksAdapter` instance. + +```ts +import { createLiveblocksAdapter } from "@liveblocks/chat-sdk-adapter"; + +const adapter = createLiveblocksAdapter({ + apiKey: "{{SECRET_KEY}}", + webhookSecret: "whsec_...", + botUserId: "my-bot-user", + botUserName: "MyBot", +}); +``` + +### Configuration options [#configuration] + + + + Liveblocks secret key (`sk_...`) for REST API calls. + + + Webhook signing secret (`whsec_...`) from the dashboard. + + + User ID used when the bot creates, edits, or reacts to comments. Should + match your app's user identifiers. + + + Display name for the bot. + + + Resolves user IDs to user info for mentions. Returns an array of user info + in the same order as the input IDs, or `undefined` to skip resolution. + + + Resolves group IDs to group info for mentions. Returns an array of group + info in the same order as the input IDs, or `undefined` to skip resolution. + + + Chat SDK-compatible logger instance. + + + +#### Resolving mentions [#resolving-mentions] + +When comments contain @mentions, the adapter needs to resolve user and group IDs +to display names. Use `resolveUsers` and `resolveGroupsInfo` to provide this +mapping: + +```ts +const adapter = createLiveblocksAdapter({ + apiKey: "{{SECRET_KEY}}", + webhookSecret: "whsec_...", + botUserId: "my-bot-user", + + resolveUsers: async ({ userIds }) => { + const users = await getUsersFromDatabase(userIds); + return users.map((user) => ({ + name: user.fullName, + avatar: user.avatarUrl, + })); + }, + + resolveGroupsInfo: async ({ groupIds }) => { + const groups = await getGroupsFromDatabase(groupIds); + return groups.map((group) => ({ name: group.displayName })); + }, +}); +``` + +### Webhook events [#webhook-events] + +The adapter processes incoming Liveblocks webhook requests via the Chat SDK's +webhook handler. Supported events: + +- `commentCreated` — Triggers message processing in the Chat SDK +- `commentReactionAdded` — Triggers reaction handlers +- `commentReactionRemoved` — Triggers reaction handlers + +```ts +export async function POST(request: Request) { + return bot.webhooks.liveblocks(request, { + waitUntil: (p) => void p, + }); +} +``` + + + +The adapter automatically verifies webhook signatures using the `webhookSecret` +provided during configuration. Invalid requests receive a `401` response. + + + +### ID encoding [#id-encoding] + +The adapter uses a prefixed encoding scheme for thread and channel IDs: + +- **Thread ID**: `liveblocks:{roomId}:{threadId}` +- **Channel ID**: `liveblocks:{roomId}` + +#### encodeThreadId [#encodeThreadId] + +Encodes a Liveblocks room ID and thread ID into a single thread ID string. + +```ts +adapter.encodeThreadId(data: { roomId: string; threadId: string }): string +``` + +```ts +const encoded = adapter.encodeThreadId({ + roomId: "my-room", + threadId: "th_abc123", +}); +// "liveblocks:my-room:th_abc123" +``` + +#### decodeThreadId [#decodeThreadId] + +Decodes an encoded thread ID string back into its room ID and thread ID +components. Throws an `Error` if the format is invalid. + +```ts +adapter.decodeThreadId(threadId: string): { roomId: string; threadId: string } +``` + +```ts +const { roomId, threadId } = adapter.decodeThreadId( + "liveblocks:my-room:th_abc123" +); +// roomId: "my-room" +// threadId: "th_abc123" +``` + + + +Room IDs may contain colons (`:`), which are preserved during encoding/decoding. +However, Liveblocks thread IDs must not contain colons as the last colon is used +as the delimiter when decoding. + + + +### Liveblocks-specific behavior [#liveblocks-specific] + +#### Reactions [#reactions] + +Liveblocks Comments only supports Unicode emoji. Custom emoji identifiers that +cannot be resolved to Unicode will fail validation. + +```ts +await adapter.addReaction(threadId, messageId, "👍"); // Works +await adapter.addReaction(threadId, messageId, "thumbs_up"); // Converted to 👍 +``` + +#### Typing indicators [#typing-indicators] + +The `startTyping` method is a no-op as typing indicators are not supported by +Liveblocks Comments. + +## Message format limitations [#limitations] + + + +Liveblocks Comments has a simpler content model than full Markdown. Content from +the Chat SDK is automatically converted, but some formatting is flattened. + + + +Liveblocks Comments supports: + +- Paragraphs with inline formatting (bold, italic, code, strikethrough) +- Links +- @mentions (users and groups) + +The following are **not supported** and will be flattened to plain text: + +- Headings — Converted to paragraphs +- Bullet and numbered lists — Converted to paragraphs +- Code blocks — Converted to paragraphs +- Tables — Converted to ASCII representation in a paragraph +- HTML — Rendered as plain text + +Card payloads from the Chat SDK are converted to markdown/plain text (or use +`fallbackText` if provided), then converted to a comment body. Interactivity is +not preserved. + +## Example [#example] + +Here's a complete example integrating the adapter with the Chat SDK: + +```ts +import { Chat } from "chat"; +import { + createLiveblocksAdapter, + LiveblocksAdapter, +} from "@liveblocks/chat-sdk-adapter"; +import { createMemoryState } from "@chat-adapter/state-memory"; + +const bot = new Chat<{ liveblocks: LiveblocksAdapter }>({ + userName: "MyBot", + adapters: { + liveblocks: createLiveblocksAdapter({ + apiKey: "{{SECRET_KEY}}", + webhookSecret: "whsec_...", + botUserId: "my-bot-user", + botUserName: "MyBot", + resolveUsers: async ({ userIds }) => { + const users = await getUsersFromDatabase(userIds); + return users.map((user) => ({ name: user.fullName })); + }, + }), + }, + state: createMemoryState(), +}); + +bot.onNewMention(async (thread, message) => { + await thread.adapter.addReaction(thread.id, message.id, "👀"); + await thread.post(`Hello, ${message.author.userName}!`); +}); + +bot.onReaction(async (event) => { + if (!event.added) return; + await event.adapter.postMessage( + event.threadId, + `${event.user.userName} reacted with "${event.emoji.name}"` + ); +}); +``` + +### Webhook handler (Next.js) + +```ts +import { bot } from "./bot"; + +export async function POST(request: Request) { + return bot.webhooks.liveblocks(request, { + waitUntil: (p) => void p, + }); +} +``` + + + +The `waitUntil` option is recommended for serverless environments (e.g., Vercel) +to allow background processing of messages after the response is sent. + + diff --git a/docs/pages/api-reference/liveblocks-client.mdx b/docs/pages/api-reference/liveblocks-client.mdx index a3d08e67870..4817b3b2948 100644 --- a/docs/pages/api-reference/liveblocks-client.mdx +++ b/docs/pages/api-reference/liveblocks-client.mdx @@ -3711,6 +3711,245 @@ console.log(url); +## Feeds + +### Room.fetchFeeds + +Fetches feeds in the current room. Returns a paginated list of feeds with an +optional cursor for fetching more. + +```ts +const { feeds, nextCursor } = await room.fetchFeeds(); + +// [{ feedId: "feed-1", metadata: {...}, timestamp: 1234567890 }, ...] +console.log(feeds); +``` + +A number of options are available for filtering and pagination. + +```ts +const { feeds, nextCursor } = await room.fetchFeeds({ + // Optional, cursor for pagination. Use nextCursor from previous response + cursor: "abc123", + + // Optional, only return feeds created or updated after this timestamp (ms) + since: 1234567890000, + + // Optional, limit the number of feeds to return + limit: 50, + + // Optional, filter feeds by metadata. Only feeds with matching metadata are returned + metadata: { + channel: true, + name: "My Feed", + }, +}); +``` + + + + Feeds within the current room. + + + Cursor for fetching the next page of feeds. + + + + + + Optional cursor for pagination. + + + Optional timestamp filter (ms). Only messages whose `createdAt` is at or + after this value are included. + + + Optional limit for the number of feeds to return. + + + Optional filter for feeds by metadata. Only feeds with matching metadata are + returned. + + + +### Room.fetchFeedMessages + +Fetches messages for a specific feed in the current room. Returns a paginated +list of messages with an optional cursor for fetching more. + +```ts +const { messages, nextCursor } = await room.fetchFeedMessages("my-feed-id"); + +// [{ id: "msg-1", timestamp: 1234567890, data: {...} }, ...] +console.log(messages); +``` + +```ts +const { messages, nextCursor } = await room.fetchFeedMessages("my-feed-id", { + // Optional, cursor for pagination + cursor: "abc123", + + // Optional, only return messages created after this timestamp (ms) + since: 1234567890000, + + // Optional, limit the number of messages to return + limit: 50, +}); +``` + + + + Messages within the feed. + + + Cursor for fetching the next page of messages. + + + +### Room.addFeed + +Adds a new feed to the room. Changes are synchronized in real-time to all +connected clients. + +```ts +room.addFeed("my-feed-id"); + +// With optional metadata and timestamp +room.addFeed("my-feed-id", { + metadata: { name: "My Feed", channel: true }, + timestamp: Date.now(), +}); +``` + + + + The ID of the feed to create. + + + Optional custom metadata for the feed. + + + Optional timestamp in milliseconds. Defaults to current time if not + provided. + + + +### Room.updateFeed + +Updates the metadata of an existing feed. Changes are synchronized in real-time +to all connected clients. + +```ts +room.updateFeed("my-feed-id", { + name: "Updated Feed Name", + updated: new Date().toISOString(), +}); +``` + + + + The ID of the feed to update. + + + The new metadata for the feed. + + + +### Room.deleteFeed + +Deletes a feed from the room. Changes are synchronized in real-time to all +connected clients. + +```ts +room.deleteFeed("my-feed-id"); +``` + + + + The ID of the feed to delete. + + + +### Room.addFeedMessage + +Adds a new message to a feed. Changes are synchronized in real-time to all +connected clients. + +```ts +room.addFeedMessage("my-feed-id", { + role: "user", + content: "Hello, world!", +}); + +// With optional id and timestamp +room.addFeedMessage( + "my-feed-id", + { role: "user", content: "Hello!" }, + { + id: "my-message-id", + timestamp: Date.now(), + } +); +``` + + + + The ID of the feed to add the message to. + + + The message data. + + + Optional message ID. One will be generated if not provided. + + + Optional timestamp in milliseconds. Defaults to current time if not + provided. + + + +### Room.updateFeedMessage + +Updates an existing feed message. Changes are synchronized in real-time to all +connected clients. + +```ts +room.updateFeedMessage("my-feed-id", "my-message-id", { + role: "user", + content: "Updated content", +}); +``` + + + + The ID of the feed containing the message. + + + The ID of the message to update. + + + The new message data. + + + +### Room.deleteFeedMessage + +Deletes a feed message. Changes are synchronized in real-time to all connected +clients. + +```ts +room.deleteFeedMessage("my-feed-id", "my-message-id"); +``` + + + + The ID of the feed containing the message. + + + The ID of the message to delete. + + + ## Notifications ### Client.getInboxNotifications diff --git a/docs/pages/api-reference/liveblocks-node.mdx b/docs/pages/api-reference/liveblocks-node.mdx index ce653537633..46e36e24df2 100644 --- a/docs/pages/api-reference/liveblocks-node.mdx +++ b/docs/pages/api-reference/liveblocks-node.mdx @@ -2715,6 +2715,166 @@ if (nextCursor) { } ``` +### Feeds + +#### Liveblocks.getFeeds [#get-rooms-roomId-feeds] + +Returns a list of feeds in a room. This is a wrapper around the +[Get Room Feeds API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-feeds) +and returns the same response. + +```ts +const { data: feeds } = await liveblocks.getFeeds({ + roomId: "my-room-id", +}); + +// [{ feedId: "feed-1", metadata: {...}, timestamp: 1234567890 }, ...] +console.log(feeds); +``` + +#### Liveblocks.createFeed [#post-rooms-roomId-feed] + +Creates a new feed in a room. This is a wrapper around the +[Create Feed API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-feed) +and returns the created feed. + +```ts +const feed = await liveblocks.createFeed({ + roomId: "my-room-id", + feedId: "my-feed-id", + + // Optional, custom metadata for the feed + metadata: { + name: "My Feed", + channel: true, + }, + + // Optional, timestamp in milliseconds. Defaults to current time if not provided + timestamp: Date.now(), +}); + +// { feedId: "my-feed-id", metadata: {...}, timestamp: 1234567890 } +console.log(feed); +``` + +#### Liveblocks.getFeed [#get-rooms-roomId-feeds-feedId] + +Returns a feed by its ID. This is a wrapper around the +[Get Feed API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-feeds-feedId) +and returns the same response. + +```ts +const feed = await liveblocks.getFeed({ + roomId: "my-room-id", + feedId: "my-feed-id", +}); + +// { feedId: "my-feed-id", metadata: {...}, timestamp: 1234567890 } +console.log(feed); +``` + +#### Liveblocks.updateFeed [#patch-rooms-roomId-feeds-feedId] + +Updates the metadata of a feed. This is a wrapper around the +[Update Feed API](/docs/api-reference/rest-api-endpoints#patch-rooms-roomId-feeds-feedId). + +```ts +await liveblocks.updateFeed({ + roomId: "my-room-id", + feedId: "my-feed-id", + metadata: { + name: "Updated Feed Name", + updated: new Date().toISOString(), + }, +}); +``` + +#### Liveblocks.deleteFeed [#delete-rooms-roomId-feeds-feedId] + +Deletes a feed. This is a wrapper around the +[Delete Feed API](/docs/api-reference/rest-api-endpoints#delete-rooms-roomId-feeds-feedId). + +```ts +await liveblocks.deleteFeed({ + roomId: "my-room-id", + feedId: "my-feed-id", +}); +``` + +#### Liveblocks.getFeedMessages [#get-rooms-roomId-feeds-feedId-messages] + +Returns a list of messages in a feed. This is a wrapper around the +[Get Feed Messages API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-feeds-feedId-messages) +and returns the same response. + +```ts +const { data: messages } = await liveblocks.getFeedMessages({ + roomId: "my-room-id", + feedId: "my-feed-id", +}); + +// [{ id: "msg-1", timestamp: 1234567890, data: {...} }, ...] +console.log(messages); +``` + +#### Liveblocks.createFeedMessage [#post-rooms-roomId-feeds-feedId-messages] + +Creates a new message in a feed. This is a wrapper around the +[Create Feed Message API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-feeds-feedId-messages) +and returns the created message. + +```ts +const message = await liveblocks.createFeedMessage({ + roomId: "my-room-id", + feedId: "my-feed-id", + + // The message data + data: { + role: "user", + content: "Hello, world!", + }, + + // Optional, custom message ID. One will be generated if not provided + id: "my-message-id", + + // Optional, timestamp in milliseconds. Defaults to current time if not provided + timestamp: Date.now(), +}); + +// { id: "my-message-id", timestamp: 1234567890, data: {...} } +console.log(message); +``` + +#### Liveblocks.updateFeedMessage [#patch-rooms-roomId-feeds-feedId-messages-messageId] + +Updates a feed message. This is a wrapper around the +[Update Feed Message API](/docs/api-reference/rest-api-endpoints#patch-rooms-roomId-feeds-feedId-messages-messageId). + +```ts +await liveblocks.updateFeedMessage({ + roomId: "my-room-id", + feedId: "my-feed-id", + messageId: "my-message-id", + data: { + role: "user", + content: "Updated content", + }, +}); +``` + +#### Liveblocks.deleteFeedMessage [#delete-rooms-roomId-feeds-feedId-messages-messageId] + +Deletes a feed message. This is a wrapper around the +[Delete Feed Message API](/docs/api-reference/rest-api-endpoints#delete-rooms-roomId-feeds-feedId-messages-messageId). + +```ts +await liveblocks.deleteFeedMessage({ + roomId: "my-room-id", + feedId: "my-feed-id", + messageId: "my-message-id", +}); +``` + ### Notifications #### Liveblocks.getInboxNotifications [#get-users-userId-inboxNotifications] diff --git a/docs/pages/api-reference/liveblocks-react-ui.mdx b/docs/pages/api-reference/liveblocks-react-ui.mdx index 53870f99ef1..7fc9c7ebd0a 100644 --- a/docs/pages/api-reference/liveblocks-react-ui.mdx +++ b/docs/pages/api-reference/liveblocks-react-ui.mdx @@ -984,7 +984,10 @@ its customization options instead. The `children` prop on `Comment` allows overriding or wrapping the comments’ content, while the `additionalContent` prop can be useful to render custom -content integrated into the comments’ content, just below the comment body. +content integrated into the comments’ content, just below the comment body. The +`body` prop is the same as the `children` prop but it only overrides the default +rich-text comment body while still keeping attachments, reactions, and +`additionalContent` as is. `Comment` also offers `avatar`, `author`, and `date` props to allow overriding or customizing the comment’s displayed avatar, author, and date respectively. @@ -1828,6 +1831,14 @@ function Component() { Additional content to display below the comment’s body. + + Override only the comment’s rich-text body. Receives the comment data and + the default content as children. + Whether to show the comment if it was deleted. If set to `false`, it will render deleted comments as `null`. diff --git a/docs/pages/api-reference/liveblocks-react.mdx b/docs/pages/api-reference/liveblocks-react.mdx index 3f0d94d1398..b27d3e66ea1 100644 --- a/docs/pages/api-reference/liveblocks-react.mdx +++ b/docs/pages/api-reference/liveblocks-react.mdx @@ -5125,6 +5125,434 @@ const { url, error, isLoading } = useAttachmentUrl("at_xxx"); +## Feeds + +### useFeeds [@badge=RoomProvider] + +Returns a paginated list of feeds within the current room. Results are sorted +oldest first and can be [filtered](#useFeeds-filtering) and +[paginated](#useFeeds-pagination). +[Suspense](/docs/api-reference/liveblocks-react#Suspense-hooks) and +[regular](/docs/api-reference/liveblocks-react#Regular-hooks) versions of this +hook are available. + +```tsx +import { useFeeds } from "@liveblocks/react"; + +const { feeds, error, isLoading } = useFeeds(); +``` + + + + Optional configuration object. + + + Optional timestamp filter (ms). Only feeds whose `createdAt` or `updatedAt` + is at or after this value are included in `feeds` (applied to the cached + data for this hook’s options). + + + Optional metadata filter. Only feeds whose metadata matches every key/value + pair are included (applied to the cached data for this hook’s options). + + + Page size for each server request when loading or loading more feeds. Does + not cap how many feeds appear in `feeds`—use `fetchMore` until + `hasFetchedAll` is true. + + + + + + An array of feeds within the current room matching `since` and `metadata` + for this call, sorted by `createdAt` ascending (tie-break on `feedId`), or + `undefined` if not yet loaded (in non-Suspense version). + + + Whether the feeds are currently being loaded. + + + Any error that occurred while loading the feeds. + + + Whether all available feeds have been fetched. + + + A function to fetch more feeds. + + + Whether more feeds are currently being fetched. + + + Any error that occurred while fetching more feeds. + + + +#### Filtering [#useFeeds-filtering] + +It’s possible to filter feeds by timestamp and metadata, and results are merged +into a per-room cache. Limit sets the page size for each fetch when +[paginating](). + +```tsx +const { feeds } = useFeeds({ + // Optional, fetch feeds from the last day + since: Date.now() - 1000 * 60 * 60 * 24, + + // Optional, fetch feeds with the `{ tag: "design" }` metadata + metadata: { + tag: "design", + }, + + // Optional, fetch only 10 at a time + limit: 10, +}); +``` + +#### Pagination [#useFeeds-pagination] + +By default, the `useFeeds` hook returns up to 50 feeds. To fetch more, the hook +provides additional fields for pagination, similar to [`useThreads`][]. + +```tsx +import { useFeeds } from "@liveblocks/react"; + +const { + feeds, + error, + isLoading, + + // +++ + fetchMore, + isFetchingMore, + hasFetchedAll, + fetchMoreError, + // +++ +} = useFeeds(); +``` + +- `hasFetchedAll` indicates whether all available feeds have been fetched. +- `fetchMore` loads up to 50 more feeds, and is always safe to call. +- `isFetchingMore` indicates whether more feeds are being fetched. +- `fetchMoreError` returns error statuses resulting from fetching more. + +##### Pagination example [#useFeeds-pagination-example] + +The following example demonstrates how to use the `fetchMore` function to +implement a “Load More” button, which fetches additional feeds when clicked. The +button is disabled while fetching is in progress. + +```tsx +import { Feed } from "@liveblocks/react-ui"; +import { useFeeds } from "@liveblocks/react/suspense"; + +function Feeds() { + const { feeds, hasFetchedAll, fetchMore, isFetchingMore } = useFeeds(); + + return ( +
+ {feeds.map((feed) => ( + + {feed.metadata.name} + + ))} + // +++ + {hasFetchedAll ? ( +
🎉 You've loaded all feeds!
+ ) : ( + + )} + // +++ +
+ ); +} +``` + +### useFeedMessages [@badge=RoomProvider] + +Returns a paginated list of messages for a specific feed in the current room. +Messages are sorted newest first using their `createdAt` property and can be +[paginated](#useFeedMessages-pagination). +[Suspense](/docs/api-reference/liveblocks-react#Suspense-hooks) and +[regular](/docs/api-reference/liveblocks-react#Regular-hooks) versions of this +hook are available. + +```tsx +import { useFeedMessages } from "@liveblocks/react"; + +const { messages, error, isLoading } = useFeedMessages("my-feed-id"); +``` + + + + The ID of the feed to get messages from. + + + Optional configuration object. + + + Optional cursor for pagination. + + + Page size for each server request when loading or loading more messages. + Does not cap how many messages appear in `messages`—use `fetchMore` until + `hasFetchedAll` is true. + + + + + + An array of messages in the feed (chronological order by `createdAt`), or + `undefined` if not yet loaded (in non-Suspense version). + + + Whether the messages are currently being loaded. + + + Any error that occurred while loading the messages. + + + Whether all available messages have been fetched. + + + A function to fetch more messages. + + + Whether more messages are currently being fetched. + + + Any error that occurred while fetching more messages. + + + +#### Pagination [#useFeedMessages-pagination] + +By default, the `useFeedMessages` hook returns up to 50 messages. To fetch more, +the hook provides additional fields for pagination, similar to [`useThreads`][]. + +```tsx +import { useFeedMessages } from "@liveblocks/react"; + +const { + messages, + error, + isLoading, + + // +++ + hasFetchedAll, + fetchMore, + isFetchingMore, + fetchMoreError, + // +++ +} = useFeedMessages("my-feed-id"); +``` + +- `hasFetchedAll` indicates whether all available messages have been fetched. +- `fetchMore` loads up to 50 more messages, and is always safe to call. +- `isFetchingMore` indicates whether more messages are being fetched. +- `fetchMoreError` returns error statuses resulting from fetching more. + +##### Pagination example [#useFeedMessages-pagination-example] + +The following example demonstrates how to use the `fetchMore` function to +implement a “Load More” button, which fetches additional messages when clicked. +The button is disabled while fetching is in progress. + +```tsx +import { useFeedMessages } from "@liveblocks/react/suspense"; + +function FeedMessages({ feedId }: { feedId: string }) { + const { messages, hasFetchedAll, fetchMore, isFetchingMore } = + useFeedMessages(feedId); + + return ( +
+ {messages.map((message) => ( +
{message.data.content}
+ ))} + // +++ + {hasFetchedAll ? ( +
🎉 You've loaded all messages!
+ ) : ( + + )} + // +++ +
+ ); +} +``` + +### useCreateFeed [@badge=RoomProvider] + +Returns a function that creates a new feed in the current room. + +```tsx +import { useCreateFeed } from "@liveblocks/react"; + +const createFeed = useCreateFeed(); +createFeed("my-feed-id", { + metadata: { name: "My Feed", channel: true }, + timestamp: Date.now(), +}); +``` + + + + A function that creates a feed. Takes the feed ID and optional metadata and + timestamp. + + + +### useDeleteFeed [@badge=RoomProvider] + +Returns a function that deletes a feed from the current room. + +```tsx +import { useDeleteFeed } from "@liveblocks/react"; + +const deleteFeed = useDeleteFeed(); +deleteFeed("my-feed-id"); +``` + + + + A function that deletes a feed. Takes the feed ID. + + + +### useUpdateFeedMetadata [@badge=RoomProvider] + +Returns a function that updates a feed's metadata in the current room. + +```tsx +import { useUpdateFeedMetadata } from "@liveblocks/react"; + +const updateFeedMetadata = useUpdateFeedMetadata(); +updateFeedMetadata("my-feed-id", { + name: "Updated Name", + updated: new Date().toISOString(), +}); +``` + + + + A function that updates a feed's metadata. Takes the feed ID and the new + metadata object. + + + +### useCreateFeedMessage [@badge=RoomProvider] + +Returns a function that adds a new message to a feed in the current room. + +```tsx +import { useCreateFeedMessage } from "@liveblocks/react"; + +const createFeedMessage = useCreateFeedMessage(); +createFeedMessage("my-feed-id", { role: "user", content: "Hello, world!" }); + +// With optional id and timestamp +createFeedMessage( + "my-feed-id", + { role: "user", content: "Hello!" }, + { + id: "my-message-id", + timestamp: Date.now(), + } +); +``` + + + + A function that adds a message to a feed. Takes the feed ID, message data, + and optional id and timestamp. + + + +### useDeleteFeedMessage [@badge=RoomProvider] + +Returns a function that deletes a message from a feed in the current room. + +```tsx +import { useDeleteFeedMessage } from "@liveblocks/react"; + +const deleteFeedMessage = useDeleteFeedMessage(); +deleteFeedMessage("my-feed-id", "my-message-id"); +``` + + + + A function that deletes a feed message. Takes the feed ID and message ID. + + + +### useUpdateFeedMessage [@badge=RoomProvider] + +Returns a function that updates a feed message in the current room. + +```tsx +import { useUpdateFeedMessage } from "@liveblocks/react"; + +const updateFeedMessage = useUpdateFeedMessage(); +updateFeedMessage("my-feed-id", "my-message-id", { + role: "user", + content: "Updated content", +}); +``` + + + + A function that updates a feed message. Takes the feed ID, message ID, and + the new data object. + + + +#### Typing feed metadata and message data [#feeds-typescript] + +You can type feed metadata and feed message data using the `Liveblocks` +interface in your `liveblocks.config.ts` file. This provides type safety for +[`useFeeds`](#useFeeds) and [`useFeedMessages`](#useFeedMessages). + +```ts +// liveblocks.config.ts +declare global { + interface Liveblocks { + FeedMetadata: { + name?: string; + channel?: boolean; + agentName?: string; + }; + + FeedMessageData: { + role: "user" | "assistant" | "system"; + content: string; + }; + } +} +``` + ## Notifications ### useInboxNotifications [@badge=LiveblocksProvider] diff --git a/docs/pages/get-started/nextjs-chat-sdk-bot.mdx b/docs/pages/get-started/nextjs-chat-sdk-bot.mdx new file mode 100644 index 00000000000..e916c67c5ac --- /dev/null +++ b/docs/pages/get-started/nextjs-chat-sdk-bot.mdx @@ -0,0 +1,249 @@ +--- +meta: + title: "Get started with a Chat SDK bot using Liveblocks and Next.js" + parentTitle: "Quickstart" + description: + "Learn how to build a bot on Liveblocks comment threads using the Chat SDK" +--- + +Liveblocks is a realtime collaboration infrastructure for building performant +collaborative experiences. Follow the following steps to start building a bot +that reads and responds to Liveblocks comment threads using the +[Chat SDK](https://chat-sdk.dev) and the +[`@liveblocks/chat-sdk-adapter`](/docs/api-reference/liveblocks-chat-sdk-adapter) +in your Next.js `/app` directory application. + +## Quickstart + + + + Install Liveblocks and Chat SDK + + + Install the Liveblocks Chat SDK adapter, the Chat SDK, and a state adapter. + + ```bash trackEvent="install_liveblocks" + npm install @liveblocks/chat-sdk-adapter @liveblocks/node chat @chat-adapter/state-memory + ``` + + + + + + Add your environment variables + + + Create a new `.env.local` file and add your Liveblocks secret key and webhook + secret from the [dashboard](/dashboard/apikeys). + + ```env file=".env.local" + LIVEBLOCKS_SECRET_KEY="{{SECRET_KEY}}" + LIVEBLOCKS_WEBHOOK_SECRET="whsec_..." + ``` + + You'll create the webhook and get the secret in the final step. + + + + + + Create a user database + + + Create a file to store your bot's user ID and a function to resolve users. + The `resolveUsers` function converts user IDs from mentions into display names. + + ```tsx file="app/database.ts" + export const BOT_USER_ID = "__bot__"; + export const BOT_USER_NAME = "My Bot"; + + // A mock database with example users + const USER_INFO = [ + { + id: "user-1", + info: { + name: "Charlie Layne", + }, + }, + { + id: "user-2", + info: { + name: "Mislav Abha", + }, + }, + { + id: BOT_USER_ID, + info: { + name: BOT_USER_NAME, + }, + }, + ]; + + export function getUser(id: string) { + return USER_INFO.find((u) => u.id === id) || undefined; + } + ``` + + + + + + Create the bot instance + + + Create a bot instance using the Chat SDK with the Liveblocks adapter. + The adapter connects your bot to Liveblocks comment threads. + + ```tsx file="app/bot.ts" + import { Chat } from "chat"; + import { + createLiveblocksAdapter, + LiveblocksAdapter, + } from "@liveblocks/chat-sdk-adapter"; + import { createMemoryState } from "@chat-adapter/state-memory"; + import { BOT_USER_ID, BOT_USER_NAME, getUser } from "./database"; + + export const bot = new Chat<{ liveblocks: LiveblocksAdapter }>({ + userName: BOT_USER_NAME, + adapters: { + liveblocks: createLiveblocksAdapter({ + apiKey: process.env.LIVEBLOCKS_SECRET_KEY!, + webhookSecret: process.env.LIVEBLOCKS_WEBHOOK_SECRET!, + botUserId: BOT_USER_ID, + botUserName: BOT_USER_NAME, + resolveUsers: ({ userIds }) => { + return userIds.map((id) => getUser(id)?.info); + }, + }), + }, + state: createMemoryState(), + }); + ``` + + + + + + Handle mentions and reactions + + + Add event handlers to respond when users mention the bot or react to messages. + Add these handlers to your `app/bot.ts` file after the bot instance. + + ```tsx file="app/bot.ts" + // Handle @-mentions of the bot + bot.onNewMention(async (thread, message) => { + // Add a reaction to acknowledge the message + await thread.adapter.addReaction(thread.id, message.id, "👀"); + + // Reply in the thread + await thread.post(`Hello ${message.author.userName}! How can I help?`); + }); + + // Handle reactions to messages + bot.onReaction(async (event) => { + if (!event.added) return; + + await event.adapter.postMessage( + event.threadId, + `${event.user.userName} reacted with "${event.emoji.name}"` + ); + }); + ``` + + + + + + Create the webhook endpoint + + + Create an API route to receive Liveblocks webhooks. The bot processes + incoming comments and reactions through this endpoint. + + ```tsx file="app/api/webhooks/liveblocks/route.ts" + import { bot } from "@/app/bot"; + + export async function POST(request: Request) { + return bot.webhooks.liveblocks(request, { + waitUntil: (p) => void p, + }); + } + ``` + + For production deployments on Vercel, use `waitUntil` from `@vercel/functions` + for background processing: + + ```tsx file="app/api/webhooks/liveblocks/route.ts" + import { bot } from "@/app/bot"; + import { waitUntil } from "@vercel/functions"; + + export async function POST(request: Request) { + return bot.webhooks.liveblocks(request, { waitUntil }); + } + ``` + + + + + + Set up Liveblocks webhooks + + + The final step is to configure Liveblocks webhooks to send events to your bot. + + 1. Follow the guide on [testing webhooks locally](/docs/guides/how-to-test-webhooks-on-localhost) + 2. When creating the webhook endpoint in the [dashboard](/dashboard), enable these events: + - `commentCreated` + - `commentReactionAdded` + - `commentReactionRemoved` + 3. Copy your **webhook secret** (`whsec_...`) and add it to `.env.local` + + Now when users @-mention your bot or react to messages in comment threads, + your bot will respond automatically. + + + + + + + + +## What to read next + +Congratulations! You've set up a bot that responds to Liveblocks comment threads +using the Chat SDK. + +- [Chat SDK documentation](https://chat-sdk.dev) +- [@liveblocks/chat-sdk-adapter README](https://github.com/liveblocks/liveblocks/tree/main/packages/liveblocks-chat-sdk-adapter) +- [Webhooks documentation](/docs/platform/webhooks) +- [How to test webhooks on localhost](/docs/guides/how-to-test-webhooks-on-localhost) + +--- + +## Examples using Chat SDK + + + + + diff --git a/docs/references/v2.openapi.json b/docs/references/v2.openapi.json index d9c06a331ae..0497b68ca3d 100644 --- a/docs/references/v2.openapi.json +++ b/docs/references/v2.openapi.json @@ -3176,6 +3176,696 @@ } ] }, + "/rooms/{roomId}/feeds": { + "get": { + "summary": "Get room feeds", + "description": "This endpoint returns the feeds in the requested room. Corresponds to [`liveblocks.getFeeds`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-rooms-roomId-feeds).", + "tags": ["Feeds"], + "operationId": "get-feeds", + "parameters": [ + { + "name": "roomId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the room", + "example": "my-room-id" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "description": "A cursor used for pagination. Get the value from the `nextCursor` response of the previous page.", + "example": "eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9" + } + }, + { + "name": "since", + "in": "query", + "schema": { + "type": "integer", + "description": "Only return feeds with `createdAt` greater than this Unix timestamp in milliseconds.", + "example": 1660000988137 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20, + "description": "A limit on the number of feeds to be returned. The limit can range between 1 and 100, and defaults to 20.", + "example": 20 + } + } + ], + "responses": { + "200": { + "description": "Success. Returns list of feeds in a room.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetFeedsResponse" + }, + "examples": { + "example": { + "value": { + "nextCursor": "eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9", + "data": [ + { + "feedId": "my-feed-id", + "metadata": { + "type": "chat", + "name": "General Discussion" + }, + "createdAt": 1660000988137, + "updatedAt": 1660000988137 + } + ] + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + } + } + }, + "post": { + "summary": "Create feed", + "description": "This endpoint creates a new feed in a room. Corresponds to [`liveblocks.createFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-rooms-roomId-feeds).", + "tags": ["Feeds"], + "operationId": "create-feed", + "parameters": [ + { + "name": "roomId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the room", + "example": "my-room-id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFeedRequestBody" + }, + "examples": { + "example": { + "value": { + "feedId": "my-feed-id", + "metadata": { + "type": "chat", + "name": "General Discussion" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success. Returns the created feed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Feed" + }, + "examples": { + "example": { + "value": { + "feedId": "my-feed-id", + "metadata": { + "type": "chat", + "name": "General Discussion" + }, + "createdAt": 1660000988137, + "updatedAt": 1660000988137 + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "422": { + "$ref": "#/components/responses/422" + } + } + } + }, + "/rooms/{roomId}/feeds/{feedId}": { + "get": { + "summary": "Get feed", + "description": "This endpoint returns a feed by its ID. Corresponds to [`liveblocks.getFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-rooms-roomId-feeds-feedId).", + "tags": ["Feeds"], + "operationId": "get-feed", + "parameters": [ + { + "name": "roomId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the room", + "example": "my-room-id" + } + }, + { + "name": "feedId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the feed", + "example": "fd_abc123" + } + } + ], + "responses": { + "200": { + "description": "Success. Returns the feed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Feed" + }, + "examples": { + "example": { + "value": { + "feedId": "my-feed-id", + "metadata": { + "type": "chat", + "name": "General Discussion" + }, + "createdAt": 1660000988137, + "updatedAt": 1660000988137 + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + } + } + }, + "patch": { + "summary": "Update feed", + "description": "This endpoint updates the metadata of a feed. Corresponds to [`liveblocks.updateFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#patch-rooms-roomId-feeds-feedId).", + "tags": ["Feeds"], + "operationId": "update-feed", + "parameters": [ + { + "name": "roomId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the room", + "example": "my-room-id" + } + }, + { + "name": "feedId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the feed", + "example": "fd_abc123" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFeedRequestBody" + }, + "examples": { + "example": { + "value": { + "metadata": { + "type": "chat", + "name": "Updated Discussion" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success. Returns the updated feed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Feed" + }, + "examples": { + "example": { + "value": { + "feedId": "my-feed-id", + "metadata": { + "type": "chat", + "name": "Updated Discussion" + }, + "createdAt": 1660000988137, + "updatedAt": 1660001000000 + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "422": { + "$ref": "#/components/responses/422" + } + } + }, + "delete": { + "summary": "Delete feed", + "description": "This endpoint deletes a feed. Corresponds to [`liveblocks.deleteFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#delete-rooms-roomId-feeds-feedId).", + "tags": ["Feeds"], + "operationId": "delete-feed", + "parameters": [ + { + "name": "roomId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the room", + "example": "my-room-id" + } + }, + { + "name": "feedId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the feed", + "example": "fd_abc123" + } + } + ], + "responses": { + "204": { + "description": "Success. The feed was deleted." + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + } + } + } + }, + "/rooms/{roomId}/feeds/{feedId}/messages": { + "get": { + "summary": "Get feed messages", + "description": "This endpoint returns the messages in a feed. Corresponds to [`liveblocks.getFeedMessages`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-rooms-roomId-feeds-feedId-messages).", + "tags": ["Feeds"], + "operationId": "get-feed-messages", + "parameters": [ + { + "name": "roomId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the room", + "example": "my-room-id" + } + }, + { + "name": "feedId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the feed", + "example": "fd_abc123" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "description": "A cursor used for pagination. Get the value from the `nextCursor` response of the previous page.", + "example": "eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9" + } + }, + { + "name": "since", + "in": "query", + "schema": { + "type": "integer", + "description": "Only return messages with `createdAt` greater than this Unix timestamp in milliseconds.", + "example": 1660000988137 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20, + "description": "A limit on the number of messages to be returned. The limit can range between 1 and 100, and defaults to 20.", + "example": 20 + } + } + ], + "responses": { + "200": { + "description": "Success. Returns list of messages in a feed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetFeedMessagesResponse" + }, + "examples": { + "example": { + "value": { + "nextCursor": "eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9", + "data": [ + { + "id": "msg_xyz789", + "data": { + "type": "text", + "content": "Hello, world!" + }, + "createdAt": 1660000988137, + "updatedAt": 1660000988137 + } + ] + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + } + } + }, + "post": { + "summary": "Create feed message", + "description": "This endpoint creates a new message in a feed. Corresponds to [`liveblocks.createFeedMessage`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-rooms-roomId-feeds-feedId-messages).", + "tags": ["Feeds"], + "operationId": "create-feed-message", + "parameters": [ + { + "name": "roomId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the room", + "example": "my-room-id" + } + }, + { + "name": "feedId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the feed", + "example": "fd_abc123" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFeedMessageRequestBody" + }, + "examples": { + "example": { + "value": { + "id": "msg_xyz789", + "data": { + "type": "text", + "content": "Hello, world!" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success. Returns the created feed message.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedMessage" + }, + "examples": { + "example": { + "value": { + "id": "msg_xyz789", + "data": { + "type": "text", + "content": "Hello, world!" + }, + "createdAt": 1660000988137, + "updatedAt": 1660000988137 + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "422": { + "$ref": "#/components/responses/422" + } + } + } + }, + "/rooms/{roomId}/feeds/{feedId}/messages/{messageId}": { + "patch": { + "summary": "Update feed message", + "description": "This endpoint updates a feed message. Corresponds to [`liveblocks.updateFeedMessage`](https://liveblocks.io/docs/api-reference/liveblocks-node#patch-rooms-roomId-feeds-feedId-messages-messageId).", + "tags": ["Feeds"], + "operationId": "update-feed-message", + "parameters": [ + { + "name": "roomId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the room", + "example": "my-room-id" + } + }, + { + "name": "feedId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the feed", + "example": "fd_abc123" + } + }, + { + "name": "messageId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the message", + "example": "msg_xyz789" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFeedMessageRequestBody" + }, + "examples": { + "example": { + "value": { + "data": { + "type": "text", + "content": "Updated message content" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Success. Returns the updated feed message.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedMessage" + }, + "examples": { + "example": { + "value": { + "id": "msg_xyz789", + "data": { + "type": "text", + "content": "Updated message content" + }, + "createdAt": 1660000988137, + "updatedAt": 1660001000000 + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "422": { + "$ref": "#/components/responses/422" + } + } + }, + "delete": { + "summary": "Delete feed message", + "description": "This endpoint deletes a feed message. Corresponds to [`liveblocks.deleteFeedMessage`](https://liveblocks.io/docs/api-reference/liveblocks-node#delete-rooms-roomId-feeds-feedId-messages-messageId).", + "tags": ["Feeds"], + "operationId": "delete-feed-message", + "parameters": [ + { + "name": "roomId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the room", + "example": "my-room-id" + } + }, + { + "name": "feedId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the feed", + "example": "fd_abc123" + } + }, + { + "name": "messageId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the message", + "example": "msg_xyz789" + } + } + ], + "responses": { + "204": { + "description": "Success. The feed message was deleted." + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + } + } + } + }, "/authorize-user": { "post": { "summary": "Get access token with secret key", @@ -7461,6 +8151,190 @@ "resolved": false } }, + "Feed": { + "type": "object", + "title": "Feed", + "description": "Feed objects returned by the API use `createdAt` and `updatedAt` (Unix time in milliseconds).", + "properties": { + "feedId": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "createdAt": { + "type": "number", + "description": "Unix timestamp in milliseconds when the feed was created." + }, + "updatedAt": { + "type": "number", + "description": "Unix timestamp in milliseconds when the feed was last updated." + } + }, + "required": ["feedId", "metadata", "createdAt", "updatedAt"] + }, + "FeedMessage": { + "type": "object", + "title": "FeedMessage", + "description": "Message objects returned by the API use `createdAt` and `updatedAt` (Unix time in milliseconds). Request bodies for create/update use `timestamp` for optional custom times.", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "number", + "description": "Unix timestamp in milliseconds when the message was created." + }, + "updatedAt": { + "type": "number", + "description": "Unix timestamp in milliseconds when the message was last updated." + }, + "data": { + "type": "object" + } + }, + "required": ["id", "createdAt", "updatedAt", "data"] + }, + "CreateFeedRequestBody": { + "type": "object", + "title": "CreateFeedRequestBody", + "description": "Request body for `POST /v2/rooms/{roomId}/feeds`. Optional creation time is sent as `timestamp` (milliseconds), not `createdAt`.", + "properties": { + "feedId": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "timestamp": { + "type": "number", + "description": "Optional. Unix timestamp in milliseconds for the feed's creation time. If omitted, the server uses the current time." + } + }, + "required": ["feedId"] + }, + "UpdateFeedRequestBody": { + "type": "object", + "title": "UpdateFeedRequestBody", + "properties": { + "metadata": { + "type": "object" + } + }, + "required": ["metadata"] + }, + "CreateFeedMessageRequestBody": { + "type": "object", + "title": "CreateFeedMessageRequestBody", + "description": "Request body for `POST /v2/rooms/{roomId}/feeds/{feedId}/messages`. Optional message time is sent as `timestamp` (milliseconds), not `createdAt`.", + "properties": { + "id": { + "type": "string", + "description": "Optional client-provided message id. If omitted, the server generates one." + }, + "timestamp": { + "type": "number", + "description": "Optional. Unix timestamp in milliseconds for the message's creation time. If omitted, the server uses the current time." + }, + "data": { + "type": "object" + } + }, + "required": ["data"] + }, + "UpdateFeedMessageRequestBody": { + "type": "object", + "title": "UpdateFeedMessageRequestBody", + "description": "Request body for `PATCH /v2/rooms/{roomId}/feeds/{feedId}/messages/{messageId}`. Optional update time is sent as `timestamp` (milliseconds), not `updatedAt`.", + "properties": { + "data": { + "type": "object" + }, + "timestamp": { + "type": "number", + "description": "Optional. Unix timestamp in milliseconds to record as the update time. If omitted, the server uses the current time." + } + }, + "required": ["data"] + }, + "GetFeedsResponse": { + "title": "GetFeedsResponse", + "type": "object", + "additionalProperties": false, + "required": ["nextCursor", "data"], + "properties": { + "nextCursor": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Pass as `cursor` to fetch the next page, or null when there are no more results." + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Feed" + } + } + }, + "example": { + "nextCursor": "eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9", + "data": [ + { + "feedId": "my-feed-id", + "metadata": { + "type": "chat", + "name": "General Discussion" + }, + "createdAt": 1660000988137, + "updatedAt": 1660000988137 + } + ] + } + }, + "GetFeedMessagesResponse": { + "title": "GetFeedMessagesResponse", + "type": "object", + "additionalProperties": false, + "required": ["nextCursor", "data"], + "properties": { + "nextCursor": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Pass as `cursor` to fetch the next page, or null when there are no more results." + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FeedMessage" + } + } + }, + "example": { + "nextCursor": "eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9", + "data": [ + { + "id": "msg_xyz789", + "data": { + "type": "text", + "content": "Hello, world!" + }, + "createdAt": 1660000988137, + "updatedAt": 1660000988137 + } + ] + } + }, "CreateThreadRequestBody": { "title": "CreateThreadRequestBody", "type": "object", @@ -11112,6 +11986,9 @@ { "name": "Comments" }, + { + "name": "Feeds" + }, { "name": "Notifications" }, diff --git a/docs/routes.json b/docs/routes.json index 13c5f0ba676..2c5888948d1 100644 --- a/docs/routes.json +++ b/docs/routes.json @@ -46,6 +46,11 @@ "path": "/get-started/nextjs-comments", "hidden": true }, + { + "title": "Chat SDK Bot", + "path": "/get-started/nextjs-chat-sdk-bot", + "hidden": true + }, { "title": "Comments / Canvas", "path": "/get-started/nextjs-comments-canvas", @@ -848,6 +853,10 @@ "title": "@liveblocks/emails", "path": "/api-reference/liveblocks-emails" }, + { + "title": "@liveblocks/chat-sdk-adapter", + "path": "/api-reference/liveblocks-chat-sdk-adapter" + }, { "title": "Python SDK", "path": "/api-reference/liveblocks-python" diff --git a/e2e/next-ai-kitchen-sink/package.json b/e2e/next-ai-kitchen-sink/package.json index 2672d068f7b..0d515db1c3c 100644 --- a/e2e/next-ai-kitchen-sink/package.json +++ b/e2e/next-ai-kitchen-sink/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@liveblocks/client": "*", + "@liveblocks/core": "*", "@liveblocks/node": "*", "@liveblocks/react": "*", "@liveblocks/react-ui": "*", diff --git a/e2e/next-feeds/.gitignore b/e2e/next-feeds/.gitignore new file mode 100644 index 00000000000..a281a739dbe --- /dev/null +++ b/e2e/next-feeds/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# playwright +test-results/ +playwright-report/ diff --git a/e2e/next-feeds/README.md b/e2e/next-feeds/README.md new file mode 100644 index 00000000000..e215bc4ccf1 --- /dev/null +++ b/e2e/next-feeds/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/e2e/next-feeds/app/api/auth/liveblocks/route.ts b/e2e/next-feeds/app/api/auth/liveblocks/route.ts new file mode 100644 index 00000000000..eea4f6be8e8 --- /dev/null +++ b/e2e/next-feeds/app/api/auth/liveblocks/route.ts @@ -0,0 +1,44 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextRequest, NextResponse } from "next/server"; + +const USERS = [ + { + id: "user-0", + info: { + name: "Charlie Layne", + avatar: "https://liveblocks.io/avatars/avatar-0.png", + }, + }, +]; + +/** + * Authenticating your Liveblocks application + * https://liveblocks.io/docs/authentication + */ + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, + baseUrl: process.env.NEXT_PUBLIC_LIVEBLOCKS_BASE_URL!, +}); + +export async function POST(request: NextRequest) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + // Get a random user from the database (for demo purposes) + const user = USERS[Math.floor(Math.random() * USERS.length)]; + + // Create a session for the current user (access token auth) + 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:*`, session.FULL_ACCESS); + + // Authorize the user and return the result + const { status, body } = await session.authorize(); + + return new NextResponse(body, { status }); +} diff --git a/e2e/next-feeds/app/api/feeds/[feedId]/messages/[messageId]/route.ts b/e2e/next-feeds/app/api/feeds/[feedId]/messages/[messageId]/route.ts new file mode 100644 index 00000000000..9f135d65f8d --- /dev/null +++ b/e2e/next-feeds/app/api/feeds/[feedId]/messages/[messageId]/route.ts @@ -0,0 +1,79 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextRequest, NextResponse } from "next/server"; + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, + baseUrl: process.env.NEXT_PUBLIC_LIVEBLOCKS_BASE_URL!, +}); + +// PATCH /api/feeds/[feedId]/messages/[messageId] - Update a feed message +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ feedId: string; messageId: string }> } +) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + try { + const { feedId, messageId } = await params; + const body = await request.json(); + const { roomId, data } = body; + + if (!roomId) { + return new NextResponse("Missing roomId in body", { status: 400 }); + } + + if (!data) { + return new NextResponse("Missing data in body", { status: 400 }); + } + + await liveblocks.updateFeedMessage({ + roomId, + feedId, + messageId, + data, + }); + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error("Error updating feed message:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +// DELETE /api/feeds/[feedId]/messages/[messageId]?roomId=xxx - Delete a feed message +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ feedId: string; messageId: string }> } +) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + const { feedId, messageId } = await params; + const { searchParams } = new URL(request.url); + const roomId = searchParams.get("roomId"); + + if (!roomId) { + return new NextResponse("Missing roomId parameter", { status: 400 }); + } + + try { + await liveblocks.deleteFeedMessage({ + roomId, + feedId, + messageId, + }); + return new NextResponse(null, { status: 204 }); + } catch (error) { + console.error("Error deleting feed message:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/e2e/next-feeds/app/api/feeds/[feedId]/messages/route.ts b/e2e/next-feeds/app/api/feeds/[feedId]/messages/route.ts new file mode 100644 index 00000000000..36070f52053 --- /dev/null +++ b/e2e/next-feeds/app/api/feeds/[feedId]/messages/route.ts @@ -0,0 +1,79 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextRequest, NextResponse } from "next/server"; + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, + baseUrl: process.env.NEXT_PUBLIC_LIVEBLOCKS_BASE_URL!, +}); + +// GET /api/feeds/[feedId]/messages?roomId=xxx - List feed messages +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ feedId: string }> } +) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + const { feedId } = await params; + const { searchParams } = new URL(request.url); + const roomId = searchParams.get("roomId"); + + if (!roomId) { + return new NextResponse("Missing roomId parameter", { status: 400 }); + } + + try { + const result = await liveblocks.getFeedMessages({ + roomId, + feedId, + }); + return NextResponse.json(result); + } catch (error) { + console.error("Error fetching feed messages:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +// POST /api/feeds/[feedId]/messages - Create a new feed message +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ feedId: string }> } +) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + try { + const { feedId } = await params; + const body = await request.json(); + const { roomId, id, createdAt, data } = body; + + if (!roomId) { + return new NextResponse("Missing roomId in body", { status: 400 }); + } + + if (!data) { + return new NextResponse("Missing data in body", { status: 400 }); + } + + const message = await liveblocks.createFeedMessage({ + roomId, + feedId, + id, + createdAt, + data, + }); + + return NextResponse.json(message); + } catch (error) { + console.error("Error creating feed message:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/e2e/next-feeds/app/api/feeds/[feedId]/route.ts b/e2e/next-feeds/app/api/feeds/[feedId]/route.ts new file mode 100644 index 00000000000..5de534218df --- /dev/null +++ b/e2e/next-feeds/app/api/feeds/[feedId]/route.ts @@ -0,0 +1,109 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextRequest, NextResponse } from "next/server"; + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, + baseUrl: process.env.NEXT_PUBLIC_LIVEBLOCKS_BASE_URL!, +}); + +// GET /api/feeds/[feedId]?roomId=xxx - Get a specific feed +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ feedId: string }> } +) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + const { feedId } = await params; + const { searchParams } = new URL(request.url); + const roomId = searchParams.get("roomId"); + + if (!roomId) { + return new NextResponse("Missing roomId parameter", { status: 400 }); + } + + try { + const feed = await liveblocks.getFeed({ + roomId, + feedId, + }); + return NextResponse.json(feed); + } catch (error) { + console.error("Error fetching feed:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +// PATCH /api/feeds/[feedId] - Update feed metadata +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ feedId: string }> } +) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + try { + const { feedId } = await params; + const body = await request.json(); + const { roomId, metadata } = body; + + if (!roomId) { + return new NextResponse("Missing roomId in body", { status: 400 }); + } + + if (!metadata) { + return new NextResponse("Missing metadata in body", { status: 400 }); + } + + await liveblocks.updateFeed({ + roomId, + feedId, + metadata, + }); + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error("Error updating feed:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +// DELETE /api/feeds/[feedId]?roomId=xxx - Delete a feed +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ feedId: string }> } +) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + const { feedId } = await params; + const { searchParams } = new URL(request.url); + const roomId = searchParams.get("roomId"); + + if (!roomId) { + return new NextResponse("Missing roomId parameter", { status: 400 }); + } + + try { + await liveblocks.deleteFeed({ + roomId, + feedId, + }); + return new NextResponse(null, { status: 204 }); + } catch (error) { + console.error("Error deleting feed:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/e2e/next-feeds/app/api/feeds/route.ts b/e2e/next-feeds/app/api/feeds/route.ts new file mode 100644 index 00000000000..bbe4659ccdc --- /dev/null +++ b/e2e/next-feeds/app/api/feeds/route.ts @@ -0,0 +1,67 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextRequest, NextResponse } from "next/server"; + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, + baseUrl: process.env.NEXT_PUBLIC_LIVEBLOCKS_BASE_URL!, +}); + +// GET /api/feeds?roomId=xxx - List all feeds +export async function GET(request: NextRequest) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const roomId = searchParams.get("roomId"); + + if (!roomId) { + return new NextResponse("Missing roomId parameter", { status: 400 }); + } + + try { + const result = await liveblocks.getFeeds({ roomId }); + return NextResponse.json(result); + } catch (error) { + console.error("Error fetching feeds:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +// POST /api/feeds - Create a new feed +export async function POST(request: NextRequest) { + if (!process.env.LIVEBLOCKS_SECRET_KEY) { + return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 }); + } + + try { + const body = await request.json(); + const { roomId, feedId, metadata, createdAt } = body; + + if (!roomId) { + return new NextResponse("Missing roomId in body", { status: 400 }); + } + + if (!feedId) { + return new NextResponse("Missing feedId in body", { status: 400 }); + } + + const feed = await liveblocks.createFeed({ + roomId, + feedId, + metadata, + createdAt, + }); + + return NextResponse.json(feed); + } catch (error) { + console.error("Error creating feed:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/e2e/next-feeds/app/chat-room/layout.tsx b/e2e/next-feeds/app/chat-room/layout.tsx new file mode 100644 index 00000000000..c527756ecb7 --- /dev/null +++ b/e2e/next-feeds/app/chat-room/layout.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { LiveblocksProvider } from "@liveblocks/react"; +import { ReactNode } from "react"; + +export default function Layout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/e2e/next-feeds/app/chat-room/page.tsx b/e2e/next-feeds/app/chat-room/page.tsx new file mode 100644 index 00000000000..bc43b026ed4 --- /dev/null +++ b/e2e/next-feeds/app/chat-room/page.tsx @@ -0,0 +1,504 @@ +"use client"; + +import { nanoid } from "@liveblocks/core"; +import { + ClientSideSuspense, + RoomProvider, + useCreateFeed, + useCreateFeedMessage, + useDeleteFeed, + useFeedMessages, + useFeeds, + useUpdateFeedMessage, + useUpdateFeedMetadata, +} from "@liveblocks/react/suspense"; +import { Suspense, useEffect, useRef, useState } from "react"; + +const ROOM_ID = "liveblocks:examples:chat-room:sql"; + +type FeedMessageData = { + role: "user" | "assistant" | "system"; + content: string; + feedThreadId?: string; +}; + +export default function Page() { + return ( + + + + + + + } + > + + + + ); +} + +function ThreadMessage({ + message, +}: { + message: { id: string; createdAt: number; data: FeedMessageData }; +}) { + const data = message.data as FeedMessageData; + return ( +
+ {data.role} + + {new Date(message.createdAt).toLocaleTimeString()} + +

{data.content}

+
+ ); +} + +function ThreadView({ + feedThreadId, + onCreateReply, +}: { + feedThreadId: string; + onCreateReply: (text: string) => void; +}) { + const { messages } = useFeedMessages(feedThreadId); + const [replyText, setReplyText] = useState(""); + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages?.length]); + + const handleSend = () => { + const text = replyText.trim(); + if (!text) return; + onCreateReply(text); + setReplyText(""); + }; + + return ( +
+
+ {messages?.map((msg) => ( + + ))} +
+
+
+ setReplyText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSend()} + placeholder="Reply in thread..." + className="flex-1 px-3 py-2 border rounded text-sm" + /> + +
+
+ ); +} + +function MessageBubble({ + message, + feedId, + onReplyNewThread, + onReplyInThread, +}: { + message: { id: string; createdAt: number; data: FeedMessageData }; + feedId: string; + onReplyNewThread: (replyText: string) => void; + onReplyInThread: (threadFeedId: string, replyText: string) => void; +}) { + const [expandedThread, setExpandedThread] = useState(false); + const [showReplyInput, setShowReplyInput] = useState(false); + const [replyInputText, setReplyInputText] = useState(""); + const data = message.data as FeedMessageData; + const hasThread = !!data.feedThreadId; + + const handleSubmitNewThread = () => { + const text = replyInputText.trim(); + if (!text) return; + onReplyNewThread(text); + setReplyInputText(""); + setShowReplyInput(false); + setExpandedThread(true); + }; + + return ( +
+
+
+ + {data.role} + + + {new Date(message.createdAt).toLocaleTimeString()} + +

{data.content}

+
+ {!hasThread ? ( + + ) : ( + + )} +
+ {!hasThread && showReplyInput && ( +
+ setReplyInputText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSubmitNewThread()} + placeholder="Type a reply..." + className="flex-1 px-3 py-2 border rounded text-sm" + autoFocus + /> + +
+ )} + {hasThread && expandedThread && ( + Loading thread...
+ } + > + onReplyInThread(data.feedThreadId!, text)} + /> + + )} +
+ ); +} + +function MessagesPanel({ + feedId, + onReplyToMessage, +}: { + feedId: string; + onReplyToMessage: ( + parentFeedId: string, + messageId: string, + messageData: FeedMessageData, + replyText: string + ) => void; +}) { + const { messages } = useFeedMessages(feedId); + const [newMessageText, setNewMessageText] = useState(""); + const bottomRef = useRef(null); + const createFeedMessageFn = useCreateFeedMessage(); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages?.length]); + + const sendMessage = () => { + const text = newMessageText.trim(); + if (!text) return; + createFeedMessageFn(feedId, { content: text, role: "user" }); + setNewMessageText(""); + }; + + const handleReplyInThread = (threadFeedId: string, replyText: string) => { + createFeedMessageFn(threadFeedId, { content: replyText, role: "user" }); + }; + + return ( +
+
+ {!messages || messages.length === 0 ? ( +

No messages yet. Say hello!

+ ) : ( + messages.map((message) => ( +
+ + onReplyToMessage(feedId, message.id, message.data as FeedMessageData, replyText) + } + onReplyInThread={handleReplyInThread} + /> +
+ )) + )} +
+
+
+ setNewMessageText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && sendMessage()} + placeholder="Type a message..." + className="flex-1 px-3 py-2 border rounded" + /> + +
+
+ ); +} + +function ChatRoom() { + const { feeds, isLoading } = useFeeds({ metadata: { channel: "true" } }); + const [selectedFeedId, setSelectedFeedId] = useState(null); + const createFeedFn = useCreateFeed(); + const deleteFeedFn = useDeleteFeed(); + const updateFeedMetadataFn = useUpdateFeedMetadata(); + const createFeedMessageFn = useCreateFeedMessage(); + const updateFeedMessageFn = useUpdateFeedMessage(); + + const [editingFeedId, setEditingFeedId] = useState(null); + const [editingName, setEditingName] = useState(""); + + const channels = feeds ?? []; + + useEffect(() => { + if (channels.length && !selectedFeedId) { + setSelectedFeedId(channels[0].feedId); + } + }, [feeds, selectedFeedId]); + + useEffect(() => { + if (selectedFeedId && !channels.some((c) => c.feedId === selectedFeedId)) { + const mostRecent = [...channels].sort( + (a, b) => b.updatedAt - a.updatedAt + )[0]; + setSelectedFeedId(mostRecent?.feedId ?? null); + } + }, [channels, selectedFeedId]); + + const createChatroom = () => { + const feedId = nanoid(); + createFeedFn(feedId, { + metadata: { + name: "New Channel", + channel: "true", + created: new Date().toISOString(), + }, + }); + setSelectedFeedId(feedId); + }; + + const startRenaming = (feed: { feedId: string; metadata?: { name?: string } }, e: React.MouseEvent) => { + e.stopPropagation(); + setEditingFeedId(feed.feedId); + setEditingName(feed.metadata?.name || `${feed.feedId.slice(0, 12)}...`); + }; + + const saveRename = ( + feedId: string, + metadata: Record + ) => { + const trimmed = editingName.trim(); + if (trimmed) { + updateFeedMetadataFn(feedId, { ...metadata, name: trimmed }); + } + setEditingFeedId(null); + setEditingName(""); + }; + + const cancelRename = () => { + setEditingFeedId(null); + setEditingName(""); + }; + + const deleteChannel = (feedId: string, e: React.MouseEvent) => { + e.stopPropagation(); + deleteFeedFn(feedId); + if (selectedFeedId === feedId) { + const remaining = channels + .filter((c) => c.feedId !== feedId) + .sort((a, b) => b.updatedAt - a.updatedAt); + setSelectedFeedId(remaining[0]?.feedId ?? null); + } + }; + + const handleReplyToMessage = ( + parentFeedId: string, + messageId: string, + messageData: FeedMessageData, + replyText: string + ) => { + const threadFeedId = nanoid(); + createFeedFn(threadFeedId, { + metadata: { + name: "Thread", + channel: "false", + created: new Date().toISOString(), + }, + }); + updateFeedMessageFn(parentFeedId, messageId, { + ...messageData, + feedThreadId: threadFeedId, + }); + createFeedMessageFn(threadFeedId, { content: replyText, role: "user" }); + }; + + const feedName = (feed: { feedId: string; metadata?: { name?: string } }) => + feed.metadata?.name || `${feed.feedId.slice(0, 12)}...`; + + return ( +
+ +
+ {selectedFeedId ? ( + + Loading messages... +
+ } + > + + + ) : ( +
+ Select a chat room or create a new one +
+ )} +
+ + ); +} diff --git a/e2e/next-feeds/app/favicon.ico b/e2e/next-feeds/app/favicon.ico new file mode 100644 index 00000000000..718d6fea483 Binary files /dev/null and b/e2e/next-feeds/app/favicon.ico differ diff --git a/e2e/next-feeds/app/globals.css b/e2e/next-feeds/app/globals.css new file mode 100644 index 00000000000..af28c899b00 --- /dev/null +++ b/e2e/next-feeds/app/globals.css @@ -0,0 +1,41 @@ +@import "tailwindcss"; + +@theme { + --color-neutral-950: #0a0a0a; + --color-neutral-900: #171717; + --color-neutral-800: #262626; + --color-neutral-700: #404040; + --color-neutral-100: #f5f5f5; + --color-neutral-50: #fafafa; +} + +@variant dark (.dark &); + +@layer base { + html { + color-scheme: light; + } + + .dark html { + color-scheme: dark; + } +} +@import "./typography.css"; +@import "@liveblocks/react-ui/styles.css"; +@import "@liveblocks/react-ui/styles/dark/media-query.css"; + +.lb-ai-chat { + --lb-ai-chat-container-width: 896px; +} + +@keyframes pulse-dot { + 0%, + 100% { + transform: scale(1); + opacity: 0.8; + } + 50% { + transform: scale(1.5); + opacity: 1; + } +} diff --git a/e2e/next-feeds/app/kitchen-sink/layout.tsx b/e2e/next-feeds/app/kitchen-sink/layout.tsx new file mode 100644 index 00000000000..ae2f9dbd9a3 --- /dev/null +++ b/e2e/next-feeds/app/kitchen-sink/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { LiveblocksProvider } from "@liveblocks/react"; +import { ReactNode } from "react"; + +export default function Layout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/e2e/next-feeds/app/kitchen-sink/page.tsx b/e2e/next-feeds/app/kitchen-sink/page.tsx new file mode 100644 index 00000000000..a3353e5390f --- /dev/null +++ b/e2e/next-feeds/app/kitchen-sink/page.tsx @@ -0,0 +1,518 @@ +"use client"; +import { LiveblocksError, nanoid } from "@liveblocks/core"; +import { + RoomProvider, + useCreateFeed, + useCreateFeedMessage, + useDeleteFeed, + useDeleteFeedMessage, + useFeedMessages, + useFeeds, + useUpdateFeedMetadata, + useUpdateFeedMessage, + useRoom, +} from "@liveblocks/react"; +import { useState } from "react"; + +const ROOM_ID = "liveblocks:examples:feeds:sql"; + +const FEEDS_PAGE_LIMIT = 3; + +function randomSinkTag(): "alpha" | "beta" { + return Math.random() < 0.5 ? "alpha" : "beta"; +} + +export default function Page() { + return ( + + + + ); +} + +function FeedMessages({ + feedId, + newMessageText, + onMessageTextChange, + onCreateMessage, + onCreateMessageHttp, + onUpdateMessage, + onUpdateMessageHttp, + onDeleteMessage, + onDeleteMessageHttp, +}: { + feedId: string; + newMessageText: string; + onMessageTextChange: (text: string) => void; + onCreateMessage: () => void; + onCreateMessageHttp: () => void; + onUpdateMessage: (messageId: string) => void; + onUpdateMessageHttp: (messageId: string) => void; + onDeleteMessage: (messageId: string) => void; + onDeleteMessageHttp: (messageId: string) => void; +}) { + const { messages, error: messagesError, isLoading: messagesLoading } = + useFeedMessages(feedId); + + if (messagesError) { + return ( +
+

Could not load messages

+

{messagesError.message}

+
+ ); + } + + if (messagesLoading) { + return ( +
+ Loading messages... +
+ ); + } + + return ( +
+

Messages ({messages?.length || 0})

+ +
+ onMessageTextChange(e.target.value)} + onKeyPress={(e) => { + if (e.key === "Enter") { + onCreateMessage(); + } + }} + placeholder="Type a message..." + className="flex-1 px-3 py-2 border rounded" + /> + + +
+ +
+ {!messages || messages.length === 0 ? ( +

No messages yet

+ ) : ( + messages.map((message) => ( +
+
+
+ {new Date(message.createdAt).toLocaleString()} +
+
+                  {JSON.stringify(message.data, null, 2)}
+                
+
+
+ + + + +
+
+ )) + )} +
+
+ ); +} + +function Sample() { + const [sinkFilter, setSinkFilter] = useState<"all" | "alpha" | "beta">("all"); + const { + feeds, + isLoading, + error, + hasFetchedAll, + isFetchingMore, + fetchMore, + fetchMoreError, + } = useFeeds({ + limit: FEEDS_PAGE_LIMIT, + metadata: + sinkFilter === "all" ? undefined : { sinkTag: sinkFilter }, + }); + const [expandedFeed, setExpandedFeed] = useState(null); + const [newMessageText, setNewMessageText] = useState>({}); + + const createFeedFn = useCreateFeed(); + const deleteFeedFn = useDeleteFeed(); + const updateFeedMetadataFn = useUpdateFeedMetadata(); + const createFeedMessageFn = useCreateFeedMessage(); + const updateFeedMessageFn = useUpdateFeedMessage(); + const deleteFeedMessageFn = useDeleteFeedMessage(); + + const createFeed = () => { + const feedId = nanoid(); + createFeedFn(feedId, { + metadata: { + created: new Date().toISOString(), + sinkTag: randomSinkTag(), + }, + }); + }; + + const createFeedHttp = async () => { + try { + const response = await fetch("/api/feeds", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + roomId: ROOM_ID, + feedId: nanoid(), + metadata: { + created: new Date().toISOString(), + sinkTag: randomSinkTag(), + }, + }), + }); + const data = await response.json(); + console.log("Created feed (http):", data); + } catch (error) { + console.error("Error creating feed:", error); + } + }; + + const deleteFeed = (feedId: string) => { + deleteFeedFn(feedId); + if (expandedFeed === feedId) { + setExpandedFeed(null); + } + }; + + const deleteFeedHttp = async (feedId: string) => { + try { + const response = await fetch( + `/api/feeds/${feedId}?roomId=${encodeURIComponent(ROOM_ID)}`, + { method: "DELETE" } + ); + if (response.ok) { + console.log("Deleted feed (http):", feedId); + if (expandedFeed === feedId) { + setExpandedFeed(null); + } + } + } catch (error) { + console.error("Error deleting feed:", error); + } + }; + + const updateFeedMetadata = (feedId: string) => { + updateFeedMetadataFn(feedId, { updated: new Date().toISOString() }); + }; + + const updateFeedMetadataHttp = async (feedId: string) => { + try { + const response = await fetch(`/api/feeds/${feedId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + roomId: ROOM_ID, + metadata: { updated: new Date().toISOString() }, + }), + }); + const data = await response.json(); + console.log("Updated feed (http):", data); + } catch (error) { + console.error("Error updating feed:", error); + } + }; + + const toggleMessages = (feedId: string) => { + setExpandedFeed(expandedFeed === feedId ? null : feedId); + }; + + const createMessage = (feedId: string) => { + const text = newMessageText[feedId]?.trim(); + if (!text) return; + + createFeedMessageFn(feedId, { content: text, role: "user" }); + setNewMessageText((prev) => ({ ...prev, [feedId]: "" })); + }; + + const createMessageHttp = async (feedId: string) => { + const text = newMessageText[feedId]?.trim(); + if (!text) return; + + try { + const response = await fetch(`/api/feeds/${feedId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + roomId: ROOM_ID, + data: { content: text, role: "user" }, + }), + }); + const data = await response.json(); + console.log("Created message (http):", data); + setNewMessageText((prev) => ({ ...prev, [feedId]: "" })); + } catch (error) { + console.error("Error creating message:", error); + } + }; + + const updateMessage = (feedId: string, messageId: string) => { + updateFeedMessageFn(feedId, messageId, { + content: `(updated via ws at ${new Date().toISOString()})`, + role: "user", + }); + }; + + const updateMessageHttp = async (feedId: string, messageId: string) => { + try { + const response = await fetch( + `/api/feeds/${feedId}/messages/${messageId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + roomId: ROOM_ID, + data: { + content: `(updated via http at ${new Date().toISOString()})`, + role: "user", + }, + }), + } + ); + if (response.ok) { + console.log("Updated message (http):", messageId); + } + } catch (error) { + console.error("Error updating message:", error); + } + }; + + const deleteMessage = (feedId: string, messageId: string) => { + deleteFeedMessageFn(feedId, messageId); + }; + + const deleteMessageHttp = async (feedId: string, messageId: string) => { + try { + const response = await fetch( + `/api/feeds/${feedId}/messages/${messageId}?roomId=${encodeURIComponent(ROOM_ID)}`, + { method: "DELETE" } + ); + if (response.ok) { + console.log("Deleted message (http):", messageId); + } + } catch (error) { + console.error("Error deleting message:", error); + } + }; + + if (error) { + const detail = + error instanceof LiveblocksError && + error.context.type === "FEED_REQUEST_ERROR" + ? ` (${error.context.code})` + : ""; + return ( +
+
+

Could not load feeds{detail}

+

{error.message}

+

+ Feeds require a room on storage engine v2. Create or use a v2 room, + or check the server error above. +

+
+
+ ); + } + + return ( +
+
+

Feeds

+ {!isLoading && ( +
+ + +
+ )} +
+ +
+ Filter by sinkTag: + {(["all", "alpha", "beta"] as const).map((key) => ( + + ))} +
+ +
+ + Page size: {FEEDS_PAGE_LIMIT} · hasFetchedAll:{" "} + {String(hasFetchedAll ?? false)} · isFetchingMore:{" "} + {String(isFetchingMore ?? false)} + + {fetchMoreError ? ( + fetchMore: {fetchMoreError.message} + ) : null} + +
+ +
+ {feeds?.length === 0 && ( +

No feeds yet. Create one to get started!

+ )} + + {feeds?.map((feed) => { + const isExpanded = expandedFeed === feed.feedId; + + return ( +
+
+
+

+ Feed: {feed.feedId.slice(0, 20)}... +

+

+ Created: {new Date(feed.createdAt).toLocaleString()} +

+ {feed.metadata && Object.keys(feed.metadata).length > 0 && ( +
+

Metadata:

+
+                        {JSON.stringify(feed.metadata, null, 2)}
+                      
+
+ )} +
+
+ + + + + +
+
+ + {isExpanded && ( + + setNewMessageText((prev) => ({ + ...prev, + [feed.feedId]: text, + })) + } + onCreateMessage={() => createMessage(feed.feedId)} + onCreateMessageHttp={() => createMessageHttp(feed.feedId)} + onUpdateMessage={(messageId) => + updateMessage(feed.feedId, messageId) + } + onUpdateMessageHttp={(messageId) => + updateMessageHttp(feed.feedId, messageId) + } + onDeleteMessage={(messageId) => + deleteMessage(feed.feedId, messageId) + } + onDeleteMessageHttp={(messageId) => + deleteMessageHttp(feed.feedId, messageId) + } + /> + )} +
+ ); + })} +
+
+ ); +} diff --git a/e2e/next-feeds/app/layout.tsx b/e2e/next-feeds/app/layout.tsx new file mode 100644 index 00000000000..6476ec94337 --- /dev/null +++ b/e2e/next-feeds/app/layout.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "../liveblocks.config"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/e2e/next-feeds/app/page.tsx b/e2e/next-feeds/app/page.tsx new file mode 100644 index 00000000000..538ed552e25 --- /dev/null +++ b/e2e/next-feeds/app/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import Link from "next/link"; + + +export default function Home() { + return ( +
+ +

Feeds E2E Playground

+
    +
  • + Kitchen Sink +
  • +
  • + Chat Room +
  • +
+
+ ); +} diff --git a/e2e/next-feeds/app/typography.css b/e2e/next-feeds/app/typography.css new file mode 100644 index 00000000000..e559ea7b7d4 --- /dev/null +++ b/e2e/next-feeds/app/typography.css @@ -0,0 +1,311 @@ +.prose { + --prose-color: var(--color-neutral-950); + --prose-heading-color: var(--color-neutral-950); + --prose-strong-color: var(--color-neutral-950); + --prose-link-color: var(--color-neutral-950); + --prose-code-color: var(--color-neutral-950); + --prose-marker-color: color-mix( + in oklab, + var(--color-neutral-700) 25%, + transparent + ); + --prose-link-underline-color: var(--color-sky-400); + --prose-th-borders: var(--color-neutral-300); + --prose-td-borders: var(--color-neutral-200); + --prose-hr-color: color-mix( + in oklab, + var(--color-neutral-950) 5%, + transparent + ); + --prose-blockquote-border-color: var(--color-neutral-300); + + @media (prefers-color-scheme: dark) { + --prose-color: var(--color-neutral-50); + --prose-heading-color: var(--color-white); + --prose-strong-color: var(--color-white); + --prose-link-color: var(--color-white); + --prose-code-color: var(--color-white); + --prose-marker-color: color-mix( + in oklab, + var(--color-neutral-300) 35%, + transparent + ); + --prose-link-underline-color: var(--color-sky-400); + --prose-th-borders: var(--color-neutral-600); + --prose-td-borders: var(--color-neutral-700); + --prose-hr-color: color-mix(in oklab, var(--color-white) 10%, transparent); + --prose-blockquote-border-color: var(--color-neutral-600); + } + + *:where(:not(.not-prose, .not-prose *)) + + *:where(:not(.not-prose, .not-prose *)) { + margin-top: calc(var(--spacing) * 6); + } + + h2:where(:not(.not-prose, .not-prose *)) { + font-size: var(--text-lg); + line-height: calc(28 / 18); + letter-spacing: -0.025em; + color: var(--prose-code-color); + font-weight: var(--font-weight-semibold); + margin-top: calc(var(--spacing) * 20); + } + + h2:has(+ h3):where(:not(.not-prose, .not-prose *)) { + font-size: var(--text-xs); + line-height: 2; + font-weight: var(--font-weight-medium); + font-family: var(--font-mono); + font-variant-ligatures: none; + letter-spacing: 0.1em; + color: var(--prose-color); + text-transform: uppercase; + } + + h3:where(:not(.not-prose, .not-prose *)) { + font-size: var(--text-base); + line-height: calc(28 / 18); + color: var(--prose-heading-color); + font-weight: var(--font-weight-semibold); + margin-top: calc(var(--spacing) * 16); + } + + h2 + h3:where(:not(.not-prose, .not-prose *)) { + margin-top: calc(var(--spacing) * 6); + } + + h4:where(:not(.not-prose, .not-prose *)) { + font-size: var(--text-sm); + line-height: calc(28 / 14); + color: var(--prose-heading-color); + font-weight: var(--font-weight-semibold); + margin-top: calc(var(--spacing) * 12); + } + + :is(h2, h3, h4):where(:not(.not-prose, .not-prose *)) { + scroll-margin-top: calc(var(--spacing) * 32); + @variant lg { + scroll-margin-top: calc(var(--spacing) * 18); + } + } + + ol:where(:not(.not-prose, .not-prose *)) { + padding-left: calc(var(--spacing) * 6); + list-style-type: decimal; + } + + ol li:where(:not(.not-prose, .not-prose *)) { + padding-left: calc(var(--spacing) * 3); + } + + ol li + li:where(:not(.not-prose, .not-prose *)) { + margin-top: calc(var(--spacing) * 4); + } + + ol li:where(:not(.not-prose, .not-prose *))::marker { + font-weight: 600; + color: var(--prose-strong-color); + } + + ul:where(:not(.not-prose, .not-prose *)) { + padding-left: calc(var(--spacing) * 6); + list-style-type: square; + } + + ul li:where(:not(.not-prose, .not-prose *)) { + padding-left: calc(var(--spacing) * 3); + } + + ul li + li:where(:not(.not-prose, .not-prose *)) { + margin-top: calc(var(--spacing) * 4); + } + + ul li:where(:not(.not-prose, .not-prose *))::marker { + color: var(--prose-marker-color); + } + + a:not(:where(:is(h2, h3, h4) *)):where(:not(.not-prose, .not-prose *)) { + color: var(--prose-link-color); + font-weight: var(--font-weight-semibold); + text-decoration: underline; + text-underline-offset: 3px; + text-decoration-color: var(--prose-link-underline-color); + text-decoration-thickness: 1px; + & code { + font-weight: var(--font-weight-semibold); + } + } + + a:hover:where(:not(.not-prose, .not-prose *)) { + text-decoration-thickness: 2px; + } + + a:where(:not(.not-prose, .not-prose *)):has(> [data-media]) { + display: block; + } + + strong:where(:not(.not-prose, .not-prose *)) { + color: var(--prose-strong-color); + font-weight: var(--font-weight-semibold); + } + + code:where(:not(.not-prose, .not-prose *)) { + font-variant-ligatures: none; + font-family: var(--font-mono); + font-weight: var(--font-weight-medium); + color: var(--prose-code-color); + } + + :where(h2, h3, h4) code:where(:not(.not-prose, .not-prose *)) { + font-weight: var(--font-weight-semibold); + } + + code:where(:not(.not-prose, .not-prose *))::before, + code:where(:not(.not-prose, .not-prose *))::after { + display: inline; + content: "`"; + } + + pre:where(:not(.not-prose, .not-prose *)) { + margin-top: calc(var(--spacing) * 4); + margin-bottom: calc(var(--spacing) * 10); + } + + pre code * + *:where(:not(.not-prose, .not-prose *)) { + margin-top: 0; + } + + pre code:where(:not(.not-prose, .not-prose *))::before, + pre code:where(:not(.not-prose, .not-prose *))::after { + content: none; + } + + pre code:where(:not(.not-prose, .not-prose *)) { + font-variant-ligatures: none; + font-family: var(--font-mono); + font-size: var(--text-sm); + line-height: 2; + } + + table:where(:not(.not-prose, .not-prose *)) { + width: 100%; + table-layout: auto; + margin-top: 2em; + margin-bottom: 2em; + font-size: var(--text-sm); + line-height: 1.4; + } + + thead:where(:not(.not-prose, .not-prose *)) { + border-bottom-width: 1px; + border-bottom-color: var(--prose-th-borders); + } + + thead th:where(:not(.not-prose, .not-prose *)) { + color: var(--prose-heading-color); + font-weight: 600; + vertical-align: bottom; + padding-inline-end: 0.6em; + padding-bottom: 0.8em; + padding-inline-start: 0.6em; + } + + thead th:first-child:where(:not(.not-prose, .not-prose *)) { + padding-inline-start: 0; + } + + thead th:last-child:where(:not(.not-prose, .not-prose *)) { + padding-inline-end: 0; + } + + tbody tr:where(:not(.not-prose, .not-prose *)) { + border-bottom-width: 1px; + border-bottom-color: var(--prose-td-borders); + } + + tbody tr:last-child:where(:not(.not-prose, .not-prose *)) { + border-bottom-width: 0; + } + + tbody td:where(:not(.not-prose, .not-prose *)) { + vertical-align: baseline; + } + + tfoot:where(:not(.not-prose, .not-prose *)) { + border-top-width: 1px; + border-top-color: var(--prose-th-borders); + } + + tfoot td:where(:not(.not-prose, .not-prose *)) { + vertical-align: top; + } + + tbody td:where(:not(.not-prose, .not-prose *)), + tfoot td:where(:not(.not-prose, .not-prose *)) { + padding-top: 0.8em; + padding-inline-end: 0.6em; + padding-bottom: 0.8em; + padding-inline-start: 0.6em; + } + + tbody td:first-child:where(:not(.not-prose, .not-prose *)), + tfoot td:first-child:where(:not(.not-prose, .not-prose *)) { + padding-inline-start: 0; + } + + tbody td:last-child:where(:not(.not-prose, .not-prose *)), + tfoot td:last-child:where(:not(.not-prose, .not-prose *)) { + padding-inline-end: 0; + } + + th:where(:not(.not-prose, .not-prose *)), + td:where(:not(.not-prose, .not-prose *)) { + text-align: start; + } + + td code:where(:not(.not-prose, .not-prose *)) { + font-size: 0.8125rem; + } + + hr:where(:not(.not-prose, .not-prose *)) { + border-color: var(--prose-hr-color); + margin-block: --spacing(16); + & + h2 { + margin-top: --spacing(16); + } + } + + blockquote { + font-style: italic; + border-inline-start-width: 0.25rem; + border-inline-start-color: var(--prose-blockquote-border-color); + padding-inline-start: calc(var(--spacing) * 4); + } + + blockquote p:first-of-type::before { + content: open-quote; + } + + blockquote p:last-of-type::after { + content: close-quote; + } + + figure:where(:not(.not-prose, .not-prose *)) { + figcaption:where(:not(.not-prose, .not-prose *)) { + margin-top: calc(var(--spacing) * 3); + text-align: center; + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); + font-style: italic; + color: color-mix(in oklab, var(--prose-color) 75%, transparent); + } + } + + :first-child:where(:not(.not-prose, .not-prose *)) { + margin-top: 0; + } + + :last-child:where(:not(.not-prose, .not-prose *)) { + margin-bottom: 0; + } +} diff --git a/e2e/next-feeds/eslint.config.mjs b/e2e/next-feeds/eslint.config.mjs new file mode 100644 index 00000000000..348c45a2fd8 --- /dev/null +++ b/e2e/next-feeds/eslint.config.mjs @@ -0,0 +1,14 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [...compat.extends("next/core-web-vitals")]; + +export default eslintConfig; diff --git a/e2e/next-feeds/liveblocks.config.ts b/e2e/next-feeds/liveblocks.config.ts new file mode 100644 index 00000000000..f2cadd2df02 --- /dev/null +++ b/e2e/next-feeds/liveblocks.config.ts @@ -0,0 +1,27 @@ +declare global { + interface Liveblocks { + // Feed metadata (used for Feed.metadata from useFeeds) + FeedMetadata: { + agentName?: string; + model?: string; + temperature?: string; + created?: string; + updated?: string; + name?: string; + /** Channel feeds use `"true"`; thread feeds use `"false"`. */ + channel?: string; + /** Kitchen-sink demo: random tag for metadata filter examples. */ + sinkTag?: string; + }; + + // Feed message data (used for FeedMessage.data from useFeedMessages) + FeedMessageData: { + role: "user" | "assistant" | "system"; + content: string; + tokens?: number; + feedThreadId?: string; + }; + } +} + +export {}; diff --git a/e2e/next-feeds/next.config.ts b/e2e/next-feeds/next.config.ts new file mode 100644 index 00000000000..e9ffa3083ad --- /dev/null +++ b/e2e/next-feeds/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/e2e/next-feeds/package.json b/e2e/next-feeds/package.json new file mode 100644 index 00000000000..67574e948fd --- /dev/null +++ b/e2e/next-feeds/package.json @@ -0,0 +1,35 @@ +{ + "name": "@liveblocks/next-feeds", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3009", + "build": "next build", + "start": "next start --port 3009", + "lint": "next lint", + "format": "(eslint --fix app/ test/ || true) && prettier --write app/ test/", + "test": "playwright test --max-failures=1", + "test:headed": "playwright --max-failures=1 test --headed", + "test:ui": "playwright test --max-failures=1 --workers=1 --ui" + }, + "dependencies": { + "@liveblocks/client": "*", + "@liveblocks/node": "*", + "@liveblocks/react": "*", + "@liveblocks/react-ui": "*", + "next": "15.3.7", + "radix-ui": "^1.3.4", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@playwright/test": "^1.55.0", + "@tailwindcss/postcss": "^4", + "eslint": "^9", + "eslint-config-next": "15.3.1", + "playwright": "^1.55.0", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/e2e/next-feeds/playwright.config.ts b/e2e/next-feeds/playwright.config.ts new file mode 100644 index 00000000000..77d36235502 --- /dev/null +++ b/e2e/next-feeds/playwright.config.ts @@ -0,0 +1,78 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./test", + /* Maximum time one test can run for. */ + timeout: 60 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 10000, + }, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 4 : 6, + /* Fully parallel test execution */ + fullyParallel: true, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ["html", { title: "Liveblocks AI Kitchen Sink E2E Tests" }], + ["github"], + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + viewport: { width: 640, height: 800 }, + permissions: ["clipboard-write", "clipboard-read"], + /* Maximum time each action such as `click()` can take. 10s local, 15s CI. */ + actionTimeout: process.env.CI ? 15000 : 10000, + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + video: "on-first-retry", + screenshot: "on-first-failure", // Only captures main page + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + channel: "chromium", // Enable the use of New Headless mode in Chromium + }, + }, + // { + // name: "firefox", + // use: { ...devices["Desktop Firefox"] }, + // }, + ], + + /* Folder for test artifacts such as screenshots, videos, traces, etc. */ + // outputDir: 'test-results/', + + /* Run your local dev server before starting the tests */ + webServer: { + command: process.env.CI + ? "npm run start" // Test production builds on CI + : "npm run dev", // Test dev builds on CI (with React StrictMode enabled) + port: 3008, // AI kitchen sink port + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); diff --git a/e2e/next-feeds/postcss.config.mjs b/e2e/next-feeds/postcss.config.mjs new file mode 100644 index 00000000000..c7bcb4b1ee1 --- /dev/null +++ b/e2e/next-feeds/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/e2e/next-feeds/tsconfig.json b/e2e/next-feeds/tsconfig.json new file mode 100644 index 00000000000..b201af42228 --- /dev/null +++ b/e2e/next-feeds/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "liveblocks.config.ts"], + "exclude": ["node_modules"] +} diff --git a/e2e/next-feeds/turbo.json b/e2e/next-feeds/turbo.json new file mode 100644 index 00000000000..51e3070e1ce --- /dev/null +++ b/e2e/next-feeds/turbo.json @@ -0,0 +1,13 @@ +{ + "extends": ["//"], + "tasks": { + "test": { + "dependsOn": ["build"], + "cache": false + }, + "test:ui": { + "dependsOn": ["build"], + "cache": false + } + } +} diff --git a/examples/nextjs-chat-sdk-ai-bot/.gitignore b/examples/nextjs-chat-sdk-ai-bot/.gitignore new file mode 100644 index 00000000000..3a68e0cfc9d --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/.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-chat-sdk-ai-bot/README.md b/examples/nextjs-chat-sdk-ai-bot/README.md new file mode 100644 index 00000000000..8959e638061 --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/README.md @@ -0,0 +1,149 @@ +

+ + Liveblocks + + + Liveblocks + +

+ +# AI bot (Chat SDK + Liveblocks Comments) + +

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

+ +This example is a **Next.js** app with **Liveblocks Comments** (`Thread`, +`Composer`) in a room. The server runs the **[Chat SDK](https://chat-sdk.dev)** +with: + +- [`@liveblocks/chat-sdk-adapter`](https://liveblocks.io/docs/api-reference/liveblocks-chat-sdk-adapter) + — Liveblocks **platform adapter** (comments and threads mapped to Chat SDK + channels) +- [`@chat-adapter/state-memory`](https://www.npmjs.com/package/@chat-adapter/state-memory) + — Chat SDK **state** adapter (in-memory) +- [`ai`](https://sdk.vercel.ai/docs) and + [`@ai-sdk/anthropic`](https://sdk.vercel.ai/providers/ai-sdk-providers/anthropic) + — streaming replies from **Claude** into the thread + +### What the bot does + +When someone **@-mentions** the bot in a comment thread, the webhook handler +(`POST /api/webhooks/liveblocks`) runs [`bot.onNewMention`](app/bot.ts): it adds +a **👀** reaction to the message, calls **`streamText`** with Claude +(`claude-sonnet-4-20250514` by default), and **`stream`s the model output into +the thread** as the bot's reply. + +The bot **does not** handle comment reactions (there is no `onReaction` +handler). The system prompt in [`app/bot.ts`](app/bot.ts) explains Liveblocks +**CommentBody** limits so the model favors formatting that survives in comments. + +Threads and composer + +## Getting started + +Run the following command to try this example locally: + +```bash +npx create-liveblocks-app@latest --example liveblocks-chat-sdk-ai-bot --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. + +### Environment variables + +Add these to `.env.local` (see manual setup below if you are not using the CLI): + +| Variable | Purpose | +| --------------------------- | --------------------------------------------------------------------------------- | +| `LIVEBLOCKS_SECRET_KEY` | Liveblocks secret key (`sk_…`) for REST and auth | +| `LIVEBLOCKS_WEBHOOK_SECRET` | Webhook signing secret (`whsec_…`) | +| `ANTHROPIC_API_KEY` | [Anthropic API key](https://docs.anthropic.com/en/api/getting-started) for Claude | + +### Setting up webhooks + +The Liveblocks adapter (`@liveblocks/chat`) needs your server to receive +Liveblocks webhooks at `POST /api/webhooks/liveblocks` (see +[`app/api/webhooks/liveblocks/route.ts`](app/api/webhooks/liveblocks/route.ts)). + +- Follow our guide on + [testing webhooks locally](https://liveblocks.io/docs/guides/how-to-test-webhooks-on-localhost). + When creating the webhook endpoint, enable at least **commentCreated** (see + [webhook events](https://liveblocks.io/docs/platform/webhooks#edit-endpoint-events)). + That event is required so new mentions reach `bot.onNewMention`. +- **commentReactionAdded** and **commentReactionRemoved** are optional for this + example; they are only needed if you add reaction handling (the non-AI + [liveblocks-chat-sdk-bot](../liveblocks-chat-sdk-bot) example uses them). +- Copy your **webhook secret** (`whsec_…`) from the webhooks dashboard +- Add it to `.env.local` as `LIVEBLOCKS_WEBHOOK_SECRET` + +### 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 with: + - `LIVEBLOCKS_SECRET_KEY` — Liveblocks secret key + - `LIVEBLOCKS_WEBHOOK_SECRET` — webhook signing secret (after you configure + webhooks) + - `ANTHROPIC_API_KEY` — from the + [Anthropic Console](https://console.anthropic.com/) +- Run `npm run dev` and go to [http://localhost:3000](http://localhost:3000) +- Follow the “Setting up webhooks” section above + +
+ +### 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 liveblocks-chat-sdk-ai-bot --vercel +``` + +This will download the example and ask permission to open your browser, enabling +you to deploy to Vercel. + +Add **`ANTHROPIC_API_KEY`** (and the Liveblocks variables) in the Vercel project +settings. Then follow the “Setting up webhooks” section above using your +production webhook URL. + +
+ +### Develop on CodeSandbox + +
Read more + +

+ +After forking +[this example](https://codesandbox.io/s/github/liveblocks/liveblocks/tree/main/examples/liveblocks-chat-sdk-ai-bot) +on CodeSandbox, add **`LIVEBLOCKS_SECRET_KEY`**, +**`LIVEBLOCKS_WEBHOOK_SECRET`**, and **`ANTHROPIC_API_KEY`** as +[secrets](https://codesandbox.io/docs/secrets). + +Webhook delivery to a sandbox URL may require a tunnel (see +[testing webhooks locally](https://liveblocks.io/docs/guides/how-to-test-webhooks-on-localhost)). + +
diff --git a/examples/nextjs-chat-sdk-ai-bot/app/Providers.tsx b/examples/nextjs-chat-sdk-ai-bot/app/Providers.tsx new file mode 100644 index 00000000000..56509ae34ed --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/app/Providers.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { LiveblocksProvider } from "@liveblocks/react"; +import { PropsWithChildren, Suspense } from "react"; + +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-chat-sdk-ai-bot/app/api/liveblocks-auth/route.ts b/examples/nextjs-chat-sdk-ai-bot/app/api/liveblocks-auth/route.ts new file mode 100644 index 00000000000..45fb089b7e0 --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/app/api/liveblocks-auth/route.ts @@ -0,0 +1,28 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextRequest } from "next/server"; +import { getRandomUser } from "@/app/database"; + +// Authenticating your Liveblocks application +// https://liveblocks.io/docs/authentication + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY as string, +}); + +export async function POST(_request: NextRequest) { + // Get the current user's unique id and info from your database + const user = getRandomUser(); + + // Create a session for the current user + // userInfo is made available in Liveblocks presence hooks, e.g. useOthers + 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:*`, session.FULL_ACCESS); + + // Authorize the user and return the result + const { body, status } = await session.authorize(); + return new Response(body, { status }); +} diff --git a/examples/nextjs-chat-sdk-ai-bot/app/api/users/route.ts b/examples/nextjs-chat-sdk-ai-bot/app/api/users/route.ts new file mode 100644 index 00000000000..15bd38e741b --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/app/api/users/route.ts @@ -0,0 +1,16 @@ +import { getUser } from "@/app/database"; +import { NextRequest, NextResponse } from "next/server"; + +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), + { status: 200 } + ); +} diff --git a/examples/nextjs-chat-sdk-ai-bot/app/api/users/search/route.ts b/examples/nextjs-chat-sdk-ai-bot/app/api/users/search/route.ts new file mode 100644 index 00000000000..933e54091f3 --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/app/api/users/search/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUsers } from "@/app/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") as string; + + const userIds = getUsers() + .filter((user) => { + return user.info.name.toLowerCase().includes(text.toLowerCase()); + }) + .map((user) => user.id); + + return NextResponse.json(userIds); +} diff --git a/examples/nextjs-chat-sdk-ai-bot/app/api/webhooks/liveblocks/route.ts b/examples/nextjs-chat-sdk-ai-bot/app/api/webhooks/liveblocks/route.ts new file mode 100644 index 00000000000..9ee2b3f1a8a --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/app/api/webhooks/liveblocks/route.ts @@ -0,0 +1,8 @@ +import { bot } from "@/app/bot"; + +export async function POST(request: Request) { + // Use your runtime's waitUntil for background processing (e.g. Vercel waitUntil) + return bot.webhooks.liveblocks(request, { + waitUntil: (p) => void p, + }); +} diff --git a/examples/nextjs-chat-sdk-ai-bot/app/bot.ts b/examples/nextjs-chat-sdk-ai-bot/app/bot.ts new file mode 100644 index 00000000000..bf317cd87ca --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/app/bot.ts @@ -0,0 +1,175 @@ +import { + AiAssistantMessage, + AiFilePart, + AiImagePart, + AiMessage, + AiMessagePart, + AiUserMessage, + Chat, + Message, +} from "chat"; +import { + createLiveblocksAdapter, + LiveblocksAdapter, +} from "@liveblocks/chat-sdk-adapter"; +import { createMemoryState } from "@chat-adapter/state-memory"; +import { BOT_USER_ID, BOT_USER_NAME, getUser } from "./database"; +import { streamText } from "ai"; +import { createAnthropic } from "@ai-sdk/anthropic"; + +const SYSTEM_PROMPT = `You are a helpful assistant in a **demo app**: **Liveblocks Comments** (threads and replies in a room) integrated with the **Chat SDK** via **\`@liveblocks/chat-sdk-adapter\`**—Liveblocks' **adapter for the Chat SDK** (mapping comments to Chat SDK channels)—plus the Chat SDK's **in-memory state** adapter. You answer when someone @-mentions the bot. + +**How replies are stored.** User messages and your replies are turned into Liveblocks **CommentBody**, not arbitrary Markdown. A comment is a list of **paragraph** blocks. Each paragraph only has **inline** nodes: + +- Text runs with optional **bold**, *italic*, \`code\`, and ~~strikethrough~~ +- **Links** (\`[label](url)\` in Markdown terms) +- **@mentions** of users or groups + +There are **no** real block-level Markdown features in comments: headings, bullet/numbered lists, fenced code blocks, tables, and raw HTML are **not** preserved as such—the pipeline **flattens** them into plain paragraphs (e.g. headings and code fences become paragraph text; tables may become plain text). So prefer short, clear paragraphs and inline emphasis and links rather than relying on lists, headings, or code blocks for structure.`; + +export const bot = new Chat<{ liveblocks: LiveblocksAdapter }>({ + userName: BOT_USER_NAME, + adapters: { + liveblocks: createLiveblocksAdapter({ + apiKey: process.env.LIVEBLOCKS_SECRET_KEY!, + webhookSecret: process.env.LIVEBLOCKS_WEBHOOK_SECRET!, + botUserId: BOT_USER_ID, + botUserName: BOT_USER_NAME, + resolveUsers: ({ userIds }) => { + return userIds.map((id) => getUser(id)?.info); + }, + }), + }, + state: createMemoryState(), +}); + +// Handle @-mentions of the bot +bot.onNewMention(async (thread, message) => { + await thread.adapter.addReaction(thread.id, message.id, "👀"); + + const model = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! })( + "claude-sonnet-4-20250514" + ); + + const response = streamText({ + model, + system: SYSTEM_PROMPT, + messages: [await convertChatMessageToAiMessage(message)], + }); + await thread.post(response.fullStream); +}); + +async function convertChatMessageToAiMessage( + message: Message +): Promise { + let links = ""; + if (message.links.length > 0) { + links += + "\n\nLinks:\n" + + message.links + .map((link) => { + const parts: string[] = []; + if (link.fetchMessage) { + parts.push(`[Embedded message: ${link.url}]`); + } else { + parts.push(link.url); + } + if (link.title) { + parts.push(`Title: ${link.title}`); + } + if (link.description) { + parts.push(`Description: ${link.description}`); + } + if (link.siteName) { + parts.push(`Site: ${link.siteName}`); + } + return parts.join("\n"); + }) + .join("\n\n"); + } + + if (message.author.isMe) { + return { + role: "assistant", + content: message.text + links, + } satisfies AiAssistantMessage; + } else { + const results: PromiseSettledResult[] = + await Promise.allSettled( + message.attachments.map(async (attachment) => { + if (attachment.type === "image") { + if (attachment.url !== undefined) { + return { + type: "image", + image: new URL(attachment.url), + mediaType: attachment.mimeType, + } satisfies AiImagePart; + } else if (attachment.data !== undefined) { + return { + type: "image", + image: + attachment.data instanceof Uint8Array + ? attachment.data + : new Uint8Array(await attachment.data.arrayBuffer()), + mediaType: attachment.mimeType, + } satisfies AiImagePart; + } else if (attachment.fetchData !== undefined) { + const buffer = await attachment.fetchData(); + return { + type: "image", + image: buffer, + mediaType: attachment.mimeType, + } satisfies AiImagePart; + } else { + return null; + } + } else if (attachment.type === "file") { + if (attachment.data !== undefined) { + return { + type: "file", + data: + attachment.data instanceof Blob + ? new Uint8Array(await attachment.data.arrayBuffer()) + : attachment.data, + mediaType: attachment.mimeType ?? "application/octet-stream", + } satisfies AiFilePart; + } else if (attachment.url !== undefined) { + return { + type: "file", + data: new URL(attachment.url), + mediaType: attachment.mimeType ?? "application/octet-stream", + } satisfies AiFilePart; + } else if (attachment.fetchData !== undefined) { + const buffer = await attachment.fetchData(); + return { + type: "file", + data: + buffer instanceof Blob + ? new Uint8Array(await buffer.arrayBuffer()) + : buffer, + mediaType: attachment.mimeType ?? "application/octet-stream", + } satisfies AiFilePart; + } else { + return null; + } + } else { + return null; + } + }) + ); + + return { + role: "user", + content: [ + { + type: "text", + text: `${message.author.userName}: ${message.text + links}`, + }, + ...results + .filter((result) => result.status === "fulfilled") + .filter((result) => result.value !== null) + .map((result) => result.value as AiMessagePart), + ], + } satisfies AiUserMessage; + } +} diff --git a/examples/nextjs-chat-sdk-ai-bot/app/chatMessagesToAiMessages.ts b/examples/nextjs-chat-sdk-ai-bot/app/chatMessagesToAiMessages.ts new file mode 100644 index 00000000000..3633208f708 --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/app/chatMessagesToAiMessages.ts @@ -0,0 +1,203 @@ +import type { AiMessage, Attachment, Message } from "chat"; + +/** + * Same MIME prefixes as the `chat` package's `toAiMessages` for text file attachments. + */ +const TEXT_MIME_PREFIXES = [ + "text/", + "application/json", + "application/xml", + "application/javascript", + "application/typescript", + "application/yaml", + "application/x-yaml", + "application/toml", +] as const; + +function isTextMimeType(mimeType: string): boolean { + return TEXT_MIME_PREFIXES.some( + (prefix) => mimeType === prefix || mimeType.startsWith(prefix) + ); +} + +function attachmentBytes( + data: ArrayBuffer | Uint8Array | Buffer +): Uint8Array { + if (data instanceof Uint8Array) { + return data; + } + return new Uint8Array(data); +} + +export interface ChatMessagesToAiMessagesOptions { + includeNames?: boolean; + onUnsupportedAttachment?: (attachment: Attachment, message: Message) => void; + /** + * Called after default processing. Return `null` to skip the message. + */ + transformMessage?: ( + aiMessage: AiMessage, + source: Message + ) => AiMessage | null | Promise; +} + +/** + * Converts Chat SDK messages to AI SDK `ModelMessage[]`, matching `toAiMessages` from + * `chat` except attachment parts use binary `data` instead of `data:...;base64,...` + * strings. That avoids AI SDK 6 treating data URLs as remote URLs and throwing + * `AI_DownloadError` ("URL scheme must be http or https, got data:"). + */ +export async function chatMessagesToAiMessages( + messages: Message[], + options?: ChatMessagesToAiMessagesOptions +): Promise { + const includeNames = options?.includeNames ?? false; + const transformMessage = options?.transformMessage; + const onUnsupported = + options?.onUnsupportedAttachment ?? + ((att: Attachment) => { + console.warn( + `chatMessagesToAiMessages: unsupported attachment type "${att.type}"${ + att.name ? ` (${att.name})` : "" + } — skipped` + ); + }); + + const sorted = [...messages].sort( + (a, b) => + (a.metadata.dateSent?.getTime() ?? 0) - + (b.metadata.dateSent?.getTime() ?? 0) + ); + const filtered = sorted.filter((msg) => msg.text.trim()); + + const results = await Promise.all( + filtered.map(async (msg) => { + const role = msg.author.isMe ? "assistant" : "user"; + let textContent = + includeNames && role === "user" + ? `[${msg.author.userName}]: ${msg.text}` + : msg.text; + + if (msg.links && msg.links.length > 0) { + const linkParts = msg.links + .map((link) => { + const parts = link.fetchMessage + ? [`[Embedded message: ${link.url}]`] + : [link.url]; + if (link.title) { + parts.push(`Title: ${link.title}`); + } + if (link.description) { + parts.push(`Description: ${link.description}`); + } + if (link.siteName) { + parts.push(`Site: ${link.siteName}`); + } + return parts.join("\n"); + }) + .join("\n\n"); + textContent += `\n\nLinks:\n${linkParts}`; + } + + let aiMessage: AiMessage; + + if (role === "user") { + const attachmentParts: Array< + | { type: "text"; text: string } + | { + type: "file"; + data: Uint8Array; + mediaType: string; + filename?: string; + } + > = []; + + for (const att of msg.attachments ?? []) { + const filePart = await attachmentToAiFilePart(att); + if (filePart) { + attachmentParts.push(filePart); + } else if (att.type === "video" || att.type === "audio") { + onUnsupported(att, msg); + } + } + + if (attachmentParts.length > 0) { + aiMessage = { + role, + content: [{ type: "text", text: textContent }, ...attachmentParts], + }; + } else { + aiMessage = { role, content: textContent }; + } + } else { + aiMessage = { role: "assistant", content: textContent }; + } + + if (transformMessage) { + return { + result: await transformMessage(aiMessage, msg), + source: msg, + }; + } + return { result: aiMessage, source: msg }; + }) + ); + + return results + .filter((r) => r.result != null) + .map((r) => r.result as AiMessage); +} + +async function attachmentToAiFilePart( + att: Attachment +): Promise<{ + type: "file"; + data: Uint8Array; + mediaType: string; + filename?: string; +} | null> { + if (att.type === "image") { + if (!att.fetchData) { + return null; + } + try { + const buffer = await att.fetchData(); + const mimeType = att.mimeType ?? "image/png"; + return { + type: "file", + data: attachmentBytes(buffer), + mediaType: mimeType, + filename: att.name, + }; + } catch (error) { + console.error( + "chatMessagesToAiMessages: failed to fetch image data", + error + ); + return null; + } + } + + if (att.type === "file" && att.mimeType && isTextMimeType(att.mimeType)) { + if (!att.fetchData) { + return null; + } + try { + const buffer = await att.fetchData(); + return { + type: "file", + data: attachmentBytes(buffer), + filename: att.name, + mediaType: att.mimeType, + }; + } catch (error) { + console.error( + "chatMessagesToAiMessages: failed to fetch file data", + error + ); + return null; + } + } + + return null; +} diff --git a/examples/nextjs-chat-sdk-ai-bot/app/database.ts b/examples/nextjs-chat-sdk-ai-bot/app/database.ts new file mode 100644 index 00000000000..441303c76ff --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/app/database.ts @@ -0,0 +1,44 @@ +export const BOT_USER_ID = "__ai__"; +export const BOT_USER_NAME = "Liveblocks AI"; + +// A mock database with example users +const USER_INFO: Liveblocks["UserMeta"][] = [ + { + id: "charlie.layne@example.com", + info: { + name: "Charlie Layne", + color: "#D583F0", + avatar: "https://liveblocks.io/avatars/avatar-1.png", + }, + }, + { + id: "mislav.abha@example.com", + info: { + name: "Mislav Abha", + color: "#F08385", + avatar: "https://liveblocks.io/avatars/avatar-2.png", + }, + }, + { + id: BOT_USER_ID, + info: { + name: BOT_USER_NAME, + color: "#000000", + avatar: "/ai-avatar.png", + }, + }, +]; + +export function getRandomUser() { + return USER_INFO.filter((user) => user.id !== BOT_USER_ID)[ + Math.floor(Math.random() * 10) % USER_INFO.length + ]; +} + +export function getUser(id: string) { + return USER_INFO.find((u) => u.id === id) || undefined; +} + +export function getUsers() { + return USER_INFO; +} diff --git a/examples/nextjs-chat-sdk-ai-bot/app/globals.css b/examples/nextjs-chat-sdk-ai-bot/app/globals.css new file mode 100644 index 00000000000..40ccd234afc --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/app/globals.css @@ -0,0 +1,128 @@ +@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 { + position: relative; + border-radius: 0.75rem; + overflow: hidden; + 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%); +} + +@media (prefers-color-scheme: dark) { + html, + body { + background: #111; + } + + .lb-root { + --lb-accent: #77f; + } + + .loading img { + filter: invert(1); + } + + .error { + color: #fff; + } + + .thread::after, + .composer::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%); + } +} + +.no-key { + position: fixed; + top: 0; + right: 0; + left: 0; + bottom: 0; + height: 100%; + padding: 16px; + display: flex; + justify-content: center; + align-items: center; + background: rgba(255, 255, 255, 0.8); +} + +pre { + background: rgba(45, 45, 45, 1); + border-radius: 6px; + color: #fff; + padding: 12px; +} + +pre code { + line-height: 1.5; + background: none; +} + +code { + background: rgba(230, 230, 230, 1); + padding: 4px; + border-radius: 4px; +} diff --git a/examples/nextjs-chat-sdk-ai-bot/app/layout.tsx b/examples/nextjs-chat-sdk-ai-bot/app/layout.tsx new file mode 100644 index 00000000000..ebf97e3bb27 --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/app/layout.tsx @@ -0,0 +1,36 @@ +import "./globals.css"; +import { Providers } from "./Providers"; +import { Suspense } from "react"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + Liveblocks + + + + + + + + {children} + + + + ); +} diff --git a/examples/nextjs-chat-sdk-ai-bot/app/page.tsx b/examples/nextjs-chat-sdk-ai-bot/app/page.tsx new file mode 100644 index 00000000000..6cb4d8cb63b --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/app/page.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useMemo } from "react"; +import { useSearchParams } from "next/navigation"; +import { RoomProvider, useThreads } 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"; + +/** + * Displays a list of threads, along with a composer for creating + * new threads. + */ + +function Example() { + const { threads } = useThreads({ query: { resolved: false } }); + + return ( +
+ {threads.map((thread) => ( + + ))} + +
+ ); +} + +export default function Page() { + const roomId = useExampleRoomId("liveblocks:examples:nextjs-comments-ai"); + + 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-chat-sdk-ai-bot/components/Loading.tsx b/examples/nextjs-chat-sdk-ai-bot/components/Loading.tsx new file mode 100644 index 00000000000..1d604ac02d5 --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/components/Loading.tsx @@ -0,0 +1,7 @@ +export function Loading() { + return ( +
+ Loading +
+ ); +} diff --git a/examples/nextjs-chat-sdk-ai-bot/liveblocks.config.ts b/examples/nextjs-chat-sdk-ai-bot/liveblocks.config.ts new file mode 100644 index 00000000000..7ecf28928f8 --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/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-chat-sdk-ai-bot/next.config.ts b/examples/nextjs-chat-sdk-ai-bot/next.config.ts new file mode 100644 index 00000000000..bf3c9c65b15 --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/next.config.ts @@ -0,0 +1,14 @@ +import type { NextConfig } from "next"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const configDir = path.dirname(fileURLToPath(import.meta.url)); + +const config: NextConfig = { + // Monorepo root (lockfile). Avoids Next picking a parent directory (e.g. another lockfile). + turbopack: { + root: path.join(configDir, "../.."), + }, +}; + +export default config; diff --git a/examples/nextjs-chat-sdk-ai-bot/package.json b/examples/nextjs-chat-sdk-ai-bot/package.json new file mode 100644 index 00000000000..389f616eb34 --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/package.json @@ -0,0 +1,34 @@ +{ + "name": "@liveblocks-examples/nextjs-chat-sdk-ai-bot", + "description": "This example shows how to build an AI-powered Chat SDK bot with Liveblocks and Next.js.", + "license": "Apache-2.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@chat-adapter/state-memory": "^4.23.0", + "@liveblocks/chat-sdk-adapter": "^3.15.5", + "@liveblocks/client": "^3.15.5", + "@liveblocks/node": "^3.15.5", + "@liveblocks/react": "^3.15.5", + "@liveblocks/react-ui": "^3.15.5", + "@ai-sdk/anthropic": "^3.0.64", + "ai": "^6.0.140", + "zod": "^4.3.6", + "chat": "^4.22.0", + "next": "16.2.1", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/examples/nextjs-chat-sdk-ai-bot/public/ai-avatar.png b/examples/nextjs-chat-sdk-ai-bot/public/ai-avatar.png new file mode 100644 index 00000000000..aceaa9ed80c Binary files /dev/null and b/examples/nextjs-chat-sdk-ai-bot/public/ai-avatar.png differ diff --git a/examples/nextjs-chat-sdk-ai-bot/tsconfig.json b/examples/nextjs-chat-sdk-ai-bot/tsconfig.json new file mode 100644 index 00000000000..3a13f90a773 --- /dev/null +++ b/examples/nextjs-chat-sdk-ai-bot/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/examples/nextjs-chat-sdk-bot/.gitignore b/examples/nextjs-chat-sdk-bot/.gitignore new file mode 100644 index 00000000000..3a68e0cfc9d --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/.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-chat-sdk-bot/README.md b/examples/nextjs-chat-sdk-bot/README.md new file mode 100644 index 00000000000..6ef08e46317 --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/README.md @@ -0,0 +1,115 @@ +

+ + Liveblocks + + + Liveblocks + +

+ +# Chat SDK + Liveblocks adapter bot + +

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

+ +This example shows how to build a bot on Liveblocks comment threads using the +[Chat SDK](https://chat-sdk.dev): +[`@liveblocks/chat-sdk-adapter`](https://liveblocks.io/docs/api-reference/liveblocks-chat-sdk-adapter) +is Liveblocks’ **platform adapter** for the Chat SDK, and +[`@chat-adapter/state-memory`](https://www.npmjs.com/package/@chat-adapter/state-memory) +provides the Chat SDK **state** adapter. The UI runs on +[Liveblocks](https://liveblocks.io) and [Next.js](https://nextjs.org/). + +When someone @-mentions the bot in a thread, it replies in the thread; adding a +reaction to a message triggers a short reply as well. + +Threads and composer + +## Getting started + +Run the following command to try this example locally: + +```bash +npx create-liveblocks-app@latest --example liveblocks-chat-sdk-bot --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. + +### Setting up webhooks + +The Liveblocks adapter (`@liveblocks/chat-sdk-adapter`) needs Liveblocks +webhooks to receive new comments and reactions. + +- Follow our guide on + [testing webhooks locally](https://liveblocks.io/docs/guides/how-to-test-webhooks-on-localhost). + When creating the webhook endpoint, enable the **commentCreated**, + **commentReactionAdded**, and **commentReactionRemoved** events (see + [webhook events](https://liveblocks.io/docs/platform/webhooks#edit-endpoint-events)) +- Copy your **webhook secret** (`whsec_…`) from the webhooks dashboard +- Add it to `.env.local` as the `LIVEBLOCKS_WEBHOOK_SECRET` environment variable + +### 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) +- Follow the “Setting up webhooks” section above + +
+ +### 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 liveblocks-chat-sdk-bot --vercel +``` + +This will download the example and ask permission to open your browser, enabling +you to deploy to Vercel. + +Next, follow the “Setting up webhooks” section above (use your production +webhook URL). + +
+ +### Develop on CodeSandbox + +
Read more + +

+ +After forking +[this example](https://codesandbox.io/s/github/liveblocks/liveblocks/tree/main/examples/liveblocks-chat-sdk-bot) +on CodeSandbox, add the `LIVEBLOCKS_SECRET_KEY` and `LIVEBLOCKS_WEBHOOK_SECRET` +environment variables as [secrets](https://codesandbox.io/docs/secrets). + +Webhook delivery to a sandbox URL may require a tunnel (see +[testing webhooks locally](https://liveblocks.io/docs/guides/how-to-test-webhooks-on-localhost)). + +
diff --git a/examples/nextjs-chat-sdk-bot/app/Providers.tsx b/examples/nextjs-chat-sdk-bot/app/Providers.tsx new file mode 100644 index 00000000000..56509ae34ed --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/app/Providers.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { LiveblocksProvider } from "@liveblocks/react"; +import { PropsWithChildren, Suspense } from "react"; + +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-chat-sdk-bot/app/api/liveblocks-auth/route.ts b/examples/nextjs-chat-sdk-bot/app/api/liveblocks-auth/route.ts new file mode 100644 index 00000000000..45fb089b7e0 --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/app/api/liveblocks-auth/route.ts @@ -0,0 +1,28 @@ +import { Liveblocks } from "@liveblocks/node"; +import { NextRequest } from "next/server"; +import { getRandomUser } from "@/app/database"; + +// Authenticating your Liveblocks application +// https://liveblocks.io/docs/authentication + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY as string, +}); + +export async function POST(_request: NextRequest) { + // Get the current user's unique id and info from your database + const user = getRandomUser(); + + // Create a session for the current user + // userInfo is made available in Liveblocks presence hooks, e.g. useOthers + 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:*`, session.FULL_ACCESS); + + // Authorize the user and return the result + const { body, status } = await session.authorize(); + return new Response(body, { status }); +} diff --git a/examples/nextjs-chat-sdk-bot/app/api/users/route.ts b/examples/nextjs-chat-sdk-bot/app/api/users/route.ts new file mode 100644 index 00000000000..15bd38e741b --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/app/api/users/route.ts @@ -0,0 +1,16 @@ +import { getUser } from "@/app/database"; +import { NextRequest, NextResponse } from "next/server"; + +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), + { status: 200 } + ); +} diff --git a/examples/nextjs-chat-sdk-bot/app/api/users/search/route.ts b/examples/nextjs-chat-sdk-bot/app/api/users/search/route.ts new file mode 100644 index 00000000000..933e54091f3 --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/app/api/users/search/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUsers } from "@/app/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") as string; + + const userIds = getUsers() + .filter((user) => { + return user.info.name.toLowerCase().includes(text.toLowerCase()); + }) + .map((user) => user.id); + + return NextResponse.json(userIds); +} diff --git a/examples/nextjs-chat-sdk-bot/app/api/webhooks/liveblocks/route.ts b/examples/nextjs-chat-sdk-bot/app/api/webhooks/liveblocks/route.ts new file mode 100644 index 00000000000..9ee2b3f1a8a --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/app/api/webhooks/liveblocks/route.ts @@ -0,0 +1,8 @@ +import { bot } from "@/app/bot"; + +export async function POST(request: Request) { + // Use your runtime's waitUntil for background processing (e.g. Vercel waitUntil) + return bot.webhooks.liveblocks(request, { + waitUntil: (p) => void p, + }); +} diff --git a/examples/nextjs-chat-sdk-bot/app/bot.ts b/examples/nextjs-chat-sdk-bot/app/bot.ts new file mode 100644 index 00000000000..16d4022aaf4 --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/app/bot.ts @@ -0,0 +1,113 @@ +import { Chat } from "chat"; +import { + createLiveblocksAdapter, + LiveblocksAdapter, +} from "@liveblocks/chat-sdk-adapter"; +import { createMemoryState } from "@chat-adapter/state-memory"; +import { BOT_USER_ID, BOT_USER_NAME, getUser } from "./database"; + +export const bot = new Chat<{ liveblocks: LiveblocksAdapter }>({ + userName: BOT_USER_NAME, + adapters: { + liveblocks: createLiveblocksAdapter({ + apiKey: process.env.LIVEBLOCKS_SECRET_KEY!, + webhookSecret: process.env.LIVEBLOCKS_WEBHOOK_SECRET!, + botUserId: BOT_USER_ID, + botUserName: BOT_USER_NAME, + resolveUsers: ({ userIds }) => { + return userIds.map((id) => getUser(id)?.info); + }, + }), + }, + state: createMemoryState(), +}); + +// Handle @-mentions of the bot +bot.onNewMention(async (thread, message) => { + await thread.adapter.addReaction(thread.id, message.id, "👀"); + + await thread.post({ + ast: { + type: "root", + children: [ + { + type: "paragraph", + children: [ + { + type: "text", + value: "Hello ", + }, + { + type: "strong", + children: [ + { + type: "text", + value: message.author.userName, + }, + ], + }, + { + type: "text", + value: "!", + }, + ], + }, + { + type: "paragraph", + children: [ + { + type: "text", + value: "I'm ", + }, + { + type: "strong", + children: [ + { + type: "text", + value: "Liveblocks Bot", + }, + ], + }, + { + type: "text", + value: + ". You can @-mention me again or react to my messages to see me respond.", + }, + ], + }, + { + type: "paragraph", + children: [ + { + type: "text", + value: "You can learn more about this demo by visiting the ", + }, + { + type: "link", + url: "https://liveblocks.io/docs/examples/liveblocks-chat-sdk", + children: [ + { + type: "text", + value: "Liveblocks + Chat SDK example documentation", + }, + ], + }, + { + type: "text", + value: ".", + }, + ], + }, + ], + }, + }); +}); + +bot.onReaction(async (event) => { + // Ignore reactions that are not added + if (!event.added) return; + await event.adapter.postMessage( + event.threadId, + `${event.user.userName} reacted with "${event.emoji.name}"` + ); +}); diff --git a/examples/nextjs-chat-sdk-bot/app/database.ts b/examples/nextjs-chat-sdk-bot/app/database.ts new file mode 100644 index 00000000000..2d1df1eecc2 --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/app/database.ts @@ -0,0 +1,44 @@ +export const BOT_USER_ID = "__bot__"; +export const BOT_USER_NAME = "Liveblocks Bot"; + +// A mock database with example users +const USER_INFO: Liveblocks["UserMeta"][] = [ + { + id: "charlie.layne@example.com", + info: { + name: "Charlie Layne", + color: "#D583F0", + avatar: "https://liveblocks.io/avatars/avatar-1.png", + }, + }, + { + id: "mislav.abha@example.com", + info: { + name: "Mislav Abha", + color: "#F08385", + avatar: "https://liveblocks.io/avatars/avatar-2.png", + }, + }, + { + id: BOT_USER_ID, + info: { + name: BOT_USER_NAME, + color: "#000000", + avatar: "/bot-avatar.png", + }, + }, +]; + +export function getRandomUser() { + return USER_INFO.filter((user) => user.id !== BOT_USER_ID)[ + Math.floor(Math.random() * 10) % USER_INFO.length + ]; +} + +export function getUser(id: string) { + return USER_INFO.find((u) => u.id === id) || undefined; +} + +export function getUsers() { + return USER_INFO; +} diff --git a/examples/nextjs-chat-sdk-bot/app/globals.css b/examples/nextjs-chat-sdk-bot/app/globals.css new file mode 100644 index 00000000000..40ccd234afc --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/app/globals.css @@ -0,0 +1,128 @@ +@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 { + position: relative; + border-radius: 0.75rem; + overflow: hidden; + 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%); +} + +@media (prefers-color-scheme: dark) { + html, + body { + background: #111; + } + + .lb-root { + --lb-accent: #77f; + } + + .loading img { + filter: invert(1); + } + + .error { + color: #fff; + } + + .thread::after, + .composer::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%); + } +} + +.no-key { + position: fixed; + top: 0; + right: 0; + left: 0; + bottom: 0; + height: 100%; + padding: 16px; + display: flex; + justify-content: center; + align-items: center; + background: rgba(255, 255, 255, 0.8); +} + +pre { + background: rgba(45, 45, 45, 1); + border-radius: 6px; + color: #fff; + padding: 12px; +} + +pre code { + line-height: 1.5; + background: none; +} + +code { + background: rgba(230, 230, 230, 1); + padding: 4px; + border-radius: 4px; +} diff --git a/examples/nextjs-chat-sdk-bot/app/layout.tsx b/examples/nextjs-chat-sdk-bot/app/layout.tsx new file mode 100644 index 00000000000..ebf97e3bb27 --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/app/layout.tsx @@ -0,0 +1,36 @@ +import "./globals.css"; +import { Providers } from "./Providers"; +import { Suspense } from "react"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + Liveblocks + + + + + + + + {children} + + + + ); +} diff --git a/examples/nextjs-chat-sdk-bot/app/page.tsx b/examples/nextjs-chat-sdk-bot/app/page.tsx new file mode 100644 index 00000000000..3c35cbce7ed --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/app/page.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useMemo } from "react"; +import { useSearchParams } from "next/navigation"; +import { RoomProvider, useThreads } 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"; + +/** + * Displays a list of threads, along with a composer for creating + * new threads. + */ + +function Example() { + const { threads } = useThreads({ query: { resolved: false } }); + + return ( +
+ {threads.map((thread) => ( + + ))} + +
+ ); +} + +export default function Page() { + const roomId = useExampleRoomId("liveblocks:examples:nextjs-comments-ai"); + + 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-chat-sdk-bot/components/Loading.tsx b/examples/nextjs-chat-sdk-bot/components/Loading.tsx new file mode 100644 index 00000000000..1d604ac02d5 --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/components/Loading.tsx @@ -0,0 +1,7 @@ +export function Loading() { + return ( +
+ Loading +
+ ); +} diff --git a/examples/nextjs-chat-sdk-bot/liveblocks.config.ts b/examples/nextjs-chat-sdk-bot/liveblocks.config.ts new file mode 100644 index 00000000000..7ecf28928f8 --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/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-chat-sdk-bot/next.config.ts b/examples/nextjs-chat-sdk-bot/next.config.ts new file mode 100644 index 00000000000..cea65890aca --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/next.config.ts @@ -0,0 +1,13 @@ +import type { NextConfig } from "next"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const configDir = path.dirname(fileURLToPath(import.meta.url)); + +const nextConfig: NextConfig = { + turbopack: { + root: path.join(configDir, "../.."), + }, +}; + +export default nextConfig; diff --git a/examples/nextjs-chat-sdk-bot/package.json b/examples/nextjs-chat-sdk-bot/package.json new file mode 100644 index 00000000000..ea57cb201a6 --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/package.json @@ -0,0 +1,31 @@ +{ + "name": "@liveblocks-examples/nextjs-chat-sdk-bot", + "description": "This example shows how to build a Chat SDK bot with Liveblocks and Next.js.", + "license": "Apache-2.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@chat-adapter/state-memory": "^4.23.0", + "@liveblocks/chat-sdk-adapter": "^3.15.5", + "@liveblocks/client": "^3.15.5", + "@liveblocks/node": "^3.15.5", + "@liveblocks/react": "^3.15.5", + "@liveblocks/react-ui": "^3.15.5", + "chat": "^4.22.0", + "next": "16.2.1", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/examples/nextjs-chat-sdk-bot/postcss.config.mjs b/examples/nextjs-chat-sdk-bot/postcss.config.mjs new file mode 100644 index 00000000000..61e36849cf7 --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/examples/nextjs-chat-sdk-bot/public/bot-avatar.png b/examples/nextjs-chat-sdk-bot/public/bot-avatar.png new file mode 100644 index 00000000000..aceaa9ed80c Binary files /dev/null and b/examples/nextjs-chat-sdk-bot/public/bot-avatar.png differ diff --git a/examples/nextjs-chat-sdk-bot/tsconfig.json b/examples/nextjs-chat-sdk-bot/tsconfig.json new file mode 100644 index 00000000000..3a13f90a773 --- /dev/null +++ b/examples/nextjs-chat-sdk-bot/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/package-lock.json b/package-lock.json index 9d572c58b37..494c175347a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "version": "0.1.0", "dependencies": { "@liveblocks/client": "*", + "@liveblocks/core": "*", "@liveblocks/node": "*", "@liveblocks/react": "*", "@liveblocks/react-ui": "*", @@ -1888,17 +1889,6 @@ } } }, - "node_modules/@blocknote/core/node_modules/highlight.js": { - "version": "11.11.1", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", - "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/@blocknote/core/node_modules/prosemirror-highlight": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/prosemirror-highlight/-/prosemirror-highlight-0.13.1.tgz", @@ -4703,6 +4693,10 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@liveblocks/chat-sdk-adapter": { + "resolved": "packages/liveblocks-chat-sdk-adapter", + "link": true + }, "node_modules/@liveblocks/client": { "resolved": "packages/liveblocks-client", "link": true @@ -17397,7 +17391,6 @@ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "*" } @@ -17512,8 +17505,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/use-sync-external-store": { "version": "1.5.0", @@ -18126,6 +18118,13 @@ "version": "3.3.4", "license": "MIT" }, + "node_modules/@workflow/serde": { + "version": "4.1.0-beta.2", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0-beta.2.tgz", + "integrity": "sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@xmldom/xmldom": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -18828,7 +18827,6 @@ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -19228,7 +19226,6 @@ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -19275,7 +19272,6 @@ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -19307,6 +19303,22 @@ "version": "0.7.0", "license": "MIT" }, + "node_modules/chat": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/chat/-/chat-4.23.0.tgz", + "integrity": "sha512-Gmw8yyDrrH9Vxs+TfxF7FoTENrCzJV4T0FiAh7APGcQPhMMYAhrpq66PKj8azhkUSEyaen8ujbneogoJWiY8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@workflow/serde": "4.1.0-beta.2", + "mdast-util-to-string": "^4.0.0", + "remark-gfm": "^4.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "remend": "^1.2.1", + "unified": "^11.0.5" + } + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -20410,7 +20422,6 @@ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", "license": "MIT", - "peer": true, "dependencies": { "character-entities": "^2.0.0" }, @@ -20750,7 +20761,6 @@ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", "license": "MIT", - "peer": true, "dependencies": { "dequal": "^2.0.0" }, @@ -22496,8 +22506,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/extend-shallow": { "version": "3.0.2", @@ -23837,7 +23846,7 @@ }, "node_modules/highlight.js": { "version": "10.7.3", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": "*" @@ -26659,7 +26668,6 @@ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -26801,7 +26809,6 @@ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -26910,7 +26917,6 @@ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", @@ -26927,7 +26933,6 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -26940,7 +26945,6 @@ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", @@ -26965,7 +26969,6 @@ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", "license": "MIT", - "peer": true, "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", @@ -26985,7 +26988,6 @@ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", @@ -27003,7 +27005,6 @@ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", @@ -27021,7 +27022,6 @@ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", @@ -27037,7 +27037,6 @@ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", @@ -27055,7 +27054,6 @@ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", @@ -27072,7 +27070,6 @@ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" @@ -27109,7 +27106,6 @@ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", @@ -27131,7 +27127,6 @@ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdast": "^4.0.0" }, @@ -27262,7 +27257,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -27298,7 +27292,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", @@ -27323,7 +27316,6 @@ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", "license": "MIT", - "peer": true, "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", @@ -27344,7 +27336,6 @@ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", "license": "MIT", - "peer": true, "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", @@ -27361,7 +27352,6 @@ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", "license": "MIT", - "peer": true, "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", @@ -27382,7 +27372,6 @@ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", "license": "MIT", - "peer": true, "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", @@ -27401,7 +27390,6 @@ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", "license": "MIT", - "peer": true, "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", @@ -27419,7 +27407,6 @@ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", "license": "MIT", - "peer": true, "dependencies": { "micromark-util-types": "^2.0.0" }, @@ -27433,7 +27420,6 @@ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", "license": "MIT", - "peer": true, "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", @@ -27461,7 +27447,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", @@ -27483,7 +27468,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", @@ -27506,7 +27490,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" @@ -27527,7 +27510,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", @@ -27550,7 +27532,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", @@ -27573,7 +27554,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" @@ -27594,7 +27574,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "micromark-util-symbol": "^2.0.0" } @@ -27614,7 +27593,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", @@ -27636,7 +27614,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" @@ -27657,7 +27634,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "micromark-util-symbol": "^2.0.0" } @@ -27677,7 +27653,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", @@ -27699,8 +27674,7 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/micromark-util-html-tag-name": { "version": "2.0.1", @@ -27716,8 +27690,7 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/micromark-util-normalize-identifier": { "version": "2.0.1", @@ -27734,7 +27707,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "micromark-util-symbol": "^2.0.0" } @@ -27754,7 +27726,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "micromark-util-types": "^2.0.0" } @@ -27774,7 +27745,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", @@ -27796,7 +27766,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", @@ -27818,8 +27787,7 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/micromark-util-types": { "version": "2.0.2", @@ -27835,8 +27803,7 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/micromatch": { "version": "4.0.8", @@ -31229,7 +31196,6 @@ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", @@ -31248,7 +31214,6 @@ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", @@ -31283,7 +31248,6 @@ "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", @@ -31294,6 +31258,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remend": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/remend/-/remend-1.3.0.tgz", + "integrity": "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/repeat-element": { "version": "1.1.4", "license": "MIT", @@ -33815,7 +33786,6 @@ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -34903,9 +34873,9 @@ } }, "node_modules/tsup/node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "optional": true, @@ -35561,7 +35531,6 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -35581,7 +35550,6 @@ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -35642,7 +35610,6 @@ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0" }, @@ -35670,7 +35637,6 @@ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0" }, @@ -35684,7 +35650,6 @@ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", @@ -35700,7 +35665,6 @@ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" @@ -35977,7 +35941,6 @@ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" @@ -36007,7 +35970,6 @@ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" @@ -37172,18 +37134,34 @@ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, + "packages/liveblocks-chat-sdk-adapter": { + "name": "@liveblocks/chat-sdk-adapter", + "version": "3.16.0", + "license": "Apache-2.0", + "dependencies": { + "@liveblocks/core": "3.16.0", + "@liveblocks/node": "3.16.0" + }, + "devDependencies": { + "@liveblocks/eslint-config": "*", + "@liveblocks/vitest-config": "*", + "chat": "^4.21.0" + }, + "peerDependencies": { + "chat": ">=4.20.0" + } + }, "packages/liveblocks-client": { "name": "@liveblocks/client", - "version": "3.15.5", + "version": "3.16.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.15.5" + "@liveblocks/core": "3.16.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37192,7 +37170,7 @@ }, "packages/liveblocks-core": { "name": "@liveblocks/core", - "version": "3.15.5", + "version": "3.16.0", "license": "Apache-2.0", "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37210,11 +37188,11 @@ }, "packages/liveblocks-emails": { "name": "@liveblocks/emails", - "version": "3.15.5", + "version": "3.16.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.15.5", - "@liveblocks/node": "3.15.5" + "@liveblocks/core": "3.16.0", + "@liveblocks/node": "3.16.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37233,10 +37211,10 @@ }, "packages/liveblocks-node": { "name": "@liveblocks/node", - "version": "3.15.5", + "version": "3.16.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.15.5", + "@liveblocks/core": "3.16.0", "@stablelib/base64": "^1.0.1", "fast-sha256": "^1.3.0", "node-fetch": "^2.6.1" @@ -37251,11 +37229,11 @@ }, "packages/liveblocks-node-lexical": { "name": "@liveblocks/node-lexical", - "version": "3.15.5", + "version": "3.16.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.15.5", - "@liveblocks/node": "3.15.5", + "@liveblocks/core": "3.16.0", + "@liveblocks/node": "3.16.0", "yjs": "^13.6.18" }, "devDependencies": { @@ -37272,11 +37250,11 @@ }, "packages/liveblocks-node-prosemirror": { "name": "@liveblocks/node-prosemirror", - "version": "3.15.5", + "version": "3.16.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.15.5", - "@liveblocks/node": "3.15.5", + "@liveblocks/core": "3.16.0", + "@liveblocks/node": "3.16.0", "yjs": "^13.6.20" }, "devDependencies": { @@ -37296,11 +37274,11 @@ }, "packages/liveblocks-react": { "name": "@liveblocks/react", - "version": "3.15.5", + "version": "3.16.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5" + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37330,15 +37308,15 @@ }, "packages/liveblocks-react-blocknote": { "name": "@liveblocks/react-blocknote", - "version": "3.15.5", + "version": "3.16.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5", - "@liveblocks/react": "3.15.5", - "@liveblocks/react-tiptap": "3.15.5", - "@liveblocks/react-ui": "3.15.5", - "@liveblocks/yjs": "3.15.5", + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0", + "@liveblocks/react": "3.16.0", + "@liveblocks/react-tiptap": "3.16.0", + "@liveblocks/react-ui": "3.16.0", + "@liveblocks/yjs": "3.16.0", "@tiptap/core": "^3.19.0", "vitest-tsconfig-paths": "^3.4.1" }, @@ -37375,15 +37353,15 @@ }, "packages/liveblocks-react-lexical": { "name": "@liveblocks/react-lexical", - "version": "3.15.5", + "version": "3.16.0", "license": "Apache-2.0", "dependencies": { "@floating-ui/react-dom": "^2.1.0", - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5", - "@liveblocks/react": "3.15.5", - "@liveblocks/react-ui": "3.15.5", - "@liveblocks/yjs": "3.15.5", + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0", + "@liveblocks/react": "3.16.0", + "@liveblocks/react-ui": "3.16.0", + "@liveblocks/yjs": "3.16.0", "radix-ui": "^1.4.0", "yjs": "^13.6.18" }, @@ -39017,15 +38995,15 @@ }, "packages/liveblocks-react-tiptap": { "name": "@liveblocks/react-tiptap", - "version": "3.15.5", + "version": "3.16.0", "license": "Apache-2.0", "dependencies": { "@floating-ui/react-dom": "^2.1.0", - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5", - "@liveblocks/react": "3.15.5", - "@liveblocks/react-ui": "3.15.5", - "@liveblocks/yjs": "3.15.5", + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0", + "@liveblocks/react": "3.16.0", + "@liveblocks/react-ui": "3.16.0", + "@liveblocks/yjs": "3.16.0", "@tiptap/core": "^3.19.0", "@tiptap/react": "^3.19.0", "@tiptap/suggestion": "^3.19.0", @@ -40673,13 +40651,13 @@ }, "packages/liveblocks-react-ui": { "name": "@liveblocks/react-ui", - "version": "3.15.5", + "version": "3.16.0", "license": "Apache-2.0", "dependencies": { "@floating-ui/react-dom": "^2.1.0", - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5", - "@liveblocks/react": "3.15.5", + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0", + "@liveblocks/react": "3.16.0", "frimousse": "^0.2.0", "marked": "^15.0.11", "radix-ui": "^1.4.0", @@ -41053,11 +41031,11 @@ }, "packages/liveblocks-redux": { "name": "@liveblocks/redux", - "version": "3.15.5", + "version": "3.16.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5" + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -41213,11 +41191,11 @@ }, "packages/liveblocks-yjs": { "name": "@liveblocks/yjs", - "version": "3.15.5", + "version": "3.16.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5", + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0", "@noble/hashes": "^1.8.0", "js-base64": "^3.7.7", "y-indexeddb": "^9.0.12" @@ -41282,11 +41260,11 @@ }, "packages/liveblocks-zustand": { "name": "@liveblocks/zustand", - "version": "3.15.5", + "version": "3.16.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5" + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", diff --git a/packages/liveblocks-chat-sdk-adapter/.eslintrc.cjs b/packages/liveblocks-chat-sdk-adapter/.eslintrc.cjs new file mode 100644 index 00000000000..4fce48fbbe5 --- /dev/null +++ b/packages/liveblocks-chat-sdk-adapter/.eslintrc.cjs @@ -0,0 +1,9 @@ +const commonRestrictedSyntax = require("@liveblocks/eslint-config/restricted-syntax"); + +module.exports = { + root: true, + extends: ["@liveblocks/eslint-config"], + rules: { + "no-restricted-syntax": ["error", ...commonRestrictedSyntax], + }, +}; diff --git a/packages/liveblocks-chat-sdk-adapter/README.md b/packages/liveblocks-chat-sdk-adapter/README.md new file mode 100644 index 00000000000..44c2264c590 --- /dev/null +++ b/packages/liveblocks-chat-sdk-adapter/README.md @@ -0,0 +1,57 @@ +

+ Liveblocks + Liveblocks +

+ +# `@liveblocks/chat-sdk-adapter` + +

+ NPM + Size + License +

+ +`@liveblocks/chat-sdk-adapter` is a [Chat SDK](https://chat-sdk.dev) platform +adapter backed by [Liveblocks](https://liveblocks.io) **Comments**. It maps +rooms, threads, and comments to the Chat SDK’s `Channel` / `Thread` / `Message` +model so you can build bots that read and post in Liveblocks comment threads. + +## Installation + +``` +npm install @liveblocks/chat-sdk-adapter chat +``` + +## Documentation + +Read the +[documentation](https://liveblocks.io/docs/api-reference/liveblocks-chat-sdk-adapter) +for guides and API references. + +## Examples + +Explore our [collaborative examples](https://liveblocks.io/examples) to help you +get started. + +> All examples are open-source and live in this repository, within +> [`/examples`](../../examples). + +## Releases + +See the [latest changes](https://github.com/liveblocks/liveblocks/releases) or +learn more about +[upcoming releases](https://github.com/liveblocks/liveblocks/milestones). + +## Community + +- [Discord](https://liveblocks.io/discord) - To get involved with the Liveblocks + community, ask questions and share tips. +- [X](https://x.com/liveblocks) - To receive updates, announcements, blog posts, + and general Liveblocks tips. + +## License + +Licensed under the Apache License 2.0, Copyright © 2021-present +[Liveblocks](https://liveblocks.io). + +See [LICENSE](../../licenses/LICENSE-APACHE-2.0) for more information. diff --git a/packages/liveblocks-chat-sdk-adapter/package.json b/packages/liveblocks-chat-sdk-adapter/package.json new file mode 100644 index 00000000000..ba56e880659 --- /dev/null +++ b/packages/liveblocks-chat-sdk-adapter/package.json @@ -0,0 +1,65 @@ +{ + "name": "@liveblocks/chat-sdk-adapter", + "version": "3.16.0", + "description": "Liveblocks adapter for the Chat SDK.", + "license": "Apache-2.0", + "author": "Liveblocks Inc.", + "type": "module", + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "module": "./dist/index.js", + "default": "./dist/index.cjs" + } + } + }, + "files": [ + "dist/**", + "README.md" + ], + "scripts": { + "dev": "tsup --watch", + "build": "tsup", + "format": "(eslint --fix src/ || true) && prettier --write src/", + "lint": "eslint src/", + "lint:package": "publint --strict && attw --pack", + "test": "NODE_OPTIONS=\"--no-deprecation\" vitest run", + "test:ci": "NODE_OPTIONS=\"--no-deprecation\" vitest run", + "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" + }, + "dependencies": { + "@liveblocks/core": "3.16.0", + "@liveblocks/node": "3.16.0" + }, + "peerDependencies": { + "chat": ">=4.20.0" + }, + "devDependencies": { + "@liveblocks/eslint-config": "*", + "@liveblocks/vitest-config": "*", + "chat": "^4.21.0" + }, + "sideEffects": false, + "bugs": { + "url": "https://github.com/liveblocks/liveblocks/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/liveblocks/liveblocks.git", + "directory": "packages/liveblocks-chat" + }, + "homepage": "https://liveblocks.io", + "keywords": [ + "liveblocks", + "chat-sdk", + "comments", + "collaboration" + ] +} diff --git a/packages/liveblocks-chat-sdk-adapter/src/__tests__/index.test.ts b/packages/liveblocks-chat-sdk-adapter/src/__tests__/index.test.ts new file mode 100644 index 00000000000..100db131007 --- /dev/null +++ b/packages/liveblocks-chat-sdk-adapter/src/__tests__/index.test.ts @@ -0,0 +1,2625 @@ +import type { + BaseGroupInfo, + BaseUserMeta, + CommentBody, + CommentData, + ResolveGroupsInfoArgs, + ResolveUsersArgs, +} from "@liveblocks/core"; +import type { ChatInstance, Root } from "chat"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { + convertPostableMessageToCommentBody, + decodePaginationCursorByCreatedAt, + encodePaginationCursorByCreatedAt, + getRoomIdFromChannelId, + LiveblocksAdapter, + type LiveblocksAdapterConfig, +} from "../adapter"; + +type AdapterConfig = LiveblocksAdapterConfig; + +const mocks = vi.hoisted(() => { + class MockLiveblocksError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } + } + return { + MockLiveblocksError, + mockVerifyRequest: vi.fn(), + mockGetComment: vi.fn(), + mockGetThread: vi.fn(), + mockGetThreads: vi.fn(), + mockCreateComment: vi.fn(), + mockEditComment: vi.fn(), + mockDeleteComment: vi.fn(), + mockAddCommentReaction: vi.fn(), + mockRemoveCommentReaction: vi.fn(), + mockCreateThread: vi.fn(), + mockGetRoom: vi.fn(), + mockGetAttachment: vi.fn(), + }; +}); + +vi.mock("@liveblocks/node", () => ({ + Liveblocks: vi.fn(function () { + return { + getComment: mocks.mockGetComment, + getThread: mocks.mockGetThread, + getThreads: mocks.mockGetThreads, + createComment: mocks.mockCreateComment, + editComment: mocks.mockEditComment, + deleteComment: mocks.mockDeleteComment, + addCommentReaction: mocks.mockAddCommentReaction, + removeCommentReaction: mocks.mockRemoveCommentReaction, + createThread: mocks.mockCreateThread, + getRoom: mocks.mockGetRoom, + getAttachment: mocks.mockGetAttachment, + }; + }), + WebhookHandler: vi.fn(function () { + return { + verifyRequest: mocks.mockVerifyRequest, + }; + }), + LiveblocksError: mocks.MockLiveblocksError, +})); + +function createDummyAdapter(options?: { + botUserId?: string; + resolveUsers?: AdapterConfig["resolveUsers"]; + resolveGroupsInfo?: AdapterConfig["resolveGroupsInfo"]; +}) { + return new LiveblocksAdapter({ + apiKey: "sk_test_xxx", + webhookSecret: "whsec_test_xxx", + botUserId: options?.botUserId ?? "bot-user-id", + botUserName: "Bot", + resolveUsers: options?.resolveUsers, + resolveGroupsInfo: options?.resolveGroupsInfo, + }); +} + +function createDummyComment( + overrides: Partial & { body: CommentBody } +): CommentData { + return { + type: "comment", + id: "cm_1", + threadId: "th_1", + roomId: "room_1", + userId: "user_1", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + reactions: [], + attachments: [], + metadata: {}, + ...overrides, + }; +} + +function createDummyThread( + comments: CommentData[], + overrides?: Partial<{ + id: string; + roomId: string; + createdAt: Date; + updatedAt: Date; + resolved: boolean; + metadata: Record; + }> +) { + return { + type: "thread" as const, + id: "th_1", + roomId: "room_1", + createdAt: new Date(), + updatedAt: new Date(), + comments, + metadata: {}, + resolved: false, + ...overrides, + }; +} + +describe("LiveblocksAdapter", () => { + beforeEach(() => { + mocks.mockVerifyRequest.mockReset(); + mocks.mockGetComment.mockReset(); + mocks.mockGetThread.mockReset(); + mocks.mockGetThreads.mockReset(); + mocks.mockCreateComment.mockReset(); + mocks.mockEditComment.mockReset(); + mocks.mockDeleteComment.mockReset(); + mocks.mockAddCommentReaction.mockReset(); + mocks.mockRemoveCommentReaction.mockReset(); + mocks.mockCreateThread.mockReset(); + mocks.mockGetRoom.mockReset(); + mocks.mockGetAttachment.mockReset(); + }); + + describe("encodeThreadId / decodeThreadId", () => { + test("encodes a thread ID correctly", () => { + const adapter = createDummyAdapter(); + const encoded = adapter.encodeThreadId({ + roomId: "my-room", + threadId: "th_abc123", + }); + expect(encoded).toBe("liveblocks:my-room:th_abc123"); + }); + + test("decodes a thread ID correctly", () => { + const adapter = createDummyAdapter(); + const decoded = adapter.decodeThreadId("liveblocks:my-room:th_abc123"); + expect(decoded).toEqual({ + roomId: "my-room", + threadId: "th_abc123", + }); + }); + + test("handles room IDs with colons", () => { + const adapter = createDummyAdapter(); + const encoded = adapter.encodeThreadId({ + roomId: "org:team:project", + threadId: "th_abc123", + }); + expect(encoded).toBe("liveblocks:org:team:project:th_abc123"); + + const decoded = adapter.decodeThreadId(encoded); + expect(decoded).toEqual({ + roomId: "org:team:project", + threadId: "th_abc123", + }); + }); + + test("roundtrip encoding/decoding preserves data", () => { + const adapter = createDummyAdapter(); + const original = { roomId: "test-room", threadId: "th_test" }; + const encoded = adapter.encodeThreadId(original); + const decoded = adapter.decodeThreadId(encoded); + expect(decoded).toEqual(original); + }); + + test("throws for invalid thread ID format", () => { + const adapter = createDummyAdapter(); + expect(() => adapter.decodeThreadId("invalid")).toThrow( + "Invalid thread ID" + ); + }); + + test("throws for thread ID with wrong prefix", () => { + const adapter = createDummyAdapter(); + expect(() => adapter.decodeThreadId("slack:room:thread")).toThrow( + "Invalid thread ID" + ); + }); + + test("throws for thread ID with only two parts", () => { + const adapter = createDummyAdapter(); + expect(() => adapter.decodeThreadId("liveblocks:room")).toThrow( + "Invalid thread ID" + ); + }); + }); + + describe("channelIdFromThreadId", () => { + test("extracts channel ID from thread ID", () => { + const adapter = createDummyAdapter(); + const channelId = adapter.channelIdFromThreadId( + "liveblocks:my-room:th_abc123" + ); + expect(channelId).toBe("liveblocks:my-room"); + }); + + test("handles room IDs with colons", () => { + const adapter = createDummyAdapter(); + const channelId = adapter.channelIdFromThreadId( + "liveblocks:org:team:project:th_abc123" + ); + expect(channelId).toBe("liveblocks:org:team:project"); + }); + }); + + describe("getRoomIdFromChannelId", () => { + test("strips liveblocks: prefix for REST calls", () => { + expect(getRoomIdFromChannelId("liveblocks:my-room")).toBe("my-room"); + }); + + test("rejects bare room ids", () => { + expect(() => getRoomIdFromChannelId("my-room")).toThrow( + /Invalid channel ID: "my-room"/ + ); + }); + + test("rejects empty room segment", () => { + expect(() => getRoomIdFromChannelId("liveblocks:")).toThrow( + /Invalid channel ID: "liveblocks:"/ + ); + }); + + test("handles channel ids whose room id contains colons", () => { + expect(getRoomIdFromChannelId("liveblocks:org:team:project")).toBe( + "org:team:project" + ); + }); + }); + + describe("startTyping", () => { + test("returns a resolved promise (no-op)", async () => { + const adapter = createDummyAdapter(); + await expect( + adapter.startTyping("liveblocks:room:thread") + ).resolves.toBeUndefined(); + }); + + test("accepts optional status parameter", async () => { + const adapter = createDummyAdapter(); + await expect( + adapter.startTyping("liveblocks:room:thread", "typing...") + ).resolves.toBeUndefined(); + }); + }); + + describe("handleWebhook", () => { + test("returns 401 when verification fails", async () => { + const adapter = createDummyAdapter(); + mocks.mockVerifyRequest.mockImplementation(() => { + throw new Error("bad sig"); + }); + + const res = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "{}", + }) + ); + expect(res.status).toBe(401); + expect(await res.text()).toBe("Invalid webhook request"); + }); + + test("returns 200 for non-commentCreated events without calling processMessage", async () => { + const adapter = createDummyAdapter(); + const processMessage = vi.fn(); + await adapter.initialize({ processMessage } as unknown as ChatInstance); + + mocks.mockVerifyRequest.mockReturnValue({ + type: "userEntered", + data: { + projectId: "p1", + connectionId: 1, + enteredAt: "2024-01-01T00:00:00.000Z", + numActiveUsers: 1, + roomId: "room_1", + userId: "user_1", + userInfo: null, + }, + }); + + const res = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "{}", + }) + ); + expect(res.status).toBe(200); + expect(processMessage).not.toHaveBeenCalled(); + expect(mocks.mockGetComment).not.toHaveBeenCalled(); + }); + + test("returns 200 for deleted comments without calling processMessage", async () => { + const adapter = createDummyAdapter(); + const processMessage = vi.fn(); + await adapter.initialize({ processMessage } as unknown as ChatInstance); + + mocks.mockVerifyRequest.mockReturnValue({ + type: "commentCreated", + data: { + projectId: "p1", + roomId: "room_1", + threadId: "th_1", + commentId: "cm_1", + createdAt: "2024-01-01T00:00:00.000Z", + createdBy: "user_1", + }, + }); + + mocks.mockGetComment.mockResolvedValue({ + type: "comment", + id: "cm_1", + threadId: "th_1", + roomId: "room_1", + userId: "user_1", + createdAt: new Date(), + reactions: [], + attachments: [], + metadata: {}, + deletedAt: new Date(), + }); + + const res = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "{}", + }) + ); + expect(res.status).toBe(200); + expect(processMessage).not.toHaveBeenCalled(); + }); + + test("calls processMessage for valid commentCreated", async () => { + const adapter = createDummyAdapter(); + const processMessage = vi.fn(); + await adapter.initialize({ processMessage } as unknown as ChatInstance); + + const body: CommentBody = { + version: 1, + content: [ + { + type: "paragraph", + children: [{ text: "hi" }], + }, + ], + }; + + mocks.mockVerifyRequest.mockReturnValue({ + type: "commentCreated", + data: { + projectId: "p1", + roomId: "room_1", + threadId: "th_1", + commentId: "cm_1", + createdAt: "2024-01-01T00:00:00.000Z", + createdBy: "user_1", + }, + }); + + mocks.mockGetComment.mockResolvedValue(createDummyComment({ body })); + + const res = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "{}", + }) + ); + expect(res.status).toBe(200); + expect(processMessage).toHaveBeenCalledTimes(1); + const call = processMessage.mock.calls[0]!; + expect(call[0]).toBe(adapter); + expect(call[1]).toBe("liveblocks:room_1:th_1"); + expect(typeof call[2]).toBe("function"); + }); + + test("calls processReaction with added: true for commentReactionAdded", async () => { + const adapter = createDummyAdapter(); + const processReaction = vi.fn(); + await adapter.initialize({ processReaction } as unknown as ChatInstance); + + mocks.mockVerifyRequest.mockReturnValue({ + type: "commentReactionAdded", + data: { + projectId: "p1", + roomId: "room_1", + threadId: "th_1", + commentId: "cm_1", + emoji: "👍", + addedAt: "2024-01-01T00:00:00.000Z", + addedBy: "user_1", + }, + }); + + const res = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "{}", + }) + ); + expect(res.status).toBe(200); + expect(processReaction).toHaveBeenCalledTimes(1); + const call = processReaction.mock.calls[0]!; + const event = call[0]; + expect(event.added).toBe(true); + expect(event.rawEmoji).toBe("👍"); + expect(event.messageId).toBe("cm_1"); + expect(event.threadId).toBe("liveblocks:room_1:th_1"); + expect(event.user.userId).toBe("user_1"); + expect(event.user.userName).toBe("user_1"); + expect(event.user.isBot).toBe(false); + expect(event.user.isMe).toBe(false); + expect(event.adapter).toBe(adapter); + }); + + test("sets isBot and isMe to true when reaction is from bot user", async () => { + const adapter = createDummyAdapter({ botUserId: "bot-user-id" }); + const processReaction = vi.fn(); + await adapter.initialize({ processReaction } as unknown as ChatInstance); + + mocks.mockVerifyRequest.mockReturnValue({ + type: "commentReactionAdded", + data: { + projectId: "p1", + roomId: "room_1", + threadId: "th_1", + commentId: "cm_1", + emoji: "👍", + addedAt: "2024-01-01T00:00:00.000Z", + addedBy: "bot-user-id", + }, + }); + + const res = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "{}", + }) + ); + expect(res.status).toBe(200); + const event = processReaction.mock.calls[0]![0]; + expect(event.user.isBot).toBe(true); + expect(event.user.isMe).toBe(true); + }); + + test("resolves user name via resolveUsers for reaction events", async () => { + const resolveUsers = vi.fn().mockResolvedValue([{ name: "Alice Smith" }]); + const adapter = createDummyAdapter({ resolveUsers }); + const processReaction = vi.fn(); + await adapter.initialize({ processReaction } as unknown as ChatInstance); + + mocks.mockVerifyRequest.mockReturnValue({ + type: "commentReactionAdded", + data: { + projectId: "p1", + roomId: "room_1", + threadId: "th_1", + commentId: "cm_1", + emoji: "👍", + addedAt: "2024-01-01T00:00:00.000Z", + addedBy: "user_alice", + }, + }); + + const res = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "{}", + }) + ); + expect(res.status).toBe(200); + expect(resolveUsers).toHaveBeenCalledWith({ userIds: ["user_alice"] }); + const event = processReaction.mock.calls[0]![0]; + expect(event.user.userId).toBe("user_alice"); + expect(event.user.userName).toBe("Alice Smith"); + expect(event.user.fullName).toBe("Alice Smith"); + }); + + test("calls processReaction with added: false for commentReactionRemoved", async () => { + const adapter = createDummyAdapter(); + const processReaction = vi.fn(); + await adapter.initialize({ processReaction } as unknown as ChatInstance); + + mocks.mockVerifyRequest.mockReturnValue({ + type: "commentReactionRemoved", + data: { + projectId: "p1", + roomId: "room_1", + threadId: "th_1", + commentId: "cm_1", + emoji: "❤️", + removedAt: "2024-01-01T00:00:00.000Z", + removedBy: "user_2", + }, + }); + + const res = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "{}", + }) + ); + expect(res.status).toBe(200); + expect(processReaction).toHaveBeenCalledTimes(1); + const call = processReaction.mock.calls[0]!; + const event = call[0]; + expect(event.added).toBe(false); + expect(event.rawEmoji).toBe("❤️"); + expect(event.messageId).toBe("cm_1"); + expect(event.threadId).toBe("liveblocks:room_1:th_1"); + expect(event.user.userId).toBe("user_2"); + expect(event.user.userName).toBe("user_2"); + expect(event.adapter).toBe(adapter); + }); + + test("passes options to processReaction for reaction events", async () => { + const adapter = createDummyAdapter(); + const processReaction = vi.fn(); + await adapter.initialize({ processReaction } as unknown as ChatInstance); + + mocks.mockVerifyRequest.mockReturnValue({ + type: "commentReactionAdded", + data: { + projectId: "p1", + roomId: "room_1", + threadId: "th_1", + commentId: "cm_1", + emoji: "🎉", + addedAt: "2024-01-01T00:00:00.000Z", + addedBy: "user_1", + }, + }); + + const waitUntil = vi.fn(); + const res = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "{}", + }), + { waitUntil } + ); + expect(res.status).toBe(200); + expect(processReaction).toHaveBeenCalledTimes(1); + const call = processReaction.mock.calls[0]!; + expect(call[1]).toEqual({ waitUntil }); + }); + }); + + describe("fetchMessages (comment to Message conversion)", () => { + test("maps plain text comment to message text and formatted AST", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread([ + createDummyComment({ + id: "c1", + userId: "alice", + body: { + version: 1, + content: [ + { + type: "paragraph", + children: [{ text: "Hello world" }], + }, + ], + }, + }), + ]) + ); + + const { messages } = await adapter.fetchMessages( + "liveblocks:room_1:th_1" + ); + expect(messages).toHaveLength(1); + expect(messages[0]!.text).toBe("Hello world"); + expect(messages[0]!.formatted).toEqual({ + type: "root", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "Hello world" }], + }, + ], + }); + expect(messages[0]!.author.userId).toBe("alice"); + expect(messages[0]!.author.userName).toBe("alice"); + expect(messages[0]!.metadata.dateSent).toEqual( + new Date("2024-01-01T00:00:00.000Z") + ); + expect(messages[0]!.metadata.edited).toBe(false); + }); + + test("resolves user mentions via resolveUsers", async () => { + const adapter = createDummyAdapter({ + resolveUsers: async ({ userIds }: ResolveUsersArgs) => + userIds.map((id: string) => + id === "user-1" ? { name: "Alice" } : undefined + ), + }); + mocks.mockGetThread.mockResolvedValue( + createDummyThread([ + createDummyComment({ + body: { + version: 1, + content: [ + { + type: "paragraph", + children: [{ type: "mention", kind: "user", id: "user-1" }], + }, + ], + }, + }), + ]) + ); + + const { messages } = await adapter.fetchMessages( + "liveblocks:room_1:th_1" + ); + expect(messages[0]!.text).toBe("Alice"); + expect(messages[0]!.formatted).toEqual({ + type: "root", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "Alice" }], + }, + ], + }); + }); + + test("resolves group mentions via resolveGroupsInfo", async () => { + const adapter = createDummyAdapter({ + resolveGroupsInfo: async ({ groupIds }: ResolveGroupsInfoArgs) => + groupIds.map((id: string) => + id === "group-1" ? { name: "Engineering" } : undefined + ), + }); + mocks.mockGetThread.mockResolvedValue( + createDummyThread([ + createDummyComment({ + body: { + version: 1, + content: [ + { + type: "paragraph", + children: [{ type: "mention", kind: "group", id: "group-1" }], + }, + ], + }, + }), + ]) + ); + + const { messages } = await adapter.fetchMessages( + "liveblocks:room_1:th_1" + ); + expect(messages[0]!.text).toBe("Engineering"); + }); + + test("maps links to formatted link nodes and plain text", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread([ + createDummyComment({ + body: { + version: 1, + content: [ + { + type: "paragraph", + children: [ + { + type: "link", + url: "https://example.com", + text: "click", + }, + ], + }, + ], + }, + }), + ]) + ); + + const { messages } = await adapter.fetchMessages( + "liveblocks:room_1:th_1" + ); + expect(messages[0]!.text).toBe("click"); + expect(messages[0]!.links).toEqual([{ url: "https://example.com" }]); + expect(messages[0]!.formatted).toEqual({ + type: "root", + children: [ + { + type: "paragraph", + children: [ + { + type: "link", + url: "https://example.com", + children: [{ type: "text", value: "click" }], + }, + ], + }, + ], + }); + }); + + test("maps bold, italic, code, and strikethrough text", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread([ + createDummyComment({ + body: { + version: 1, + content: [ + { + type: "paragraph", + children: [ + { text: "b", bold: true }, + { text: "i", italic: true }, + { text: "s", strikethrough: true }, + { text: "c", code: true }, + ], + }, + ], + }, + }), + ]) + ); + + const { messages } = await adapter.fetchMessages( + "liveblocks:room_1:th_1" + ); + expect(messages[0]!.text).toBe("bisc"); + expect(messages[0]!.formatted).toEqual({ + type: "root", + children: [ + { + type: "paragraph", + children: [ + { + type: "strong", + children: [{ type: "text", value: "b" }], + }, + { + type: "emphasis", + children: [{ type: "text", value: "i" }], + }, + { + type: "delete", + children: [{ type: "text", value: "s" }], + }, + { + type: "inlineCode", + value: "c", + }, + ], + }, + ], + }); + }); + + test("sets isMention when the bot user is mentioned", async () => { + const botUserId = "bot-user-id"; + const adapter = createDummyAdapter({ + botUserId, + resolveUsers: async ({ userIds }: ResolveUsersArgs) => + userIds.map((id: string) => + id === botUserId ? { name: "Botty" } : undefined + ), + }); + mocks.mockGetThread.mockResolvedValue( + createDummyThread([ + createDummyComment({ + body: { + version: 1, + content: [ + { + type: "paragraph", + children: [{ type: "mention", kind: "user", id: botUserId }], + }, + ], + }, + }), + ]) + ); + + const { messages } = await adapter.fetchMessages( + "liveblocks:room_1:th_1" + ); + expect(messages[0]!.isMention).toBe(true); + }); + + test("filters out deleted comments", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread([ + createDummyComment({ + id: "c1", + body: { + version: 1, + content: [ + { + type: "paragraph", + children: [{ text: "visible" }], + }, + ], + }, + }), + { + type: "comment", + id: "c2", + threadId: "th_1", + roomId: "room_1", + userId: "user_1", + createdAt: new Date(), + reactions: [], + attachments: [], + metadata: {}, + deletedAt: new Date(), + }, + ]) + ); + + const { messages } = await adapter.fetchMessages( + "liveblocks:room_1:th_1" + ); + expect(messages).toHaveLength(1); + expect(messages[0]!.text).toBe("visible"); + }); + + test("derives attachment types from mime types", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread([ + createDummyComment({ + attachments: [ + { + type: "attachment", + id: "a1", + name: "p.png", + mimeType: "image/png", + size: 10, + }, + { + type: "attachment", + id: "a2", + name: "v.mp4", + mimeType: "video/mp4", + size: 10, + }, + { + type: "attachment", + id: "a3", + name: "s.mp3", + mimeType: "audio/mpeg", + size: 10, + }, + { + type: "attachment", + id: "a4", + name: "f.pdf", + mimeType: "application/pdf", + size: 10, + }, + ], + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "x" }] }], + }, + }), + ]) + ); + + const { messages } = await adapter.fetchMessages( + "liveblocks:room_1:th_1" + ); + const atts = messages[0]!.attachments; + expect(atts.map((a) => a.type)).toEqual([ + "image", + "video", + "audio", + "file", + ]); + }); + + describe("pagination", () => { + function createDummyComments(count: number) { + return Array.from({ length: count }, (_, i) => + createDummyComment({ + id: `c${i}`, + createdAt: new Date(1000 * i), + body: { + version: 1, + content: [ + { + type: "paragraph", + children: [{ text: `msg-${i}` }], + }, + ], + }, + }) + ); + } + + test("returns all messages when no options are provided", async () => { + const adapter = createDummyAdapter(); + const comments = createDummyComments(60); + mocks.mockGetThread.mockResolvedValue(createDummyThread(comments)); + + const result = await adapter.fetchMessages("liveblocks:room_1:th_1"); + expect(result.messages).toHaveLength(60); + expect(result.messages[0]!.text).toBe("msg-0"); + expect(result.messages[59]!.text).toBe("msg-59"); + expect(result.nextCursor).toBeUndefined(); + }); + + test("backward: respects limit and returns the newest page", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread(createDummyComments(10)) + ); + + const result = await adapter.fetchMessages("liveblocks:room_1:th_1", { + limit: 3, + }); + expect(result.messages).toHaveLength(3); + expect(result.messages[0]!.text).toBe("msg-7"); + expect(result.messages[2]!.text).toBe("msg-9"); + }); + + test("backward: returns all messages when limit >= total", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread(createDummyComments(3)) + ); + + const result = await adapter.fetchMessages("liveblocks:room_1:th_1", { + limit: 100, + }); + expect(result.messages).toHaveLength(3); + expect(result.nextCursor).toBeUndefined(); + }); + + test("backward: paginates through all messages using cursors", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread(createDummyComments(5)) + ); + + const page1 = await adapter.fetchMessages("liveblocks:room_1:th_1", { + direction: "backward", + limit: 3, + }); + expect(page1.messages.map((m) => m.text)).toEqual([ + "msg-2", + "msg-3", + "msg-4", + ]); + expect(page1.nextCursor).toBeDefined(); + expect(decodePaginationCursorByCreatedAt(page1.nextCursor!)).toEqual({ + id: "c2", + createdAt: 2000, + }); + + const page2 = await adapter.fetchMessages("liveblocks:room_1:th_1", { + direction: "backward", + limit: 3, + cursor: page1.nextCursor, + }); + expect(page2.messages.map((m) => m.text)).toEqual(["msg-0", "msg-1"]); + expect(page2.nextCursor).toBeUndefined(); + }); + + test("forward: returns oldest messages first", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread(createDummyComments(5)) + ); + + const result = await adapter.fetchMessages("liveblocks:room_1:th_1", { + direction: "forward", + limit: 3, + }); + expect(result.messages.map((m) => m.text)).toEqual([ + "msg-0", + "msg-1", + "msg-2", + ]); + expect(result.nextCursor).toBeDefined(); + expect(decodePaginationCursorByCreatedAt(result.nextCursor!)).toEqual({ + id: "c2", + createdAt: 2000, + }); + }); + + test("forward: paginates through all messages using cursors", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread(createDummyComments(5)) + ); + + const page1 = await adapter.fetchMessages("liveblocks:room_1:th_1", { + direction: "forward", + limit: 2, + }); + expect(page1.messages.map((m) => m.text)).toEqual(["msg-0", "msg-1"]); + expect(decodePaginationCursorByCreatedAt(page1.nextCursor!)).toEqual({ + id: "c1", + createdAt: 1000, + }); + + const page2 = await adapter.fetchMessages("liveblocks:room_1:th_1", { + direction: "forward", + limit: 2, + cursor: page1.nextCursor, + }); + expect(page2.messages.map((m) => m.text)).toEqual(["msg-2", "msg-3"]); + expect(decodePaginationCursorByCreatedAt(page2.nextCursor!)).toEqual({ + id: "c3", + createdAt: 3000, + }); + + const page3 = await adapter.fetchMessages("liveblocks:room_1:th_1", { + direction: "forward", + limit: 2, + cursor: page2.nextCursor, + }); + expect(page3.messages.map((m) => m.text)).toEqual(["msg-4"]); + expect(page3.nextCursor).toBeUndefined(); + }); + + test("cursor is base64url-encoded JSON matching backend format", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread(createDummyComments(5)) + ); + + const result = await adapter.fetchMessages("liveblocks:room_1:th_1", { + direction: "forward", + limit: 2, + }); + const decoded = decodePaginationCursorByCreatedAt(result.nextCursor!); + expect(decoded).toEqual({ id: "c1", createdAt: 1000 }); + expect(result.nextCursor).not.toContain("+"); + expect(result.nextCursor).not.toContain("/"); + expect(result.nextCursor).not.toContain("="); + }); + + test("returns empty result for invalid cursor", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread(createDummyComments(3)) + ); + + await expect( + adapter.fetchMessages("liveblocks:room_1:th_1", { + cursor: "not_a_valid_cursor", + limit: 10, + }) + ).rejects.toThrow("Invalid pagination cursor"); + }); + + test("backward: foreign cursor after all messages falls back to newest page", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread(createDummyComments(5)) + ); + + const foreignCursor = encodePaginationCursorByCreatedAt( + "ghost-id", + new Date(9_999_999_000) + ); + + const result = await adapter.fetchMessages("liveblocks:room_1:th_1", { + direction: "backward", + limit: 3, + cursor: foreignCursor, + }); + + expect(result.messages.map((m) => m.text)).toEqual([ + "msg-2", + "msg-3", + "msg-4", + ]); + }); + + test("forward: foreign cursor after all messages returns empty page", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread(createDummyComments(5)) + ); + + const foreignCursor = encodePaginationCursorByCreatedAt( + "ghost-id", + new Date(9_999_999_000) + ); + + const result = await adapter.fetchMessages("liveblocks:room_1:th_1", { + direction: "forward", + limit: 3, + cursor: foreignCursor, + }); + + expect(result.messages).toHaveLength(0); + expect(result.nextCursor).toBeUndefined(); + }); + + test("empty thread returns no messages and no cursor", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue(createDummyThread([])); + + const result = await adapter.fetchMessages("liveblocks:room_1:th_1", { + limit: 10, + }); + expect(result.messages).toHaveLength(0); + expect(result.nextCursor).toBeUndefined(); + }); + + test("deleted comments are excluded before pagination", async () => { + const adapter = createDummyAdapter(); + const comments: CommentData[] = [ + createDummyComment({ + id: "c0", + createdAt: new Date(1000), + body: { + version: 1, + content: [ + { type: "paragraph", children: [{ text: "visible-0" }] }, + ], + }, + }), + { + type: "comment", + id: "c1", + threadId: "th_1", + roomId: "room_1", + userId: "user_1", + createdAt: new Date(2000), + reactions: [], + attachments: [], + metadata: {}, + deletedAt: new Date(), + }, + createDummyComment({ + id: "c2", + createdAt: new Date(3000), + body: { + version: 1, + content: [ + { type: "paragraph", children: [{ text: "visible-1" }] }, + ], + }, + }), + ]; + mocks.mockGetThread.mockResolvedValue(createDummyThread(comments)); + + const result = await adapter.fetchMessages("liveblocks:room_1:th_1", { + direction: "forward", + limit: 10, + }); + expect(result.messages).toHaveLength(2); + expect(result.messages.map((m) => m.text)).toEqual([ + "visible-0", + "visible-1", + ]); + }); + }); + }); + + describe("listThreads", () => { + test("returns thread summaries with root message, replyCount, and lastReplyAt", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThreads.mockResolvedValue({ + data: [ + createDummyThread( + [ + createDummyComment({ + id: "c1", + threadId: "th_a", + roomId: "room_1", + body: { + version: 1, + content: [ + { + type: "paragraph", + children: [{ text: "root a" }], + }, + ], + }, + }), + createDummyComment({ + id: "c2", + threadId: "th_a", + roomId: "room_1", + createdAt: new Date("2024-01-02T00:00:00.000Z"), + body: { + version: 1, + content: [ + { + type: "paragraph", + children: [{ text: "reply" }], + }, + ], + }, + }), + ], + { + id: "th_a", + updatedAt: new Date("2024-06-01T00:00:00.000Z"), + } + ), + createDummyThread( + [ + createDummyComment({ + id: "c3", + threadId: "th_b", + roomId: "room_1", + body: { + version: 1, + content: [ + { + type: "paragraph", + children: [{ text: "root b" }], + }, + ], + }, + }), + ], + { + id: "th_b", + updatedAt: new Date("2024-06-02T00:00:00.000Z"), + } + ), + ], + }); + + const result = await adapter.listThreads("liveblocks:room_1"); + expect(result.threads).toHaveLength(2); + expect(result.nextCursor).toBeUndefined(); + + expect(result.threads[0]!.id).toBe("liveblocks:room_1:th_a"); + expect(result.threads[0]!.replyCount).toBe(1); + expect(result.threads[0]!.lastReplyAt).toEqual( + new Date("2024-06-01T00:00:00.000Z") + ); + expect(result.threads[0]!.rootMessage.text).toBe("root a"); + + expect(result.threads[1]!.id).toBe("liveblocks:room_1:th_b"); + expect(result.threads[1]!.replyCount).toBe(0); + expect(result.threads[1]!.rootMessage.text).toBe("root b"); + + expect(mocks.mockGetThreads).toHaveBeenCalledWith({ roomId: "room_1" }); + }); + + test("passes bare room id to getThreads when channel id is prefixed", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThreads.mockResolvedValue({ data: [] }); + + await adapter.listThreads("liveblocks:room_1"); + + expect(mocks.mockGetThreads).toHaveBeenCalledWith({ + roomId: "room_1", + }); + }); + + test("skips threads where all comments are deleted or lack a body", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThreads.mockResolvedValue({ + data: [ + createDummyThread( + [ + { + type: "comment", + id: "c_del", + threadId: "th_dead", + roomId: "room_1", + userId: "u1", + createdAt: new Date(), + reactions: [], + attachments: [], + metadata: {}, + deletedAt: new Date(), + }, + ], + { id: "th_dead" } + ), + createDummyThread( + [ + createDummyComment({ + id: "c_ok", + threadId: "th_ok", + roomId: "room_1", + body: { + version: 1, + content: [ + { type: "paragraph", children: [{ text: "still here" }] }, + ], + }, + }), + ], + { id: "th_ok" } + ), + ], + }); + + const result = await adapter.listThreads("liveblocks:room_1"); + expect(result.threads).toHaveLength(1); + expect(result.threads[0]!.id).toBe("liveblocks:room_1:th_ok"); + expect(result.threads[0]!.rootMessage.text).toBe("still here"); + }); + + test("supports limit-based pagination (newest threads first)", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThreads.mockResolvedValue({ + data: [ + createDummyThread( + [ + createDummyComment({ + id: "r1", + threadId: "th_1", + roomId: "room_1", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "t1" }] }], + }, + }), + ], + { id: "th_1", updatedAt: new Date(1000) } + ), + createDummyThread( + [ + createDummyComment({ + id: "r2", + threadId: "th_2", + roomId: "room_1", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "t2" }] }], + }, + }), + ], + { id: "th_2", updatedAt: new Date(2000) } + ), + createDummyThread( + [ + createDummyComment({ + id: "r3", + threadId: "th_3", + roomId: "room_1", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "t3" }] }], + }, + }), + ], + { id: "th_3", updatedAt: new Date(3000) } + ), + ], + }); + + const page = await adapter.listThreads("liveblocks:room_1", { limit: 2 }); + expect(page.threads.map((t) => t.id)).toEqual([ + "liveblocks:room_1:th_2", + "liveblocks:room_1:th_3", + ]); + expect(page.nextCursor).toBeDefined(); + expect(page.nextCursor).not.toContain("+"); + expect(page.nextCursor).not.toContain("/"); + expect(page.nextCursor).not.toContain("="); + }); + + test("paginates through threads using cursor", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThreads.mockResolvedValue({ + data: [ + createDummyThread( + [ + createDummyComment({ + id: "r1", + threadId: "th_1", + roomId: "room_1", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "t1" }] }], + }, + }), + ], + { id: "th_1", updatedAt: new Date(1000) } + ), + createDummyThread( + [ + createDummyComment({ + id: "r2", + threadId: "th_2", + roomId: "room_1", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "t2" }] }], + }, + }), + ], + { id: "th_2", updatedAt: new Date(2000) } + ), + createDummyThread( + [ + createDummyComment({ + id: "r3", + threadId: "th_3", + roomId: "room_1", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "t3" }] }], + }, + }), + ], + { id: "th_3", updatedAt: new Date(3000) } + ), + ], + }); + + const page1 = await adapter.listThreads("liveblocks:room_1", { + limit: 2, + }); + const page2 = await adapter.listThreads("liveblocks:room_1", { + limit: 2, + cursor: page1.nextCursor, + }); + expect(page1.threads.map((t) => t.id)).toEqual([ + "liveblocks:room_1:th_2", + "liveblocks:room_1:th_3", + ]); + expect(page2.threads.map((t) => t.id)).toEqual([ + "liveblocks:room_1:th_1", + ]); + expect(page2.nextCursor).toBeUndefined(); + }); + + test("returns empty threads when room has no threads", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThreads.mockResolvedValue({ data: [] }); + const result = await adapter.listThreads("liveblocks:room_1"); + expect(result.threads).toEqual([]); + expect(result.nextCursor).toBeUndefined(); + }); + + test("rejects invalid pagination cursor", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThreads.mockResolvedValue({ + data: [ + createDummyThread( + [ + createDummyComment({ + id: "r1", + threadId: "th_1", + roomId: "room_1", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "t1" }] }], + }, + }), + ], + { id: "th_1", updatedAt: new Date(1000) } + ), + ], + }); + await expect( + adapter.listThreads("liveblocks:room_1", { + limit: 2, + cursor: "not_a_valid_cursor", + }) + ).rejects.toThrow("Invalid pagination cursor"); + }); + + test("foreign cursor after all threads falls back to newest page", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThreads.mockResolvedValue({ + data: [ + createDummyThread( + [ + createDummyComment({ + id: "r1", + threadId: "th_1", + roomId: "room_1", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "t1" }] }], + }, + }), + ], + { id: "th_1", updatedAt: new Date(1000) } + ), + createDummyThread( + [ + createDummyComment({ + id: "r2", + threadId: "th_2", + roomId: "room_1", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "t2" }] }], + }, + }), + ], + { id: "th_2", updatedAt: new Date(2000) } + ), + createDummyThread( + [ + createDummyComment({ + id: "r3", + threadId: "th_3", + roomId: "room_1", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "t3" }] }], + }, + }), + ], + { id: "th_3", updatedAt: new Date(3000) } + ), + ], + }); + + const foreignCursor = btoa( + JSON.stringify([ + ["id", "ghost-id"], + ["updatedAt", 9_999_999_000], + ]) + ) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + const page = await adapter.listThreads("liveblocks:room_1", { + limit: 2, + cursor: foreignCursor, + }); + expect(page.threads.map((t) => t.id)).toEqual([ + "liveblocks:room_1:th_2", + "liveblocks:room_1:th_3", + ]); + }); + }); + + describe("fetchChannelMessages", () => { + test("returns root comments from all threads in chronological order", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThreads.mockResolvedValue({ + data: [ + createDummyThread( + [ + createDummyComment({ + id: "root_b", + threadId: "th_b", + roomId: "room_1", + createdAt: new Date("2024-01-02T00:00:00.000Z"), + body: { + version: 1, + content: [ + { type: "paragraph", children: [{ text: "second" }] }, + ], + }, + }), + ], + { id: "th_b" } + ), + createDummyThread( + [ + createDummyComment({ + id: "root_a", + threadId: "th_a", + roomId: "room_1", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + body: { + version: 1, + content: [ + { type: "paragraph", children: [{ text: "first" }] }, + ], + }, + }), + ], + { id: "th_a" } + ), + ], + }); + + const { messages } = + await adapter.fetchChannelMessages("liveblocks:room_1"); + expect(messages.map((m) => m.text)).toEqual(["first", "second"]); + expect(mocks.mockGetThreads).toHaveBeenCalledWith({ roomId: "room_1" }); + }); + + test("passes bare room id to getThreads when channel id is prefixed", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThreads.mockResolvedValue({ data: [] }); + + await adapter.fetchChannelMessages("liveblocks:room_1"); + + expect(mocks.mockGetThreads).toHaveBeenCalledWith({ + roomId: "room_1", + }); + }); + + test("omits threads whose only comments are deleted", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThreads.mockResolvedValue({ + data: [ + createDummyThread( + [ + { + type: "comment", + id: "gone", + threadId: "th_x", + roomId: "room_1", + userId: "u1", + createdAt: new Date(), + reactions: [], + attachments: [], + metadata: {}, + deletedAt: new Date(), + }, + ], + { id: "th_x" } + ), + createDummyThread( + [ + createDummyComment({ + id: "ok", + threadId: "th_y", + roomId: "room_1", + body: { + version: 1, + content: [ + { type: "paragraph", children: [{ text: "keep" }] }, + ], + }, + }), + ], + { id: "th_y" } + ), + ], + }); + + const { messages } = + await adapter.fetchChannelMessages("liveblocks:room_1"); + expect(messages).toHaveLength(1); + expect(messages[0]!.text).toBe("keep"); + }); + + test("backward: respects limit and returns newest root messages first", async () => { + const adapter = createDummyAdapter(); + const threads = [1, 2, 3, 4, 5].map((i) => + createDummyThread( + [ + createDummyComment({ + id: `root_${i}`, + threadId: `th_${i}`, + roomId: "room_1", + createdAt: new Date(1000 * i), + body: { + version: 1, + content: [ + { type: "paragraph", children: [{ text: `msg-${i}` }] }, + ], + }, + }), + ], + { id: `th_${i}` } + ) + ); + mocks.mockGetThreads.mockResolvedValue({ data: threads }); + + const result = await adapter.fetchChannelMessages("liveblocks:room_1", { + limit: 2, + }); + expect(result.messages.map((m) => m.text)).toEqual(["msg-4", "msg-5"]); + expect(result.nextCursor).toBeDefined(); + }); + + test("forward: paginates root messages using cursor", async () => { + const adapter = createDummyAdapter(); + const threads = [0, 1, 2, 3, 4].map((i) => + createDummyThread( + [ + createDummyComment({ + id: `root_${i}`, + threadId: `th_${i}`, + roomId: "room_1", + createdAt: new Date(1000 * i), + body: { + version: 1, + content: [ + { type: "paragraph", children: [{ text: `msg-${i}` }] }, + ], + }, + }), + ], + { id: `th_${i}` } + ) + ); + mocks.mockGetThreads.mockResolvedValue({ data: threads }); + + const page1 = await adapter.fetchChannelMessages("liveblocks:room_1", { + direction: "forward", + limit: 2, + }); + expect(page1.messages.map((m) => m.text)).toEqual(["msg-0", "msg-1"]); + + const page2 = await adapter.fetchChannelMessages("liveblocks:room_1", { + direction: "forward", + limit: 2, + cursor: page1.nextCursor, + }); + expect(page2.messages.map((m) => m.text)).toEqual(["msg-2", "msg-3"]); + }); + + test("returns no messages when room has no threads", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThreads.mockResolvedValue({ data: [] }); + const result = await adapter.fetchChannelMessages("liveblocks:room_1"); + expect(result.messages).toEqual([]); + expect(result.nextCursor).toBeUndefined(); + }); + }); + + describe("parseMessage", () => { + test("returns message with ids, bot flags, metadata, empty formatted", () => { + const adapter = createDummyAdapter({ botUserId: "bot-1" }); + const msg = adapter.parseMessage( + createDummyComment({ + id: "cm_x", + userId: "bot-1", + editedAt: new Date("2024-02-01T00:00:00.000Z"), + body: { + version: 1, + content: [ + { type: "paragraph", children: [{ text: "ignored in parse" }] }, + ], + }, + }) + ); + expect(msg.id).toBe("cm_x"); + expect(msg.threadId).toBe("liveblocks:room_1:th_1"); + expect(msg.author.isBot).toBe(true); + expect(msg.author.isMe).toBe(true); + expect(msg.metadata.edited).toBe(true); + expect(msg.formatted).toEqual({ type: "root", children: [] }); + expect(msg.text).toBe(""); + }); + + test("sets isBot and isMe to false for non-bot user", () => { + const adapter = createDummyAdapter({ botUserId: "bot-1" }); + const msg = adapter.parseMessage( + createDummyComment({ + id: "cm_y", + userId: "human-user", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "hello" }] }], + }, + }) + ); + expect(msg.author.isBot).toBe(false); + expect(msg.author.isMe).toBe(false); + expect(msg.author.userId).toBe("human-user"); + }); + + test("maps attachments correctly", () => { + const adapter = createDummyAdapter(); + const msg = adapter.parseMessage( + createDummyComment({ + attachments: [ + { + type: "attachment", + id: "att_1", + name: "photo.jpg", + mimeType: "image/jpeg", + size: 5000, + }, + ], + body: { + version: 1, + content: [ + { type: "paragraph", children: [{ text: "see attachment" }] }, + ], + }, + }) + ); + expect(msg.attachments).toHaveLength(1); + expect(msg.attachments[0]!.type).toBe("image"); + expect(msg.attachments[0]!.name).toBe("photo.jpg"); + expect(msg.attachments[0]!.mimeType).toBe("image/jpeg"); + expect(msg.attachments[0]!.size).toBe(5000); + }); + }); + + describe("postMessage", () => { + test("calls createComment with correct args and returns RawMessage", async () => { + const adapter = createDummyAdapter(); + const returnedComment = createDummyComment({ + id: "cm_new", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "Hello" }] }], + }, + }); + mocks.mockCreateComment.mockResolvedValue(returnedComment); + + const result = await adapter.postMessage( + "liveblocks:room_1:th_1", + "Hello" + ); + + expect(mocks.mockCreateComment).toHaveBeenCalledWith({ + roomId: "room_1", + threadId: "th_1", + data: { + userId: "bot-user-id", + body: expect.objectContaining({ version: 1 }), + }, + }); + expect(result.id).toBe("cm_new"); + expect(result.threadId).toBe("liveblocks:room_1:th_1"); + expect(result.raw).toBe(returnedComment); + }); + }); + + describe("editMessage", () => { + test("calls editComment with correct args and returns RawMessage", async () => { + const adapter = createDummyAdapter(); + const returnedComment = createDummyComment({ + id: "cm_edited", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "Updated" }] }], + }, + }); + mocks.mockEditComment.mockResolvedValue(returnedComment); + + const result = await adapter.editMessage( + "liveblocks:room_1:th_1", + "cm_edited", + "Updated" + ); + + expect(mocks.mockEditComment).toHaveBeenCalledWith({ + roomId: "room_1", + threadId: "th_1", + commentId: "cm_edited", + data: { + body: expect.objectContaining({ version: 1 }), + }, + }); + expect(result.id).toBe("cm_edited"); + expect(result.threadId).toBe("liveblocks:room_1:th_1"); + expect(result.raw).toBe(returnedComment); + }); + }); + + describe("deleteMessage", () => { + test("calls deleteComment with correct args", async () => { + const adapter = createDummyAdapter(); + mocks.mockDeleteComment.mockResolvedValue(undefined); + + await adapter.deleteMessage("liveblocks:room_1:th_1", "cm_del"); + + expect(mocks.mockDeleteComment).toHaveBeenCalledWith({ + roomId: "room_1", + threadId: "th_1", + commentId: "cm_del", + }); + }); + }); + + describe("addReaction", () => { + test("calls addCommentReaction with correct args", async () => { + const adapter = createDummyAdapter(); + mocks.mockAddCommentReaction.mockResolvedValue(undefined); + + await adapter.addReaction("liveblocks:room_1:th_1", "cm_1", "👍"); + + expect(mocks.mockAddCommentReaction).toHaveBeenCalledWith({ + roomId: "room_1", + threadId: "th_1", + commentId: "cm_1", + data: { + emoji: expect.any(String), + userId: "bot-user-id", + }, + }); + }); + }); + + describe("removeReaction", () => { + test("calls removeCommentReaction with correct args", async () => { + const adapter = createDummyAdapter(); + mocks.mockRemoveCommentReaction.mockResolvedValue(undefined); + + await adapter.removeReaction("liveblocks:room_1:th_1", "cm_1", "👍"); + + expect(mocks.mockRemoveCommentReaction).toHaveBeenCalledWith({ + roomId: "room_1", + threadId: "th_1", + commentId: "cm_1", + data: { + emoji: expect.any(String), + userId: "bot-user-id", + }, + }); + }); + }); + + describe("fetchThread", () => { + test("returns ThreadInfo with metadata and resolved status", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread([], { + id: "th_abc", + roomId: "room_1", + resolved: true, + metadata: { priority: "high" }, + }) + ); + + const info = await adapter.fetchThread("liveblocks:room_1:th_abc"); + expect(info).toEqual({ + id: "liveblocks:room_1:th_abc", + channelId: "liveblocks:room_1", + metadata: { + resolved: true, + priority: "high", + }, + channelName: "room_1", + isDM: false, + }); + }); + + test("returns unresolved thread with empty metadata", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread([], { + id: "th_xyz", + roomId: "room_1", + }) + ); + + const info = await adapter.fetchThread("liveblocks:room_1:th_xyz"); + expect(info.metadata.resolved).toBe(false); + expect(info.isDM).toBe(false); + }); + }); + + describe("fetchMessage", () => { + test("returns a message for a valid comment", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetComment.mockResolvedValue( + createDummyComment({ + id: "cm_found", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "found it" }] }], + }, + }) + ); + + const msg = await adapter.fetchMessage( + "liveblocks:room_1:th_1", + "cm_found" + ); + expect(msg).not.toBeNull(); + expect(msg!.id).toBe("cm_found"); + expect(msg!.text).toBe("found it"); + }); + + test("returns null for a deleted comment", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetComment.mockResolvedValue({ + type: "comment", + id: "cm_del", + threadId: "th_1", + roomId: "room_1", + userId: "user_1", + createdAt: new Date(), + reactions: [], + attachments: [], + metadata: {}, + deletedAt: new Date(), + }); + + const msg = await adapter.fetchMessage( + "liveblocks:room_1:th_1", + "cm_del" + ); + expect(msg).toBeNull(); + }); + + test("returns null when LiveblocksError with status 404 is thrown", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetComment.mockRejectedValue( + new mocks.MockLiveblocksError("Not found", 404) + ); + + const msg = await adapter.fetchMessage( + "liveblocks:room_1:th_1", + "cm_missing" + ); + expect(msg).toBeNull(); + }); + + test("rethrows non-404 LiveblocksError", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetComment.mockRejectedValue( + new mocks.MockLiveblocksError("Forbidden", 403) + ); + + await expect( + adapter.fetchMessage("liveblocks:room_1:th_1", "cm_forbidden") + ).rejects.toThrow("Forbidden"); + }); + + test("rethrows non-LiveblocksError errors", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetComment.mockRejectedValue(new Error("Network failure")); + + await expect( + adapter.fetchMessage("liveblocks:room_1:th_1", "cm_err") + ).rejects.toThrow("Network failure"); + }); + }); + + describe("fetchChannelInfo", () => { + test("returns ChannelInfo and resolves Chat channel id for getRoom", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetRoom.mockResolvedValue({ + id: "my-room", + type: "room", + }); + + const info = await adapter.fetchChannelInfo("liveblocks:my-room"); + expect(info).toEqual({ + id: "my-room", + name: "my-room", + isDM: false, + metadata: {}, + }); + + expect(mocks.mockGetRoom).toHaveBeenCalledWith("my-room"); + }); + }); + + describe("postChannelMessage", () => { + test("creates a thread and returns the first comment as RawMessage", async () => { + const adapter = createDummyAdapter(); + const comment = createDummyComment({ + id: "cm_root", + threadId: "th_new", + roomId: "channel_1", + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "hi" }] }], + }, + }); + mocks.mockCreateThread.mockResolvedValue({ + type: "thread", + id: "th_new", + roomId: "channel_1", + comments: [comment], + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + resolved: false, + }); + + const result = await adapter.postChannelMessage( + "liveblocks:channel_1", + "hi" + ); + + expect(mocks.mockCreateThread).toHaveBeenCalledWith({ + roomId: "channel_1", + data: { + comment: { + userId: "bot-user-id", + body: expect.objectContaining({ version: 1 }), + }, + }, + }); + expect(result.id).toBe("cm_root"); + expect(result.threadId).toBe("liveblocks:channel_1:th_new"); + expect(result.raw).toBe(comment); + }); + + test("throws when thread has no comments", async () => { + const adapter = createDummyAdapter(); + mocks.mockCreateThread.mockResolvedValue({ + type: "thread", + id: "th_empty", + roomId: "channel_1", + comments: [], + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + resolved: false, + }); + + await expect( + adapter.postChannelMessage("liveblocks:channel_1", "hello") + ).rejects.toThrow("Failed to create thread in room liveblocks:channel_1"); + }); + }); + + describe("renderFormatted", () => { + test("converts formatted content to plain text", () => { + const adapter = createDummyAdapter(); + const result = adapter.renderFormatted({ + type: "root", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "Hello world" }], + }, + ], + } as any); + expect(result).toContain("Hello world"); + }); + + test("handles multiple paragraphs", () => { + const adapter = createDummyAdapter(); + const result = adapter.renderFormatted({ + type: "root", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "Line 1" }], + }, + { + type: "paragraph", + children: [{ type: "text", value: "Line 2" }], + }, + ], + } as any); + expect(result).toContain("Line 1"); + expect(result).toContain("Line 2"); + }); + }); + + describe("attachment fetchData", () => { + test("fetches data from the attachment URL", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread([ + createDummyComment({ + attachments: [ + { + type: "attachment", + id: "att_1", + name: "test.txt", + mimeType: "text/plain", + size: 11, + }, + ], + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "x" }] }], + }, + }), + ]) + ); + + mocks.mockGetAttachment.mockResolvedValue({ + url: "https://storage.example.com/att_1", + }); + + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("hello world", { status: 200 })); + + const { messages } = await adapter.fetchMessages( + "liveblocks:room_1:th_1" + ); + const data = await messages[0]!.attachments[0]!.fetchData!(); + + expect(mocks.mockGetAttachment).toHaveBeenCalledWith({ + roomId: "room_1", + attachmentId: "att_1", + }); + expect(fetchSpy).toHaveBeenCalledWith( + "https://storage.example.com/att_1" + ); + expect(data).toBeInstanceOf(Buffer); + expect(data.toString()).toBe("hello world"); + + fetchSpy.mockRestore(); + }); + + test("throws on non-OK response", async () => { + const adapter = createDummyAdapter(); + mocks.mockGetThread.mockResolvedValue( + createDummyThread([ + createDummyComment({ + attachments: [ + { + type: "attachment", + id: "att_2", + name: "missing.txt", + mimeType: "text/plain", + size: 0, + }, + ], + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "x" }] }], + }, + }), + ]) + ); + + mocks.mockGetAttachment.mockResolvedValue({ + url: "https://storage.example.com/att_2", + }); + + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + new Response(null, { status: 404, statusText: "Not Found" }) + ); + + const { messages } = await adapter.fetchMessages( + "liveblocks:room_1:th_1" + ); + await expect(messages[0]!.attachments[0]!.fetchData!()).rejects.toThrow( + 'Failed to fetch attachment "missing.txt": 404 Not Found' + ); + + fetchSpy.mockRestore(); + }); + }); +}); + +describe("convertPostableMessageToCommentBody", () => { + test("converts string input to a single paragraph of text", () => { + const body = convertPostableMessageToCommentBody("Hello world"); + expect(body).toEqual({ + version: 1, + content: [ + { + type: "paragraph", + children: [{ text: "Hello world" }], + }, + ], + }); + }); + + test("converts { raw: string } like plain string", () => { + const body = convertPostableMessageToCommentBody({ raw: "Hello world" }); + expect(body.content[0]).toEqual({ + type: "paragraph", + children: [{ text: "Hello world" }], + }); + }); + + test("converts markdown with bold and italic", () => { + const body = convertPostableMessageToCommentBody({ + markdown: "**bold** and *italic*", + }); + expect(body.version).toBe(1); + expect(body.content).toHaveLength(1); + const para = body.content[0]!; + expect(para.type).toBe("paragraph"); + expect(para.children).toEqual([ + { text: "bold", bold: true }, + { text: " and " }, + { text: "italic", italic: true }, + ]); + }); + + test("converts { ast: Root } without reparsing markdown", () => { + const ast: Root = { + type: "root", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "direct" }], + }, + ], + }; + const body = convertPostableMessageToCommentBody({ ast }); + expect(body).toEqual({ + version: 1, + content: [ + { + type: "paragraph", + children: [{ text: "direct" }], + }, + ], + }); + }); + + test("converts { card: CardElement } via markdown fallback", () => { + const body = convertPostableMessageToCommentBody({ + card: { + type: "card", + title: "Title", + subtitle: "Subtitle", + children: [{ type: "text", content: "Line" }], + }, + }); + expect(body.version).toBe(1); + expect(body.content.length).toBeGreaterThan(0); + const textJoined = body.content + .flatMap((p) => p.children.map((c) => ("text" in c ? c.text : ""))) + .join(""); + expect(textJoined).toContain("Title"); + expect(textJoined).toContain("Subtitle"); + expect(textJoined).toContain("Line"); + }); + + test("converts inline code", () => { + const body = convertPostableMessageToCommentBody("`code`"); + const para = body.content[0]!; + expect(para.children).toContainEqual({ + text: "code", + code: true, + }); + }); + + test("converts markdown links", () => { + const body = convertPostableMessageToCommentBody( + "[click](https://example.com)" + ); + expect(body.content[0]!.children[0]).toEqual({ + type: "link", + url: "https://example.com", + text: "click", + }); + }); + + test("converts strikethrough (GFM)", () => { + const body = convertPostableMessageToCommentBody("~~deleted~~"); + expect(body.content[0]!.children[0]).toMatchObject({ + text: "deleted", + strikethrough: true, + }); + }); + + test("flattens headings and code blocks into paragraph-compatible content", () => { + const body = convertPostableMessageToCommentBody( + "# Heading\n\n```\ncode block\n```" + ); + expect(body.version).toBe(1); + const texts = body.content.flatMap((block) => + block.children.filter( + (c) => "text" in c && !("type" in c && c.type === "link") + ) + ); + expect(texts.some((t) => "text" in t && t.text.includes("Heading"))).toBe( + true + ); + expect( + texts.some((t) => "text" in t && t.text.includes("code block")) + ).toBe(true); + }); + + test("returns empty content for unexpected message shape", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const body = convertPostableMessageToCommentBody( + {} as Parameters[0] + ); + expect(body).toEqual({ version: 1, content: [] }); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + test("converts card with fallbackText instead of card content", () => { + const body = convertPostableMessageToCommentBody({ + card: { + type: "card", + title: "Card Title", + children: [{ type: "text", content: "Card content" }], + }, + fallbackText: "Fallback text here", + }); + expect(body.version).toBe(1); + const textJoined = body.content + .flatMap((p) => p.children.map((c) => ("text" in c ? c.text : ""))) + .join(""); + expect(textJoined).toContain("Fallback text here"); + expect(textJoined).not.toContain("Card Title"); + }); + + test("converts inline CardElement (type === 'card')", () => { + const body = convertPostableMessageToCommentBody({ + type: "card", + title: "Inline Card", + children: [{ type: "text", content: "Some text" }], + } as any); + const textJoined = body.content + .flatMap((p) => p.children.map((c) => ("text" in c ? c.text : ""))) + .join(""); + expect(textJoined).toContain("Inline Card"); + expect(textJoined).toContain("Some text"); + }); + + test("converts card with fields", () => { + const body = convertPostableMessageToCommentBody({ + card: { + type: "card", + title: "Info", + children: [ + { + type: "fields", + children: [ + { type: "field", label: "Priority", value: "High" }, + { type: "field", label: "Status", value: "Open" }, + ], + }, + ], + }, + }); + const textJoined = body.content + .flatMap((p) => p.children.map((c) => ("text" in c ? c.text : ""))) + .join(""); + expect(textJoined).toContain("Priority"); + expect(textJoined).toContain("High"); + expect(textJoined).toContain("Open"); + }); + + test("converts card with actions (excluded from output)", () => { + const body = convertPostableMessageToCommentBody({ + card: { + type: "card", + title: "Actions Card", + children: [ + { + type: "actions", + children: [ + { + type: "button", + label: "Click me", + url: "https://example.com", + }, + ], + } as any, + ], + }, + }); + const textJoined = body.content + .flatMap((p) => p.children.map((c) => ("text" in c ? c.text : ""))) + .join(""); + expect(textJoined).toContain("Actions Card"); + expect(textJoined).not.toContain("Click me"); + }); + + test("converts card with link child", () => { + const body = convertPostableMessageToCommentBody({ + card: { + type: "card", + children: [ + { type: "link", label: "Visit", url: "https://example.com" }, + ], + }, + }); + const allChildren = body.content.flatMap((p) => p.children); + const linkChild = allChildren.find((c) => "type" in c && c.type === "link"); + expect(linkChild).toBeDefined(); + }); + + test("converts card with divider", () => { + const body = convertPostableMessageToCommentBody({ + card: { + type: "card", + title: "Top", + children: [{ type: "divider" }, { type: "text", content: "Bottom" }], + }, + }); + const textJoined = body.content + .flatMap((p) => p.children.map((c) => ("text" in c ? c.text : ""))) + .join(""); + expect(textJoined).toContain("Top"); + expect(textJoined).toContain("Bottom"); + }); + + test("converts card with image", () => { + const body = convertPostableMessageToCommentBody({ + card: { + type: "card", + children: [ + { + type: "image", + url: "https://example.com/img.png", + alt: "photo", + }, + ], + }, + }); + const textJoined = body.content + .flatMap((p) => p.children.map((c) => ("text" in c ? c.text : ""))) + .join(""); + expect(textJoined).toContain("https://example.com/img.png"); + }); + + test("converts card with section containing nested children", () => { + const body = convertPostableMessageToCommentBody({ + card: { + type: "card", + children: [ + { + type: "section", + children: [ + { type: "text", content: "Section text" }, + { type: "link", label: "Link", url: "https://example.com" }, + ], + }, + ], + }, + }); + const textJoined = body.content + .flatMap((p) => p.children.map((c) => ("text" in c ? c.text : ""))) + .join(""); + expect(textJoined).toContain("Section text"); + }); + + test("converts card with table", () => { + const body = convertPostableMessageToCommentBody({ + card: { + type: "card", + children: [ + { + type: "table", + headers: ["Name", "Age"], + rows: [ + ["Alice", "30"], + ["Bob", "25"], + ], + }, + ], + }, + }); + const textJoined = body.content + .flatMap((p) => p.children.map((c) => ("text" in c ? c.text : ""))) + .join(""); + expect(textJoined).toContain("Name"); + expect(textJoined).toContain("Alice"); + expect(textJoined).toContain("Bob"); + }); + + test("converts card with only subtitle (no title)", () => { + const body = convertPostableMessageToCommentBody({ + card: { + type: "card", + subtitle: "Just a subtitle", + children: [], + }, + }); + const textJoined = body.content + .flatMap((p) => p.children.map((c) => ("text" in c ? c.text : ""))) + .join(""); + expect(textJoined).toContain("Just a subtitle"); + }); + + test("flattens blockquotes into paragraphs", () => { + const body = convertPostableMessageToCommentBody("> quoted text"); + const textJoined = body.content + .flatMap((p) => p.children.map((c) => ("text" in c ? c.text : ""))) + .join(""); + expect(textJoined).toContain("quoted text"); + }); + + test("flattens lists into paragraphs", () => { + const body = convertPostableMessageToCommentBody("- item one\n- item two"); + const textJoined = body.content + .flatMap((p) => p.children.map((c) => ("text" in c ? c.text : ""))) + .join(""); + expect(textJoined).toContain("item one"); + expect(textJoined).toContain("item two"); + }); + + test("converts HTML nodes to text paragraphs", () => { + const body = convertPostableMessageToCommentBody({ + ast: { + type: "root", + children: [{ type: "html", value: "bold" }], + }, + }); + const textJoined = body.content + .flatMap((p) => p.children.map((c) => ("text" in c ? c.text : ""))) + .join(""); + expect(textJoined).toContain("bold"); + }); + + test("drops unsupported node types (break, thematicBreak, etc.)", () => { + const body = convertPostableMessageToCommentBody({ + ast: { + type: "root", + children: [ + { type: "thematicBreak" }, + { + type: "paragraph", + children: [{ type: "text", value: "after break" }], + }, + ], + }, + }); + const textJoined = body.content + .flatMap((p) => p.children.map((c) => ("text" in c ? c.text : ""))) + .join(""); + expect(textJoined).toContain("after break"); + expect(body.content).toHaveLength(1); + }); +}); diff --git a/packages/liveblocks-chat-sdk-adapter/src/adapter.ts b/packages/liveblocks-chat-sdk-adapter/src/adapter.ts new file mode 100644 index 00000000000..ee55560edf4 --- /dev/null +++ b/packages/liveblocks-chat-sdk-adapter/src/adapter.ts @@ -0,0 +1,1337 @@ +import { + type Awaitable, + type BaseGroupInfo, + type BaseUserMeta, + type CommentBody, + type CommentBodyInlineElement, + type CommentBodyParagraph, + getMentionsFromCommentBody, + isCommentBodyLink, + isCommentBodyMention, + isCommentBodyText, + type ResolveGroupsInfoArgs, + type ResolveUsersArgs, +} from "@liveblocks/core"; +import { + type CommentData, + Liveblocks, + LiveblocksError, + type WebhookEvent, + WebhookHandler, +} from "@liveblocks/node"; +import { + type Adapter, + type AdapterPostableMessage, + type Attachment, + type CardChild, + type CardElement, + type ChannelInfo, + type ChatInstance, + ConsoleLogger, + defaultEmojiResolver, + type EmojiValue, + type FetchOptions, + type FetchResult, + type FormattedContent, + type Link, + type ListThreadsOptions, + type ListThreadsResult, + type Logger, + Message, + type Paragraph, + parseMarkdown, + type RawMessage, + type Root, + tableToAscii, + type Text, + type ThreadInfo, + toPlainText, + type WebhookOptions, + type Delete, + type Emphasis, + type InlineCode, + type Strong, +} from "chat"; + +type PhrasingContent = Paragraph["children"][number]; + +const ADAPTER_PREFIX = "liveblocks"; +export class LiveblocksAdapter< + U extends BaseUserMeta = BaseUserMeta, + DGI extends BaseGroupInfo = BaseGroupInfo, +> implements Adapter<{ roomId: string; threadId: string }, CommentData> +{ + readonly name = "liveblocks"; + readonly userName: string; + readonly #client: Liveblocks; + readonly #webhookHandler: WebhookHandler; + readonly #resolveUsers: + | (( + args: ResolveUsersArgs + ) => Awaitable<(U["info"] | undefined)[] | undefined>) + | undefined; + readonly #resolveGroupsInfo: + | (( + args: ResolveGroupsInfoArgs + ) => Awaitable<(DGI | undefined)[] | undefined>) + | undefined; + readonly #logger: Logger; + readonly #botUserId: string; + #chat: ChatInstance | null = null; + constructor(config: LiveblocksAdapterConfig) { + this.#client = new Liveblocks({ secret: config.apiKey }); + this.#webhookHandler = new WebhookHandler(config.webhookSecret); + this.#resolveUsers = config.resolveUsers; + this.#resolveGroupsInfo = config.resolveGroupsInfo; + this.#botUserId = config.botUserId; + this.userName = config.botUserName ?? "liveblocks-bot"; + this.#logger = + config.logger ?? new ConsoleLogger("info").child(ADAPTER_PREFIX); + } + + async initialize(chat: ChatInstance): Promise { + this.#chat = chat; + } + + async handleWebhook( + request: Request, + options?: WebhookOptions + ): Promise { + let event: WebhookEvent; + try { + event = this.#webhookHandler.verifyRequest({ + headers: request.headers, + rawBody: await request.text(), + }); + } catch (error) { + this.#logger.error("Failed to verify webhook request", { error }); + return new Response("Invalid webhook request", { status: 401 }); + } + + if (event.type === "commentCreated") { + const threadId = this.encodeThreadId({ + roomId: event.data.roomId, + threadId: event.data.threadId, + }); + + const comment = await this.#client.getComment({ + roomId: event.data.roomId, + threadId: event.data.threadId, + commentId: event.data.commentId, + }); + if (comment.deletedAt !== undefined) { + return new Response(null, { status: 200 }); + } + + this.#chat?.processMessage( + this, + threadId, + () => this.#convertLiveblocksCommentDataToChatMessage(comment), + options + ); + } else if ( + event.type === "commentReactionAdded" || + event.type === "commentReactionRemoved" + ) { + const threadId = this.encodeThreadId({ + roomId: event.data.roomId, + threadId: event.data.threadId, + }); + + const userId = + event.type === "commentReactionAdded" + ? event.data.addedBy + : event.data.removedBy; + + const resolvedUsers = await this.#resolveUsers?.({ userIds: [userId] }); + const user = resolvedUsers?.[0]; + + this.#chat?.processReaction( + { + added: event.type === "commentReactionAdded", + emoji: defaultEmojiResolver.fromGChat(event.data.emoji), + rawEmoji: event.data.emoji, + messageId: event.data.commentId, + threadId, + user: { + userId, + userName: user?.name ?? userId, + fullName: user?.name ?? userId, + // This assumes that the current bot is the only bot in the thread; if we want + // to support multiple bots, we need to add a way to determine the bot's user id. + isBot: userId === this.#botUserId, + isMe: userId === this.#botUserId, + }, + raw: event.data, + adapter: this, + }, + options + ); + } + + return new Response(null, { status: 200 }); + } + + async postMessage( + threadId: string, + message: AdapterPostableMessage + ): Promise> { + const { roomId, threadId: threadId_liveblocks } = + this.decodeThreadId(threadId); + const comment = await this.#client.createComment({ + roomId, + threadId: threadId_liveblocks, + data: { + userId: this.#botUserId, + body: convertPostableMessageToCommentBody(message), + }, + }); + return { id: comment.id, threadId, raw: comment }; + } + + async editMessage( + threadId: string, + messageId: string, + message: AdapterPostableMessage + ): Promise> { + const { roomId, threadId: threadId_liveblocks } = + this.decodeThreadId(threadId); + + const comment = await this.#client.editComment({ + roomId, + threadId: threadId_liveblocks, + commentId: messageId, + data: { + body: convertPostableMessageToCommentBody(message), + }, + }); + + return { id: comment.id, threadId, raw: comment }; + } + + async deleteMessage(threadId: string, messageId: string): Promise { + const { roomId, threadId: threadId_liveblocks } = + this.decodeThreadId(threadId); + await this.#client.deleteComment({ + roomId, + threadId: threadId_liveblocks, + commentId: messageId, + }); + } + + async addReaction( + threadId: string, + messageId: string, + emoji: EmojiValue | string + ): Promise { + const { roomId, threadId: threadId_liveblocks } = + this.decodeThreadId(threadId); + + await this.#client.addCommentReaction({ + roomId, + threadId: threadId_liveblocks, + commentId: messageId, + data: { + // Liveblocks expects unicode emoji; 'toGChat' converts normalized names (e.g. 'thumbs_up') to unicode ('👍'). + // Unknown normalized names (e.g. 'custom_emoji') will fail Liveblocks validation since they are not valid unicode emoji. + emoji: defaultEmojiResolver.toGChat(emoji), + userId: this.#botUserId, + }, + }); + } + + async removeReaction( + threadId: string, + messageId: string, + emoji: EmojiValue | string + ): Promise { + const { roomId, threadId: threadId_liveblocks } = + this.decodeThreadId(threadId); + + await this.#client.removeCommentReaction({ + roomId, + threadId: threadId_liveblocks, + commentId: messageId, + data: { + emoji: defaultEmojiResolver.toGChat(emoji), + userId: this.#botUserId, + }, + }); + } + + async fetchMessages( + threadId: string, + options?: FetchOptions + ): Promise> { + const { roomId, threadId: threadId_liveblocks } = + this.decodeThreadId(threadId); + + const thread = await this.#client.getThread({ + roomId, + threadId: threadId_liveblocks, + }); + + const comments = thread.comments.filter( + (comment) => comment.deletedAt === undefined + ); + + const direction = options?.direction ?? "backward"; + const limit = options?.limit; + const startingAfter = options?.cursor; + + // The 'Get thread' API returns all comments in the thread in chronological order, + // so we perform in-memory pagination to match Chat SDK's expected behavior. + const sliced = slicePageByCreatedAt(comments, { + direction: direction === "forward" ? "ascending" : "descending", + limit, + startingAfter, + }); + + const messages = await Promise.all( + sliced.data.map((comment) => + this.#convertLiveblocksCommentDataToChatMessage(comment) + ) + ); + + return { messages, nextCursor: sliced.nextCursor }; + } + + async fetchThread(threadId: string): Promise { + const { roomId, threadId: threadId_liveblocks } = + this.decodeThreadId(threadId); + + const thread = await this.#client.getThread({ + roomId, + threadId: threadId_liveblocks, + }); + + return { + id: threadId, + channelId: `${ADAPTER_PREFIX}:${roomId}`, + metadata: { + resolved: thread.resolved, + ...thread.metadata, + }, + channelName: thread.roomId, + isDM: false, + }; + } + + async fetchMessage( + threadId: string, + messageId: string + ): Promise | null> { + try { + const { roomId, threadId: threadId_liveblocks } = + this.decodeThreadId(threadId); + + const comment = await this.#client.getComment({ + roomId, + threadId: threadId_liveblocks, + commentId: messageId, + }); + if (comment.deletedAt !== undefined) { + return null; + } + return this.#convertLiveblocksCommentDataToChatMessage(comment); + } catch (error) { + if (error instanceof LiveblocksError && error.status === 404) { + return null; + } + throw error; + } + } + + async listThreads( + channelId: string, + options?: ListThreadsOptions + ): Promise> { + const roomId = getRoomIdFromChannelId(channelId); + const { data } = await this.#client.getThreads({ roomId }); + const threads = data + .map((thread) => { + const nonDeletedComments = thread.comments + .filter((comment) => comment.deletedAt === undefined) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + + const firstNonDeletedComment = nonDeletedComments[0]; + if (firstNonDeletedComment === undefined) return null; + + return { + id: this.encodeThreadId({ + roomId, + threadId: thread.id, + }), + updatedAt: thread.updatedAt, + numOfComments: nonDeletedComments.length, + firstComment: firstNonDeletedComment, + }; + }) + .filter((thread) => thread !== null); + + const limit = options?.limit; + const startingAfter = options?.cursor; + + // The 'Get threads' API returns all threads in the room in chronological order, + // so we perform in-memory pagination to match Chat SDK's expected behavior. + const sliced = slicePageByUpdatedAt(threads, { + limit, + startingAfter, + }); + + return { + threads: await Promise.all( + sliced.data.map(async (thread) => ({ + id: thread.id, + rootMessage: await this.#convertLiveblocksCommentDataToChatMessage( + thread.firstComment + ), + lastReplyAt: thread.updatedAt, + replyCount: thread.numOfComments - 1, + })) + ), + nextCursor: sliced.nextCursor, + }; + } + + async fetchChannelInfo(channelId: string): Promise { + const room = await this.#client.getRoom(getRoomIdFromChannelId(channelId)); + return { + id: room.id, + name: room.id, + isDM: false, + metadata: {}, + }; + } + + async fetchChannelMessages( + channelId: string, + options?: FetchOptions + ): Promise> { + const roomId = getRoomIdFromChannelId(channelId); + const { data } = await this.#client.getThreads({ roomId }); + + const comments = data + .map((thread) => { + const nonDeletedComments = thread.comments + .filter((comment) => comment.deletedAt === undefined) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + const firstNonDeletedComment = nonDeletedComments[0]; + if (firstNonDeletedComment !== undefined) { + return firstNonDeletedComment; + } + return null; + }) + .filter((comment) => comment !== null); + + const direction = options?.direction ?? "backward"; + const limit = options?.limit; + const startingAfter = options?.cursor; + + // The 'Get threads' API returns all threads in the room (sorted by creation date in ascending order) + // and each thread contains all comments in the thread (sorted by creation date in ascending order), + // so we perform in-memory pagination to match Chat SDK's expected behavior. + const sliced = slicePageByCreatedAt(comments, { + direction: direction === "forward" ? "ascending" : "descending", + limit, + startingAfter, + }); + + const messages = await Promise.all( + sliced.data.map((comment) => + this.#convertLiveblocksCommentDataToChatMessage(comment) + ) + ); + + return { messages, nextCursor: sliced.nextCursor }; + } + + async postChannelMessage( + channelId: string, + message: AdapterPostableMessage + ): Promise> { + const roomId = getRoomIdFromChannelId(channelId); + const thread = await this.#client.createThread({ + roomId, + data: { + comment: { + userId: this.#botUserId, + body: convertPostableMessageToCommentBody(message), + }, + }, + }); + const firstComment = thread.comments[0]; + if (firstComment === undefined) { + throw new Error(`Failed to create thread in room ${channelId}`); + } + return { + id: firstComment.id, + threadId: this.encodeThreadId({ + roomId, + threadId: thread.id, + }), + raw: firstComment, + }; + } + + // This method isn't used by the Chat SDK, but it's required to implement the Adapter interface, + // so we will return a less rich message here. We have a separate (asynchronous) method for converting a comment to a message. + parseMessage(data: CommentData): Message { + return new Message({ + id: data.id, + threadId: this.encodeThreadId({ + roomId: data.roomId, + threadId: data.threadId, + }), + raw: data, + formatted: { type: "root", children: [] }, + text: "", + author: { + userId: data.userId, + userName: data.userId, + fullName: data.userId, + isBot: data.userId === this.#botUserId, + isMe: data.userId === this.#botUserId, + }, + metadata: { + dateSent: data.createdAt, + edited: !!data.editedAt, + editedAt: data.editedAt, + }, + attachments: data.attachments.map((att) => + this.#createAttachment(data.roomId, att) + ), + }); + } + + renderFormatted(content: FormattedContent): string { + // Liveblocks comments do not support markdown as input, so we convert the content to plain text. + return toPlainText(content); + } + + channelIdFromThreadId(threadId: string): string { + const { roomId } = this.decodeThreadId(threadId); + return `${ADAPTER_PREFIX}:${roomId}`; + } + + /** + * This method is a no-op as typing indicators are not supported by Liveblocks Comments. + */ + startTyping(_threadId: string, _status?: string): Promise { + return Promise.resolve(); + } + + #createAttachment( + roomId: string, + attachment: CommentData["attachments"][number] + ): Attachment { + const client = this.#client; + return { + type: getAttachmentType(attachment.mimeType), + name: attachment.name, + mimeType: attachment.mimeType, + size: attachment.size, + fetchData: async () => { + const { url } = await client.getAttachment({ + roomId, + attachmentId: attachment.id, + }); + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to fetch attachment "${attachment.name}": ${response.status} ${response.statusText}` + ); + } + return Buffer.from(await response.arrayBuffer()); + }, + }; + } + + /** + * Encodes a Liveblocks room ID and thread ID into a single thread ID string. + * + * Format: `liveblocks:{roomId}:{threadId}` + * + * **Note**: Room IDs may contain colons (':'), which are preserved during encoding/decoding. + * However, Liveblocks thread IDs must not contain colons as the last colon is used as the delimiter when decoding. + */ + encodeThreadId(data: { roomId: string; threadId: string }): string { + return `${ADAPTER_PREFIX}:${data.roomId}:${data.threadId}`; + } + + /** + * Decodes an encoded thread ID string back into its room ID and thread ID components. + * + * @throws {Error} If the thread ID format is invalid + */ + decodeThreadId(threadId: string): { roomId: string; threadId: string } { + const parts = threadId.split(":"); + if (parts.length < 3 || parts[0] !== ADAPTER_PREFIX) { + throw new Error( + `Invalid thread ID: ${threadId}. Expected format: liveblocks:{roomId}:{threadId}` + ); + } + return { + roomId: parts.slice(1, -1).join(":"), + threadId: parts[parts.length - 1]!, + }; + } + + async #convertLiveblocksCommentDataToChatMessage( + comment: Extract + ): Promise> { + const mentions = getMentionsFromCommentBody(comment.body); + const userIds = new Set([comment.userId]); // Initialize with the author's user id + const groupIds = new Set(); + for (const mention of mentions) { + if (mention.kind === "user") { + userIds.add(mention.id); + } else if (mention.kind === "group") { + groupIds.add(mention.id); + } + } + + const [users, groups] = await Promise.all([ + this.#resolveUsers + ? this.#resolveUsers({ userIds: Array.from(userIds) }) + : undefined, + this.#resolveGroupsInfo && groupIds.size > 0 + ? this.#resolveGroupsInfo({ groupIds: Array.from(groupIds) }) + : undefined, + ]); + + const resolvedUsers = new Map(); + if (users !== undefined) { + for (const [index, userId] of Array.from(userIds).entries()) { + const user = users[index]; + if (user === undefined) continue; + resolvedUsers.set(userId, user); + } + } + const resolvedGroups = new Map(); + if (groups !== undefined) { + for (const [index, groupId] of Array.from(groupIds).entries()) { + const group = groups[index]; + if (group === undefined) continue; + resolvedGroups.set(groupId, group); + } + } + + const links = new Set(); + + const nodes: Paragraph[] = comment.body.content.map((block) => { + const children: Array< + Text | Link | Emphasis | Strong | InlineCode | Delete + > = []; + for (const inline of block.children) { + if (isCommentBodyMention(inline)) { + if (inline.kind === "user") { + children.push({ + type: "text", + value: resolvedUsers.get(inline.id)?.name ?? inline.id, + }); + } else { + children.push({ + type: "text", + value: resolvedGroups.get(inline.id)?.name ?? inline.id, + }); + } + } else if (isCommentBodyLink(inline)) { + links.add(inline.url); + children.push({ + type: "link", + children: [{ type: "text", value: inline.text ?? "" }], + url: inline.url, + }); + } else if (isCommentBodyText(inline)) { + if (inline.code) { + children.push({ + type: "inlineCode", + value: inline.text, + }); + } else { + // Build nested structure for combined styles (bold, italic, strikethrough) + let node: Text | Emphasis | Strong | Delete = { + type: "text", + value: inline.text, + }; + + if (inline.strikethrough) { + node = { type: "delete", children: [node] }; + } + if (inline.italic) { + node = { type: "emphasis", children: [node] }; + } + if (inline.bold) { + node = { type: "strong", children: [node] }; + } + + children.push(node); + } + } + } + return { type: "paragraph", children }; + }); + + const text = comment.body.content.reduce((acc, block) => { + return ( + acc + + block.children.reduce((acc, inline) => { + if (isCommentBodyMention(inline)) { + if (inline.kind === "user") { + return acc + (resolvedUsers.get(inline.id)?.name ?? inline.id); + } else { + return acc + (resolvedGroups.get(inline.id)?.name ?? inline.id); + } + } else if (isCommentBodyLink(inline)) { + if (inline.text) { + return acc + inline.text; + } else { + return acc + inline.url; + } + } else if (isCommentBodyText(inline)) { + return acc + inline.text; + } + return acc; + }, "") + ); + }, ""); + + return new Message({ + id: comment.id, + threadId: this.encodeThreadId({ + roomId: comment.roomId, + threadId: comment.threadId, + }), + raw: comment, + formatted: { type: "root", children: nodes }, + text, + isMention: resolvedUsers.has(this.#botUserId), + links: Array.from(links.values()).map((url) => ({ url })), + author: { + userId: comment.userId, + userName: resolvedUsers.get(comment.userId)?.name ?? comment.userId, + fullName: resolvedUsers.get(comment.userId)?.name ?? comment.userId, + // This assumes that the current bot is the only bot in the thread; if we want + // to support multiple bots, we need to add a way to determine the bot's user id. + isBot: comment.userId === this.#botUserId, + isMe: comment.userId === this.#botUserId, + }, + metadata: { + dateSent: comment.createdAt, + edited: comment.editedAt !== undefined, + editedAt: comment.editedAt, + }, + attachments: comment.attachments.map((attachment) => + this.#createAttachment(comment.roomId, attachment) + ), + }); + } +} + +/** + * Parses a Chat SDK channel id into the Liveblocks room id for REST API calls. + * + * @throws {Error} If `channelId` is missing the `liveblocks:` prefix or has an empty room segment. + */ +export function getRoomIdFromChannelId(channelId: string): string { + const prefix = `${ADAPTER_PREFIX}:`; + if (!channelId.startsWith(prefix)) { + throw new Error( + `Invalid channel ID: "${channelId}". Expected format: ${prefix}{roomId}` + ); + } + const roomId = channelId.slice(prefix.length); + if (roomId === "") { + throw new Error( + `Invalid channel ID: "${channelId}". Expected format: ${prefix}{roomId}` + ); + } + return roomId; +} + +export function convertPostableMessageToCommentBody( + message: AdapterPostableMessage +): CommentBody { + if (typeof message === "string") { + return convertChatRootElementToCommentBodyRootElement( + parseMarkdown(message) + ); + } else if ("raw" in message) { + return convertChatRootElementToCommentBodyRootElement( + parseMarkdown(message.raw) + ); + } else if ("markdown" in message) { + return convertChatRootElementToCommentBodyRootElement( + parseMarkdown(message.markdown) + ); + } else if ("ast" in message) { + return convertChatRootElementToCommentBodyRootElement(message.ast); + } else if ("card" in message) { + // Liveblocks comments do not support cards and card elements, so we convert the message to markdown and then to a comment body + return convertChatRootElementToCommentBodyRootElement( + parseMarkdown( + message.fallbackText ?? convertCardToMarkdownString(message.card) + ) + ); + } else if ("type" in message && message.type === "card") { + return convertChatRootElementToCommentBodyRootElement( + parseMarkdown(convertCardToMarkdownString(message)) + ); + } else { + console.error(`Unexpected message type: ${JSON.stringify(message)}`); + return { + version: 1, + content: [], + }; + } +} + +function convertCardToMarkdownString(card: CardElement): string { + const parts: string[] = []; + if (card.title) { + parts.push(`**${card.title}**`); + } + if (card.subtitle) { + parts.push(card.subtitle); + } + for (const child of card.children) { + parts.push(convertCardChildToMarkdownString(child)); + } + return parts.join("\n"); +} + +function convertCardChildToMarkdownString(child: CardChild): string { + switch (child.type) { + case "text": + return child.content; + case "fields": + return child.children + .map((field) => `**${field.label}**: ${field.value}`) + .join("\n"); + case "actions": + // Actions are interactive-only — exclude from fallback text. See: https://docs.slack.dev/reference/methods/chat.postMessage + return ""; + case "table": { + let markdown = "|"; + for (const header of child.headers) { + markdown += ` ${header} |`; + } + markdown += "\n|"; + for (const _ of child.headers) { + markdown += "--- |"; + } + markdown += "\n"; + for (const row of child.rows) { + markdown += "|"; + for (const cell of row) { + markdown += ` ${cell} |`; + } + markdown += "\n"; + } + return markdown; + } + case "section": + return child.children + .map((c) => convertCardChildToMarkdownString(c)) + .filter(Boolean) + .join("\n"); + case "link": + return `[${child.label}](${child.url})`; + case "divider": + return "---"; + case "image": + return `![${child.alt ?? ""}](${child.url})`; + default: + return ""; + } +} + +function convertChatRootElementToCommentBodyRootElement( + root: Root +): CommentBody { + return { + version: 1, + content: root.children.flatMap((child) => + convertChatBlockElementToCommentBodyBlockElement(child) + ), + }; +} + +function convertChatBlockElementToCommentBodyBlockElement( + node: Root["children"][number] +): CommentBodyParagraph | CommentBodyParagraph[] { + switch (node.type) { + case "paragraph": { + const children: CommentBodyInlineElement[] = []; + for (const child of node.children) { + children.push( + convertChatInlineElementToCommentBodyInlineElement(child) + ); + } + return { + type: "paragraph", + children, + }; + } + case "blockquote": { + return node.children.flatMap((child) => { + return convertChatBlockElementToCommentBodyBlockElement(child); + }); + } + case "list": { + return node.children.flatMap((child) => { + return convertChatBlockElementToCommentBodyBlockElement(child); + }); + } + case "listItem": { + return node.children.flatMap((child) => { + return convertChatBlockElementToCommentBodyBlockElement(child); + }); + } + case "heading": { + // Render headings as paragraphs as Liveblocks comments do not support headings + return convertChatBlockElementToCommentBodyBlockElement({ + type: "paragraph", + children: node.children, + }); + } + case "code": { + // Render code blocks as paragraphs as Liveblocks comments do not support code blocks + return convertChatBlockElementToCommentBodyBlockElement({ + type: "paragraph", + children: [{ type: "text", value: node.value }], + }); + } + case "html": { + // Render HTML as paragraphs as Liveblocks comments do not support HTML + return convertChatBlockElementToCommentBodyBlockElement({ + type: "paragraph", + children: [{ type: "text", value: node.value }], + }); + } + case "table": { + // Convert table to ASCII table string and render as paragraph as Liveblocks comments do not support tables + return { + type: "paragraph", + children: [{ text: tableToAscii(node) }], + }; + } + case "link": + case "image": + case "strong": + case "emphasis": + case "inlineCode": + case "delete": + case "text": { + return { + type: "paragraph", + children: [convertChatInlineElementToCommentBodyInlineElement(node)], + }; + } + case "break": + case "thematicBreak": + case "definition": + case "tableCell": + case "tableRow": + case "yaml": + case "footnoteDefinition": + case "footnoteReference": + case "imageReference": + case "linkReference": + default: { + return []; + } + } +} + +function convertChatInlineElementToCommentBodyInlineElement( + inline: PhrasingContent +): CommentBodyInlineElement { + switch (inline.type) { + case "text": + return { text: inline.value }; + case "link": + return { + type: "link", + url: inline.url, + // Link elements in Liveblocks comments are considered leaf nodes (i.e. they do not have inline elements as children), + // so we convert the children to plain text to match the expected format + text: inline.children + .map((child) => { + return convertChatInlineElementToPlainText(child); + }) + .join(""), + }; + case "image": + return { text: inline.url }; + case "emphasis": + return { + // Emphasis elements in Liveblocks comments are considered leaf nodes (i.e. they do not have inline elements as children), + // so we convert the children to plain text to match the expected format + text: inline.children + .map((child) => { + return convertChatInlineElementToPlainText(child); + }) + .join(""), + italic: true, + }; + case "strong": + return { + text: inline.children + .map((child) => { + return convertChatInlineElementToPlainText(child); + }) + .join(""), + bold: true, + }; + case "delete": + return { + text: inline.children + .map((child) => { + return convertChatInlineElementToPlainText(child); + }) + .join(""), + strikethrough: true, + }; + case "inlineCode": + return { + text: inline.value, + code: true, + }; + case "html": + return { text: inline.value }; + case "break": + case "linkReference": + case "imageReference": + case "footnoteReference": + default: { + return { text: "" }; + } + } +} + +function convertChatInlineElementToPlainText(inline: PhrasingContent): string { + switch (inline.type) { + case "text": + return inline.value; + case "link": + return inline.children + .map((child) => { + return convertChatInlineElementToPlainText(child); + }) + .join(""); + case "image": + return inline.url; + case "emphasis": + return inline.children + .map((child) => { + return convertChatInlineElementToPlainText(child); + }) + .join(""); + case "strong": + return inline.children + .map((child) => { + return convertChatInlineElementToPlainText(child); + }) + .join(""); + case "delete": + return inline.children + .map((child) => { + return convertChatInlineElementToPlainText(child); + }) + .join(""); + case "inlineCode": + return inline.value; + case "html": + return inline.value; + case "break": + case "linkReference": + case "imageReference": + case "footnoteReference": + default: + return ""; + } +} + +/** + * Encode a pagination cursor using the format `base64url( [["id", ], ["createdAt", ]] )` + */ +export function encodePaginationCursorByCreatedAt( + id: string, + createdAt: Date +): string { + return base64UrlEncode( + JSON.stringify([ + ["id", id], + ["createdAt", createdAt.getTime()], + ]) + ); +} + +export function decodePaginationCursorByCreatedAt(cursor: string): { + id: string; + createdAt: Date; +} { + try { + const parsed = JSON.parse(base64UrlDecode(cursor)); + if ( + !Array.isArray(parsed) || + parsed.length !== 2 || + parsed[0]?.[0] !== "id" || + parsed[1]?.[0] !== "createdAt" + ) { + throw new Error("Invalid cursor structure"); + } + return { + id: parsed[0][1] as string, + createdAt: new Date(parsed[1][1] as number), + }; + } catch { + throw new Error(`Invalid pagination cursor: ${cursor}`); + } +} + +export function encodePaginationCursorByUpdatedAt( + id: string, + updatedAt: Date +): string { + return base64UrlEncode( + JSON.stringify([ + ["id", id], + ["updatedAt", updatedAt.getTime()], + ]) + ); +} + +export function decodePaginationCursorByUpdatedAt(cursor: string): { + id: string; + updatedAt: Date; +} { + try { + const parsed = JSON.parse(base64UrlDecode(cursor)); + if ( + !Array.isArray(parsed) || + parsed.length !== 2 || + parsed[0]?.[0] !== "id" || + parsed[1]?.[0] !== "updatedAt" + ) { + throw new Error("Invalid cursor structure"); + } + return { + id: parsed[0][1] as string, + updatedAt: new Date(parsed[1][1] as number), + }; + } catch { + throw new Error(`Invalid pagination cursor: ${cursor}`); + } +} + +function base64UrlEncode(str: string): string { + const bytes = new TextEncoder().encode(str); + const binary = String.fromCharCode(...bytes); + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function base64UrlDecode(str: string): string { + let s = str.replace(/-/g, "+").replace(/_/g, "/"); + while (s.length % 4) s += "="; + const binary = atob(s); + const bytes = Uint8Array.from(binary, (c) => c.codePointAt(0)!); + return new TextDecoder().decode(bytes); +} + +/** + * Slice a page from an in-memory list sorted by `createdAt` (oldest first). + * + * Cursors use the same `[["id", ...], ["createdAt", ...]]` format as the + * Liveblocks REST API so that they will be forward-compatible if the backend + * adds server-side pagination. + * + * The cursor is always built from the boundary item of the current page and + * means "start after this item" in the current traversal direction — matching + * the `startingAfter` semantics used throughout the backend. + * + * When no `limit` is provided, all matching items are returned (preserving the + * pre-pagination behaviour of returning the full list). + */ +function slicePageByCreatedAt( + data: T[], + options: { + /** + * The direction to slice the page in. + * - "ascending": Slice the page from the oldest item to the newest item. + * - "descending": Slice the page from the newest item to the oldest item. + */ + direction: "ascending" | "descending"; + limit?: number; + startingAfter?: string; + } +): { data: T[]; nextCursor: string | undefined } { + const { direction, limit, startingAfter } = options; + + // Sort data by 'createdAt' (oldest first) and use 'id' as a tie-breaker + data = data.slice().sort((a, b) => { + if (a.createdAt.getTime() !== b.createdAt.getTime()) { + return a.createdAt.getTime() - b.createdAt.getTime(); + } + return b.id.localeCompare(a.id); + }); + + let startIndex: number; + let endIndex: number; + + if (direction === "descending") { + if (startingAfter) { + const cursor = decodePaginationCursorByCreatedAt(startingAfter); + // Find the cursor's position in sort order, then take everything before it. + endIndex = data.findIndex( + (c) => + c.createdAt.getTime() > cursor.createdAt.getTime() || + (c.createdAt.getTime() === cursor.createdAt.getTime() && + c.id <= cursor.id) + ); + if (endIndex === -1) { + endIndex = data.length; + } + } else { + endIndex = data.length; + } + startIndex = limit !== undefined ? Math.max(0, endIndex - limit) : 0; + } else { + if (startingAfter) { + const cursor = decodePaginationCursorByCreatedAt(startingAfter); + // Find the first item strictly after the cursor in sort order. + startIndex = data.findIndex( + (c) => + c.createdAt.getTime() > cursor.createdAt.getTime() || + (c.createdAt.getTime() === cursor.createdAt.getTime() && + c.id < cursor.id) + ); + if (startIndex === -1) { + return { data: [], nextCursor: undefined }; + } + } else { + startIndex = 0; + } + endIndex = + limit !== undefined + ? Math.min(data.length, startIndex + limit) + : data.length; + } + + const page = data.slice(startIndex, endIndex); + + if (page.length === 0) { + return { data: [], nextCursor: undefined }; + } + + let nextCursor: string | undefined; + if (direction === "descending") { + nextCursor = + startIndex > 0 + ? encodePaginationCursorByCreatedAt(page[0]!.id, page[0]!.createdAt) + : undefined; + } else { + nextCursor = + endIndex < data.length + ? encodePaginationCursorByCreatedAt( + page[page.length - 1]!.id, + page[page.length - 1]!.createdAt + ) + : undefined; + } + + return { data: page, nextCursor }; +} + +/** + * Same as {@link slicePageByCreatedAt} (descending direction only) but sorts and + * paginates on `updatedAt`. Thread listing does not expose forward pagination. + */ +function slicePageByUpdatedAt( + data: T[], + options: { + limit?: number; + startingAfter?: string; + } +): { data: T[]; nextCursor: string | undefined } { + const { limit, startingAfter } = options; + + // Sort data by 'updatedAt' (oldest first) and use 'id' as a tie-breaker + data = data.slice().sort((a, b) => { + if (a.updatedAt.getTime() !== b.updatedAt.getTime()) { + return a.updatedAt.getTime() - b.updatedAt.getTime(); + } + return b.id.localeCompare(a.id); + }); + + let endIndex: number; + if (startingAfter) { + const cursor = decodePaginationCursorByUpdatedAt(startingAfter); + // Find the cursor's position in sort order, then take everything before it. + endIndex = data.findIndex( + (c) => + c.updatedAt.getTime() > cursor.updatedAt.getTime() || + (c.updatedAt.getTime() === cursor.updatedAt.getTime() && + c.id <= cursor.id) + ); + if (endIndex === -1) { + endIndex = data.length; + } + } else { + endIndex = data.length; + } + const startIndex = limit !== undefined ? Math.max(0, endIndex - limit) : 0; + + const page = data.slice(startIndex, endIndex); + + if (page.length === 0) { + return { data: [], nextCursor: undefined }; + } + + const nextCursor = + startIndex > 0 + ? encodePaginationCursorByUpdatedAt(page[0]!.id, page[0]!.updatedAt) + : undefined; + + return { data: page, nextCursor }; +} + +function getAttachmentType(mimeType: string): Attachment["type"] { + if (mimeType.startsWith("image/")) { + return "image"; + } else if (mimeType.startsWith("video/")) { + return "video"; + } else if (mimeType.startsWith("audio/")) { + return "audio"; + } + return "file"; +} + +export interface LiveblocksAdapterConfig< + U extends BaseUserMeta, + DGI extends BaseGroupInfo, +> { + apiKey: string; + webhookSecret: string; + resolveUsers?: ( + args: ResolveUsersArgs + ) => Awaitable<(U["info"] | undefined)[] | undefined>; + resolveGroupsInfo?: ( + args: ResolveGroupsInfoArgs + ) => Awaitable<(DGI | undefined)[] | undefined>; + botUserId: string; + botUserName?: string; + logger?: Logger; +} + +export function createLiveblocksAdapter< + U extends BaseUserMeta = BaseUserMeta, + DGI extends BaseGroupInfo = BaseGroupInfo, +>(config: LiveblocksAdapterConfig): LiveblocksAdapter { + return new LiveblocksAdapter(config); +} diff --git a/packages/liveblocks-chat-sdk-adapter/src/index.ts b/packages/liveblocks-chat-sdk-adapter/src/index.ts new file mode 100644 index 00000000000..5d700624fa2 --- /dev/null +++ b/packages/liveblocks-chat-sdk-adapter/src/index.ts @@ -0,0 +1,5 @@ +export { + createLiveblocksAdapter, + type LiveblocksAdapter, + type LiveblocksAdapterConfig, +} from "./adapter"; diff --git a/packages/liveblocks-chat-sdk-adapter/tsconfig.json b/packages/liveblocks-chat-sdk-adapter/tsconfig.json new file mode 100644 index 00000000000..63103a687e6 --- /dev/null +++ b/packages/liveblocks-chat-sdk-adapter/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../shared/tsconfig.common.json", + "include": ["src"] +} diff --git a/packages/liveblocks-chat-sdk-adapter/tsup.config.ts b/packages/liveblocks-chat-sdk-adapter/tsup.config.ts new file mode 100644 index 00000000000..e1a963ff0c7 --- /dev/null +++ b/packages/liveblocks-chat-sdk-adapter/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + dts: true, + splitting: true, + clean: true, + format: ["esm", "cjs"], + sourcemap: true, + external: ["chat"], +}); diff --git a/packages/liveblocks-chat-sdk-adapter/vitest.config.ts b/packages/liveblocks-chat-sdk-adapter/vitest.config.ts new file mode 100644 index 00000000000..449aeca8281 --- /dev/null +++ b/packages/liveblocks-chat-sdk-adapter/vitest.config.ts @@ -0,0 +1,7 @@ +import { defaultLiveblocksVitestConfig } from "@liveblocks/vitest-config"; + +export default defaultLiveblocksVitestConfig({ + test: { + environment: "node", + }, +}); diff --git a/packages/liveblocks-client/package.json b/packages/liveblocks-client/package.json index 0865f05d6b5..e3196303667 100644 --- a/packages/liveblocks-client/package.json +++ b/packages/liveblocks-client/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/client", - "version": "3.15.5", + "version": "3.16.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.", @@ -36,7 +36,7 @@ "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" }, "dependencies": { - "@liveblocks/core": "3.15.5" + "@liveblocks/core": "3.16.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", diff --git a/packages/liveblocks-client/src/index.ts b/packages/liveblocks-client/src/index.ts index 16279c40c88..0807466bbe4 100644 --- a/packages/liveblocks-client/src/index.ts +++ b/packages/liveblocks-client/src/index.ts @@ -30,6 +30,12 @@ export type { CommentMixedAttachment, CommentReaction, EnsureJson, + Feed, + FeedCreateMetadata, + FeedFetchMetadataFilter, + FeedRequestError, + FeedRequestFailedServerMsg, + FeedUpdateMetadata, History, HistoryVersion, Immutable, @@ -73,6 +79,7 @@ export type { export { createClient, defineAiTool, + FeedRequestErrorCode, getMentionsFromCommentBody, isNotificationChannelEnabled, LiveblocksError, diff --git a/packages/liveblocks-core/package.json b/packages/liveblocks-core/package.json index 578ae4d5bbf..635f2a7fe12 100644 --- a/packages/liveblocks-core/package.json +++ b/packages/liveblocks-core/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/core", - "version": "3.15.5", + "version": "3.16.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__/room.mockserver.test.ts b/packages/liveblocks-core/src/__tests__/room.mockserver.test.ts index f6d2e1e0db3..3c766be4c00 100644 --- a/packages/liveblocks-core/src/__tests__/room.mockserver.test.ts +++ b/packages/liveblocks-core/src/__tests__/room.mockserver.test.ts @@ -2476,4 +2476,204 @@ describe("room", () => { expect(room.isStorageReady()).toEqual(true); }); }); + + describe("feed mutations", () => { + test("addFeed client message includes requestId", async () => { + const { room, wss } = createTestableRoom({}, undefined, undefined, { + throttleDelay: 0, + }); + room.connect(); + await waitUntilStatus(room, "connected"); + const p = room.addFeed("my-feed", { metadata: { name: "x" } }); + await vi.waitFor(() => { + const found = wss.receivedMessagesRaw.some((raw) => { + const batch = JSON.parse(raw) as Array<{ type: number }>; + return batch.some((m) => m.type === ClientMsgCode.ADD_FEED); + }); + expect(found).toBe(true); + }); + const raw = wss.receivedMessagesRaw.find((r) => { + const batch = JSON.parse(r) as Array<{ type: number }>; + return batch.some((m) => m.type === ClientMsgCode.ADD_FEED); + })!; + const batch = JSON.parse(raw) as Array<{ + type: number; + requestId?: string; + feedId?: string; + }>; + const addFeedMsg = batch.find((m) => m.type === ClientMsgCode.ADD_FEED)!; + expect(addFeedMsg.requestId).toBeDefined(); + expect(addFeedMsg.feedId).toBe("my-feed"); + const now = Date.now(); + wss.last.send( + serverMessage({ + type: ServerMsgCode.FEEDS_ADDED, + feeds: [ + { + feedId: "my-feed", + metadata: {}, + createdAt: now, + updatedAt: now, + }, + ], + }) + ); + await p; + }); + + test("addFeed rejects when server sends FEED_REQUEST_FAILED", async () => { + const { room, wss } = createTestableRoom({}, undefined, undefined, { + throttleDelay: 0, + }); + room.connect(); + await waitUntilStatus(room, "connected"); + const p = room.addFeed("dup", {}); + await vi.waitFor(() => { + const found = wss.receivedMessagesRaw.some((raw) => { + const batch = JSON.parse(raw) as Array<{ type: number }>; + return batch.some((m) => m.type === ClientMsgCode.ADD_FEED); + }); + expect(found).toBe(true); + }); + const raw = wss.receivedMessagesRaw.find((r) => { + const batch = JSON.parse(r) as Array<{ type: number }>; + return batch.some((m) => m.type === ClientMsgCode.ADD_FEED); + })!; + const batch = JSON.parse(raw) as Array<{ + requestId: string; + type: number; + }>; + const requestId = batch.find( + (m) => m.type === ClientMsgCode.ADD_FEED + )!.requestId; + wss.last.send( + serverMessage({ + type: ServerMsgCode.FEED_REQUEST_FAILED, + requestId, + code: "FEED_ALREADY_EXISTS", + reason: "exists", + }) + ); + await expect(p).rejects.toMatchObject({ + name: "LiveblocksError", + context: { + type: "FEED_REQUEST_ERROR", + roomId: "room-id", + requestId, + code: "FEED_ALREADY_EXISTS", + reason: "exists", + }, + }); + }); + + test("fetchFeeds sends limit on first page and cursor on the next page", async () => { + const { room, wss } = createTestableRoom({}, undefined, undefined, { + throttleDelay: 0, + }); + room.connect(); + await waitUntilStatus(room, "connected"); + + const p1 = room.fetchFeeds({ limit: 1 }); + + await vi.waitFor(() => { + const found = wss.receivedMessagesRaw.some((raw) => { + const batch = JSON.parse(raw) as Array<{ type: number }>; + return batch.some((m) => m.type === ClientMsgCode.FETCH_FEEDS); + }); + expect(found).toBe(true); + }); + + const raw1 = wss.receivedMessagesRaw.find((r) => { + const batch = JSON.parse(r) as Array<{ type: number }>; + return batch.some((m) => m.type === ClientMsgCode.FETCH_FEEDS); + })!; + const batch1 = JSON.parse(raw1) as Array<{ + type: number; + requestId?: string; + limit?: number; + cursor?: string; + }>; + const fetchMsg1 = batch1.find( + (m) => m.type === ClientMsgCode.FETCH_FEEDS + )!; + expect(fetchMsg1.limit).toBe(1); + expect(fetchMsg1.cursor).toBeUndefined(); + + const requestId1 = fetchMsg1.requestId!; + const t1 = Date.now(); + wss.last.send( + serverMessage({ + type: ServerMsgCode.FEEDS_LIST, + requestId: requestId1, + feeds: [ + { + feedId: "feed-page-1", + metadata: {}, + createdAt: t1, + updatedAt: t1, + }, + ], + nextCursor: "cursor-2", + }) + ); + + const result1 = await p1; + expect(result1.feeds).toHaveLength(1); + expect(result1.feeds[0]?.feedId).toBe("feed-page-1"); + expect(result1.nextCursor).toBe("cursor-2"); + + const p2 = room.fetchFeeds({ cursor: "cursor-2", limit: 1 }); + + await vi.waitFor(() => { + const rawsWithFetch = wss.receivedMessagesRaw.filter((raw) => { + const batch = JSON.parse(raw) as Array<{ type: number }>; + return batch.some((m) => m.type === ClientMsgCode.FETCH_FEEDS); + }); + expect(rawsWithFetch.length).toBeGreaterThanOrEqual(2); + }); + + const rawsWithFetch = wss.receivedMessagesRaw.filter((raw) => { + const batch = JSON.parse(raw) as Array<{ type: number }>; + return batch.some((m) => m.type === ClientMsgCode.FETCH_FEEDS); + }); + const lastRaw = rawsWithFetch[rawsWithFetch.length - 1]; + expect(lastRaw).toBeDefined(); + const batch2 = JSON.parse(lastRaw) as Array<{ + type: number; + requestId?: string; + limit?: number; + cursor?: string; + }>; + const fetchMsg2 = batch2.find( + (m) => m.type === ClientMsgCode.FETCH_FEEDS + )!; + expect(fetchMsg2.limit).toBe(1); + expect(fetchMsg2.cursor).toBe("cursor-2"); + + const requestId2 = fetchMsg2.requestId!; + const t2 = Date.now(); + wss.last.send( + serverMessage({ + type: ServerMsgCode.FEEDS_LIST, + requestId: requestId2, + feeds: [ + { + feedId: "feed-page-2", + metadata: {}, + createdAt: t2, + updatedAt: t2, + }, + ], + nextCursor: undefined, + }) + ); + + const result2 = await p2; + expect(result2.feeds).toHaveLength(1); + expect(result2.feeds[0]?.feedId).toBe("feed-page-2"); + expect(result2.nextCursor).toBeUndefined(); + + room.destroy(); + }); + }); }); diff --git a/packages/liveblocks-core/src/client.ts b/packages/liveblocks-core/src/client.ts index 0d766d97d33..2a933511bb2 100644 --- a/packages/liveblocks-core/src/client.ts +++ b/packages/liveblocks-core/src/client.ts @@ -9,6 +9,8 @@ import { linkDevTools, setupDevTools, unlinkDevTools } from "./devtools"; import type { DCM, DE, + DFM, + DFMD, DGI, DP, DRI, @@ -185,6 +187,8 @@ export type PrivateClientApi< U extends BaseUserMeta, TM extends BaseMetadata, CM extends BaseMetadata, + FM extends Json = DFM, + FMD extends Json = DFMD, > = { readonly currentUserId: Signal; readonly mentionSuggestionsCache: Map; @@ -195,7 +199,12 @@ export type PrivateClientApi< readonly getRoomIds: () => string[]; readonly httpClient: LiveblocksHttpApi; // Type-level helper - as(): Client; + as< + TM2 extends BaseMetadata, + CM2 extends BaseMetadata, + FM2 extends Json = FM, + FMD2 extends Json = FMD, + >(): Client; // Tracking pending changes globally createSyncSource(): SyncSource; emitError(context: LiveblocksErrorContext, cause?: Error): void; @@ -363,6 +372,8 @@ export type Client< U extends BaseUserMeta = DU, TM extends BaseMetadata = DTM, CM extends BaseMetadata = DCM, + FM extends Json = DFM, + FMD extends Json = DFMD, > = { /** * Gets a room. Returns null if {@link Client.enter} has not been called previously. @@ -375,9 +386,11 @@ export type Client< E extends Json = DE, TM2 extends BaseMetadata = TM, CM2 extends BaseMetadata = CM, + FM2 extends Json = FM, + FMD2 extends Json = FMD, >( roomId: string - ): Room | null; + ): Room | null; /** * Enter a room. @@ -391,6 +404,8 @@ export type Client< E extends Json = DE, TM2 extends BaseMetadata = TM, CM2 extends BaseMetadata = CM, + FM2 extends Json = FM, + FMD2 extends Json = FMD, >( roomId: string, ...args: OptionalTupleUnless< @@ -398,7 +413,7 @@ export type Client< [options: EnterOptions, NoInfr>] > ): { - room: Room; + room: Room; leave: () => void; }; @@ -471,7 +486,7 @@ export type Client< * will probably happen if you do. */ // TODO Make this a getter, so we can provide M - readonly [kInternal]: PrivateClientApi; + readonly [kInternal]: PrivateClientApi; /** * Returns the current global sync status of the Liveblocks client. If any @@ -701,10 +716,12 @@ export function createClient( E extends Json, TM extends BaseMetadata, CM extends BaseMetadata, + FM extends Json, + FMD extends Json, >( details: RoomDetails ): { - room: Room; + room: Room; leave: () => void; } { // Create a new self-destructing leave function @@ -725,7 +742,7 @@ export function createClient( details.unsubs.add(leave); return { - room: details.room as Room, + room: details.room as Room, leave, }; } @@ -737,6 +754,8 @@ export function createClient( E extends Json, TM extends BaseMetadata, CM extends BaseMetadata, + FM extends Json, + FMD extends Json, >( roomId: string, ...args: OptionalTupleUnless< @@ -744,7 +763,7 @@ export function createClient( [options: EnterOptions, NoInfr>] > ): { - room: Room; + room: Room; leave: () => void; } { const existing = roomsById.get(roomId); @@ -763,7 +782,7 @@ export function createClient( ? options.initialStorage(roomId) : options.initialStorage) ?? ({} as S); - const newRoom = createRoom( + const newRoom = createRoom( { initialPresence, initialStorage }, { roomId, @@ -824,9 +843,11 @@ export function createClient( E extends Json, TM extends BaseMetadata, CM extends BaseMetadata, - >(roomId: string): Room | null { + FM extends Json, + FMD extends Json, + >(roomId: string): Room | null { const room = roomsById.get(roomId)?.room; - return room ? (room as Room) : null; + return room ? (room as Room) : null; } function logout() { diff --git a/packages/liveblocks-core/src/globals/augmentation.ts b/packages/liveblocks-core/src/globals/augmentation.ts index fa630ea8db8..440383696af 100644 --- a/packages/liveblocks-core/src/globals/augmentation.ts +++ b/packages/liveblocks-core/src/globals/augmentation.ts @@ -24,6 +24,8 @@ type ExtendableTypes = | "RoomEvent" | "ThreadMetadata" | "CommentMetadata" + | "FeedMetadata" + | "FeedMessageData" | "RoomInfo" | "GroupInfo" | "ActivitiesData"; @@ -80,6 +82,10 @@ export type DTM = GetOverride<"ThreadMetadata", BaseMetadata>; export type DCM = GetOverride<"CommentMetadata", BaseMetadata>; +export type DFM = GetOverride<"FeedMetadata", Json, "is not a valid JSON value">; + +export type DFMD = GetOverride<"FeedMessageData", Json, "is not a valid JSON value">; + export type DRI = GetOverride<"RoomInfo", BaseRoomInfo>; export type DGI = GetOverride<"GroupInfo", BaseGroupInfo>; diff --git a/packages/liveblocks-core/src/index.ts b/packages/liveblocks-core/src/index.ts index d4ec50e0c0f..eb48f92481e 100644 --- a/packages/liveblocks-core/src/index.ts +++ b/packages/liveblocks-core/src/index.ts @@ -99,6 +99,8 @@ export type { DAD, DCM, DE, + DFM, + DFMD, DGI, DP, DRI, @@ -202,6 +204,9 @@ export type { BaseUserMeta, IUserInfo } from "./protocol/BaseUserMeta"; export type { BroadcastEventClientMsg, ClientMsg, + FeedCreateMetadata, + FeedFetchMetadataFilter, + FeedUpdateMetadata, FetchStorageClientMsg, FetchYDocClientMsg, UpdatePresenceClientMsg, @@ -240,6 +245,7 @@ export type { ThreadDataWithDeleteInfo, } from "./protocol/Comments"; export type { ThreadDeleteInfo } from "./protocol/Comments"; +export type { Feed, FeedMessage } from "./protocol/Feeds"; export type { GroupData, GroupDataPlain, @@ -295,6 +301,17 @@ export type { export type { BroadcastedEventServerMsg, CommentsEventServerMsg, + FeedDeletedServerMsg, + FeedMessagesAddedServerMsg, + FeedMessagesDeletedServerMsg, + FeedMessagesListServerMsg, + FeedMessagesUpdatedServerMsg, + FeedRequestError, + FeedRequestFailedServerMsg, + FeedsAddedServerMsg, + FeedsEventServerMsg, + FeedsListServerMsg, + FeedsUpdatedServerMsg, RejectedStorageOpServerMsg, RoomStateServerMsg, ServerMsg, @@ -305,7 +322,7 @@ export type { UserLeftServerMsg, YDocUpdateServerMsg, } from "./protocol/ServerMsg"; -export { ServerMsgCode } from "./protocol/ServerMsg"; +export { FeedRequestErrorCode, ServerMsgCode } from "./protocol/ServerMsg"; export type { ChildStorageNode, CompactChildNode, diff --git a/packages/liveblocks-core/src/protocol/AuthToken.ts b/packages/liveblocks-core/src/protocol/AuthToken.ts index 735d92abcd2..ebb0c8da445 100644 --- a/packages/liveblocks-core/src/protocol/AuthToken.ts +++ b/packages/liveblocks-core/src/protocol/AuthToken.ts @@ -9,6 +9,7 @@ export enum Permission { PresenceWrite = "room:presence:write", CommentsWrite = "comments:write", CommentsRead = "comments:read", + FeedsWrite = "feeds:write", } export type LiveblocksPermissions = Record; diff --git a/packages/liveblocks-core/src/protocol/ClientMsg.ts b/packages/liveblocks-core/src/protocol/ClientMsg.ts index d61a568149c..62865ff6519 100644 --- a/packages/liveblocks-core/src/protocol/ClientMsg.ts +++ b/packages/liveblocks-core/src/protocol/ClientMsg.ts @@ -14,6 +14,16 @@ export const ClientMsgCode = Object.freeze({ // For Yjs support FETCH_YDOC: 300, UPDATE_YDOC: 301, + + // For Feeds + FETCH_FEEDS: 510, + FETCH_FEED_MESSAGES: 511, + ADD_FEED: 512, + UPDATE_FEED: 513, + DELETE_FEED: 514, + ADD_FEED_MESSAGE: 515, + UPDATE_FEED_MESSAGE: 516, + DELETE_FEED_MESSAGE: 517, }); export namespace ClientMsgCode { @@ -23,6 +33,14 @@ export namespace ClientMsgCode { export type UPDATE_STORAGE = typeof ClientMsgCode.UPDATE_STORAGE; export type FETCH_YDOC = typeof ClientMsgCode.FETCH_YDOC; export type UPDATE_YDOC = typeof ClientMsgCode.UPDATE_YDOC; + export type FETCH_FEEDS = typeof ClientMsgCode.FETCH_FEEDS; + export type FETCH_FEED_MESSAGES = typeof ClientMsgCode.FETCH_FEED_MESSAGES; + export type ADD_FEED = typeof ClientMsgCode.ADD_FEED; + export type UPDATE_FEED = typeof ClientMsgCode.UPDATE_FEED; + export type DELETE_FEED = typeof ClientMsgCode.DELETE_FEED; + export type ADD_FEED_MESSAGE = typeof ClientMsgCode.ADD_FEED_MESSAGE; + export type UPDATE_FEED_MESSAGE = typeof ClientMsgCode.UPDATE_FEED_MESSAGE; + export type DELETE_FEED_MESSAGE = typeof ClientMsgCode.DELETE_FEED_MESSAGE; } /** @@ -39,7 +57,17 @@ export type ClientMsg

= // For Yjs support | FetchYDocClientMsg - | UpdateYDocClientMsg; + | UpdateYDocClientMsg + + // For Feeds + | FetchFeedsClientMsg + | FetchFeedMessagesClientMsg + | AddFeedClientMsg + | UpdateFeedClientMsg + | DeleteFeedClientMsg + | AddFeedMessageClientMsg + | UpdateFeedMessageClientMsg + | DeleteFeedMessageClientMsg; export type BroadcastEventClientMsg = { type: ClientMsgCode.BROADCAST_EVENT; @@ -104,3 +132,76 @@ export type UpdateYDocClientMsg = { readonly guid?: string; // an optional guid to identify a subdoc readonly v2?: boolean; // if it's a v2 update }; + +/** Metadata filter for {@link FetchFeedsClientMsg}. Values are matched as strings. */ +export type FeedFetchMetadataFilter = Record; + +/** Metadata for {@link AddFeedClientMsg}. */ +export type FeedCreateMetadata = Record; + +/** Metadata for {@link UpdateFeedClientMsg}. Use `null` to remove a key. */ +export type FeedUpdateMetadata = Record; + +export type FetchFeedsClientMsg = { + readonly type: ClientMsgCode.FETCH_FEEDS; + readonly requestId: string; + readonly cursor?: string; + readonly since?: number; + readonly limit?: number; + readonly metadata?: FeedFetchMetadataFilter; +}; + +export type FetchFeedMessagesClientMsg = { + readonly type: ClientMsgCode.FETCH_FEED_MESSAGES; + readonly requestId: string; + readonly feedId: string; + readonly cursor?: string; + readonly since?: number; + readonly limit?: number; +}; + +export type AddFeedClientMsg = { + readonly type: ClientMsgCode.ADD_FEED; + readonly requestId: string; + readonly feedId: string; + readonly metadata?: FeedCreateMetadata; + readonly createdAt?: number; +}; + +export type UpdateFeedClientMsg = { + readonly type: ClientMsgCode.UPDATE_FEED; + readonly requestId: string; + readonly feedId: string; + readonly metadata: FeedUpdateMetadata; +}; + +export type DeleteFeedClientMsg = { + readonly type: ClientMsgCode.DELETE_FEED; + readonly requestId: string; + readonly feedId: string; +}; + +export type AddFeedMessageClientMsg = { + readonly type: ClientMsgCode.ADD_FEED_MESSAGE; + readonly requestId: string; + readonly feedId: string; + readonly data: JsonObject; + readonly id?: string; + readonly createdAt?: number; +}; + +export type UpdateFeedMessageClientMsg = { + readonly type: ClientMsgCode.UPDATE_FEED_MESSAGE; + readonly requestId: string; + readonly feedId: string; + readonly messageId: string; + readonly data: JsonObject; + readonly updatedAt?: number; +}; + +export type DeleteFeedMessageClientMsg = { + readonly type: ClientMsgCode.DELETE_FEED_MESSAGE; + readonly requestId: string; + readonly feedId: string; + readonly messageId: string; +}; diff --git a/packages/liveblocks-core/src/protocol/Feeds.ts b/packages/liveblocks-core/src/protocol/Feeds.ts new file mode 100644 index 00000000000..d5a82e766b4 --- /dev/null +++ b/packages/liveblocks-core/src/protocol/Feeds.ts @@ -0,0 +1,15 @@ +import type { Json } from "../lib/Json"; + +export type Feed = { + feedId: string; + metadata: FM; + createdAt: number; + updatedAt: number; +}; + +export type FeedMessage = { + id: string; + createdAt: number; + updatedAt: number; + data: FMD; +}; diff --git a/packages/liveblocks-core/src/protocol/ServerMsg.ts b/packages/liveblocks-core/src/protocol/ServerMsg.ts index 2ef037970da..75ca89bbb84 100644 --- a/packages/liveblocks-core/src/protocol/ServerMsg.ts +++ b/packages/liveblocks-core/src/protocol/ServerMsg.ts @@ -1,5 +1,6 @@ import type { Json, JsonObject } from "../lib/Json"; import type { BaseUserMeta } from "./BaseUserMeta"; +import type { Feed, FeedMessage } from "./Feeds"; import type { ServerWireOp } from "./Op"; import type { CompactNode, StorageNode } from "./StorageNode"; @@ -33,6 +34,17 @@ export const ServerMsgCode = Object.freeze({ COMMENT_REACTION_REMOVED: 406, COMMENT_METADATA_UPDATED: 409, + // For Feeds + FEEDS_LIST: 500, + FEEDS_ADDED: 501, + FEEDS_UPDATED: 502, + FEED_DELETED: 503, + FEED_MESSAGES_LIST: 504, + FEED_MESSAGES_ADDED: 505, + FEED_MESSAGES_UPDATED: 506, + FEED_MESSAGES_DELETED: 507, + FEED_REQUEST_FAILED: 508, + // Error codes REJECT_STORAGE_OP: 299, // Sent if a mutation was not allowed on the server (i.e. due to permissions, limit exceeded, etc) }); @@ -60,6 +72,17 @@ export namespace ServerMsgCode { typeof ServerMsgCode.COMMENT_REACTION_ADDED; export type COMMENT_REACTION_REMOVED = typeof ServerMsgCode.COMMENT_REACTION_REMOVED; + export type FEEDS_LIST = typeof ServerMsgCode.FEEDS_LIST; + export type FEEDS_ADDED = typeof ServerMsgCode.FEEDS_ADDED; + export type FEEDS_UPDATED = typeof ServerMsgCode.FEEDS_UPDATED; + export type FEED_DELETED = typeof ServerMsgCode.FEED_DELETED; + export type FEED_MESSAGES_LIST = typeof ServerMsgCode.FEED_MESSAGES_LIST; + export type FEED_MESSAGES_ADDED = typeof ServerMsgCode.FEED_MESSAGES_ADDED; + export type FEED_MESSAGES_UPDATED = + typeof ServerMsgCode.FEED_MESSAGES_UPDATED; + export type FEED_MESSAGES_DELETED = + typeof ServerMsgCode.FEED_MESSAGES_DELETED; + export type FEED_REQUEST_FAILED = typeof ServerMsgCode.FEED_REQUEST_FAILED; export type COMMENT_METADATA_UPDATED = typeof ServerMsgCode.COMMENT_METADATA_UPDATED; export type REJECT_STORAGE_OP = typeof ServerMsgCode.REJECT_STORAGE_OP; @@ -89,7 +112,10 @@ export type ServerMsg< | RejectedStorageOpServerMsg // For a single client // Comments - | CommentsEventServerMsg; + | CommentsEventServerMsg + + // Feeds + | FeedsEventServerMsg; export type CommentsEventServerMsg = | ThreadCreatedEvent @@ -359,3 +385,88 @@ export type RejectedStorageOpServerMsg = { readonly opIds: string[]; readonly reason: string; }; + +export type FeedsEventServerMsg< + FM extends Json = Json, + FMD extends Json = Json, +> = + | FeedsListServerMsg + | FeedsAddedServerMsg + | FeedsUpdatedServerMsg + | FeedDeletedServerMsg + | FeedMessagesListServerMsg + | FeedMessagesAddedServerMsg + | FeedMessagesUpdatedServerMsg + | FeedMessagesDeletedServerMsg + | FeedRequestFailedServerMsg; + +/** Error codes for {@link FeedRequestFailedServerMsg}. */ +export const FeedRequestErrorCode = { + INTERNAL: "INTERNAL", + FEED_ALREADY_EXISTS: "FEED_ALREADY_EXISTS", + FEED_NOT_FOUND: "FEED_NOT_FOUND", + FEED_MESSAGE_NOT_FOUND: "FEED_MESSAGE_NOT_FOUND", +} as const; + +/** String literals accepted in {@link FeedRequestFailedServerMsg}.code */ +export type FeedRequestError = + (typeof FeedRequestErrorCode)[keyof typeof FeedRequestErrorCode]; + +/** + * Sent to the client when a feed mutation referenced by `requestId` failed + * (e.g. validation or permission error). + */ +export type FeedRequestFailedServerMsg = { + readonly type: ServerMsgCode.FEED_REQUEST_FAILED; + readonly requestId: string; + readonly code: string; + readonly reason?: string; +}; + +export type FeedsListServerMsg = { + readonly type: ServerMsgCode.FEEDS_LIST; + readonly requestId: string; + readonly feeds: Feed[]; + readonly nextCursor?: string; +}; + +export type FeedsAddedServerMsg = { + readonly type: ServerMsgCode.FEEDS_ADDED; + readonly feeds: Feed[]; +}; + +export type FeedsUpdatedServerMsg = { + readonly type: ServerMsgCode.FEEDS_UPDATED; + readonly feeds: Feed[]; +}; + +export type FeedDeletedServerMsg = { + readonly type: ServerMsgCode.FEED_DELETED; + readonly feedId: string; +}; + +export type FeedMessagesListServerMsg = { + readonly type: ServerMsgCode.FEED_MESSAGES_LIST; + readonly requestId: string; + readonly feedId: string; + readonly messages: FeedMessage[]; + readonly nextCursor?: string; +}; + +export type FeedMessagesAddedServerMsg = { + readonly type: ServerMsgCode.FEED_MESSAGES_ADDED; + readonly feedId: string; + readonly messages: FeedMessage[]; +}; + +export type FeedMessagesUpdatedServerMsg = { + readonly type: ServerMsgCode.FEED_MESSAGES_UPDATED; + readonly feedId: string; + readonly messages: FeedMessage[]; +}; + +export type FeedMessagesDeletedServerMsg = { + readonly type: ServerMsgCode.FEED_MESSAGES_DELETED; + readonly feedId: string; + readonly messageIds: readonly string[]; +}; diff --git a/packages/liveblocks-core/src/room.ts b/packages/liveblocks-core/src/room.ts index f8b1a249d73..c35f29c73be 100644 --- a/packages/liveblocks-core/src/room.ts +++ b/packages/liveblocks-core/src/room.ts @@ -17,7 +17,16 @@ import { import { LiveObject } from "./crdts/LiveObject"; import type { LiveStructure, LsonObject } from "./crdts/Lson"; import type { StorageCallback, StorageUpdate } from "./crdts/StorageUpdates"; -import type { DCM, DE, DP, DS, DTM, DU } from "./globals/augmentation"; +import type { + DCM, + DE, + DFM, + DFMD, + DP, + DS, + DTM, + DU, +} from "./globals/augmentation"; import { kInternal } from "./internal"; import { assertNever, nn } from "./lib/assert"; import type { BatchStore } from "./lib/batch"; @@ -29,6 +38,7 @@ import { makeEventSource } from "./lib/EventSource"; import * as console from "./lib/fancy-console"; import type { Json, JsonObject } from "./lib/Json"; import { isJsonArray, isJsonObject } from "./lib/Json"; +import { nanoid } from "./lib/nanoid"; import { asPos } from "./lib/position"; import { DerivedSignal, PatchableSignal, Signal } from "./lib/signals"; import { makeStopWatch } from "./lib/stopwatch"; @@ -47,7 +57,21 @@ import type { import type { Permission } from "./protocol/AuthToken"; import { canComment, canWriteStorage } from "./protocol/AuthToken"; import type { BaseUserMeta, IUserInfo } from "./protocol/BaseUserMeta"; -import type { ClientMsg, UpdateYDocClientMsg } from "./protocol/ClientMsg"; +import type { + AddFeedClientMsg, + AddFeedMessageClientMsg, + ClientMsg, + DeleteFeedClientMsg, + DeleteFeedMessageClientMsg, + FeedCreateMetadata, + FeedFetchMetadataFilter, + FeedUpdateMetadata, + FetchFeedMessagesClientMsg, + FetchFeedsClientMsg, + UpdateFeedClientMsg, + UpdateFeedMessageClientMsg, + UpdateYDocClientMsg, +} from "./protocol/ClientMsg"; import { ClientMsgCode } from "./protocol/ClientMsg"; import type { BaseMetadata, @@ -60,6 +84,7 @@ import type { ThreadData, ThreadDeleteInfo, } from "./protocol/Comments"; +import type { Feed, FeedMessage } from "./protocol/Feeds"; import type { InboxNotificationData, InboxNotificationDeleteInfo, @@ -70,6 +95,14 @@ import { isIgnoredOp, OpCode } from "./protocol/Op"; import type { RoomSubscriptionSettings } from "./protocol/RoomSubscriptionSettings"; import type { CommentsEventServerMsg, + FeedMessagesAddedServerMsg, + FeedMessagesListServerMsg, + FeedMessagesUpdatedServerMsg, + FeedRequestFailedServerMsg, + FeedsAddedServerMsg, + FeedsEventServerMsg, + FeedsListServerMsg, + FeedsUpdatedServerMsg, RoomStateServerMsg, ServerMsg, UpdatePresenceServerMsg, @@ -110,6 +143,8 @@ import { PKG_VERSION } from "./version"; export type TimeoutID = ReturnType; +const FEEDS_TIMEOUT = 5_000; // 5 seconds + // // NOTE: // This type looks an awful lot like InternalOthersEvent, but don't change this @@ -506,7 +541,10 @@ export type OpaqueRoom = Room< LsonObject, BaseUserMeta, Json, - BaseMetadata + BaseMetadata, + BaseMetadata, + Json, + Json >; export type Room< @@ -516,6 +554,8 @@ export type Room< E extends Json = DE, TM extends BaseMetadata = DTM, CM extends BaseMetadata = DCM, + FM extends Json = DFM, + FMD extends Json = DFMD, > = { /** * @private @@ -602,6 +642,84 @@ export type Room< */ fetchYDoc(stateVector: string, guid?: string, isV2?: boolean): void; + /** + * Fetches feeds for the room. + */ + fetchFeeds(options?: { + cursor?: string; + since?: number; + limit?: number; + metadata?: FeedFetchMetadataFilter; + }): Promise<{ + feeds: Feed[]; + nextCursor?: string; + }>; + + /** + * Fetches messages for a specific feed. + */ + fetchFeedMessages( + feedId: string, + options?: { + cursor?: string; + since?: number; + limit?: number; + } + ): Promise<{ + messages: FeedMessage[]; + nextCursor?: string; + }>; + + /** + * Adds a new feed to the room via WebSocket. + * Resolves when the server broadcasts the new feed, or rejects on + * FEED_REQUEST_FAILED (508) or timeout. + */ + addFeed( + feedId: string, + options?: { + metadata?: FeedCreateMetadata; + createdAt?: number; + } + ): Promise; + + /** + * Updates metadata for an existing feed via WebSocket. + */ + updateFeed(feedId: string, metadata: FeedUpdateMetadata): Promise; + + /** + * Deletes a feed via WebSocket. + */ + deleteFeed(feedId: string): Promise; + + /** + * Adds a new message to a feed via WebSocket. + */ + addFeedMessage( + feedId: string, + data: JsonObject, + options?: { + id?: string; + createdAt?: number; + } + ): Promise; + + /** + * Updates an existing feed message via WebSocket. + */ + updateFeedMessage( + feedId: string, + messageId: string, + data: JsonObject, + options?: { updatedAt?: number } + ): Promise; + + /** + * Deletes a feed message via WebSocket. + */ + deleteFeedMessage(feedId: string, messageId: string): Promise; + /** * Broadcasts an event to other users in the room. Event broadcasted to the room can be listened with {@link Room.subscribe}("event"). * @param {any} event the event to broadcast. Should be serializable to JSON @@ -666,6 +784,7 @@ export type Room< readonly storageStatus: Observable; readonly ydoc: Observable; readonly comments: Observable; + readonly feeds: Observable>; /** * Called right before the room is destroyed. The event cannot be used to @@ -1375,10 +1494,12 @@ export function createRoom< E extends Json, TM extends BaseMetadata, CM extends BaseMetadata, + FM extends Json = DFM, + FMD extends Json = DFMD, >( options: { initialPresence: P; initialStorage: S }, config: RoomConfig -): Room { +): Room { const roomId = config.roomId; const initialPresence = options.initialPresence; // ?? {}; const initialStorage = options.initialStorage; // ?? {}; @@ -1622,6 +1743,7 @@ export function createRoom< ydoc: makeEventSource(), comments: makeEventSource(), + feeds: makeEventSource>(), roomWillDestroy: makeEventSource(), }; @@ -2199,6 +2321,12 @@ export function createRoom< sendMessages(messages); } + function isFeedRequestFailedMsg( + msg: ServerMsg + ): msg is FeedRequestFailedServerMsg { + return msg.type === ServerMsgCode.FEED_REQUEST_FAILED; + } + /** * Handles a message received on the WebSocket. Will never be a "pong". The * "pong" is handled at the connection manager level. @@ -2338,6 +2466,104 @@ export function createRoom< break; } + case ServerMsgCode.FEEDS_LIST: { + const feedsListMsg = message as FeedsListServerMsg; + const pending = pendingFeedsRequests.get(feedsListMsg.requestId); + if (pending) { + pending.resolve({ + feeds: feedsListMsg.feeds, + nextCursor: feedsListMsg.nextCursor, + }); + pendingFeedsRequests.delete(feedsListMsg.requestId); + } + eventHub.feeds.notify(feedsListMsg); + break; + } + + case ServerMsgCode.FEEDS_ADDED: { + const feedsAddedMsg = message as FeedsAddedServerMsg; + eventHub.feeds.notify(feedsAddedMsg); + tryResolvePendingFeedMutationsFromFeedsEvent(feedsAddedMsg); + break; + } + + case ServerMsgCode.FEEDS_UPDATED: { + const feedsUpdatedMsg = message as FeedsUpdatedServerMsg; + eventHub.feeds.notify(feedsUpdatedMsg); + tryResolvePendingFeedMutationsFromFeedsEvent(feedsUpdatedMsg); + break; + } + + case ServerMsgCode.FEED_DELETED: { + eventHub.feeds.notify(message); + tryResolvePendingFeedMutationsFromFeedsEvent(message); + break; + } + + case ServerMsgCode.FEED_MESSAGES_LIST: { + const feedMsgsListMsg = message as FeedMessagesListServerMsg; + const pending = pendingFeedMessagesRequests.get( + feedMsgsListMsg.requestId + ); + if (pending) { + pending.resolve({ + messages: feedMsgsListMsg.messages, + nextCursor: feedMsgsListMsg.nextCursor, + }); + pendingFeedMessagesRequests.delete(feedMsgsListMsg.requestId); + } + eventHub.feeds.notify(feedMsgsListMsg); + break; + } + + case ServerMsgCode.FEED_MESSAGES_ADDED: { + const feedMsgsAddedMsg = message as FeedMessagesAddedServerMsg; + eventHub.feeds.notify(feedMsgsAddedMsg); + tryResolvePendingFeedMutationsFromFeedsEvent(feedMsgsAddedMsg); + break; + } + + case ServerMsgCode.FEED_MESSAGES_UPDATED: { + const feedMsgsUpdatedMsg = + message as FeedMessagesUpdatedServerMsg; + eventHub.feeds.notify(feedMsgsUpdatedMsg); + tryResolvePendingFeedMutationsFromFeedsEvent(feedMsgsUpdatedMsg); + break; + } + + case ServerMsgCode.FEED_MESSAGES_DELETED: { + eventHub.feeds.notify(message); + tryResolvePendingFeedMutationsFromFeedsEvent(message); + break; + } + + case ServerMsgCode.FEED_REQUEST_FAILED: { + if (!isFeedRequestFailedMsg(message)) { + break; + } + const { requestId, code, reason } = message; + const err = new LiveblocksError(reason ?? "Feed request failed", { + type: "FEED_REQUEST_ERROR", + roomId, + requestId, + code, + reason, + }); + if (pendingFeedMutations.has(requestId)) { + settleFeedMutation(requestId, "error", err); + } else if (pendingFeedsRequests.has(requestId)) { + const pending = pendingFeedsRequests.get(requestId); + pendingFeedsRequests.delete(requestId); + pending?.reject(err); + } else if (pendingFeedMessagesRequests.has(requestId)) { + const pending = pendingFeedMessagesRequests.get(requestId); + pendingFeedMessagesRequests.delete(requestId); + pending?.reject(err); + } + eventHub.feeds.notify(message); + break; + } + case ServerMsgCode.STORAGE_STATE_V7: // No longer used in V8 default: // Ignore unknown server messages @@ -2472,6 +2698,220 @@ export function createRoom< let _getStorage$: Promise | null = null; let _resolveStoragePromise: (() => void) | null = null; + // Pending feeds fetch requests (keyed by requestId) + const pendingFeedsRequests = new Map< + string, + { + resolve: (value: { feeds: Feed[]; nextCursor?: string }) => void; + reject: (error: Error) => void; + } + >(); + + // Pending feed messages fetch requests (keyed by requestId) + const pendingFeedMessagesRequests = new Map< + string, + { + resolve: (value: { + messages: FeedMessage[]; + nextCursor?: string; + }) => void; + reject: (error: Error) => void; + } + >(); + + type PendingFeedMutationKind = + | "add-feed" + | "update-feed" + | "delete-feed" + | "add-message" + | "update-message" + | "delete-message"; + + type PendingFeedMutation = { + resolve: () => void; + reject: (error: Error) => void; + timeoutId: TimeoutID; + kind: PendingFeedMutationKind; + feedId: string; + messageId?: string; + expectedClientMessageId?: string; + }; + + const pendingFeedMutations = new Map(); + const pendingAddMessageFifoByFeed = new Map(); + + function settleFeedMutation( + requestId: string, + outcome: "ok" | "error", + error?: Error + ): void { + const pending = pendingFeedMutations.get(requestId); + if (pending === undefined) { + return; + } + clearTimeout(pending.timeoutId); + pendingFeedMutations.delete(requestId); + if (pending.kind === "add-message" && !pending.expectedClientMessageId) { + const q = pendingAddMessageFifoByFeed.get(pending.feedId); + if (q !== undefined) { + const idx = q.indexOf(requestId); + if (idx >= 0) { + q.splice(idx, 1); + } + if (q.length === 0) { + pendingAddMessageFifoByFeed.delete(pending.feedId); + } + } + } + if (outcome === "ok") { + pending.resolve(); + } else { + pending.reject(error ?? new Error("Feed mutation failed")); + } + } + + function registerFeedMutation( + requestId: string, + kind: PendingFeedMutationKind, + feedId: string, + options?: { messageId?: string; expectedClientMessageId?: string } + ): Promise { + const { promise, resolve, reject } = Promise_withResolvers(); + const timeoutId: TimeoutID = setTimeout(() => { + if (pendingFeedMutations.has(requestId)) { + settleFeedMutation( + requestId, + "error", + new Error("Feed mutation timeout") + ); + } + }, FEEDS_TIMEOUT); + + pendingFeedMutations.set(requestId, { + resolve, + reject, + timeoutId, + kind, + feedId, + messageId: options?.messageId, + expectedClientMessageId: options?.expectedClientMessageId, + }); + + if ( + kind === "add-message" && + options?.expectedClientMessageId === undefined + ) { + const q = pendingAddMessageFifoByFeed.get(feedId) ?? []; + q.push(requestId); + pendingAddMessageFifoByFeed.set(feedId, q); + } + + return promise; + } + + function tryResolvePendingFeedMutationsFromFeedsEvent( + message: FeedsEventServerMsg + ): void { + switch (message.type) { + case ServerMsgCode.FEEDS_ADDED: { + for (const feed of message.feeds) { + for (const [requestId, pending] of [...pendingFeedMutations]) { + if (pending.kind === "add-feed" && pending.feedId === feed.feedId) { + settleFeedMutation(requestId, "ok"); + break; + } + } + } + break; + } + case ServerMsgCode.FEEDS_UPDATED: { + for (const feed of message.feeds) { + for (const [requestId, pending] of [...pendingFeedMutations]) { + if ( + pending.kind === "update-feed" && + pending.feedId === feed.feedId + ) { + settleFeedMutation(requestId, "ok"); + } + } + } + break; + } + case ServerMsgCode.FEED_DELETED: { + for (const [requestId, pending] of [...pendingFeedMutations]) { + if ( + pending.kind === "delete-feed" && + pending.feedId === message.feedId + ) { + settleFeedMutation(requestId, "ok"); + break; + } + } + break; + } + case ServerMsgCode.FEED_MESSAGES_ADDED: { + for (const m of message.messages) { + let matched = false; + for (const [requestId, pending] of [...pendingFeedMutations]) { + if ( + pending.kind === "add-message" && + pending.feedId === message.feedId && + pending.expectedClientMessageId === m.id + ) { + settleFeedMutation(requestId, "ok"); + matched = true; + break; + } + } + if (!matched) { + const q = pendingAddMessageFifoByFeed.get(message.feedId); + const headId = q?.[0]; + if (headId !== undefined) { + const pending = pendingFeedMutations.get(headId); + if ( + pending?.kind === "add-message" && + pending.expectedClientMessageId === undefined + ) { + settleFeedMutation(headId, "ok"); + } + } + } + } + break; + } + case ServerMsgCode.FEED_MESSAGES_UPDATED: { + for (const m of message.messages) { + for (const [requestId, pending] of [...pendingFeedMutations]) { + if ( + pending.kind === "update-message" && + pending.feedId === message.feedId && + pending.messageId === m.id + ) { + settleFeedMutation(requestId, "ok"); + } + } + } + break; + } + case ServerMsgCode.FEED_MESSAGES_DELETED: { + for (const mid of message.messageIds) { + for (const [requestId, pending] of [...pendingFeedMutations]) { + if ( + pending.kind === "delete-message" && + pending.feedId === message.feedId && + pending.messageId === mid + ) { + settleFeedMutation(requestId, "ok"); + } + } + } + break; + } + default: + break; + } + } + function processInitialStorage(nodes: NodeMap) { const unacknowledgedOps = new Map(context.unacknowledgedOps); createOrUpdateRootFromMessage(nodes); @@ -2582,6 +3022,191 @@ export function createRoom< flushNowOrSoon(); } + async function fetchFeeds(options?: { + cursor?: string; + since?: number; + limit?: number; + metadata?: FeedFetchMetadataFilter; + }): Promise<{ feeds: Feed[]; nextCursor?: string }> { + const requestId = nanoid(); + + const { promise, resolve, reject } = Promise_withResolvers<{ + feeds: Feed[]; + nextCursor?: string; + }>(); + + pendingFeedsRequests.set(requestId, { resolve, reject }); + + const message: FetchFeedsClientMsg = { + type: ClientMsgCode.FETCH_FEEDS, + requestId, + cursor: options?.cursor, + since: options?.since, + limit: options?.limit, + metadata: options?.metadata, + }; + + context.buffer.messages.push(message); + flushNowOrSoon(); + + setTimeout(() => { + if (pendingFeedsRequests.has(requestId)) { + pendingFeedsRequests.delete(requestId); + reject(new Error("Feeds fetch timeout")); + } + }, FEEDS_TIMEOUT); + + return promise; + } + + async function fetchFeedMessages( + feedId: string, + options?: { + cursor?: string; + since?: number; + limit?: number; + } + ): Promise<{ messages: FeedMessage[]; nextCursor?: string }> { + const requestId = nanoid(); + + const { promise, resolve, reject } = Promise_withResolvers<{ + messages: FeedMessage[]; + nextCursor?: string; + }>(); + + pendingFeedMessagesRequests.set(requestId, { resolve, reject }); + + const message: FetchFeedMessagesClientMsg = { + type: ClientMsgCode.FETCH_FEED_MESSAGES, + requestId, + feedId, + cursor: options?.cursor, + since: options?.since, + limit: options?.limit, + }; + + context.buffer.messages.push(message); + flushNowOrSoon(); + + setTimeout(() => { + if (pendingFeedMessagesRequests.has(requestId)) { + pendingFeedMessagesRequests.delete(requestId); + reject(new Error("Feed messages fetch timeout")); + } + }, FEEDS_TIMEOUT); + + return promise; + } + + function addFeed( + feedId: string, + options?: { metadata?: FeedCreateMetadata; createdAt?: number } + ): Promise { + const requestId = nanoid(); + const promise = registerFeedMutation(requestId, "add-feed", feedId); + const message: AddFeedClientMsg = { + type: ClientMsgCode.ADD_FEED, + requestId, + feedId, + metadata: options?.metadata, + createdAt: options?.createdAt, + }; + context.buffer.messages.push(message); + flushNowOrSoon(); + return promise; + } + + function updateFeed( + feedId: string, + metadata: FeedUpdateMetadata + ): Promise { + const requestId = nanoid(); + const promise = registerFeedMutation(requestId, "update-feed", feedId); + const message: UpdateFeedClientMsg = { + type: ClientMsgCode.UPDATE_FEED, + requestId, + feedId, + metadata, + }; + context.buffer.messages.push(message); + flushNowOrSoon(); + return promise; + } + + function deleteFeed(feedId: string): Promise { + const requestId = nanoid(); + const promise = registerFeedMutation(requestId, "delete-feed", feedId); + const message: DeleteFeedClientMsg = { + type: ClientMsgCode.DELETE_FEED, + requestId, + feedId, + }; + context.buffer.messages.push(message); + flushNowOrSoon(); + return promise; + } + + function addFeedMessage( + feedId: string, + data: JsonObject, + options?: { id?: string; createdAt?: number } + ): Promise { + const requestId = nanoid(); + const promise = registerFeedMutation(requestId, "add-message", feedId, { + expectedClientMessageId: options?.id, + }); + const message: AddFeedMessageClientMsg = { + type: ClientMsgCode.ADD_FEED_MESSAGE, + requestId, + feedId, + data, + id: options?.id, + createdAt: options?.createdAt, + }; + context.buffer.messages.push(message); + flushNowOrSoon(); + return promise; + } + + function updateFeedMessage( + feedId: string, + messageId: string, + data: JsonObject, + options?: { updatedAt?: number } + ): Promise { + const requestId = nanoid(); + const promise = registerFeedMutation(requestId, "update-message", feedId, { + messageId, + }); + const message: UpdateFeedMessageClientMsg = { + type: ClientMsgCode.UPDATE_FEED_MESSAGE, + requestId, + feedId, + messageId, + data, + updatedAt: options?.updatedAt, + }; + context.buffer.messages.push(message); + flushNowOrSoon(); + return promise; + } + + function deleteFeedMessage(feedId: string, messageId: string): Promise { + const requestId = nanoid(); + const promise = registerFeedMutation(requestId, "delete-message", feedId, { + messageId, + }); + const message: DeleteFeedMessageClientMsg = { + type: ClientMsgCode.DELETE_FEED_MESSAGE, + requestId, + feedId, + messageId, + }; + context.buffer.messages.push(message); + flushNowOrSoon(); + return promise; + } + function undo() { if (context.activeBatch) { throw new Error("undo is not allowed during a batch"); @@ -2777,6 +3402,7 @@ export function createRoom< ydoc: eventHub.ydoc.observable, comments: eventHub.comments.observable, + feeds: eventHub.feeds.observable, roomWillDestroy: eventHub.roomWillDestroy.observable, }; @@ -3080,7 +3706,7 @@ export function createRoom< id: roomId, subscribe: makeClassicSubscribeFn( roomId, - events, + eventHub, config.errorEventSource ), @@ -3088,6 +3714,12 @@ export function createRoom< reconnect: () => managedSocket.reconnect(), disconnect: () => managedSocket.disconnect(), destroy: () => { + pendingFeedsRequests.forEach((request) => + request.reject(new Error("Room destroyed")) + ); + pendingFeedMessagesRequests.forEach((request) => + request.reject(new Error("Room destroyed")) + ); // remove the roomWillDestroy event from the event hub const { roomWillDestroy, ...eventsExceptDestroy } = eventHub; // Unregister all registered callbacks @@ -3123,6 +3755,14 @@ export function createRoom< }, fetchYDoc, + fetchFeeds, + fetchFeedMessages, + addFeed, + updateFeed, + deleteFeed, + addFeedMessage, + updateFeedMessage, + deleteFeedMessage, getStorage, getStorageSnapshot, getStorageStatus, diff --git a/packages/liveblocks-core/src/types/LiveblocksError.ts b/packages/liveblocks-core/src/types/LiveblocksError.ts index b63af2a924e..24231467aa4 100644 --- a/packages/liveblocks-core/src/types/LiveblocksError.ts +++ b/packages/liveblocks-core/src/types/LiveblocksError.ts @@ -20,6 +20,14 @@ type LargeMessageErrorContext = { type: "LARGE_MESSAGE_ERROR"; }; +type FeedRequestErrorContext = { + type: "FEED_REQUEST_ERROR"; + roomId: string; + requestId: string; + code: string; + reason?: string; +}; + // All possible errors originating from using Comments or Notifications type CommentsOrNotificationsErrorContext = | { @@ -106,6 +114,7 @@ export type LiveblocksErrorContext = Relax< | CommentsOrNotificationsErrorContext // from Comments or Notifications or UserNotificationSettings | AiConnectionErrorContext // from AI | LargeMessageErrorContext // whena message is too large + | FeedRequestErrorContext // feed WebSocket mutations >; export class LiveblocksError extends Error { @@ -183,6 +192,9 @@ function defaultMessageFromContext(context: LiveblocksErrorContext): string { case "UPDATE_NOTIFICATION_SETTINGS_ERROR": return "Could not update notification settings"; case "LARGE_MESSAGE_ERROR": return "Could not send large message"; + case "FEED_REQUEST_ERROR": + return context.reason ?? "Feed request failed"; + default: return assertNever(context, "Unhandled case"); } diff --git a/packages/liveblocks-emails/package.json b/packages/liveblocks-emails/package.json index 9f9d007f477..fe9259c4b8d 100644 --- a/packages/liveblocks-emails/package.json +++ b/packages/liveblocks-emails/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/emails", - "version": "3.15.5", + "version": "3.16.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.", @@ -37,8 +37,8 @@ "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" }, "dependencies": { - "@liveblocks/core": "3.15.5", - "@liveblocks/node": "3.15.5" + "@liveblocks/core": "3.16.0", + "@liveblocks/node": "3.16.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc" diff --git a/packages/liveblocks-node-lexical/package.json b/packages/liveblocks-node-lexical/package.json index 299060f5e08..d25e1ee6ecb 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.15.5", + "version": "3.16.0", "description": "A server-side utility that lets you modify lexical documents hosted in Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -36,8 +36,8 @@ "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" }, "dependencies": { - "@liveblocks/core": "3.15.5", - "@liveblocks/node": "3.15.5", + "@liveblocks/core": "3.16.0", + "@liveblocks/node": "3.16.0", "yjs": "^13.6.18" }, "peerDependencies": { diff --git a/packages/liveblocks-node-prosemirror/package.json b/packages/liveblocks-node-prosemirror/package.json index 7b2e7d6645e..b345a34f6b3 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.15.5", + "version": "3.16.0", "description": "A server-side utility that lets you modify prosemirror and tiptap documents hosted in Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -36,8 +36,8 @@ "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" }, "dependencies": { - "@liveblocks/core": "3.15.5", - "@liveblocks/node": "3.15.5", + "@liveblocks/core": "3.16.0", + "@liveblocks/node": "3.16.0", "yjs": "^13.6.20" }, "peerDependencies": { diff --git a/packages/liveblocks-node/package.json b/packages/liveblocks-node/package.json index dab95d2bd66..c2dd1a8e8e4 100644 --- a/packages/liveblocks-node/package.json +++ b/packages/liveblocks-node/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node", - "version": "3.15.5", + "version": "3.16.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.", @@ -36,7 +36,7 @@ "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" }, "dependencies": { - "@liveblocks/core": "3.15.5", + "@liveblocks/core": "3.16.0", "@stablelib/base64": "^1.0.1", "fast-sha256": "^1.3.0", "node-fetch": "^2.6.1" diff --git a/packages/liveblocks-node/src/Session.ts b/packages/liveblocks-node/src/Session.ts index 0ca6ce1a4ef..521eb9540d1 100644 --- a/packages/liveblocks-node/src/Session.ts +++ b/packages/liveblocks-node/src/Session.ts @@ -17,6 +17,7 @@ const ALL_PERMISSIONS = Object.freeze([ "room:presence:write", "comments:write", "comments:read", + "feeds:write", ] as const); export type Permission = (typeof ALL_PERMISSIONS)[number]; @@ -34,15 +35,15 @@ const MAX_PERMS_PER_SET = 10; */ const READ_ACCESS = Object.freeze([ "room:read", - "room:presence:write", - "comments:read", + "room:presence:write", // TODO: Remove once backend no longer requires this + "comments:read", // TODO: Remove — implied by room:read ] as const); /** * Assign this to a room (or wildcard pattern) if you want to grant the user * permissions to read and write to the room's storage and comments. */ -const FULL_ACCESS = Object.freeze(["room:write", "comments:write"] as const); +const FULL_ACCESS = Object.freeze(["room:write"] as const); const roomPatternRegex = /^([*]|[^*]{1,128}[*]?)$/; diff --git a/packages/liveblocks-node/src/__tests__/Session.test.ts b/packages/liveblocks-node/src/__tests__/Session.test.ts index 9219a862db0..254ab596efe 100644 --- a/packages/liveblocks-node/src/__tests__/Session.test.ts +++ b/packages/liveblocks-node/src/__tests__/Session.test.ts @@ -69,7 +69,7 @@ describe("authorization (new API)", () => { expect( session.allow("xyz", session.FULL_ACCESS).serializePermissions() ).toEqual({ - xyz: ["room:write", "comments:write"], + xyz: ["room:write"], }); }); diff --git a/packages/liveblocks-node/src/__tests__/client.test.ts b/packages/liveblocks-node/src/__tests__/client.test.ts index 61473468a64..33d70eb3ee1 100644 --- a/packages/liveblocks-node/src/__tests__/client.test.ts +++ b/packages/liveblocks-node/src/__tests__/client.test.ts @@ -1,6 +1,8 @@ import type { CommentData, CommentUserReaction, + Feed, + FeedMessage, NotificationSettingsPlain, RoomSubscriptionSettings, StorageNode, @@ -687,7 +689,7 @@ describe("client", () => { avatar: "https://example.com/avatar.png", }, ttl: 60, - }), + }) ).resolves.toBeUndefined(); }); @@ -699,7 +701,7 @@ describe("client", () => { userId: "agent-ai", data: { status: "active" }, userInfo: { name: "AI Assistant" }, - }), + }) ).resolves.toBeUndefined(); }); @@ -708,9 +710,9 @@ describe("client", () => { http.post(`${DEFAULT_BASE_URL}/v2/rooms/:roomId/presence`, () => { return HttpResponse.json( { error: "INVALID_REQUEST", message: "Invalid presence data" }, - { status: 422 }, + { status: 422 } ); - }), + }) ); const client = new Liveblocks({ secret: "sk_xxx" }); @@ -4774,5 +4776,339 @@ describe("client", () => { } }); }); + + describe("feeds", () => { + const feed: Feed = { + feedId: "feed_123", + metadata: { key: "value" }, + createdAt: 1234567890, + updatedAt: 1234567890, + }; + + const feedMessage: FeedMessage = { + id: "msg_123", + createdAt: 1234567890, + updatedAt: 1234567890, + data: { content: "Hello" }, + }; + + describe("getFeeds", () => { + test("should return a list of feeds", async () => { + server.use( + http.get(`${DEFAULT_BASE_URL}/v2/rooms/:roomId/feeds`, () => { + return HttpResponse.json({ data: [feed] }, { status: 200 }); + }) + ); + + const client = new Liveblocks({ secret: "sk_xxx" }); + await expect( + client.getFeeds({ roomId: "room_123" }) + ).resolves.toEqual({ data: [feed] }); + }); + + test("should throw a LiveblocksError on error response", async () => { + server.use( + http.get(`${DEFAULT_BASE_URL}/v2/rooms/:roomId/feeds`, () => { + return HttpResponse.json( + { message: "Room not found" }, + { status: 404 } + ); + }) + ); + + const client = new Liveblocks({ secret: "sk_xxx" }); + try { + await client.getFeeds({ roomId: "nonexistent" }); + expect(true).toBe(false); + } catch (err) { + expect(err instanceof LiveblocksError).toBe(true); + if (err instanceof LiveblocksError) { + expect(err.status).toBe(404); + } + } + }); + }); + + describe("createFeed", () => { + test("should create a feed", async () => { + server.use( + http.post(`${DEFAULT_BASE_URL}/v2/rooms/:roomId/feeds`, () => { + return HttpResponse.json(feed, { status: 200 }); + }) + ); + + const client = new Liveblocks({ secret: "sk_xxx" }); + await expect( + client.createFeed({ + roomId: "room_123", + feedId: "feed_123", + metadata: { key: "value" }, + }) + ).resolves.toEqual(feed); + }); + + test("should create a feed without metadata", async () => { + server.use( + http.post(`${DEFAULT_BASE_URL}/v2/rooms/:roomId/feeds`, () => { + return HttpResponse.json(feed, { status: 200 }); + }) + ); + + const client = new Liveblocks({ secret: "sk_xxx" }); + await expect( + client.createFeed({ + roomId: "room_123", + feedId: "feed_123", + }) + ).resolves.toEqual(feed); + }); + + test("should send createdAt as timestamp in the request body", async () => { + server.use( + http.post( + `${DEFAULT_BASE_URL}/v2/rooms/:roomId/feeds`, + async ({ request }) => { + expect(await request.json()).toEqual({ + feedId: "feed_123", + metadata: { key: "value" }, + timestamp: 99_000, + }); + return HttpResponse.json(feed, { status: 200 }); + } + ) + ); + + const client = new Liveblocks({ secret: "sk_xxx" }); + await client.createFeed({ + roomId: "room_123", + feedId: "feed_123", + metadata: { key: "value" }, + createdAt: 99_000, + }); + }); + }); + + describe("getFeed", () => { + test("should return a feed", async () => { + server.use( + http.get( + `${DEFAULT_BASE_URL}/v2/rooms/:roomId/feeds/:feedId`, + () => { + return HttpResponse.json(feed, { status: 200 }); + } + ) + ); + + const client = new Liveblocks({ secret: "sk_xxx" }); + await expect( + client.getFeed({ + roomId: "room_123", + feedId: "feed_123", + }) + ).resolves.toEqual(feed); + }); + }); + + describe("updateFeed", () => { + test("should update feed metadata and return the updated feed", async () => { + const updatedFeed = { + ...feed, + metadata: { updated: "metadata" }, + }; + server.use( + http.patch( + `${DEFAULT_BASE_URL}/v2/rooms/:roomId/feeds/:feedId`, + () => { + return HttpResponse.json(updatedFeed, { status: 200 }); + } + ) + ); + + const client = new Liveblocks({ secret: "sk_xxx" }); + await expect( + client.updateFeed({ + roomId: "room_123", + feedId: "feed_123", + metadata: { updated: "metadata" }, + }) + ).resolves.toEqual(updatedFeed); + }); + }); + + describe("deleteFeed", () => { + test("should delete a feed", async () => { + server.use( + http.delete( + `${DEFAULT_BASE_URL}/v2/rooms/:roomId/feeds/:feedId`, + () => { + return new HttpResponse(null, { status: 204 }); + } + ) + ); + + const client = new Liveblocks({ secret: "sk_xxx" }); + await expect( + client.deleteFeed({ + roomId: "room_123", + feedId: "feed_123", + }) + ).resolves.toBeUndefined(); + }); + }); + + describe("getFeedMessages", () => { + test("should return a list of feed messages", async () => { + server.use( + http.get( + `${DEFAULT_BASE_URL}/v2/rooms/:roomId/feeds/:feedId/messages`, + () => { + return HttpResponse.json( + { data: [feedMessage] }, + { status: 200 } + ); + } + ) + ); + + const client = new Liveblocks({ secret: "sk_xxx" }); + await expect( + client.getFeedMessages({ + roomId: "room_123", + feedId: "feed_123", + }) + ).resolves.toEqual({ data: [feedMessage] }); + }); + }); + + describe("createFeedMessage", () => { + test("should create a feed message", async () => { + server.use( + http.post( + `${DEFAULT_BASE_URL}/v2/rooms/:roomId/feeds/:feedId/messages`, + () => { + return HttpResponse.json(feedMessage, { status: 200 }); + } + ) + ); + + const client = new Liveblocks({ secret: "sk_xxx" }); + await expect( + client.createFeedMessage({ + roomId: "room_123", + feedId: "feed_123", + data: { content: "Hello" }, + }) + ).resolves.toEqual(feedMessage); + }); + + test("should create a feed message with id and createdAt", async () => { + server.use( + http.post( + `${DEFAULT_BASE_URL}/v2/rooms/:roomId/feeds/:feedId/messages`, + async ({ request }) => { + expect(await request.json()).toEqual({ + data: { content: "Hello" }, + id: "msg_123", + timestamp: 1234567890, + }); + return HttpResponse.json(feedMessage, { status: 200 }); + } + ) + ); + + const client = new Liveblocks({ secret: "sk_xxx" }); + await expect( + client.createFeedMessage({ + roomId: "room_123", + feedId: "feed_123", + id: "msg_123", + createdAt: 1234567890, + data: { content: "Hello" }, + }) + ).resolves.toEqual(feedMessage); + }); + }); + + describe("updateFeedMessage", () => { + test("should update a feed message and return the updated message", async () => { + const updatedMessage = { + ...feedMessage, + data: { content: "Updated" }, + updatedAt: 1234567891, + }; + server.use( + http.patch( + `${DEFAULT_BASE_URL}/v2/rooms/:roomId/feeds/:feedId/messages/:messageId`, + () => { + return HttpResponse.json(updatedMessage, { status: 200 }); + } + ) + ); + + const client = new Liveblocks({ secret: "sk_xxx" }); + await expect( + client.updateFeedMessage({ + roomId: "room_123", + feedId: "feed_123", + messageId: "msg_123", + data: { content: "Updated" }, + }) + ).resolves.toEqual(updatedMessage); + }); + + test("should send updatedAt as timestamp in the request body", async () => { + const updatedMessage = { + ...feedMessage, + data: { content: "Updated" }, + updatedAt: 42_000, + }; + server.use( + http.patch( + `${DEFAULT_BASE_URL}/v2/rooms/:roomId/feeds/:feedId/messages/:messageId`, + async ({ request }) => { + expect(await request.json()).toEqual({ + data: { content: "Updated" }, + timestamp: 42_000, + }); + return HttpResponse.json(updatedMessage, { status: 200 }); + } + ) + ); + + const client = new Liveblocks({ secret: "sk_xxx" }); + await expect( + client.updateFeedMessage({ + roomId: "room_123", + feedId: "feed_123", + messageId: "msg_123", + data: { content: "Updated" }, + updatedAt: 42_000, + }) + ).resolves.toEqual(updatedMessage); + }); + }); + + describe("deleteFeedMessage", () => { + test("should delete a feed message", async () => { + server.use( + http.delete( + `${DEFAULT_BASE_URL}/v2/rooms/:roomId/feeds/:feedId/messages/:messageId`, + () => { + return new HttpResponse(null, { status: 204 }); + } + ) + ); + + const client = new Liveblocks({ secret: "sk_xxx" }); + await expect( + client.deleteFeedMessage({ + roomId: "room_123", + feedId: "feed_123", + messageId: "msg_123", + }) + ).resolves.toBeUndefined(); + }); + }); + }); }); }); diff --git a/packages/liveblocks-node/src/client.ts b/packages/liveblocks-node/src/client.ts index 4072e8387af..ea2275d0ded 100644 --- a/packages/liveblocks-node/src/client.ts +++ b/packages/liveblocks-node/src/client.ts @@ -17,9 +17,15 @@ import type { DAD, DCM, DE, + DFM, + DFMD, DS, DTM, DU, + Feed, + FeedCreateMetadata, + FeedMessage, + FeedUpdateMetadata, GroupData, GroupDataPlain, GroupScopes, @@ -620,6 +626,30 @@ export type GetWebKnowledgeSourceLinksOptions = { knowledgeSourceId: string; } & PaginationOptions; +export type CreateFeedOptions = { + feedId: string; + metadata?: FeedCreateMetadata; + /** Creation time in ms; serialized as `timestamp` in the REST request body. */ + createdAt?: number; +}; + +export type UpdateFeedOptions = { + metadata: FeedUpdateMetadata; +}; + +export type CreateFeedMessageOptions = { + id?: string; + /** Creation time in ms; serialized as `timestamp` in the REST request body. */ + createdAt?: number; + data: FMD; +}; + +export type UpdateFeedMessageOptions = { + data: FMD; + /** Update time in ms; serialized as `timestamp` in the REST request body. */ + updatedAt?: number; +}; + type KnowledgeSourcePlain = DateToString; export type KnowledgeSource = ( @@ -760,6 +790,27 @@ export class Liveblocks { return res; } + async #patch( + path: URLSafeString, + json: Json, + options?: RequestOptions + ): Promise { + const url = urljoin(this.#baseUrl, path); + const headers = { + Authorization: `Bearer ${this.#secret}`, + "Content-Type": "application/json", + }; + const fetch = await fetchPolyfill(); + const res = await fetch(url, { + method: "PATCH", + headers, + body: JSON.stringify(json), + signal: options?.signal, + }); + xwarn(res, "PATCH", path); + return res; + } + async #putBinary( path: URLSafeString, body: Uint8Array, @@ -3333,6 +3384,247 @@ export class Liveblocks { data: page.data.map(inflateWebKnowledgeSourceLink), }; } + + /* ------------------------------------------------------------------------------------------------- + * Feeds + * -----------------------------------------------------------------------------------------------*/ + + /** + * Returns a list of feeds in a room. + * @param params.roomId The room ID to get the feeds from. + * @param options.signal (optional) An abort signal to cancel the request. + * @returns A list of feeds. + */ + public async getFeeds( + params: { roomId: string }, + options?: RequestOptions + ): Promise<{ data: Feed[] }> { + const { roomId } = params; + const res = await this.#get( + url`/v2/rooms/${roomId}/feeds`, + undefined, + options + ); + if (!res.ok) { + throw await LiveblocksError.from(res); + } + return (await res.json()) as { data: Feed[] }; + } + + /** + * Creates a new feed in a room. + * @param params.roomId The room ID to create the feed in. + * @param params.feedId The feed ID. + * @param params.metadata (optional) The metadata for the feed. + * @param params.createdAt (optional) Creation time in ms. Sent to the API as `timestamp`. If not provided, the server uses the current time. + * @param options.signal (optional) An abort signal to cancel the request. + * @returns The created feed. + */ + public async createFeed( + params: { roomId: string } & CreateFeedOptions, + options?: RequestOptions + ): Promise> { + const { roomId, feedId, metadata, createdAt } = params; + const res = await this.#post( + url`/v2/rooms/${roomId}/feeds`, + { + feedId, + ...(metadata !== undefined ? { metadata } : {}), + ...(createdAt !== undefined ? { timestamp: createdAt } : {}), + }, + options + ); + if (!res.ok) { + throw await LiveblocksError.from(res); + } + return (await res.json()) as Feed; + } + + /** + * Returns a feed with the given id. + * @param params.roomId The room ID to get the feed from. + * @param params.feedId The feed ID. + * @param options.signal (optional) An abort signal to cancel the request. + * @returns The feed. + */ + public async getFeed( + params: { roomId: string; feedId: string }, + options?: RequestOptions + ): Promise> { + const { roomId, feedId } = params; + const res = await this.#get( + url`/v2/rooms/${roomId}/feeds/${feedId}`, + undefined, + options + ); + if (!res.ok) { + throw await LiveblocksError.from(res); + } + return (await res.json()) as Feed; + } + + /** + * Updates the metadata of a feed. + * @param params.roomId The room ID to update the feed in. + * @param params.feedId The feed ID to update. + * @param params.metadata The metadata for the feed. + * @param options.signal (optional) An abort signal to cancel the request. + * @returns The updated feed. + */ + public async updateFeed( + params: { + roomId: string; + feedId: string; + } & UpdateFeedOptions, + options?: RequestOptions + ): Promise> { + const { roomId, feedId, metadata } = params; + const res = await this.#patch( + url`/v2/rooms/${roomId}/feeds/${feedId}`, + { metadata }, + options + ); + if (!res.ok) { + throw await LiveblocksError.from(res); + } + return (await res.json()) as Feed; + } + + /** + * Deletes a feed. + * @param params.roomId The room ID to delete the feed from. + * @param params.feedId The feed ID to delete. + * @param options.signal (optional) An abort signal to cancel the request. + */ + public async deleteFeed( + params: { roomId: string; feedId: string }, + options?: RequestOptions + ): Promise { + const { roomId, feedId } = params; + const res = await this.#delete( + url`/v2/rooms/${roomId}/feeds/${feedId}`, + undefined, + options + ); + if (!res.ok) { + throw await LiveblocksError.from(res); + } + } + + /** + * Returns a list of messages in a feed. + * @param params.roomId The room ID to get the feed messages from. + * @param params.feedId The feed ID to get the messages from. + * @param options.signal (optional) An abort signal to cancel the request. + * @returns A list of feed messages. + */ + public async getFeedMessages( + params: { roomId: string; feedId: string }, + options?: RequestOptions + ): Promise<{ data: FeedMessage[] }> { + const { roomId, feedId } = params; + const res = await this.#get( + url`/v2/rooms/${roomId}/feeds/${feedId}/messages`, + undefined, + options + ); + if (!res.ok) { + throw await LiveblocksError.from(res); + } + return (await res.json()) as { data: FeedMessage[] }; + } + + /** + * Creates a new message in a feed. + * @param params.roomId The room ID to create the feed message in. + * @param params.feedId The feed ID to create the message in. + * @param params.id (optional) The message ID. If not provided, one will be generated. + * @param params.createdAt (optional) Creation time in ms. Sent to the API as `timestamp`. If not provided, the server uses the current time. + * @param params.data The message data. + * @param options.signal (optional) An abort signal to cancel the request. + * @returns The created feed message. + */ + public async createFeedMessage( + params: { + roomId: string; + feedId: string; + } & CreateFeedMessageOptions, + options?: RequestOptions + ): Promise> { + const { roomId, feedId, id, createdAt, data } = params; + const res = await this.#post( + url`/v2/rooms/${roomId}/feeds/${feedId}/messages`, + { + data, + ...(id !== undefined ? { id } : {}), + ...(createdAt !== undefined ? { timestamp: createdAt } : {}), + }, + options + ); + if (!res.ok) { + throw await LiveblocksError.from(res); + } + return (await res.json()) as FeedMessage; + } + + /** + * Updates a feed message. + * @param params.roomId The room ID to update the feed message in. + * @param params.feedId The feed ID to update the message in. + * @param params.messageId The message ID to update. + * @param params.data The message data. + * @param params.updatedAt (optional) Update time in ms. Sent to the API as `timestamp`. If omitted, the server uses the current time. + * @param options.signal (optional) An abort signal to cancel the request. + * @returns The updated feed message. + */ + public async updateFeedMessage( + params: { + roomId: string; + feedId: string; + messageId: string; + } & UpdateFeedMessageOptions, + options?: RequestOptions + ): Promise> { + const { roomId, feedId, messageId, data, updatedAt } = params; + const res = await this.#patch( + url`/v2/rooms/${roomId}/feeds/${feedId}/messages/${messageId}`, + { + data, + ...(updatedAt !== undefined ? { timestamp: updatedAt } : {}), + }, + options + ); + if (!res.ok) { + throw await LiveblocksError.from(res); + } + return (await res.json()) as FeedMessage; + } + + /** + * Deletes a feed message. + * @param params.roomId The room ID to delete the feed message from. + * @param params.feedId The feed ID to delete the message from. + * @param params.messageId The message ID to delete. + * @param options.signal (optional) An abort signal to cancel the request. + */ + public async deleteFeedMessage( + params: { + roomId: string; + feedId: string; + messageId: string; + }, + options?: RequestOptions + ): Promise { + const { roomId, feedId, messageId } = params; + const res = await this.#delete( + url`/v2/rooms/${roomId}/feeds/${feedId}/messages/${messageId}`, + undefined, + options + ); + if (!res.ok) { + throw await LiveblocksError.from(res); + } + } } export class LiveblocksError extends Error { diff --git a/packages/liveblocks-node/src/index.ts b/packages/liveblocks-node/src/index.ts index ff6620d6c07..7f778a6657e 100644 --- a/packages/liveblocks-node/src/index.ts +++ b/packages/liveblocks-node/src/index.ts @@ -7,6 +7,8 @@ detectDupes(PKG_NAME, PKG_VERSION, PKG_FORMAT); export type { AiCopilot, CreateAiCopilotOptions, + CreateFeedMessageOptions, + CreateFeedOptions, CreateFileKnowledgeSourceOptions, CreateRoomOptions, CreateWebKnowledgeSourceOptions, @@ -32,6 +34,8 @@ export type { SetPresenceOptions, ThreadParticipants, UpdateAiCopilotOptions, + UpdateFeedMessageOptions, + UpdateFeedOptions, UpdateRoomOptions, UpsertRoomOptions, WebKnowledgeSourceLink, diff --git a/packages/liveblocks-python-codegen/config.yaml b/packages/liveblocks-python-codegen/config.yaml index e8a6956bccd..4700594b389 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.15.5 +package_version_override: 3.16.0 post_hooks: - "uvx ruff check --fix-only ." diff --git a/packages/liveblocks-python/README.md b/packages/liveblocks-python/README.md index 019c3811a55..3967fe48966 100644 --- a/packages/liveblocks-python/README.md +++ b/packages/liveblocks-python/README.md @@ -1200,6 +1200,245 @@ print(result) | `thread_id` | `str` | Yes | ID of the thread | +--- + +### Feeds + +#### `get_feeds` + +This endpoint returns the feeds in the requested room. Corresponds to [`liveblocks.getFeeds`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-rooms-roomId-feeds). + +**Example** +```python +result = client.get_feeds( + room_id="my-room-id", + # cursor="eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9", + # since=1660000988137, + # limit=20, +) +print(result) +``` +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `room_id` | `str` | Yes | ID of the room | +| `cursor` | `str \| Unset` | No | A cursor used for pagination. Get the value from the `nextCursor` response of the previous page. | +| `since` | `int \| Unset` | No | Only return feeds with `createdAt` greater than this Unix timestamp in milliseconds. | +| `limit` | `int \| Unset` | No | A limit on the number of feeds to be returned. The limit can range between 1 and 100, and defaults to 20. *(default: `20`)* | + + +--- + +#### `create_feed` + +This endpoint creates a new feed in a room. Corresponds to [`liveblocks.createFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-rooms-roomId-feeds). + +**Example** +```python +from liveblocks.models import CreateFeedRequestBody + +result = client.create_feed( + room_id="my-room-id", + body=CreateFeedRequestBody( + feed_id="...", + # metadata=..., + # timestamp=0.0, + ), +) +print(result) +``` +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `room_id` | `str` | Yes | ID of the room | +| `body` | `CreateFeedRequestBody` | Yes | Request body (application/json) | + + +--- + +#### `get_feed` + +This endpoint returns a feed by its ID. Corresponds to [`liveblocks.getFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-rooms-roomId-feeds-feedId). + +**Example** +```python +result = client.get_feed( + room_id="my-room-id", + feed_id="fd_abc123", +) +print(result) +``` +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `room_id` | `str` | Yes | ID of the room | +| `feed_id` | `str` | Yes | ID of the feed | + + +--- + +#### `delete_feed` + +This endpoint deletes a feed. Corresponds to [`liveblocks.deleteFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#delete-rooms-roomId-feeds-feedId). + +**Example** +```python +client.delete_feed( + room_id="my-room-id", + feed_id="fd_abc123", +) +``` +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `room_id` | `str` | Yes | ID of the room | +| `feed_id` | `str` | Yes | ID of the feed | + + +--- + +#### `update_feed` + +This endpoint updates the metadata of a feed. Corresponds to [`liveblocks.updateFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#patch-rooms-roomId-feeds-feedId). + +**Example** +```python +from liveblocks.models import UpdateFeedRequestBody + +result = client.update_feed( + room_id="my-room-id", + feed_id="fd_abc123", + body=UpdateFeedRequestBody( + metadata=..., + ), +) +print(result) +``` +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `room_id` | `str` | Yes | ID of the room | +| `feed_id` | `str` | Yes | ID of the feed | +| `body` | `UpdateFeedRequestBody` | Yes | Request body (application/json) | + + +--- + +#### `get_feed_messages` + +This endpoint returns the messages in a feed. Corresponds to [`liveblocks.getFeedMessages`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-rooms-roomId-feeds-feedId-messages). + +**Example** +```python +result = client.get_feed_messages( + room_id="my-room-id", + feed_id="fd_abc123", + # cursor="eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9", + # since=1660000988137, + # limit=20, +) +print(result) +``` +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `room_id` | `str` | Yes | ID of the room | +| `feed_id` | `str` | Yes | ID of the feed | +| `cursor` | `str \| Unset` | No | A cursor used for pagination. Get the value from the `nextCursor` response of the previous page. | +| `since` | `int \| Unset` | No | Only return messages with `createdAt` greater than this Unix timestamp in milliseconds. | +| `limit` | `int \| Unset` | No | A limit on the number of messages to be returned. The limit can range between 1 and 100, and defaults to 20. *(default: `20`)* | + + +--- + +#### `create_feed_message` + +This endpoint creates a new message in a feed. Corresponds to [`liveblocks.createFeedMessage`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-rooms-roomId-feeds-feedId-messages). + +**Example** +```python +from liveblocks.models import CreateFeedMessageRequestBody + +result = client.create_feed_message( + room_id="my-room-id", + feed_id="fd_abc123", + body=CreateFeedMessageRequestBody( + data=..., + # id="...", + # timestamp=0.0, + ), +) +print(result) +``` +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `room_id` | `str` | Yes | ID of the room | +| `feed_id` | `str` | Yes | ID of the feed | +| `body` | `CreateFeedMessageRequestBody` | Yes | Request body (application/json) | + + +--- + +#### `delete_feed_message` + +This endpoint deletes a feed message. Corresponds to [`liveblocks.deleteFeedMessage`](https://liveblocks.io/docs/api-reference/liveblocks-node#delete-rooms-roomId-feeds-feedId-messages-messageId). + +**Example** +```python +client.delete_feed_message( + room_id="my-room-id", + feed_id="fd_abc123", + message_id="msg_xyz789", +) +``` +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `room_id` | `str` | Yes | ID of the room | +| `feed_id` | `str` | Yes | ID of the feed | +| `message_id` | `str` | Yes | ID of the message | + + +--- + +#### `update_feed_message` + +This endpoint updates a feed message. Corresponds to [`liveblocks.updateFeedMessage`](https://liveblocks.io/docs/api-reference/liveblocks-node#patch-rooms-roomId-feeds-feedId-messages-messageId). + +**Example** +```python +from liveblocks.models import UpdateFeedMessageRequestBody + +result = client.update_feed_message( + room_id="my-room-id", + feed_id="fd_abc123", + message_id="msg_xyz789", + body=UpdateFeedMessageRequestBody( + data=..., + # timestamp=0.0, + ), +) +print(result) +``` +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `room_id` | `str` | Yes | ID of the room | +| `feed_id` | `str` | Yes | ID of the feed | +| `message_id` | `str` | Yes | ID of the message | +| `body` | `UpdateFeedMessageRequestBody` | Yes | Request body (application/json) | + + --- ### Auth diff --git a/packages/liveblocks-python/README.mdx b/packages/liveblocks-python/README.mdx index 1d48a82a63c..a802640fa64 100644 --- a/packages/liveblocks-python/README.mdx +++ b/packages/liveblocks-python/README.mdx @@ -1620,6 +1620,371 @@ print(result) +## Feeds + +### get_feeds + +This endpoint returns the feeds in the requested room. Corresponds to [`liveblocks.getFeeds`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-rooms-roomId-feeds). + +```python +result = client.get_feeds( + room_id="my-room-id", + # cursor="eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9", + # since=1660000988137, + # limit=20, +) +print(result) +``` + + + + ID of the room + + + + A cursor used for pagination. Get the value from the `nextCursor` response of the previous page. + + + Only return feeds with `createdAt` greater than this Unix timestamp in milliseconds. + + + A limit on the number of feeds to be returned. The limit can range between 1 and 100, and defaults to 20. *(default: `20`)* + + + + +### create_feed + +This endpoint creates a new feed in a room. Corresponds to [`liveblocks.createFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-rooms-roomId-feeds). + +```python +from liveblocks.models import CreateFeedRequestBody + +result = client.create_feed( + room_id="my-room-id", + body=CreateFeedRequestBody( + feed_id="...", + # metadata=..., + # timestamp=0.0, + ), +) +print(result) +``` + + + + ID of the room + + + + Request body (application/json). + + + + + +### get_feed + +This endpoint returns a feed by its ID. Corresponds to [`liveblocks.getFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-rooms-roomId-feeds-feedId). + +```python +result = client.get_feed( + room_id="my-room-id", + feed_id="fd_abc123", +) +print(result) +``` + + + + ID of the room + + + + ID of the feed + + + + + +### delete_feed + +This endpoint deletes a feed. Corresponds to [`liveblocks.deleteFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#delete-rooms-roomId-feeds-feedId). + +```python +client.delete_feed( + room_id="my-room-id", + feed_id="fd_abc123", +) +``` + + + + ID of the room + + + + ID of the feed + + + + + +### update_feed + +This endpoint updates the metadata of a feed. Corresponds to [`liveblocks.updateFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#patch-rooms-roomId-feeds-feedId). + +```python +from liveblocks.models import UpdateFeedRequestBody + +result = client.update_feed( + room_id="my-room-id", + feed_id="fd_abc123", + body=UpdateFeedRequestBody( + metadata=..., + ), +) +print(result) +``` + + + + ID of the room + + + + ID of the feed + + + + Request body (application/json). + + + + + +### get_feed_messages + +This endpoint returns the messages in a feed. Corresponds to [`liveblocks.getFeedMessages`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-rooms-roomId-feeds-feedId-messages). + +```python +result = client.get_feed_messages( + room_id="my-room-id", + feed_id="fd_abc123", + # cursor="eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9", + # since=1660000988137, + # limit=20, +) +print(result) +``` + + + + ID of the room + + + + ID of the feed + + + + A cursor used for pagination. Get the value from the `nextCursor` response of the previous page. + + + Only return messages with `createdAt` greater than this Unix timestamp in milliseconds. + + + A limit on the number of messages to be returned. The limit can range between 1 and 100, and defaults to 20. *(default: `20`)* + + + + +### create_feed_message + +This endpoint creates a new message in a feed. Corresponds to [`liveblocks.createFeedMessage`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-rooms-roomId-feeds-feedId-messages). + +```python +from liveblocks.models import CreateFeedMessageRequestBody + +result = client.create_feed_message( + room_id="my-room-id", + feed_id="fd_abc123", + body=CreateFeedMessageRequestBody( + data=..., + # id="...", + # timestamp=0.0, + ), +) +print(result) +``` + + + + ID of the room + + + + ID of the feed + + + + Request body (application/json). + + + + + +### delete_feed_message + +This endpoint deletes a feed message. Corresponds to [`liveblocks.deleteFeedMessage`](https://liveblocks.io/docs/api-reference/liveblocks-node#delete-rooms-roomId-feeds-feedId-messages-messageId). + +```python +client.delete_feed_message( + room_id="my-room-id", + feed_id="fd_abc123", + message_id="msg_xyz789", +) +``` + + + + ID of the room + + + + ID of the feed + + + + ID of the message + + + + + +### update_feed_message + +This endpoint updates a feed message. Corresponds to [`liveblocks.updateFeedMessage`](https://liveblocks.io/docs/api-reference/liveblocks-node#patch-rooms-roomId-feeds-feedId-messages-messageId). + +```python +from liveblocks.models import UpdateFeedMessageRequestBody + +result = client.update_feed_message( + room_id="my-room-id", + feed_id="fd_abc123", + message_id="msg_xyz789", + body=UpdateFeedMessageRequestBody( + data=..., + # timestamp=0.0, + ), +) +print(result) +``` + + + + ID of the room + + + + ID of the feed + + + + ID of the message + + + + Request body (application/json). + + + + + ## Auth ### authorize_user diff --git a/packages/liveblocks-python/liveblocks/api/feeds/__init__.py b/packages/liveblocks-python/liveblocks/api/feeds/__init__.py new file mode 100644 index 00000000000..2d7c0b23da3 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/api/feeds/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/packages/liveblocks-python/liveblocks/api/feeds/create_feed.py b/packages/liveblocks-python/liveblocks/api/feeds/create_feed.py new file mode 100644 index 00000000000..50be7e83463 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/api/feeds/create_feed.py @@ -0,0 +1,74 @@ +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...models.create_feed_request_body import CreateFeedRequestBody +from ...models.feed import Feed + + +def _get_kwargs( + room_id: str, + *, + body: CreateFeedRequestBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/v2/rooms/{room_id}/feeds".format( + room_id=quote(str(room_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, response: httpx.Response) -> Feed: + if response.status_code == 200: + response_200 = Feed.from_dict(response.json()) + + return response_200 + + raise errors.LiveblocksError.from_response(response) + + +def _sync( + room_id: str, + *, + client: httpx.Client, + body: CreateFeedRequestBody, +) -> Feed: + kwargs = _get_kwargs( + room_id=room_id, + body=body, + ) + + response = client.request( + **kwargs, + ) + return _parse_response(response=response) + + +async def _asyncio( + room_id: str, + *, + client: httpx.AsyncClient, + body: CreateFeedRequestBody, +) -> Feed: + kwargs = _get_kwargs( + room_id=room_id, + body=body, + ) + + response = await client.request( + **kwargs, + ) + + return _parse_response(response=response) diff --git a/packages/liveblocks-python/liveblocks/api/feeds/create_feed_message.py b/packages/liveblocks-python/liveblocks/api/feeds/create_feed_message.py new file mode 100644 index 00000000000..eabd06de4ed --- /dev/null +++ b/packages/liveblocks-python/liveblocks/api/feeds/create_feed_message.py @@ -0,0 +1,80 @@ +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...models.create_feed_message_request_body import CreateFeedMessageRequestBody +from ...models.feed_message import FeedMessage + + +def _get_kwargs( + room_id: str, + feed_id: str, + *, + body: CreateFeedMessageRequestBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/v2/rooms/{room_id}/feeds/{feed_id}/messages".format( + room_id=quote(str(room_id), safe=""), + feed_id=quote(str(feed_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, response: httpx.Response) -> FeedMessage: + if response.status_code == 200: + response_200 = FeedMessage.from_dict(response.json()) + + return response_200 + + raise errors.LiveblocksError.from_response(response) + + +def _sync( + room_id: str, + feed_id: str, + *, + client: httpx.Client, + body: CreateFeedMessageRequestBody, +) -> FeedMessage: + kwargs = _get_kwargs( + room_id=room_id, + feed_id=feed_id, + body=body, + ) + + response = client.request( + **kwargs, + ) + return _parse_response(response=response) + + +async def _asyncio( + room_id: str, + feed_id: str, + *, + client: httpx.AsyncClient, + body: CreateFeedMessageRequestBody, +) -> FeedMessage: + kwargs = _get_kwargs( + room_id=room_id, + feed_id=feed_id, + body=body, + ) + + response = await client.request( + **kwargs, + ) + + return _parse_response(response=response) diff --git a/packages/liveblocks-python/liveblocks/api/feeds/delete_feed.py b/packages/liveblocks-python/liveblocks/api/feeds/delete_feed.py new file mode 100644 index 00000000000..237eba06b53 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/api/feeds/delete_feed.py @@ -0,0 +1,64 @@ +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors + + +def _get_kwargs( + room_id: str, + feed_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": "/v2/rooms/{room_id}/feeds/{feed_id}".format( + room_id=quote(str(room_id), safe=""), + feed_id=quote(str(feed_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response(*, response: httpx.Response) -> None: + if response.status_code == 204: + return None + + raise errors.LiveblocksError.from_response(response) + + +def _sync( + room_id: str, + feed_id: str, + *, + client: httpx.Client, +) -> None: + kwargs = _get_kwargs( + room_id=room_id, + feed_id=feed_id, + ) + + response = client.request( + **kwargs, + ) + return _parse_response(response=response) + + +async def _asyncio( + room_id: str, + feed_id: str, + *, + client: httpx.AsyncClient, +) -> None: + kwargs = _get_kwargs( + room_id=room_id, + feed_id=feed_id, + ) + + response = await client.request( + **kwargs, + ) + + return _parse_response(response=response) diff --git a/packages/liveblocks-python/liveblocks/api/feeds/delete_feed_message.py b/packages/liveblocks-python/liveblocks/api/feeds/delete_feed_message.py new file mode 100644 index 00000000000..51d71161dd9 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/api/feeds/delete_feed_message.py @@ -0,0 +1,70 @@ +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors + + +def _get_kwargs( + room_id: str, + feed_id: str, + message_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": "/v2/rooms/{room_id}/feeds/{feed_id}/messages/{message_id}".format( + room_id=quote(str(room_id), safe=""), + feed_id=quote(str(feed_id), safe=""), + message_id=quote(str(message_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response(*, response: httpx.Response) -> None: + if response.status_code == 204: + return None + + raise errors.LiveblocksError.from_response(response) + + +def _sync( + room_id: str, + feed_id: str, + message_id: str, + *, + client: httpx.Client, +) -> None: + kwargs = _get_kwargs( + room_id=room_id, + feed_id=feed_id, + message_id=message_id, + ) + + response = client.request( + **kwargs, + ) + return _parse_response(response=response) + + +async def _asyncio( + room_id: str, + feed_id: str, + message_id: str, + *, + client: httpx.AsyncClient, +) -> None: + kwargs = _get_kwargs( + room_id=room_id, + feed_id=feed_id, + message_id=message_id, + ) + + response = await client.request( + **kwargs, + ) + + return _parse_response(response=response) diff --git a/packages/liveblocks-python/liveblocks/api/feeds/get_feed.py b/packages/liveblocks-python/liveblocks/api/feeds/get_feed.py new file mode 100644 index 00000000000..863da946c85 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/api/feeds/get_feed.py @@ -0,0 +1,67 @@ +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...models.feed import Feed + + +def _get_kwargs( + room_id: str, + feed_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/v2/rooms/{room_id}/feeds/{feed_id}".format( + room_id=quote(str(room_id), safe=""), + feed_id=quote(str(feed_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response(*, response: httpx.Response) -> Feed: + if response.status_code == 200: + response_200 = Feed.from_dict(response.json()) + + return response_200 + + raise errors.LiveblocksError.from_response(response) + + +def _sync( + room_id: str, + feed_id: str, + *, + client: httpx.Client, +) -> Feed: + kwargs = _get_kwargs( + room_id=room_id, + feed_id=feed_id, + ) + + response = client.request( + **kwargs, + ) + return _parse_response(response=response) + + +async def _asyncio( + room_id: str, + feed_id: str, + *, + client: httpx.AsyncClient, +) -> Feed: + kwargs = _get_kwargs( + room_id=room_id, + feed_id=feed_id, + ) + + response = await client.request( + **kwargs, + ) + + return _parse_response(response=response) diff --git a/packages/liveblocks-python/liveblocks/api/feeds/get_feed_messages.py b/packages/liveblocks-python/liveblocks/api/feeds/get_feed_messages.py new file mode 100644 index 00000000000..2e418fb899c --- /dev/null +++ b/packages/liveblocks-python/liveblocks/api/feeds/get_feed_messages.py @@ -0,0 +1,95 @@ +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...models.get_feed_messages_response import GetFeedMessagesResponse +from ...types import UNSET, Unset + + +def _get_kwargs( + room_id: str, + feed_id: str, + *, + cursor: str | Unset = UNSET, + since: int | Unset = UNSET, + limit: int | Unset = 20, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["cursor"] = cursor + + params["since"] = since + + params["limit"] = limit + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/v2/rooms/{room_id}/feeds/{feed_id}/messages".format( + room_id=quote(str(room_id), safe=""), + feed_id=quote(str(feed_id), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response(*, response: httpx.Response) -> GetFeedMessagesResponse: + if response.status_code == 200: + response_200 = GetFeedMessagesResponse.from_dict(response.json()) + + return response_200 + + raise errors.LiveblocksError.from_response(response) + + +def _sync( + room_id: str, + feed_id: str, + *, + client: httpx.Client, + cursor: str | Unset = UNSET, + since: int | Unset = UNSET, + limit: int | Unset = 20, +) -> GetFeedMessagesResponse: + kwargs = _get_kwargs( + room_id=room_id, + feed_id=feed_id, + cursor=cursor, + since=since, + limit=limit, + ) + + response = client.request( + **kwargs, + ) + return _parse_response(response=response) + + +async def _asyncio( + room_id: str, + feed_id: str, + *, + client: httpx.AsyncClient, + cursor: str | Unset = UNSET, + since: int | Unset = UNSET, + limit: int | Unset = 20, +) -> GetFeedMessagesResponse: + kwargs = _get_kwargs( + room_id=room_id, + feed_id=feed_id, + cursor=cursor, + since=since, + limit=limit, + ) + + response = await client.request( + **kwargs, + ) + + return _parse_response(response=response) diff --git a/packages/liveblocks-python/liveblocks/api/feeds/get_feeds.py b/packages/liveblocks-python/liveblocks/api/feeds/get_feeds.py new file mode 100644 index 00000000000..5274c934788 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/api/feeds/get_feeds.py @@ -0,0 +1,89 @@ +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...models.get_feeds_response import GetFeedsResponse +from ...types import UNSET, Unset + + +def _get_kwargs( + room_id: str, + *, + cursor: str | Unset = UNSET, + since: int | Unset = UNSET, + limit: int | Unset = 20, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["cursor"] = cursor + + params["since"] = since + + params["limit"] = limit + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/v2/rooms/{room_id}/feeds".format( + room_id=quote(str(room_id), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response(*, response: httpx.Response) -> GetFeedsResponse: + if response.status_code == 200: + response_200 = GetFeedsResponse.from_dict(response.json()) + + return response_200 + + raise errors.LiveblocksError.from_response(response) + + +def _sync( + room_id: str, + *, + client: httpx.Client, + cursor: str | Unset = UNSET, + since: int | Unset = UNSET, + limit: int | Unset = 20, +) -> GetFeedsResponse: + kwargs = _get_kwargs( + room_id=room_id, + cursor=cursor, + since=since, + limit=limit, + ) + + response = client.request( + **kwargs, + ) + return _parse_response(response=response) + + +async def _asyncio( + room_id: str, + *, + client: httpx.AsyncClient, + cursor: str | Unset = UNSET, + since: int | Unset = UNSET, + limit: int | Unset = 20, +) -> GetFeedsResponse: + kwargs = _get_kwargs( + room_id=room_id, + cursor=cursor, + since=since, + limit=limit, + ) + + response = await client.request( + **kwargs, + ) + + return _parse_response(response=response) diff --git a/packages/liveblocks-python/liveblocks/api/feeds/update_feed.py b/packages/liveblocks-python/liveblocks/api/feeds/update_feed.py new file mode 100644 index 00000000000..7d5b26f0954 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/api/feeds/update_feed.py @@ -0,0 +1,80 @@ +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...models.feed import Feed +from ...models.update_feed_request_body import UpdateFeedRequestBody + + +def _get_kwargs( + room_id: str, + feed_id: str, + *, + body: UpdateFeedRequestBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": "/v2/rooms/{room_id}/feeds/{feed_id}".format( + room_id=quote(str(room_id), safe=""), + feed_id=quote(str(feed_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, response: httpx.Response) -> Feed: + if response.status_code == 200: + response_200 = Feed.from_dict(response.json()) + + return response_200 + + raise errors.LiveblocksError.from_response(response) + + +def _sync( + room_id: str, + feed_id: str, + *, + client: httpx.Client, + body: UpdateFeedRequestBody, +) -> Feed: + kwargs = _get_kwargs( + room_id=room_id, + feed_id=feed_id, + body=body, + ) + + response = client.request( + **kwargs, + ) + return _parse_response(response=response) + + +async def _asyncio( + room_id: str, + feed_id: str, + *, + client: httpx.AsyncClient, + body: UpdateFeedRequestBody, +) -> Feed: + kwargs = _get_kwargs( + room_id=room_id, + feed_id=feed_id, + body=body, + ) + + response = await client.request( + **kwargs, + ) + + return _parse_response(response=response) diff --git a/packages/liveblocks-python/liveblocks/api/feeds/update_feed_message.py b/packages/liveblocks-python/liveblocks/api/feeds/update_feed_message.py new file mode 100644 index 00000000000..51d343ee55d --- /dev/null +++ b/packages/liveblocks-python/liveblocks/api/feeds/update_feed_message.py @@ -0,0 +1,86 @@ +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...models.feed_message import FeedMessage +from ...models.update_feed_message_request_body import UpdateFeedMessageRequestBody + + +def _get_kwargs( + room_id: str, + feed_id: str, + message_id: str, + *, + body: UpdateFeedMessageRequestBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": "/v2/rooms/{room_id}/feeds/{feed_id}/messages/{message_id}".format( + room_id=quote(str(room_id), safe=""), + feed_id=quote(str(feed_id), safe=""), + message_id=quote(str(message_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, response: httpx.Response) -> FeedMessage: + if response.status_code == 200: + response_200 = FeedMessage.from_dict(response.json()) + + return response_200 + + raise errors.LiveblocksError.from_response(response) + + +def _sync( + room_id: str, + feed_id: str, + message_id: str, + *, + client: httpx.Client, + body: UpdateFeedMessageRequestBody, +) -> FeedMessage: + kwargs = _get_kwargs( + room_id=room_id, + feed_id=feed_id, + message_id=message_id, + body=body, + ) + + response = client.request( + **kwargs, + ) + return _parse_response(response=response) + + +async def _asyncio( + room_id: str, + feed_id: str, + message_id: str, + *, + client: httpx.AsyncClient, + body: UpdateFeedMessageRequestBody, +) -> FeedMessage: + kwargs = _get_kwargs( + room_id=room_id, + feed_id=feed_id, + message_id=message_id, + body=body, + ) + + response = await client.request( + **kwargs, + ) + + return _parse_response(response=response) diff --git a/packages/liveblocks-python/liveblocks/client.py b/packages/liveblocks-python/liveblocks/client.py index e92acdfd3cd..6f887e1a3cc 100644 --- a/packages/liveblocks-python/liveblocks/client.py +++ b/packages/liveblocks-python/liveblocks/client.py @@ -28,6 +28,8 @@ from .models.create_ai_copilot_options_open_ai import CreateAiCopilotOptionsOpenAi from .models.create_ai_copilot_options_open_ai_compatible import CreateAiCopilotOptionsOpenAiCompatible from .models.create_comment_request_body import CreateCommentRequestBody + from .models.create_feed_message_request_body import CreateFeedMessageRequestBody + from .models.create_feed_request_body import CreateFeedRequestBody from .models.create_file_knowledge_source_response import CreateFileKnowledgeSourceResponse from .models.create_group_request_body import CreateGroupRequestBody from .models.create_room_request_body import CreateRoomRequestBody @@ -38,7 +40,11 @@ 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 + from .models.feed import Feed + from .models.feed_message import FeedMessage from .models.get_ai_copilots_response import GetAiCopilotsResponse + from .models.get_feed_messages_response import GetFeedMessagesResponse + from .models.get_feeds_response import GetFeedsResponse from .models.get_file_knowledge_source_markdown_response import GetFileKnowledgeSourceMarkdownResponse from .models.get_groups_response import GetGroupsResponse from .models.get_inbox_notifications_response import GetInboxNotificationsResponse @@ -83,6 +89,8 @@ from .models.trigger_inbox_notification_request_body import TriggerInboxNotificationRequestBody from .models.unsubscribe_from_thread_request_body import UnsubscribeFromThreadRequestBody from .models.update_ai_copilot_request_body import UpdateAiCopilotRequestBody + from .models.update_feed_message_request_body import UpdateFeedMessageRequestBody + from .models.update_feed_request_body import UpdateFeedRequestBody from .models.update_notification_settings_request_body import UpdateNotificationSettingsRequestBody from .models.update_room_id_request_body import UpdateRoomIdRequestBody from .models.update_room_organization_id_request_body import UpdateRoomOrganizationIdRequestBody @@ -1726,6 +1734,332 @@ def get_thread_inbox_notifications( client=self._client, ) + def get_feeds( + self, + room_id: str, + *, + cursor: str | Unset = UNSET, + since: int | Unset = UNSET, + limit: int | Unset = 20, + ) -> GetFeedsResponse: + """Get room feeds + + This endpoint returns the feeds in the requested room. Corresponds to + [`liveblocks.getFeeds`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-rooms-roomId- + feeds). + + Args: + room_id (str): ID of the room Example: my-room-id. + cursor (str | Unset): A cursor used for pagination. Get the value from the `nextCursor` + response of the previous page. Example: eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9. + since (int | Unset): Only return feeds with `createdAt` greater than this Unix timestamp + in milliseconds. Example: 1660000988137. + limit (int | Unset): A limit on the number of feeds to be returned. The limit can range + between 1 and 100, and defaults to 20. Default: 20. Example: 20. + + 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: + GetFeedsResponse + """ + + from .api.feeds import get_feeds + + return get_feeds._sync( + room_id=room_id, + cursor=cursor, + since=since, + limit=limit, + client=self._client, + ) + + def create_feed( + self, + room_id: str, + *, + body: CreateFeedRequestBody, + ) -> Feed: + """Create feed + + This endpoint creates a new feed in a room. Corresponds to + [`liveblocks.createFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-rooms- + roomId-feeds). + + Args: + room_id (str): ID of the room Example: my-room-id. + body (CreateFeedRequestBody): Request body for `POST /v2/rooms/{roomId}/feeds`. Optional + creation time is sent as `timestamp` (milliseconds), not `createdAt`. + + 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: + Feed + """ + + from .api.feeds import create_feed + + return create_feed._sync( + room_id=room_id, + body=body, + client=self._client, + ) + + def get_feed( + self, + room_id: str, + feed_id: str, + ) -> Feed: + """Get feed + + This endpoint returns a feed by its ID. Corresponds to + [`liveblocks.getFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-rooms-roomId- + feeds-feedId). + + Args: + room_id (str): ID of the room Example: my-room-id. + feed_id (str): ID of the feed Example: fd_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: + Feed + """ + + from .api.feeds import get_feed + + return get_feed._sync( + room_id=room_id, + feed_id=feed_id, + client=self._client, + ) + + def delete_feed( + self, + room_id: str, + feed_id: str, + ) -> None: + """Delete feed + + This endpoint deletes a feed. Corresponds to + [`liveblocks.deleteFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#delete-rooms- + roomId-feeds-feedId). + + Args: + room_id (str): ID of the room Example: my-room-id. + feed_id (str): ID of the feed Example: fd_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: + None + """ + + from .api.feeds import delete_feed + + return delete_feed._sync( + room_id=room_id, + feed_id=feed_id, + client=self._client, + ) + + def update_feed( + self, + room_id: str, + feed_id: str, + *, + body: UpdateFeedRequestBody, + ) -> Feed: + """Update feed + + This endpoint updates the metadata of a feed. Corresponds to + [`liveblocks.updateFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#patch-rooms- + roomId-feeds-feedId). + + Args: + room_id (str): ID of the room Example: my-room-id. + feed_id (str): ID of the feed Example: fd_abc123. + body (UpdateFeedRequestBody): + + 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: + Feed + """ + + from .api.feeds import update_feed + + return update_feed._sync( + room_id=room_id, + feed_id=feed_id, + body=body, + client=self._client, + ) + + def get_feed_messages( + self, + room_id: str, + feed_id: str, + *, + cursor: str | Unset = UNSET, + since: int | Unset = UNSET, + limit: int | Unset = 20, + ) -> GetFeedMessagesResponse: + """Get feed messages + + This endpoint returns the messages in a feed. Corresponds to + [`liveblocks.getFeedMessages`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-rooms- + roomId-feeds-feedId-messages). + + Args: + room_id (str): ID of the room Example: my-room-id. + feed_id (str): ID of the feed Example: fd_abc123. + cursor (str | Unset): A cursor used for pagination. Get the value from the `nextCursor` + response of the previous page. Example: eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9. + since (int | Unset): Only return messages with `createdAt` greater than this Unix + timestamp in milliseconds. Example: 1660000988137. + limit (int | Unset): A limit on the number of messages to be returned. The limit can range + between 1 and 100, and defaults to 20. Default: 20. Example: 20. + + 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: + GetFeedMessagesResponse + """ + + from .api.feeds import get_feed_messages + + return get_feed_messages._sync( + room_id=room_id, + feed_id=feed_id, + cursor=cursor, + since=since, + limit=limit, + client=self._client, + ) + + def create_feed_message( + self, + room_id: str, + feed_id: str, + *, + body: CreateFeedMessageRequestBody, + ) -> FeedMessage: + """Create feed message + + This endpoint creates a new message in a feed. Corresponds to + [`liveblocks.createFeedMessage`](https://liveblocks.io/docs/api-reference/liveblocks-node#post- + rooms-roomId-feeds-feedId-messages). + + Args: + room_id (str): ID of the room Example: my-room-id. + feed_id (str): ID of the feed Example: fd_abc123. + body (CreateFeedMessageRequestBody): Request body for `POST + /v2/rooms/{roomId}/feeds/{feedId}/messages`. Optional message time is sent as `timestamp` + (milliseconds), not `createdAt`. + + 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: + FeedMessage + """ + + from .api.feeds import create_feed_message + + return create_feed_message._sync( + room_id=room_id, + feed_id=feed_id, + body=body, + client=self._client, + ) + + def delete_feed_message( + self, + room_id: str, + feed_id: str, + message_id: str, + ) -> None: + """Delete feed message + + This endpoint deletes a feed message. Corresponds to + [`liveblocks.deleteFeedMessage`](https://liveblocks.io/docs/api-reference/liveblocks-node#delete- + rooms-roomId-feeds-feedId-messages-messageId). + + Args: + room_id (str): ID of the room Example: my-room-id. + feed_id (str): ID of the feed Example: fd_abc123. + message_id (str): ID of the message Example: msg_xyz789. + + 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: + None + """ + + from .api.feeds import delete_feed_message + + return delete_feed_message._sync( + room_id=room_id, + feed_id=feed_id, + message_id=message_id, + client=self._client, + ) + + def update_feed_message( + self, + room_id: str, + feed_id: str, + message_id: str, + *, + body: UpdateFeedMessageRequestBody, + ) -> FeedMessage: + """Update feed message + + This endpoint updates a feed message. Corresponds to + [`liveblocks.updateFeedMessage`](https://liveblocks.io/docs/api-reference/liveblocks-node#patch- + rooms-roomId-feeds-feedId-messages-messageId). + + Args: + room_id (str): ID of the room Example: my-room-id. + feed_id (str): ID of the feed Example: fd_abc123. + message_id (str): ID of the message Example: msg_xyz789. + body (UpdateFeedMessageRequestBody): Request body for `PATCH + /v2/rooms/{roomId}/feeds/{feedId}/messages/{messageId}`. Optional update time is sent as + `timestamp` (milliseconds), not `updatedAt`. + + 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: + FeedMessage + """ + + from .api.feeds import update_feed_message + + return update_feed_message._sync( + room_id=room_id, + feed_id=feed_id, + message_id=message_id, + body=body, + client=self._client, + ) + def authorize_user( self, *, @@ -4535,6 +4869,332 @@ async def get_thread_inbox_notifications( client=self._client, ) + async def get_feeds( + self, + room_id: str, + *, + cursor: str | Unset = UNSET, + since: int | Unset = UNSET, + limit: int | Unset = 20, + ) -> GetFeedsResponse: + """Get room feeds + + This endpoint returns the feeds in the requested room. Corresponds to + [`liveblocks.getFeeds`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-rooms-roomId- + feeds). + + Args: + room_id (str): ID of the room Example: my-room-id. + cursor (str | Unset): A cursor used for pagination. Get the value from the `nextCursor` + response of the previous page. Example: eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9. + since (int | Unset): Only return feeds with `createdAt` greater than this Unix timestamp + in milliseconds. Example: 1660000988137. + limit (int | Unset): A limit on the number of feeds to be returned. The limit can range + between 1 and 100, and defaults to 20. Default: 20. Example: 20. + + 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: + GetFeedsResponse + """ + + from .api.feeds import get_feeds + + return await get_feeds._asyncio( + room_id=room_id, + cursor=cursor, + since=since, + limit=limit, + client=self._client, + ) + + async def create_feed( + self, + room_id: str, + *, + body: CreateFeedRequestBody, + ) -> Feed: + """Create feed + + This endpoint creates a new feed in a room. Corresponds to + [`liveblocks.createFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-rooms- + roomId-feeds). + + Args: + room_id (str): ID of the room Example: my-room-id. + body (CreateFeedRequestBody): Request body for `POST /v2/rooms/{roomId}/feeds`. Optional + creation time is sent as `timestamp` (milliseconds), not `createdAt`. + + 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: + Feed + """ + + from .api.feeds import create_feed + + return await create_feed._asyncio( + room_id=room_id, + body=body, + client=self._client, + ) + + async def get_feed( + self, + room_id: str, + feed_id: str, + ) -> Feed: + """Get feed + + This endpoint returns a feed by its ID. Corresponds to + [`liveblocks.getFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-rooms-roomId- + feeds-feedId). + + Args: + room_id (str): ID of the room Example: my-room-id. + feed_id (str): ID of the feed Example: fd_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: + Feed + """ + + from .api.feeds import get_feed + + return await get_feed._asyncio( + room_id=room_id, + feed_id=feed_id, + client=self._client, + ) + + async def delete_feed( + self, + room_id: str, + feed_id: str, + ) -> None: + """Delete feed + + This endpoint deletes a feed. Corresponds to + [`liveblocks.deleteFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#delete-rooms- + roomId-feeds-feedId). + + Args: + room_id (str): ID of the room Example: my-room-id. + feed_id (str): ID of the feed Example: fd_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: + None + """ + + from .api.feeds import delete_feed + + return await delete_feed._asyncio( + room_id=room_id, + feed_id=feed_id, + client=self._client, + ) + + async def update_feed( + self, + room_id: str, + feed_id: str, + *, + body: UpdateFeedRequestBody, + ) -> Feed: + """Update feed + + This endpoint updates the metadata of a feed. Corresponds to + [`liveblocks.updateFeed`](https://liveblocks.io/docs/api-reference/liveblocks-node#patch-rooms- + roomId-feeds-feedId). + + Args: + room_id (str): ID of the room Example: my-room-id. + feed_id (str): ID of the feed Example: fd_abc123. + body (UpdateFeedRequestBody): + + 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: + Feed + """ + + from .api.feeds import update_feed + + return await update_feed._asyncio( + room_id=room_id, + feed_id=feed_id, + body=body, + client=self._client, + ) + + async def get_feed_messages( + self, + room_id: str, + feed_id: str, + *, + cursor: str | Unset = UNSET, + since: int | Unset = UNSET, + limit: int | Unset = 20, + ) -> GetFeedMessagesResponse: + """Get feed messages + + This endpoint returns the messages in a feed. Corresponds to + [`liveblocks.getFeedMessages`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-rooms- + roomId-feeds-feedId-messages). + + Args: + room_id (str): ID of the room Example: my-room-id. + feed_id (str): ID of the feed Example: fd_abc123. + cursor (str | Unset): A cursor used for pagination. Get the value from the `nextCursor` + response of the previous page. Example: eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9. + since (int | Unset): Only return messages with `createdAt` greater than this Unix + timestamp in milliseconds. Example: 1660000988137. + limit (int | Unset): A limit on the number of messages to be returned. The limit can range + between 1 and 100, and defaults to 20. Default: 20. Example: 20. + + 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: + GetFeedMessagesResponse + """ + + from .api.feeds import get_feed_messages + + return await get_feed_messages._asyncio( + room_id=room_id, + feed_id=feed_id, + cursor=cursor, + since=since, + limit=limit, + client=self._client, + ) + + async def create_feed_message( + self, + room_id: str, + feed_id: str, + *, + body: CreateFeedMessageRequestBody, + ) -> FeedMessage: + """Create feed message + + This endpoint creates a new message in a feed. Corresponds to + [`liveblocks.createFeedMessage`](https://liveblocks.io/docs/api-reference/liveblocks-node#post- + rooms-roomId-feeds-feedId-messages). + + Args: + room_id (str): ID of the room Example: my-room-id. + feed_id (str): ID of the feed Example: fd_abc123. + body (CreateFeedMessageRequestBody): Request body for `POST + /v2/rooms/{roomId}/feeds/{feedId}/messages`. Optional message time is sent as `timestamp` + (milliseconds), not `createdAt`. + + 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: + FeedMessage + """ + + from .api.feeds import create_feed_message + + return await create_feed_message._asyncio( + room_id=room_id, + feed_id=feed_id, + body=body, + client=self._client, + ) + + async def delete_feed_message( + self, + room_id: str, + feed_id: str, + message_id: str, + ) -> None: + """Delete feed message + + This endpoint deletes a feed message. Corresponds to + [`liveblocks.deleteFeedMessage`](https://liveblocks.io/docs/api-reference/liveblocks-node#delete- + rooms-roomId-feeds-feedId-messages-messageId). + + Args: + room_id (str): ID of the room Example: my-room-id. + feed_id (str): ID of the feed Example: fd_abc123. + message_id (str): ID of the message Example: msg_xyz789. + + 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: + None + """ + + from .api.feeds import delete_feed_message + + return await delete_feed_message._asyncio( + room_id=room_id, + feed_id=feed_id, + message_id=message_id, + client=self._client, + ) + + async def update_feed_message( + self, + room_id: str, + feed_id: str, + message_id: str, + *, + body: UpdateFeedMessageRequestBody, + ) -> FeedMessage: + """Update feed message + + This endpoint updates a feed message. Corresponds to + [`liveblocks.updateFeedMessage`](https://liveblocks.io/docs/api-reference/liveblocks-node#patch- + rooms-roomId-feeds-feedId-messages-messageId). + + Args: + room_id (str): ID of the room Example: my-room-id. + feed_id (str): ID of the feed Example: fd_abc123. + message_id (str): ID of the message Example: msg_xyz789. + body (UpdateFeedMessageRequestBody): Request body for `PATCH + /v2/rooms/{roomId}/feeds/{feedId}/messages/{messageId}`. Optional update time is sent as + `timestamp` (milliseconds), not `updatedAt`. + + 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: + FeedMessage + """ + + from .api.feeds import update_feed_message + + return await update_feed_message._asyncio( + room_id=room_id, + feed_id=feed_id, + message_id=message_id, + body=body, + client=self._client, + ) + async def authorize_user( self, *, diff --git a/packages/liveblocks-python/liveblocks/models/__init__.py b/packages/liveblocks-python/liveblocks/models/__init__.py index ecb273ea048..2ca0b43ac33 100644 --- a/packages/liveblocks-python/liveblocks/models/__init__.py +++ b/packages/liveblocks-python/liveblocks/models/__init__.py @@ -43,6 +43,10 @@ from .create_ai_copilot_options_open_ai import CreateAiCopilotOptionsOpenAi from .create_ai_copilot_options_open_ai_compatible import CreateAiCopilotOptionsOpenAiCompatible from .create_comment_request_body import CreateCommentRequestBody +from .create_feed_message_request_body import CreateFeedMessageRequestBody +from .create_feed_message_request_body_data import CreateFeedMessageRequestBodyData +from .create_feed_request_body import CreateFeedRequestBody +from .create_feed_request_body_metadata import CreateFeedRequestBodyMetadata from .create_file_knowledge_source_response import CreateFileKnowledgeSourceResponse from .create_group_request_body import CreateGroupRequestBody from .create_group_request_body_scopes import CreateGroupRequestBodyScopes @@ -61,7 +65,13 @@ from .edit_thread_metadata_request_body import EditThreadMetadataRequestBody from .edit_thread_metadata_request_body_metadata import EditThreadMetadataRequestBodyMetadata from .error import Error +from .feed import Feed +from .feed_message import FeedMessage +from .feed_message_data import FeedMessageData +from .feed_metadata import FeedMetadata from .get_ai_copilots_response import GetAiCopilotsResponse +from .get_feed_messages_response import GetFeedMessagesResponse +from .get_feeds_response import GetFeedsResponse from .get_file_knowledge_source_markdown_response import GetFileKnowledgeSourceMarkdownResponse from .get_groups_response import GetGroupsResponse from .get_inbox_notifications_response import GetInboxNotificationsResponse @@ -140,6 +150,10 @@ from .unsubscribe_from_thread_request_body import UnsubscribeFromThreadRequestBody from .update_ai_copilot_request_body import UpdateAiCopilotRequestBody from .update_ai_copilot_request_body_provider import UpdateAiCopilotRequestBodyProvider +from .update_feed_message_request_body import UpdateFeedMessageRequestBody +from .update_feed_message_request_body_data import UpdateFeedMessageRequestBodyData +from .update_feed_request_body import UpdateFeedRequestBody +from .update_feed_request_body_metadata import UpdateFeedRequestBodyMetadata from .update_notification_settings_request_body import UpdateNotificationSettingsRequestBody from .update_room_id_request_body import UpdateRoomIdRequestBody from .update_room_organization_id_request_body import UpdateRoomOrganizationIdRequestBody @@ -207,6 +221,10 @@ "CreateAiCopilotOptionsOpenAi", "CreateAiCopilotOptionsOpenAiCompatible", "CreateCommentRequestBody", + "CreateFeedMessageRequestBody", + "CreateFeedMessageRequestBodyData", + "CreateFeedRequestBody", + "CreateFeedRequestBodyMetadata", "CreateFileKnowledgeSourceResponse", "CreateGroupRequestBody", "CreateGroupRequestBodyScopes", @@ -225,7 +243,13 @@ "EditThreadMetadataRequestBody", "EditThreadMetadataRequestBodyMetadata", "Error", + "Feed", + "FeedMessage", + "FeedMessageData", + "FeedMetadata", "GetAiCopilotsResponse", + "GetFeedMessagesResponse", + "GetFeedsResponse", "GetFileKnowledgeSourceMarkdownResponse", "GetGroupsResponse", "GetInboxNotificationsResponse", @@ -304,6 +328,10 @@ "UnsubscribeFromThreadRequestBody", "UpdateAiCopilotRequestBody", "UpdateAiCopilotRequestBodyProvider", + "UpdateFeedMessageRequestBody", + "UpdateFeedMessageRequestBodyData", + "UpdateFeedRequestBody", + "UpdateFeedRequestBodyMetadata", "UpdateNotificationSettingsRequestBody", "UpdateRoomIdRequestBody", "UpdateRoomOrganizationIdRequestBody", diff --git a/packages/liveblocks-python/liveblocks/models/create_feed_message_request_body.py b/packages/liveblocks-python/liveblocks/models/create_feed_message_request_body.py new file mode 100644 index 00000000000..04db1d0f9c7 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/create_feed_message_request_body.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +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 ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.create_feed_message_request_body_data import CreateFeedMessageRequestBodyData + + +@_attrs_define +class CreateFeedMessageRequestBody: + """Request body for `POST /v2/rooms/{roomId}/feeds/{feedId}/messages`. Optional message time is sent as `timestamp` + (milliseconds), not `createdAt`. + + Attributes: + data (CreateFeedMessageRequestBodyData): + id (str | Unset): Optional client-provided message id. If omitted, the server generates one. + timestamp (float | Unset): Optional. Unix timestamp in milliseconds for the message's creation time. If omitted, + the server uses the current time. + """ + + data: CreateFeedMessageRequestBodyData + id: str | Unset = UNSET + timestamp: float | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + data = self.data.to_dict() + + id = self.id + + timestamp = self.timestamp + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "data": data, + } + ) + if id is not UNSET: + field_dict["id"] = id + if timestamp is not UNSET: + field_dict["timestamp"] = timestamp + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.create_feed_message_request_body_data import CreateFeedMessageRequestBodyData + + d = dict(src_dict) + data = CreateFeedMessageRequestBodyData.from_dict(d.pop("data")) + + id = d.pop("id", UNSET) + + timestamp = d.pop("timestamp", UNSET) + + create_feed_message_request_body = cls( + data=data, + id=id, + timestamp=timestamp, + ) + + create_feed_message_request_body.additional_properties = d + return create_feed_message_request_body + + @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/create_feed_message_request_body_data.py b/packages/liveblocks-python/liveblocks/models/create_feed_message_request_body_data.py new file mode 100644 index 00000000000..13d2c71bf3f --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/create_feed_message_request_body_data.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + + +@_attrs_define +class CreateFeedMessageRequestBodyData: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + create_feed_message_request_body_data = cls() + + create_feed_message_request_body_data.additional_properties = d + return create_feed_message_request_body_data + + @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/create_feed_request_body.py b/packages/liveblocks-python/liveblocks/models/create_feed_request_body.py new file mode 100644 index 00000000000..2387494c46f --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/create_feed_request_body.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +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 ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.create_feed_request_body_metadata import CreateFeedRequestBodyMetadata + + +@_attrs_define +class CreateFeedRequestBody: + """Request body for `POST /v2/rooms/{roomId}/feeds`. Optional creation time is sent as `timestamp` (milliseconds), not + `createdAt`. + + Attributes: + feed_id (str): + metadata (CreateFeedRequestBodyMetadata | Unset): + timestamp (float | Unset): Optional. Unix timestamp in milliseconds for the feed's creation time. If omitted, + the server uses the current time. + """ + + feed_id: str + metadata: CreateFeedRequestBodyMetadata | Unset = UNSET + timestamp: float | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + feed_id = self.feed_id + + metadata: dict[str, Any] | Unset = UNSET + if not isinstance(self.metadata, Unset): + metadata = self.metadata.to_dict() + + timestamp = self.timestamp + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "feedId": feed_id, + } + ) + if metadata is not UNSET: + field_dict["metadata"] = metadata + if timestamp is not UNSET: + field_dict["timestamp"] = timestamp + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.create_feed_request_body_metadata import CreateFeedRequestBodyMetadata + + d = dict(src_dict) + feed_id = d.pop("feedId") + + _metadata = d.pop("metadata", UNSET) + metadata: CreateFeedRequestBodyMetadata | Unset + if isinstance(_metadata, Unset): + metadata = UNSET + else: + metadata = CreateFeedRequestBodyMetadata.from_dict(_metadata) + + timestamp = d.pop("timestamp", UNSET) + + create_feed_request_body = cls( + feed_id=feed_id, + metadata=metadata, + timestamp=timestamp, + ) + + create_feed_request_body.additional_properties = d + return create_feed_request_body + + @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/create_feed_request_body_metadata.py b/packages/liveblocks-python/liveblocks/models/create_feed_request_body_metadata.py new file mode 100644 index 00000000000..04d09a7d7fa --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/create_feed_request_body_metadata.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + + +@_attrs_define +class CreateFeedRequestBodyMetadata: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + create_feed_request_body_metadata = cls() + + create_feed_request_body_metadata.additional_properties = d + return create_feed_request_body_metadata + + @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/feed.py b/packages/liveblocks-python/liveblocks/models/feed.py new file mode 100644 index 00000000000..96098b14bb4 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/feed.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +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 + +if TYPE_CHECKING: + from ..models.feed_metadata import FeedMetadata + + +@_attrs_define +class Feed: + """Feed objects returned by the API use `createdAt` and `updatedAt` (Unix time in milliseconds). + + Attributes: + feed_id (str): + metadata (FeedMetadata): + created_at (float): Unix timestamp in milliseconds when the feed was created. + updated_at (float): Unix timestamp in milliseconds when the feed was last updated. + """ + + feed_id: str + metadata: FeedMetadata + created_at: float + updated_at: float + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + feed_id = self.feed_id + + metadata = self.metadata.to_dict() + + created_at = self.created_at + + updated_at = self.updated_at + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "feedId": feed_id, + "metadata": metadata, + "createdAt": created_at, + "updatedAt": updated_at, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.feed_metadata import FeedMetadata + + d = dict(src_dict) + feed_id = d.pop("feedId") + + metadata = FeedMetadata.from_dict(d.pop("metadata")) + + created_at = d.pop("createdAt") + + updated_at = d.pop("updatedAt") + + feed = cls( + feed_id=feed_id, + metadata=metadata, + created_at=created_at, + updated_at=updated_at, + ) + + feed.additional_properties = d + return feed + + @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/feed_message.py b/packages/liveblocks-python/liveblocks/models/feed_message.py new file mode 100644 index 00000000000..30f2e24d00f --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/feed_message.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +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 + +if TYPE_CHECKING: + from ..models.feed_message_data import FeedMessageData + + +@_attrs_define +class FeedMessage: + """Message objects returned by the API use `createdAt` and `updatedAt` (Unix time in milliseconds). Request bodies for + create/update use `timestamp` for optional custom times. + + Attributes: + id (str): + created_at (float): Unix timestamp in milliseconds when the message was created. + updated_at (float): Unix timestamp in milliseconds when the message was last updated. + data (FeedMessageData): + """ + + id: str + created_at: float + updated_at: float + data: FeedMessageData + 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 + + updated_at = self.updated_at + + data = self.data.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "createdAt": created_at, + "updatedAt": updated_at, + "data": data, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.feed_message_data import FeedMessageData + + d = dict(src_dict) + id = d.pop("id") + + created_at = d.pop("createdAt") + + updated_at = d.pop("updatedAt") + + data = FeedMessageData.from_dict(d.pop("data")) + + feed_message = cls( + id=id, + created_at=created_at, + updated_at=updated_at, + data=data, + ) + + feed_message.additional_properties = d + return feed_message + + @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/feed_message_data.py b/packages/liveblocks-python/liveblocks/models/feed_message_data.py new file mode 100644 index 00000000000..15f01240084 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/feed_message_data.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + + +@_attrs_define +class FeedMessageData: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + feed_message_data = cls() + + feed_message_data.additional_properties = d + return feed_message_data + + @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/feed_metadata.py b/packages/liveblocks-python/liveblocks/models/feed_metadata.py new file mode 100644 index 00000000000..584de6cac2e --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/feed_metadata.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + + +@_attrs_define +class FeedMetadata: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + feed_metadata = cls() + + feed_metadata.additional_properties = d + return feed_metadata + + @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/get_feed_messages_response.py b/packages/liveblocks-python/liveblocks/models/get_feed_messages_response.py new file mode 100644 index 00000000000..0f625d4b274 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/get_feed_messages_response.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, cast + +from attrs import define as _attrs_define + +if TYPE_CHECKING: + from ..models.feed_message import FeedMessage + + +@_attrs_define +class GetFeedMessagesResponse: + """ + Example: + {'nextCursor': 'eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9', 'data': [{'id': 'msg_xyz789', 'data': {'type': 'text', + 'content': 'Hello, world!'}, 'createdAt': 1660000988137, 'updatedAt': 1660000988137}]} + + Attributes: + next_cursor (None | str): Pass as `cursor` to fetch the next page, or null when there are no more results. + data (list[FeedMessage]): + """ + + next_cursor: None | str + data: list[FeedMessage] + + def to_dict(self) -> dict[str, Any]: + next_cursor: None | str + next_cursor = self.next_cursor + + data = [] + for data_item_data in self.data: + data_item = data_item_data.to_dict() + data.append(data_item) + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "nextCursor": next_cursor, + "data": data, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.feed_message import FeedMessage + + d = dict(src_dict) + + def _parse_next_cursor(data: object) -> None | str: + if data is None: + return data + return cast(None | str, data) + + next_cursor = _parse_next_cursor(d.pop("nextCursor")) + + data = [] + _data = d.pop("data") + for data_item_data in _data: + data_item = FeedMessage.from_dict(data_item_data) + + data.append(data_item) + + get_feed_messages_response = cls( + next_cursor=next_cursor, + data=data, + ) + + return get_feed_messages_response diff --git a/packages/liveblocks-python/liveblocks/models/get_feeds_response.py b/packages/liveblocks-python/liveblocks/models/get_feeds_response.py new file mode 100644 index 00000000000..e6faff17b24 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/get_feeds_response.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, cast + +from attrs import define as _attrs_define + +if TYPE_CHECKING: + from ..models.feed import Feed + + +@_attrs_define +class GetFeedsResponse: + """ + Example: + {'nextCursor': 'eyJjcmVhdGVkQXQiOjE2NjAwMDA5ODgxMzd9', 'data': [{'feedId': 'my-feed-id', 'metadata': {'type': + 'chat', 'name': 'General Discussion'}, 'createdAt': 1660000988137, 'updatedAt': 1660000988137}]} + + Attributes: + next_cursor (None | str): Pass as `cursor` to fetch the next page, or null when there are no more results. + data (list[Feed]): + """ + + next_cursor: None | str + data: list[Feed] + + def to_dict(self) -> dict[str, Any]: + next_cursor: None | str + next_cursor = self.next_cursor + + data = [] + for data_item_data in self.data: + data_item = data_item_data.to_dict() + data.append(data_item) + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "nextCursor": next_cursor, + "data": data, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.feed import Feed + + d = dict(src_dict) + + def _parse_next_cursor(data: object) -> None | str: + if data is None: + return data + return cast(None | str, data) + + next_cursor = _parse_next_cursor(d.pop("nextCursor")) + + data = [] + _data = d.pop("data") + for data_item_data in _data: + data_item = Feed.from_dict(data_item_data) + + data.append(data_item) + + get_feeds_response = cls( + next_cursor=next_cursor, + data=data, + ) + + return get_feeds_response diff --git a/packages/liveblocks-python/liveblocks/models/update_feed_message_request_body.py b/packages/liveblocks-python/liveblocks/models/update_feed_message_request_body.py new file mode 100644 index 00000000000..8ba6fde477c --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/update_feed_message_request_body.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +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 ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.update_feed_message_request_body_data import UpdateFeedMessageRequestBodyData + + +@_attrs_define +class UpdateFeedMessageRequestBody: + """Request body for `PATCH /v2/rooms/{roomId}/feeds/{feedId}/messages/{messageId}`. Optional update time is sent as + `timestamp` (milliseconds), not `updatedAt`. + + Attributes: + data (UpdateFeedMessageRequestBodyData): + timestamp (float | Unset): Optional. Unix timestamp in milliseconds to record as the update time. If omitted, + the server uses the current time. + """ + + data: UpdateFeedMessageRequestBodyData + timestamp: float | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + data = self.data.to_dict() + + timestamp = self.timestamp + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "data": data, + } + ) + if timestamp is not UNSET: + field_dict["timestamp"] = timestamp + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.update_feed_message_request_body_data import UpdateFeedMessageRequestBodyData + + d = dict(src_dict) + data = UpdateFeedMessageRequestBodyData.from_dict(d.pop("data")) + + timestamp = d.pop("timestamp", UNSET) + + update_feed_message_request_body = cls( + data=data, + timestamp=timestamp, + ) + + update_feed_message_request_body.additional_properties = d + return update_feed_message_request_body + + @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/update_feed_message_request_body_data.py b/packages/liveblocks-python/liveblocks/models/update_feed_message_request_body_data.py new file mode 100644 index 00000000000..754e6b6dae4 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/update_feed_message_request_body_data.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + + +@_attrs_define +class UpdateFeedMessageRequestBodyData: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + update_feed_message_request_body_data = cls() + + update_feed_message_request_body_data.additional_properties = d + return update_feed_message_request_body_data + + @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/update_feed_request_body.py b/packages/liveblocks-python/liveblocks/models/update_feed_request_body.py new file mode 100644 index 00000000000..3bd326ac120 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/update_feed_request_body.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +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 + +if TYPE_CHECKING: + from ..models.update_feed_request_body_metadata import UpdateFeedRequestBodyMetadata + + +@_attrs_define +class UpdateFeedRequestBody: + """ + Attributes: + metadata (UpdateFeedRequestBodyMetadata): + """ + + metadata: UpdateFeedRequestBodyMetadata + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + metadata = self.metadata.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "metadata": metadata, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.update_feed_request_body_metadata import UpdateFeedRequestBodyMetadata + + d = dict(src_dict) + metadata = UpdateFeedRequestBodyMetadata.from_dict(d.pop("metadata")) + + update_feed_request_body = cls( + metadata=metadata, + ) + + update_feed_request_body.additional_properties = d + return update_feed_request_body + + @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/update_feed_request_body_metadata.py b/packages/liveblocks-python/liveblocks/models/update_feed_request_body_metadata.py new file mode 100644 index 00000000000..62e3c795589 --- /dev/null +++ b/packages/liveblocks-python/liveblocks/models/update_feed_request_body_metadata.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + + +@_attrs_define +class UpdateFeedRequestBodyMetadata: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + update_feed_request_body_metadata = cls() + + update_feed_request_body_metadata.additional_properties = d + return update_feed_request_body_metadata + + @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/pyproject.toml b/packages/liveblocks-python/pyproject.toml index 04c821ebbda..5de36bb790c 100644 --- a/packages/liveblocks-python/pyproject.toml +++ b/packages/liveblocks-python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "liveblocks" -version = "3.15.5" +version = "3.16.0" description = "A client library for accessing Liveblocks API" authors = [] requires-python = ">=3.11" diff --git a/packages/liveblocks-react-blocknote/package.json b/packages/liveblocks-react-blocknote/package.json index 836d34cb8d0..ceb29e94c8f 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.15.5", + "version": "3.16.0", "description": "An integration of BlockNote + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -44,12 +44,12 @@ "test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest" }, "dependencies": { - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5", - "@liveblocks/react": "3.15.5", - "@liveblocks/react-tiptap": "3.15.5", - "@liveblocks/react-ui": "3.15.5", - "@liveblocks/yjs": "3.15.5", + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0", + "@liveblocks/react": "3.16.0", + "@liveblocks/react-tiptap": "3.16.0", + "@liveblocks/react-ui": "3.16.0", + "@liveblocks/yjs": "3.16.0", "@tiptap/core": "^3.19.0", "vitest-tsconfig-paths": "^3.4.1" }, diff --git a/packages/liveblocks-react-lexical/package.json b/packages/liveblocks-react-lexical/package.json index 81bc0a29d0d..a057341b43c 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.15.5", + "version": "3.16.0", "description": "An integration of Lexical + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -45,11 +45,11 @@ }, "dependencies": { "@floating-ui/react-dom": "^2.1.0", - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5", - "@liveblocks/react": "3.15.5", - "@liveblocks/react-ui": "3.15.5", - "@liveblocks/yjs": "3.15.5", + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0", + "@liveblocks/react": "3.16.0", + "@liveblocks/react-ui": "3.16.0", + "@liveblocks/yjs": "3.16.0", "radix-ui": "^1.4.0", "yjs": "^13.6.18" }, diff --git a/packages/liveblocks-react-tiptap/package.json b/packages/liveblocks-react-tiptap/package.json index 3e18a8bc1bc..449ef925cc4 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.15.5", + "version": "3.16.0", "description": "An integration of TipTap + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -45,11 +45,11 @@ }, "dependencies": { "@floating-ui/react-dom": "^2.1.0", - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5", - "@liveblocks/react": "3.15.5", - "@liveblocks/react-ui": "3.15.5", - "@liveblocks/yjs": "3.15.5", + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0", + "@liveblocks/react": "3.16.0", + "@liveblocks/react-ui": "3.16.0", + "@liveblocks/yjs": "3.16.0", "@tiptap/core": "^3.19.0", "@tiptap/react": "^3.19.0", "@tiptap/suggestion": "^3.19.0", diff --git a/packages/liveblocks-react-ui/package.json b/packages/liveblocks-react-ui/package.json index fab33654956..c5d283fddca 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.15.5", + "version": "3.16.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.", @@ -78,9 +78,9 @@ }, "dependencies": { "@floating-ui/react-dom": "^2.1.0", - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5", - "@liveblocks/react": "3.15.5", + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0", + "@liveblocks/react": "3.16.0", "frimousse": "^0.2.0", "marked": "^15.0.11", "radix-ui": "^1.4.0", diff --git a/packages/liveblocks-react-ui/src/components/Comment.tsx b/packages/liveblocks-react-ui/src/components/Comment.tsx index 1b3c0be89e9..fac1910af75 100644 --- a/packages/liveblocks-react-ui/src/components/Comment.tsx +++ b/packages/liveblocks-react-ui/src/components/Comment.tsx @@ -153,6 +153,13 @@ export interface CommentProps */ additionalContent?: ReactNode; + /** + * Override the comment's body. + */ + body?: + | ReactNode + | ((props: PropsWithChildren<{ comment: CommentData }>) => ReactNode); + /** * The event handler called when the comment is edited. */ @@ -688,6 +695,7 @@ export const Comment = Object.assign( overrides, components, additionalContent, + body, avatar, author, date, @@ -912,23 +920,31 @@ export const Comment = Object.assign( /> ); } else { + const defaultBody = ( + ( + onMentionClick?.(mention, event)} + overrides={overrides} + /> + ), + Link: CommentLink, + }} + /> + ); + content = comment.body ? ( <> - ( - onMentionClick?.(mention, event)} - overrides={overrides} - /> - ), - Link: CommentLink, - }} - /> + {body === undefined + ? defaultBody + : typeof body === "function" + ? body({ comment, children: defaultBody }) + : body} {additionalContent} {showAttachments && (mediaAttachments.length > 0 || fileAttachments.length > 0) ? ( diff --git a/packages/liveblocks-react-ui/src/styles/index.css b/packages/liveblocks-react-ui/src/styles/index.css index eb2fedb6674..9facde62867 100644 --- a/packages/liveblocks-react-ui/src/styles/index.css +++ b/packages/liveblocks-react-ui/src/styles/index.css @@ -1788,6 +1788,10 @@ black calc(var(--lb-avatar-stack-mask-size) + 0.375px) ); } + + &:where(:last-child) { + margin-inline-end: 0; + } } /************************************* diff --git a/packages/liveblocks-react/package.json b/packages/liveblocks-react/package.json index a106e01236e..adddc5e2a39 100644 --- a/packages/liveblocks-react/package.json +++ b/packages/liveblocks-react/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react", - "version": "3.15.5", + "version": "3.16.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.", @@ -63,8 +63,8 @@ "showdeps": "depcruise src --include-only '^src' --exclude='__tests__' --output-type dot | dot -T svg > /tmp/dependency-graph.svg && open /tmp/dependency-graph.svg" }, "dependencies": { - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5" + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0" }, "peerDependencies": { "@types/react": "*", diff --git a/packages/liveblocks-react/scripts/check-exports.ts b/packages/liveblocks-react/scripts/check-exports.ts index 47c0aa3d47f..3cba834f85a 100755 --- a/packages/liveblocks-react/scripts/check-exports.ts +++ b/packages/liveblocks-react/scripts/check-exports.ts @@ -22,6 +22,8 @@ const ALLOW_DIFFERENT_JSDOCS = [ "useRoomInfo", "useSelf", "useThreads", + "useFeeds", + "useFeedMessages", "useUnreadInboxNotificationsCount", "useUser", "useGroupInfo", diff --git a/packages/liveblocks-react/src/__tests__/PaginatedResource.test.ts b/packages/liveblocks-react/src/__tests__/PaginatedResource.test.ts index 92db799b706..ceeefb3026c 100644 --- a/packages/liveblocks-react/src/__tests__/PaginatedResource.test.ts +++ b/packages/liveblocks-react/src/__tests__/PaginatedResource.test.ts @@ -277,4 +277,30 @@ describe("PaginatedResource", () => { jest.useRealTimers(); } }); + + test("autoRetry: false — single attempt, error persists (no 5s reset)", async () => { + const fetcher = jest + .fn, [cursor?: string]>() + .mockImplementation(() => { + throw new Error("permanent"); + }); + + const p = new PaginatedResource(fetcher, { autoRetry: false }); + + jest.useFakeTimers(); + try { + const w$ = p.waitUntilLoaded(); + await expect(w$).rejects.toThrow("permanent"); + expect(fetcher).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(5_000); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(p.get()).toEqual({ + isLoading: false, + error: expect.objectContaining({ message: "permanent" }), + }); + } finally { + jest.useRealTimers(); + } + }); }); diff --git a/packages/liveblocks-react/src/contexts.ts b/packages/liveblocks-react/src/contexts.ts index 420501e11f0..b4a80d2d1dc 100644 --- a/packages/liveblocks-react/src/contexts.ts +++ b/packages/liveblocks-react/src/contexts.ts @@ -7,7 +7,7 @@ import type { LsonObject, Room, } from "@liveblocks/client"; -import type { OpaqueClient, OpaqueRoom } from "@liveblocks/core"; +import type { DFM, DFMD, OpaqueClient, OpaqueRoom } from "@liveblocks/core"; import { raise } from "@liveblocks/core"; import { type Context, createContext, useContext } from "react"; @@ -55,10 +55,12 @@ export function useRoomOrNull< E extends Json, TM extends BaseMetadata, CM extends BaseMetadata, + FM extends Json = DFM, + FMD extends Json = DFMD, >( RoomContext: Context = GlobalRoomContext -): Room | null { - return useContext(RoomContext) as Room | null; +): Room | null { + return useContext(RoomContext) as Room | null; } /** diff --git a/packages/liveblocks-react/src/index.ts b/packages/liveblocks-react/src/index.ts index 19d51082376..c6d664e57e2 100644 --- a/packages/liveblocks-react/src/index.ts +++ b/packages/liveblocks-react/src/index.ts @@ -87,6 +87,14 @@ export { useSelf, useStorage, useThreads, + useFeeds, + useFeedMessages, + useCreateFeed, + useDeleteFeed, + useUpdateFeedMetadata, + useCreateFeedMessage, + useDeleteFeedMessage, + useUpdateFeedMessage, useSearchComments, useAttachmentUrl, useHistoryVersions, diff --git a/packages/liveblocks-react/src/lib/querying.ts b/packages/liveblocks-react/src/lib/querying.ts index d1ceb42978e..00d8d471267 100644 --- a/packages/liveblocks-react/src/lib/querying.ts +++ b/packages/liveblocks-react/src/lib/querying.ts @@ -4,6 +4,8 @@ import type { ThreadData, } from "@liveblocks/client"; import { + type Feed, + type FeedFetchMetadataFilter, getSubscriptionKey, isNumberOperator, isStartsWithOperator, @@ -107,6 +109,35 @@ function matchesNumberOperator( ); } +/** + * Creates a predicate function that will filter Feed instances matching the + * given options. `metadata` is matched by exact equality per key. `since` + * keeps feeds whose `updatedAt` or `createdAt` is >= the given timestamp. + */ +export function makeFeedsFilter(options?: { + metadata?: FeedFetchMetadataFilter; + since?: number; +}): (feed: Feed) => boolean { + return (feed: Feed) => { + if ( + options?.since !== undefined && + feed.updatedAt < options.since && + feed.createdAt < options.since + ) { + return false; + } + if ( + options?.metadata !== undefined && + !Object.entries(options.metadata).every( + ([k, v]) => (feed.metadata as Record)[k] === v + ) + ) { + return false; + } + return true; + }; +} + export function makeInboxNotificationsFilter( query: InboxNotificationsQuery ): (inboxNotification: InboxNotificationData) => boolean { diff --git a/packages/liveblocks-react/src/room.tsx b/packages/liveblocks-react/src/room.tsx index a59892128f7..76826a6cba1 100644 --- a/packages/liveblocks-react/src/room.tsx +++ b/packages/liveblocks-react/src/room.tsx @@ -4,6 +4,8 @@ import type { BroadcastOptions, Client, CommentData, + FeedCreateMetadata, + FeedUpdateMetadata, History, Json, JsonObject, @@ -22,11 +24,14 @@ import type { CommentsEventServerMsg, DCM, DE, + DFM, + DFMD, DP, DS, DTM, DU, EnterOptions, + FeedsEventServerMsg, IYjsProvider, LiveblocksErrorContext, MentionData, @@ -90,6 +95,10 @@ import type { EditCommentMetadataOptions, EditCommentOptions, EditThreadMetadataOptions, + FeedMessagesAsyncResult, + FeedMessagesAsyncSuccess, + FeedsAsyncResult, + FeedsAsyncSuccess, HistoryVersionDataAsyncResult, HistoryVersionsAsyncResult, HistoryVersionsAsyncSuccess, @@ -103,16 +112,22 @@ import type { ThreadsAsyncResult, ThreadsAsyncSuccess, ThreadSubscription, + UseFeedMessagesOptions, + UseFeedsOptions, UseSearchCommentsOptions, UseThreadsOptions, } from "./types"; import type { UmbrellaStore } from "./umbrella-store"; -import { makeRoomThreadsQueryKey } from "./umbrella-store"; +import { + makeFeedMessagesQueryKey, + makeFeedsQueryKey, + makeRoomThreadsQueryKey, +} from "./umbrella-store"; import { useScrollToCommentOnLoadEffect } from "./use-scroll-to-comment-on-load-effect"; import { useSignal } from "./use-signal"; import { useSyncExternalStoreWithSelector } from "./use-sync-external-store-with-selector"; -const noop = () => {}; +const noop = () => { }; const identity: (x: T) => T = (x) => x; const STABLE_EMPTY_LIST = Object.freeze([]); @@ -318,8 +333,10 @@ type RoomLeavePair< E extends Json, TM extends BaseMetadata, CM extends BaseMetadata, + FM extends Json = Json, + FMD extends Json = Json, > = { - room: Room; + room: Room; leave: () => void; }; @@ -330,6 +347,8 @@ function RoomProvider< E extends Json, TM extends BaseMetadata, CM extends BaseMetadata, + FM extends Json = Json, + FMD extends Json = Json, >( props: RoomProviderProps & { /** @internal */ @@ -338,21 +357,21 @@ function RoomProvider< ) { const client = useClient(); const [cache] = useState( - () => new Map>() + () => new Map>() ); // Produce a version of client.enterRoom() that when called for the same // room ID multiple times, will not keep producing multiple leave // functions, but instead return the cached one. - const stableEnterRoom: typeof client.enterRoom = useCallback( + const stableEnterRoom: typeof client.enterRoom = useCallback( ( roomId: string, options: EnterOptions - ): RoomLeavePair => { + ): RoomLeavePair => { const cached = cache.get(roomId); if (cached) return cached; - const rv = client.enterRoom(roomId, options); + const rv = client.enterRoom(roomId, options); // Wrap the leave function to also delete the cached value const origLeave = rv.leave; @@ -387,7 +406,7 @@ function RoomProvider< // Room to not be freed and destroyed when the component unmounts later. // return ( - + {...(props as any)} stableEnterRoom={stableEnterRoom} /> @@ -401,10 +420,12 @@ type EnterRoomType< E extends Json, TM extends BaseMetadata, CM extends BaseMetadata, + FM extends Json = Json, + FMD extends Json = Json, > = ( roomId: string, options: EnterOptions -) => RoomLeavePair; +) => RoomLeavePair; /** @internal */ function RoomProviderInner< @@ -414,9 +435,11 @@ function RoomProviderInner< E extends Json, TM extends BaseMetadata, CM extends BaseMetadata, + FM extends Json = Json, + FMD extends Json = Json, >( props: RoomProviderProps & { - stableEnterRoom: EnterRoomType; + stableEnterRoom: EnterRoomType; BoundRoomContext?: Context; } ) { @@ -525,6 +548,36 @@ function RoomProviderInner< ); }, [client, room]); + useEffect(() => { + const { store } = getRoomExtrasForClient(client); + + function handleFeedEvent(message: FeedsEventServerMsg): void { + switch (message.type) { + case ServerMsgCode.FEEDS_ADDED: + case ServerMsgCode.FEEDS_UPDATED: + store.upsertFeeds(room.id, message.feeds); + break; + case ServerMsgCode.FEED_DELETED: + store.deleteFeed(room.id, message.feedId); + break; + case ServerMsgCode.FEED_MESSAGES_ADDED: + case ServerMsgCode.FEED_MESSAGES_UPDATED: + store.upsertFeedMessages(room.id, message.feedId, message.messages); + break; + case ServerMsgCode.FEED_MESSAGES_DELETED: + store.deleteFeedMessages(room.id, message.feedId, message.messageIds); + break; + // FEEDS_LIST and FEED_MESSAGES_LIST are handled by fetch promise resolution in room.ts + default: + break; + } + } + + return room.events.feeds.subscribe( + (message: FeedsEventServerMsg) => void handleFeedEvent(message) + ); + }, [client, room]); + useEffect(() => { const pair = stableEnterRoom(roomId, frozenProps); @@ -569,10 +622,12 @@ function useRoom_withRoomContext< E extends Json = DE, TM extends BaseMetadata = DTM, CM extends BaseMetadata = DCM, + FM extends Json = Json, + FMD extends Json = Json, >( RoomContext: Context, options?: { allowOutsideRoom: false } -): Room; +): Room; function useRoom_withRoomContext< P extends JsonObject = DP, S extends LsonObject = DS, @@ -580,10 +635,12 @@ function useRoom_withRoomContext< E extends Json = DE, TM extends BaseMetadata = DTM, CM extends BaseMetadata = DCM, + FM extends Json = Json, + FMD extends Json = Json, >( RoomContext: Context, options?: { allowOutsideRoom: boolean } -): Room | null; +): Room | null; function useRoom_withRoomContext< P extends JsonObject = DP, S extends LsonObject = DS, @@ -591,11 +648,13 @@ function useRoom_withRoomContext< E extends Json = DE, TM extends BaseMetadata = DTM, CM extends BaseMetadata = DCM, + FM extends Json = Json, + FMD extends Json = Json, >( RoomContext: Context, options?: { allowOutsideRoom: boolean } -): Room | null { - const room = useRoomOrNull(RoomContext); +): Room | null { + const room = useRoomOrNull(RoomContext); if (room === null && !options?.allowOutsideRoom) { throw new Error("RoomProvider is missing from the React tree."); @@ -611,7 +670,9 @@ function useRoom< E extends Json = DE, TM extends BaseMetadata = DTM, CM extends BaseMetadata = DCM, ->(options?: { allowOutsideRoom: false }): Room; + FM extends Json = Json, + FMD extends Json = Json, +>(options?: { allowOutsideRoom: false }): Room; function useRoom< P extends JsonObject = DP, S extends LsonObject = DS, @@ -619,7 +680,9 @@ function useRoom< E extends Json = DE, TM extends BaseMetadata = DTM, CM extends BaseMetadata = DCM, ->(options: { allowOutsideRoom: boolean }): Room | null; + FM extends Json = Json, + FMD extends Json = Json, +>(options: { allowOutsideRoom: boolean }): Room | null; function useRoom< P extends JsonObject = DP, S extends LsonObject = DS, @@ -627,8 +690,10 @@ function useRoom< E extends Json = DE, TM extends BaseMetadata = DTM, CM extends BaseMetadata = DCM, ->(options?: { allowOutsideRoom: boolean }): Room | null { - return useRoom_withRoomContext( + FM extends Json = Json, + FMD extends Json = Json, +>(options?: { allowOutsideRoom: boolean }): Room | null { + return useRoom_withRoomContext( GlobalRoomContext, options ); @@ -1470,6 +1535,265 @@ function useThreads_withRoomContext< return result; } +function useFeeds_withRoomContext( + RoomContext: Context, + options?: UseFeedsOptions +): FeedsAsyncResult { + const room = useRoom_withRoomContext(RoomContext); + const client = useClient(); + const { store } = getRoomExtrasForClient(client); + const queryKey = makeFeedsQueryKey(room.id, options); + + const loadableResource = store.outputs.loadingFeeds.getOrCreate(queryKey); + + useEffect(() => { + void loadableResource.waitUntilLoaded(); + }, [room, loadableResource]); + + return useSignal(loadableResource.signal); +} + +function useFeeds(options?: UseFeedsOptions): FeedsAsyncResult { + return useFeeds_withRoomContext(GlobalRoomContext, options); +} + +function useFeedMessages_withRoomContext( + RoomContext: Context, + feedId: string, + options?: UseFeedMessagesOptions +): FeedMessagesAsyncResult { + const room = useRoom_withRoomContext(RoomContext); + const client = useClient(); + const { store } = getRoomExtrasForClient(client); + const queryKey = makeFeedMessagesQueryKey(room.id, feedId, options); + + useEffect(() => { + void store.outputs.loadingFeedMessages + .getOrCreate(queryKey) + .waitUntilLoaded(); + }); + + return useSignal( + store.outputs.loadingFeedMessages.getOrCreate(queryKey).signal + ); +} + +function useFeedMessages( + feedId: string, + options?: UseFeedMessagesOptions +): FeedMessagesAsyncResult { + return useFeedMessages_withRoomContext(GlobalRoomContext, feedId, options); +} + +function useFeedsSuspense_withRoomContext( + RoomContext: Context, + options?: UseFeedsOptions +): FeedsAsyncSuccess { + ensureNotServerSide(); + const client = useClient(); + const room = useRoom_withRoomContext(RoomContext); + + const { store } = getRoomExtrasForClient(client); + const queryKey = makeFeedsQueryKey(room.id, options); + + use(store.outputs.loadingFeeds.getOrCreate(queryKey).waitUntilLoaded()); + + const result = useFeeds_withRoomContext(RoomContext, options); + assert(!result.error, "Did not expect error"); + assert(!result.isLoading, "Did not expect loading"); + return result as FeedsAsyncSuccess; +} + +function useFeedsSuspense(options?: UseFeedsOptions): FeedsAsyncSuccess { + return useFeedsSuspense_withRoomContext(GlobalRoomContext, options); +} + +function useFeedMessagesSuspense_withRoomContext( + RoomContext: Context, + feedId: string, + options?: UseFeedMessagesOptions +): FeedMessagesAsyncSuccess { + ensureNotServerSide(); + + const client = useClient(); + const room = useRoom_withRoomContext(RoomContext); + + const { store } = getRoomExtrasForClient(client); + const queryKey = makeFeedMessagesQueryKey(room.id, feedId, options); + + use(store.outputs.loadingFeedMessages.getOrCreate(queryKey).waitUntilLoaded()); + + const result = useFeedMessages_withRoomContext(RoomContext, feedId, options); + assert(!result.error, "Did not expect error"); + assert(!result.isLoading, "Did not expect loading"); + return result as FeedMessagesAsyncSuccess; +} + +function useFeedMessagesSuspense( + feedId: string, + options?: UseFeedMessagesOptions +): FeedMessagesAsyncSuccess { + return useFeedMessagesSuspense_withRoomContext( + GlobalRoomContext, + feedId, + options + ); +} + +function useCreateFeed_withRoomContext( + RoomContext: Context +): ( + feedId: string, + options?: { metadata?: FeedCreateMetadata; createdAt?: number } +) => Promise { + const room = useRoom_withRoomContext(RoomContext); + return useCallback( + (feedId, options) => room.addFeed(feedId, options), + [room] + ); +} + +/** + * Returns a function that creates a new feed in the current room. + * + * @example + * const createFeed = useCreateFeed(); + * createFeed("feed-id", { metadata: { name: "My Feed" } }); + */ +function useCreateFeed(): ( + feedId: string, + options?: { metadata?: FeedCreateMetadata; createdAt?: number } +) => Promise { + return useCreateFeed_withRoomContext(GlobalRoomContext); +} + +function useDeleteFeed_withRoomContext( + RoomContext: Context +): (feedId: string) => Promise { + const room = useRoom_withRoomContext(RoomContext); + return useCallback((feedId) => room.deleteFeed(feedId), [room]); +} + +/** + * Returns a function that deletes a feed from the current room. + * + * @example + * const deleteFeed = useDeleteFeed(); + * deleteFeed("feed-id"); + */ +function useDeleteFeed(): (feedId: string) => Promise { + return useDeleteFeed_withRoomContext(GlobalRoomContext); +} + +function useUpdateFeedMetadata_withRoomContext( + RoomContext: Context +): (feedId: string, metadata: FeedUpdateMetadata) => Promise { + const room = useRoom_withRoomContext(RoomContext); + return useCallback( + (feedId, metadata) => room.updateFeed(feedId, metadata), + [room] + ); +} + +/** + * Returns a function that updates a feed's metadata in the current room. + * + * @example + * const updateFeedMetadata = useUpdateFeedMetadata(); + * updateFeedMetadata("feed-id", { name: "Updated Name" }); + */ +function useUpdateFeedMetadata(): ( + feedId: string, + metadata: FeedUpdateMetadata +) => Promise { + return useUpdateFeedMetadata_withRoomContext(GlobalRoomContext); +} + +function useCreateFeedMessage_withRoomContext( + RoomContext: Context +): ( + feedId: string, + data: JsonObject, + options?: { id?: string; createdAt?: number } +) => Promise { + const room = useRoom_withRoomContext(RoomContext); + return useCallback( + (feedId, data, options) => room.addFeedMessage(feedId, data, options), + [room] + ); +} + +/** + * Returns a function that adds a message to a feed in the current room. + * + * @example + * const createFeedMessage = useCreateFeedMessage(); + * createFeedMessage("feed-id", { text: "Hello" }); + */ +function useCreateFeedMessage(): ( + feedId: string, + data: JsonObject, + options?: { id?: string; createdAt?: number } +) => Promise { + return useCreateFeedMessage_withRoomContext(GlobalRoomContext); +} + +function useDeleteFeedMessage_withRoomContext( + RoomContext: Context +): (feedId: string, messageId: string) => Promise { + const room = useRoom_withRoomContext(RoomContext); + return useCallback( + (feedId, messageId) => room.deleteFeedMessage(feedId, messageId), + [room] + ); +} + +/** + * Returns a function that deletes a message from a feed in the current room. + * + * @example + * const deleteFeedMessage = useDeleteFeedMessage(); + * deleteFeedMessage("feed-id", "message-id"); + */ +function useDeleteFeedMessage(): ( + feedId: string, + messageId: string +) => Promise { + return useDeleteFeedMessage_withRoomContext(GlobalRoomContext); +} + +function useUpdateFeedMessage_withRoomContext( + RoomContext: Context +): ( + feedId: string, + messageId: string, + data: JsonObject, + options?: { updatedAt?: number } +) => Promise { + const room = useRoom_withRoomContext(RoomContext); + return useCallback( + (feedId, messageId, data, options) => + room.updateFeedMessage(feedId, messageId, data, options), + [room] + ); +} + +/** + * Returns a function that updates a feed message in the current room. + * + * @example + * const updateFeedMessage = useUpdateFeedMessage(); + * updateFeedMessage("feed-id", "message-id", { text: "Updated" }); + */ +function useUpdateFeedMessage(): ( + feedId: string, + messageId: string, + data: JsonObject, + options?: { updatedAt?: number } +) => Promise { + return useUpdateFeedMessage_withRoomContext(GlobalRoomContext); +} + function useThreads( options: UseThreadsOptions = {} ): ThreadsAsyncResult { @@ -2005,9 +2329,9 @@ function useEditRoomComment( const updatedMetadata = metadata !== undefined ? { - ...comment.metadata, - ...metadata, - } + ...comment.metadata, + ...metadata, + } : comment.metadata; const optimisticId = store.optimisticUpdates.add({ @@ -2648,9 +2972,9 @@ function useRoomThreadSubscription( function useRoomSubscriptionSettings_withRoomContext( RoomContext: Context ): [ - RoomSubscriptionSettingsAsyncResult, - (settings: Partial) => void, -] { + RoomSubscriptionSettingsAsyncResult, + (settings: Partial) => void, + ] { const updateRoomSubscriptionSettings = useUpdateRoomSubscriptionSettings_withRoomContext(RoomContext); const client = useClient(); @@ -2713,9 +3037,9 @@ function useRoomSubscriptionSettings(): [ function useRoomSubscriptionSettingsSuspense_withRoomContext( RoomContext: Context ): [ - RoomSubscriptionSettingsAsyncSuccess, - (settings: Partial) => void, -] { + RoomSubscriptionSettingsAsyncSuccess, + (settings: Partial) => void, + ] { // Throw error if we're calling this hook server side ensureNotServerSide(); @@ -2785,8 +3109,8 @@ function useHistoryVersionData_withRoomContext( error instanceof Error ? error : new Error( - "An unknown error occurred while loading this version" - ), + "An unknown error occurred while loading this version" + ), }); } }; @@ -3380,9 +3704,11 @@ export function createRoomContext< E extends Json = DE, TM extends BaseMetadata = DTM, CM extends BaseMetadata = DCM, ->(client: OpaqueClient): RoomContextBundle { - type TRoom = Room; - type TRoomBundle = RoomContextBundle; + FM extends Json = Json, + FMD extends Json = Json, +>(client: OpaqueClient): RoomContextBundle { + type TRoom = Room; + type TRoomBundle = RoomContextBundle; const BoundRoomContext = createContext(null); @@ -3677,8 +4003,56 @@ export function createRoomContext< return useUpdateRoomSubscriptionSettings_withRoomContext(BoundRoomContext); } + function useFeeds_withBoundRoomContext( + ...args: Parameters + ) { + return useFeeds_withRoomContext(BoundRoomContext, ...args); + } + + function useFeedMessages_withBoundRoomContext( + ...args: Parameters + ) { + return useFeedMessages_withRoomContext(BoundRoomContext, ...args); + } + + function useFeedsSuspense_withBoundRoomContext( + ...args: Parameters + ) { + return useFeedsSuspense_withRoomContext(BoundRoomContext, ...args); + } + + function useFeedMessagesSuspense_withBoundRoomContext( + ...args: Parameters + ) { + return useFeedMessagesSuspense_withRoomContext(BoundRoomContext, ...args); + } + + function useCreateFeed_withBoundRoomContext() { + return useCreateFeed_withRoomContext(BoundRoomContext); + } + + function useDeleteFeed_withBoundRoomContext() { + return useDeleteFeed_withRoomContext(BoundRoomContext); + } + + function useUpdateFeedMetadata_withBoundRoomContext() { + return useUpdateFeedMetadata_withRoomContext(BoundRoomContext); + } + + function useCreateFeedMessage_withBoundRoomContext() { + return useCreateFeedMessage_withRoomContext(BoundRoomContext); + } + + function useDeleteFeedMessage_withBoundRoomContext() { + return useDeleteFeedMessage_withRoomContext(BoundRoomContext); + } + + function useUpdateFeedMessage_withBoundRoomContext() { + return useUpdateFeedMessage_withRoomContext(BoundRoomContext); + } + const shared = createSharedContext(client as Client); - const bundle: RoomContextBundle = { + const bundle: RoomContextBundle = { RoomContext: BoundRoomContext as Context, RoomProvider: RoomProvider_withImplicitLiveblocksProviderAndBoundRoomContext, @@ -3732,6 +4106,22 @@ export function createRoomContext< // prettier-ignore useThreads: useThreads_withBoundRoomContext as TRoomBundle["useThreads"], // prettier-ignore + useFeeds: useFeeds_withBoundRoomContext as TRoomBundle["useFeeds"], + // prettier-ignore + useFeedMessages: useFeedMessages_withBoundRoomContext as TRoomBundle["useFeedMessages"], + // prettier-ignore + useCreateFeed: useCreateFeed_withBoundRoomContext as TRoomBundle["useCreateFeed"], + // prettier-ignore + useDeleteFeed: useDeleteFeed_withBoundRoomContext as TRoomBundle["useDeleteFeed"], + // prettier-ignore + useUpdateFeedMetadata: useUpdateFeedMetadata_withBoundRoomContext as TRoomBundle["useUpdateFeedMetadata"], + // prettier-ignore + useCreateFeedMessage: useCreateFeedMessage_withBoundRoomContext as TRoomBundle["useCreateFeedMessage"], + // prettier-ignore + useDeleteFeedMessage: useDeleteFeedMessage_withBoundRoomContext as TRoomBundle["useDeleteFeedMessage"], + // prettier-ignore + useUpdateFeedMessage: useUpdateFeedMessage_withBoundRoomContext as TRoomBundle["useUpdateFeedMessage"], + // prettier-ignore useCreateThread: useCreateThread_withBoundRoomContext as TRoomBundle["useCreateThread"], // prettier-ignore useDeleteThread: useDeleteThread_withBoundRoomContext as TRoomBundle["useDeleteThread"], @@ -3832,6 +4222,22 @@ export function createRoomContext< // prettier-ignore useThreads: useThreadsSuspense_withBoundRoomContext as TRoomBundle["suspense"]["useThreads"], // prettier-ignore + useFeeds: useFeedsSuspense_withBoundRoomContext as TRoomBundle["suspense"]["useFeeds"], + // prettier-ignore + useFeedMessages: useFeedMessagesSuspense_withBoundRoomContext as TRoomBundle["suspense"]["useFeedMessages"], + // prettier-ignore + useCreateFeed: useCreateFeed_withBoundRoomContext as TRoomBundle["suspense"]["useCreateFeed"], + // prettier-ignore + useDeleteFeed: useDeleteFeed_withBoundRoomContext as TRoomBundle["suspense"]["useDeleteFeed"], + // prettier-ignore + useUpdateFeedMetadata: useUpdateFeedMetadata_withBoundRoomContext as TRoomBundle["suspense"]["useUpdateFeedMetadata"], + // prettier-ignore + useCreateFeedMessage: useCreateFeedMessage_withBoundRoomContext as TRoomBundle["suspense"]["useCreateFeedMessage"], + // prettier-ignore + useDeleteFeedMessage: useDeleteFeedMessage_withBoundRoomContext as TRoomBundle["suspense"]["useDeleteFeedMessage"], + // prettier-ignore + useUpdateFeedMessage: useUpdateFeedMessage_withBoundRoomContext as TRoomBundle["suspense"]["useUpdateFeedMessage"], + // prettier-ignore useCreateThread: useCreateThread_withBoundRoomContext as TRoomBundle["suspense"]["useCreateThread"], // prettier-ignore useDeleteThread: useDeleteThread_withBoundRoomContext as TRoomBundle["suspense"]["useDeleteThread"], @@ -3884,7 +4290,7 @@ export function createRoomContext< }); } -type TypedBundle = RoomContextBundle; +type TypedBundle = RoomContextBundle; /** * Makes a Room available in the component hierarchy below. @@ -4137,6 +4543,40 @@ const _useOthersMappedSuspense: TypedBundle["suspense"]["useOthersMapped"] = */ const _useThreads: TypedBundle["useThreads"] = useThreads; +/** + * Returns feeds for the current room. + * + * @example + * const { feeds, error, isLoading } = useFeeds(); + */ +const _useFeeds: TypedBundle["useFeeds"] = useFeeds; + +/** + * Returns messages for a specific feed in the current room. + * + * @example + * const { messages, error, isLoading } = useFeedMessages("feed-id"); + */ +const _useFeedMessages: TypedBundle["useFeedMessages"] = useFeedMessages; + +/** + * Returns feeds for the current room. + * + * @example + * const { feeds } = useFeeds(); + */ +const _useFeedsSuspense: TypedBundle["suspense"]["useFeeds"] = + useFeedsSuspense; + +/** + * Returns messages for a specific feed in the current room. + * + * @example + * const { messages } = useFeedMessages("feed-id"); + */ +const _useFeedMessagesSuspense: TypedBundle["suspense"]["useFeedMessages"] = + useFeedMessagesSuspense; + /** * Returns the result of searching comments by text in the current room. The result includes the id and the plain text content of the matched comments along with the parent thread id of the comment. * @@ -4464,11 +4904,15 @@ export { useCanRedo, useCanUndo, _useCreateComment as useCreateComment, + useCreateFeed, + useCreateFeedMessage, useCreateRoomComment, useCreateRoomThread, useCreateTextMention, _useCreateThread as useCreateThread, useDeleteComment, + useDeleteFeed, + useDeleteFeedMessage, useDeleteRoomComment, useDeleteRoomThread, useDeleteTextMention, @@ -4480,6 +4924,10 @@ export { useEditRoomThreadMetadata, _useEditThreadMetadata as useEditThreadMetadata, _useEventListener as useEventListener, + _useFeedMessages as useFeedMessages, + _useFeedMessagesSuspense as useFeedMessagesSuspense, + _useFeeds as useFeeds, + _useFeedsSuspense as useFeedsSuspense, useHistory, useHistoryVersionData, _useHistoryVersions as useHistoryVersions, @@ -4530,6 +4978,8 @@ export { useUndo, useUnsubscribeFromRoomThread, useUnsubscribeFromThread, + useUpdateFeedMessage, + useUpdateFeedMetadata, _useUpdateMyPresence as useUpdateMyPresence, useUpdateRoomSubscriptionSettings, useYjsProvider, diff --git a/packages/liveblocks-react/src/suspense.ts b/packages/liveblocks-react/src/suspense.ts index 5f38ff63f99..cced0c0c5dd 100644 --- a/packages/liveblocks-react/src/suspense.ts +++ b/packages/liveblocks-react/src/suspense.ts @@ -49,8 +49,12 @@ export { useCanRedo, useCanUndo, useCreateComment, + useCreateFeed, + useCreateFeedMessage, useCreateThread, useDeleteComment, + useDeleteFeed, + useDeleteFeedMessage, useDeleteThread, useEditComment, useEditThreadMetadata, @@ -74,6 +78,8 @@ export { useStorageRoot, useThreadSubscription, useUndo, + useUpdateFeedMetadata, + useUpdateFeedMessage, useUpdateMyPresence, useUpdateRoomSubscriptionSettings, } from "./room"; @@ -91,6 +97,8 @@ export { useAttachmentUrlSuspense as useAttachmentUrl, useHistoryVersionsSuspense as useHistoryVersions, useRoomSubscriptionSettingsSuspense as useRoomSubscriptionSettings, + useFeedsSuspense as useFeeds, + useFeedMessagesSuspense as useFeedMessages, } from "./room"; export { useInboxNotificationsSuspense as useInboxNotifications, diff --git a/packages/liveblocks-react/src/types/index.ts b/packages/liveblocks-react/src/types/index.ts index d56ab6bd712..f8229a53fd0 100644 --- a/packages/liveblocks-react/src/types/index.ts +++ b/packages/liveblocks-react/src/types/index.ts @@ -29,6 +29,11 @@ import type { CommentData, DGI, DRI, + Feed, + FeedCreateMetadata, + FeedFetchMetadataFilter, + FeedMessage, + FeedUpdateMetadata, GroupData, HistoryVersion, InboxNotificationData, @@ -210,6 +215,42 @@ export type UseInboxNotificationsOptions = { query?: InboxNotificationsQuery; }; +export type UseFeedsOptions = { + /** + * Optional timestamp filter. Applied to the client-side cache for this hook’s + * options: only feeds whose `createdAt` or `updatedAt` is at or after this + * timestamp (ms) are included in `feeds`. + */ + since?: number; + /** + * Optional metadata filter (`Record`). Applied to the + * client-side cache: only feeds whose metadata matches every key/value pair + * are included in `feeds`. + */ + metadata?: FeedFetchMetadataFilter; + /** + * Page size for each server request when loading or loading more feeds. This + * does **not** cap the length of `feeds`—use pagination (`fetchMore`, + * `hasFetchedAll`) until you have loaded every page. Different hooks with + * different `limit` values still share one cache per room; each hook’s + * `feeds` array is filtered and sorted independently. + */ + limit?: number; +}; + +export type UseFeedMessagesOptions = { + /** + * Optional cursor for pagination. + */ + cursor?: string; + /** + * Page size for each server request when loading or loading more messages. + * Does **not** cap the length of `messages`—pagination loads additional pages + * until `hasFetchedAll` is true. + */ + limit?: number; +}; + export type UserAsyncResult = AsyncResult; export type UserAsyncSuccess = AsyncSuccess; @@ -296,6 +337,12 @@ export type SearchCommentsAsyncResult = AsyncResult, export type InboxNotificationsAsyncSuccess = PagedAsyncSuccess; // prettier-ignore export type InboxNotificationsAsyncResult = PagedAsyncResult; // prettier-ignore +export type FeedsAsyncSuccess = PagedAsyncSuccess[], "feeds">; // prettier-ignore +export type FeedsAsyncResult = PagedAsyncResult[], "feeds">; // prettier-ignore + +export type FeedMessagesAsyncSuccess = PagedAsyncSuccess[], "messages">; // prettier-ignore +export type FeedMessagesAsyncResult = PagedAsyncResult[], "messages">; // prettier-ignore + export type UnreadInboxNotificationsCountAsyncSuccess = AsyncSuccess; // prettier-ignore export type UnreadInboxNotificationsCountAsyncResult = AsyncResult; // prettier-ignore @@ -601,13 +648,15 @@ type RoomContextBundleCommon< E extends Json, TM extends BaseMetadata, CM extends BaseMetadata, + FM extends Json = Json, + FMD extends Json = Json, > = { /** * You normally don't need to directly interact with the RoomContext, but * it can be necessary if you're building an advanced app where you need to * set up a context bridge between two React renderers. */ - RoomContext: Context | null>; + RoomContext: Context | null>; /** * Makes a Room available in the component hierarchy below. @@ -620,10 +669,12 @@ type RoomContextBundleCommon< * Returns the Room of the nearest RoomProvider above in the React component * tree. */ - useRoom(options?: { allowOutsideRoom: false }): Room; + useRoom(options?: { + allowOutsideRoom: false; + }): Room; useRoom(options: { allowOutsideRoom: boolean; - }): Room | null; + }): Room | null; /** * Returns the current connection status for the Room, and triggers @@ -1084,8 +1135,10 @@ export type RoomContextBundle< E extends Json, TM extends BaseMetadata, CM extends BaseMetadata, + FM extends Json = Json, + FMD extends Json = Json, > = Resolve< - RoomContextBundleCommon & + RoomContextBundleCommon & SharedContextBundle["classic"] & { /** * Extract arbitrary data from the Liveblocks Storage state, using an @@ -1158,6 +1211,97 @@ export type RoomContextBundle< */ useThreads(options?: UseThreadsOptions): ThreadsAsyncResult; + /** + * Returns feeds for the current room. + * + * @example + * const { feeds, error, isLoading } = useFeeds(); + */ + useFeeds(options?: UseFeedsOptions): FeedsAsyncResult; + + /** + * Returns messages for a specific feed in the current room. + * + * @example + * const { messages, error, isLoading } = useFeedMessages("feed-id"); + */ + useFeedMessages( + feedId: string, + options?: UseFeedMessagesOptions + ): FeedMessagesAsyncResult; + + /** + * Returns a function that creates a new feed in the current room. + * + * @example + * const createFeed = useCreateFeed(); + * createFeed("feed-id", { metadata: { name: "My Feed" } }); + */ + useCreateFeed(): ( + feedId: string, + options?: { metadata?: FeedCreateMetadata; createdAt?: number } + ) => Promise; + + /** + * Returns a function that deletes a feed from the current room. + * + * @example + * const deleteFeed = useDeleteFeed(); + * deleteFeed("feed-id"); + */ + useDeleteFeed(): (feedId: string) => Promise; + + /** + * Returns a function that updates a feed's metadata in the current room. + * + * @example + * const updateFeedMetadata = useUpdateFeedMetadata(); + * updateFeedMetadata("feed-id", { name: "Updated Name" }); + */ + useUpdateFeedMetadata(): ( + feedId: string, + metadata: FeedUpdateMetadata + ) => Promise; + + /** + * Returns a function that adds a message to a feed in the current room. + * + * @example + * const createFeedMessage = useCreateFeedMessage(); + * createFeedMessage("feed-id", { text: "Hello" }); + */ + useCreateFeedMessage(): ( + feedId: string, + data: JsonObject, + options?: { id?: string; createdAt?: number } + ) => Promise; + + /** + * Returns a function that deletes a message from a feed in the current room. + * + * @example + * const deleteFeedMessage = useDeleteFeedMessage(); + * deleteFeedMessage("feed-id", "message-id"); + */ + useDeleteFeedMessage(): ( + feedId: string, + messageId: string + ) => Promise; + + /** + * Returns a function that updates a feed message in the current room. + * + * @example + * const updateFeedMessage = useUpdateFeedMessage(); + * updateFeedMessage("feed-id", "message-id", { text: "Updated" }); + */ + useUpdateFeedMessage(): ( + feedId: string, + messageId: string, + data: JsonObject, + options?: { updatedAt?: number } + ) => Promise; + /** * Returns the result of searching comments by text in the current room. The result includes the id and the plain text content of the matched comments along with the parent thread id of the comment. * @@ -1205,7 +1349,7 @@ export type RoomContextBundle< useHistoryVersionData(id: string): HistoryVersionDataAsyncResult; suspense: Resolve< - RoomContextBundleCommon & + RoomContextBundleCommon & SharedContextBundle["suspense"] & { /** * Extract arbitrary data from the Liveblocks Storage state, using an @@ -1274,6 +1418,49 @@ export type RoomContextBundle< options?: UseThreadsOptions ): ThreadsAsyncSuccess; + /** + * Returns feeds for the current room. + * + * @example + * const { feeds } = useFeeds(); + */ + useFeeds(options?: UseFeedsOptions): FeedsAsyncSuccess; + + /** + * Returns messages for a specific feed in the current room. + * + * @example + * const { messages } = useFeedMessages("feed-id"); + */ + useFeedMessages( + feedId: string, + options?: UseFeedMessagesOptions + ): FeedMessagesAsyncSuccess; + useCreateFeed(): ( + feedId: string, + options?: { metadata?: FeedCreateMetadata; createdAt?: number } + ) => Promise; + useDeleteFeed(): (feedId: string) => Promise; + useUpdateFeedMetadata(): ( + feedId: string, + metadata: FeedUpdateMetadata + ) => Promise; + useCreateFeedMessage(): ( + feedId: string, + data: JsonObject, + options?: { id?: string; createdAt?: number } + ) => Promise; + useDeleteFeedMessage(): ( + feedId: string, + messageId: string + ) => Promise; + useUpdateFeedMessage(): ( + feedId: string, + messageId: string, + data: JsonObject, + options?: { updatedAt?: number } + ) => Promise; + /** * (Private beta) Returns a history of versions of the current room. * diff --git a/packages/liveblocks-react/src/umbrella-store.ts b/packages/liveblocks-react/src/umbrella-store.ts index 59eb0af6f57..4c8f17110b0 100644 --- a/packages/liveblocks-react/src/umbrella-store.ts +++ b/packages/liveblocks-react/src/umbrella-store.ts @@ -9,6 +9,9 @@ import type { CommentUserReaction, Cursor, DistributiveOmit, + Feed, + FeedFetchMetadataFilter, + FeedMessage, HistoryVersion, InboxNotificationData, InboxNotificationDeleteInfo, @@ -60,6 +63,8 @@ import type { AiChatAsyncResult, AiChatMessagesAsyncResult, AiChatsAsyncResult, + FeedMessagesAsyncResult, + FeedsAsyncResult, HistoryVersionsAsyncResult, InboxNotificationsAsyncResult, InboxNotificationsQuery, @@ -285,6 +290,25 @@ export function makeInboxNotificationsQueryKey( return stableStringify(query ?? {}); } +export function makeFeedsQueryKey( + roomId: string, + options?: { + since?: number; + metadata?: FeedFetchMetadataFilter; + limit?: number; + } +) { + return stableStringify([roomId, options ?? {}]); +} + +export function makeFeedMessagesQueryKey( + roomId: string, + feedId: string, + options?: { cursor?: string; limit?: number } +) { + return stableStringify([roomId, feedId, options ?? {}]); +} + /** * Like Promise, except it will have a synchronously readable `status` * field, indicating the status of the promise. @@ -362,10 +386,9 @@ const noop = Promise.resolve(); * - When calling the getter multiple times, the return value is always * referentially equal to the previous call. * - * - When in this error state, the error will remain in error state for - * 5 seconds. After those 5 seconds, the resource status gets reset, and the - * next time the "getter" is accessed, the resource will re-initiate the - * initial fetching process. + * - With `autoRetry` enabled (default), an initial-fetch error stays for 5 seconds, + * then the resource resets to loading so the next getter access can retry. + * With `autoRetry` disabled, the error persists (no automatic reset). * * - This class exposes an Observable that is notified whenever the state * changes. For now, this observable can be used to call a no-op update to @@ -398,11 +421,16 @@ export class PaginatedResource { #fetchPage: (cursor?: string) => Promise; #pendingFetchMore: Promise | null; + #autoRetry: boolean; - constructor(fetchPage: (cursor?: string) => Promise) { + constructor( + fetchPage: (cursor?: string) => Promise, + options?: { autoRetry?: boolean } + ) { this.#signal = new Signal>(ASYNC_LOADING); this.#fetchPage = fetchPage; this.#pendingFetchMore = null; + this.#autoRetry = options?.autoRetry ?? true; this.signal = this.#signal.asReadonly(); autobind(this); @@ -480,11 +508,13 @@ export class PaginatedResource { // Wrap the request to load room threads (and notifications) in an auto-retry function so that if the request fails, // we retry for at most 5 times with incremental backoff delays. If all retries fail, the auto-retry function throws an error - const initialPageFetch$ = autoRetry( - () => this.#fetchPage(/* cursor */ undefined), - 5, - [5000, 5000, 10000, 15000] - ); + const initialPageFetch$ = this.#autoRetry + ? autoRetry( + () => this.#fetchPage(/* cursor */ undefined), + 5, + [5000, 5000, 10000, 15000] + ) + : Promise.resolve().then(() => this.#fetchPage(/* cursor */ undefined)); const promise = usify(initialPageFetch$); @@ -507,11 +537,13 @@ export class PaginatedResource { (err) => { this.#signal.set(ASYNC_ERR(err as Error)); - // Wait for 5 seconds before removing the request - setTimeout(() => { - this.#cachedPromise = null; - this.#signal.set(ASYNC_LOADING); - }, 5_000); + if (this.#autoRetry) { + // Wait for 5 seconds before removing the request + setTimeout(() => { + this.#cachedPromise = null; + this.#signal.set(ASYNC_LOADING); + }, 5_000); + } } ); @@ -1039,6 +1071,106 @@ function createStore_forOptimistic< }; } +function createStore_forFeeds() { + const signal = new MutableSignal>>(new Map()); + + function upsert(roomId: string, feeds: readonly Feed[]) { + signal.mutate((map) => { + let roomMap = map.get(roomId); + if (!roomMap) { + roomMap = new Map(); + map.set(roomId, roomMap); + } + for (const feed of feeds) { + roomMap.set(feed.feedId, feed); + } + }); + } + + function deleteOne(roomId: string, feedId: string) { + signal.mutate((map) => { + map.get(roomId)?.delete(feedId); + }); + } + + function findMany( + feedsByRoomId: Map>, + roomId: string, + options?: { metadata?: FeedFetchMetadataFilter; since?: number } + ): Feed[] { + const filtered = Array.from( + feedsByRoomId.get(roomId)?.values() ?? [] + ).filter((feed) => { + if ( + options?.since !== undefined && + feed.updatedAt < options.since && + feed.createdAt < options.since + ) { + return false; + } + if (options?.metadata !== undefined) { + const meta = feed.metadata as Record; + if ( + !Object.entries(options.metadata).every(([k, v]) => meta[k] === v) + ) { + return false; + } + } + return true; + }); + // Match useThreads room order: chronological by createdAt ascending (stable tie-break on feedId). + filtered.sort((a, b) => { + const byTime = a.createdAt - b.createdAt; + if (byTime !== 0) return byTime; + return a.feedId < b.feedId ? -1 : a.feedId > b.feedId ? 1 : 0; + }); + return filtered; + } + + return { signal, upsert, delete: deleteOne, findMany }; +} + +function createStore_forFeedMessages() { + const signal = new MutableSignal>>( + new Map() + ); + + function upsert(feedId: string, messages: readonly FeedMessage[]) { + signal.mutate((map) => { + let feedMap = map.get(feedId); + if (!feedMap) { + feedMap = new Map(); + map.set(feedId, feedMap); + } + for (const msg of messages) { + feedMap.set(msg.id, msg); + } + }); + } + + function deleteOne(feedId: string, messageIds: readonly string[]) { + signal.mutate((map) => { + const feedMap = map.get(feedId); + if (feedMap) { + for (const id of messageIds) { + feedMap.delete(id); + } + } + }); + } + + function findMany( + messagesByFeedId: Map>, + feedId: string + ): FeedMessage[] { + return Array.from(messagesByFeedId.get(feedId)?.values() ?? []).sort( + (a, b) => a.createdAt - b.createdAt + ); + } + + return { signal, upsert, delete: deleteOne, findMany }; +} + export class UmbrellaStore { #client: Client; @@ -1148,6 +1280,14 @@ export class UmbrellaStore { string, LoadableResource >; + readonly loadingFeeds: DefaultMap< + string, + LoadableResource + >; + readonly loadingFeedMessages: DefaultMap< + string, + LoadableResource + >; }; // Notifications @@ -1165,6 +1305,10 @@ export class UmbrellaStore { // Notification Settings #notificationSettings: SinglePageResource; + // Feeds + readonly #feeds = createStore_forFeeds(); + readonly #feedMessages = createStore_forFeedMessages(); + constructor(client: OpaqueClient) { this.#client = client[kInternal].as(); @@ -1653,6 +1797,126 @@ export class UmbrellaStore { } ); + const loadingFeeds = new DefaultMap( + (queryKey: string): LoadableResource => { + const [roomId, options] = JSON.parse(queryKey) as [ + roomId: RoomId, + options?: { + since?: number; + metadata?: FeedFetchMetadataFilter; + limit?: number; + }, + ]; + + const resource = new PaginatedResource( + async (cursor?: string) => { + const room = this.#client.getRoom(roomId); + if (room === null) { + throw new Error( + `Room '${roomId}' is not available on client. Make sure you're calling useFeeds inside a RoomProvider.` + ); + } + + const result = await room.fetchFeeds({ + cursor, + since: options?.since, + metadata: options?.metadata, + limit: options?.limit, + }); + + this.upsertFeeds(roomId, result.feeds); + + return result.nextCursor ?? null; + }, + { autoRetry: false } + ); + + const signal = DerivedSignal.from( + resource.signal, + this.#feeds.signal, + (resourceResult, feedsByRoomId): FeedsAsyncResult => { + if (resourceResult.isLoading || resourceResult.error) { + return resourceResult; + } + + const feeds = this.#feeds.findMany(feedsByRoomId, roomId, options); + + const page = resourceResult.data; + return { + isLoading: false, + feeds, + hasFetchedAll: page.hasFetchedAll, + isFetchingMore: page.isFetchingMore, + fetchMoreError: page.fetchMoreError, + fetchMore: page.fetchMore, + }; + }, + shallow2 + ); + + return { signal, waitUntilLoaded: resource.waitUntilLoaded }; + } + ); + + const loadingFeedMessages = new DefaultMap( + (queryKey: string): LoadableResource => { + const [roomId, feedId, options] = JSON.parse(queryKey) as [ + roomId: RoomId, + feedId: string, + options?: { cursor?: string; limit?: number }, + ]; + + const resource = new PaginatedResource( + async (cursor?: string) => { + const room = this.#client.getRoom(roomId); + if (room === null) { + throw new Error( + `Room '${roomId}' is not available on client. Make sure you're calling useFeedMessages inside a RoomProvider.` + ); + } + + const result = await room.fetchFeedMessages(feedId, { + cursor, + limit: options?.limit, + }); + + this.upsertFeedMessages(roomId, feedId, result.messages); + + return result.nextCursor ?? null; + }, + { autoRetry: false } + ); + + const signal = DerivedSignal.from( + resource.signal, + this.#feedMessages.signal, + (resourceResult, messagesByFeedId): FeedMessagesAsyncResult => { + if (resourceResult.isLoading || resourceResult.error) { + return resourceResult; + } + + const messages = this.#feedMessages.findMany( + messagesByFeedId, + feedId + ); + + const page = resourceResult.data; + return { + isLoading: false, + messages, + hasFetchedAll: page.hasFetchedAll, + isFetchingMore: page.isFetchingMore, + fetchMoreError: page.fetchMoreError, + fetchMore: page.fetchMore, + }; + }, + shallow2 + ); + + return { signal, waitUntilLoaded: resource.waitUntilLoaded }; + } + ); + this.outputs = { threadifications, threads, @@ -1669,6 +1933,8 @@ export class UmbrellaStore { messagesByChatId, aiChatById, urlMetadataByUrl, + loadingFeeds, + loadingFeedMessages, }; // Auto-bind all of this class' methods here, so we can use stable @@ -2001,6 +2267,42 @@ export class UmbrellaStore { ); } + /** + * Upserts feeds in the cache (for list/added/updated operations). + */ + public upsertFeeds(roomId: RoomId, feeds: readonly Feed[]): void { + this.#feeds.upsert(roomId, feeds); + } + + /** + * Removes a feed from the cache (for deleted operations). + */ + public deleteFeed(roomId: RoomId, feedId: string): void { + this.#feeds.delete(roomId, feedId); + } + + /** + * Upserts feed messages in the cache (for list/added/updated operations). + */ + public upsertFeedMessages( + _roomId: RoomId, + feedId: string, + messages: readonly FeedMessage[] + ): void { + this.#feedMessages.upsert(feedId, messages); + } + + /** + * Removes feed messages from the cache (for deleted operations). + */ + public deleteFeedMessages( + _roomId: RoomId, + feedId: string, + messageIds: readonly string[] + ): void { + this.#feedMessages.delete(feedId, messageIds); + } + public async fetchUnreadNotificationsCount( queryKey: InboxNotificationsQueryKey, signal: AbortSignal diff --git a/packages/liveblocks-react/test-d/augmentation.test-d.tsx b/packages/liveblocks-react/test-d/augmentation.test-d.tsx index 9b9907ecda1..dee72a6cf94 100644 --- a/packages/liveblocks-react/test-d/augmentation.test-d.tsx +++ b/packages/liveblocks-react/test-d/augmentation.test-d.tsx @@ -317,7 +317,7 @@ declare global { classic.useErrorListener((err) => { expectType(err.message); expectType(err.stack); - expectType<-1 | 4001 | 4005 | 4006 | (number & {}) | undefined>( + expectType( err.context.code ); expectAssignable< @@ -343,6 +343,7 @@ declare global { | "UPDATE_ROOM_SUBSCRIPTION_SETTINGS_ERROR" | "UPDATE_NOTIFICATION_SETTINGS_ERROR" | "LARGE_MESSAGE_ERROR" + | "FEED_REQUEST_ERROR" >(err.context.type); if (err.context.type === "ROOM_CONNECTION_ERROR") { expectAssignable(err.context.code); @@ -362,7 +363,7 @@ declare global { suspense.useErrorListener((err) => { expectType(err.message); expectType(err.stack); - expectType<-1 | 4001 | 4005 | 4006 | (number & {}) | undefined>( + expectType( err.context.code ); expectAssignable< @@ -388,6 +389,7 @@ declare global { | "UPDATE_ROOM_SUBSCRIPTION_SETTINGS_ERROR" | "UPDATE_NOTIFICATION_SETTINGS_ERROR" | "LARGE_MESSAGE_ERROR" + | "FEED_REQUEST_ERROR" >(err.context.type); if (err.context.type === "ROOM_CONNECTION_ERROR") { expectAssignable(err.context.code); diff --git a/packages/liveblocks-react/test-d/factories.test-d.tsx b/packages/liveblocks-react/test-d/factories.test-d.tsx index dc973aa7bd4..59010608ac9 100644 --- a/packages/liveblocks-react/test-d/factories.test-d.tsx +++ b/packages/liveblocks-react/test-d/factories.test-d.tsx @@ -358,7 +358,7 @@ ctx.useOthersListener(({ user, type }) => { ctx.useErrorListener((err) => { expectType(err.message); expectType(err.stack); - expectType<-1 | 4001 | 4005 | 4006 | (number & {}) | undefined>( + expectType( err.context.code ); expectAssignable< @@ -384,6 +384,7 @@ ctx.useOthersListener(({ user, type }) => { | "UPDATE_ROOM_SUBSCRIPTION_SETTINGS_ERROR" | "UPDATE_NOTIFICATION_SETTINGS_ERROR" | "LARGE_MESSAGE_ERROR" + | "FEED_REQUEST_ERROR" >(err.context.type); if (err.context.type === "ROOM_CONNECTION_ERROR") { expectAssignable(err.context.code); @@ -400,7 +401,7 @@ ctx.useOthersListener(({ user, type }) => { lbctx.useErrorListener((err) => { expectType(err.message); expectType(err.stack); - expectType<-1 | 4001 | 4005 | 4006 | (number & {}) | undefined>( + expectType( err.context.code ); expectAssignable< @@ -426,6 +427,7 @@ ctx.useOthersListener(({ user, type }) => { | "UPDATE_ROOM_SUBSCRIPTION_SETTINGS_ERROR" | "UPDATE_NOTIFICATION_SETTINGS_ERROR" | "LARGE_MESSAGE_ERROR" + | "FEED_REQUEST_ERROR" >(err.context.type); if (err.context.type === "ROOM_CONNECTION_ERROR") { expectAssignable(err.context.code); @@ -442,7 +444,7 @@ ctx.useOthersListener(({ user, type }) => { lbctx.suspense.useErrorListener((err) => { expectType(err.message); expectType(err.stack); - expectType<-1 | 4001 | 4005 | 4006 | (number & {}) | undefined>( + expectType( err.context.code ); expectAssignable< @@ -468,6 +470,7 @@ ctx.useOthersListener(({ user, type }) => { | "UPDATE_ROOM_SUBSCRIPTION_SETTINGS_ERROR" | "UPDATE_NOTIFICATION_SETTINGS_ERROR" | "LARGE_MESSAGE_ERROR" + | "FEED_REQUEST_ERROR" >(err.context.type); if (err.context.type === "ROOM_CONNECTION_ERROR") { expectAssignable(err.context.code); 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 5eb452edcae..3ac08f54fbb 100644 --- a/packages/liveblocks-react/test-d/no-augmentation.test-d.tsx +++ b/packages/liveblocks-react/test-d/no-augmentation.test-d.tsx @@ -209,7 +209,7 @@ import { expectAssignable, expectError, expectType } from "tsd"; classic.useErrorListener((err) => { expectType(err.message); expectType(err.stack); - expectType<-1 | 4001 | 4005 | 4006 | (number & {}) | undefined>( + expectType( err.context.code ); expectAssignable< @@ -235,6 +235,7 @@ import { expectAssignable, expectError, expectType } from "tsd"; | "UPDATE_ROOM_SUBSCRIPTION_SETTINGS_ERROR" | "UPDATE_NOTIFICATION_SETTINGS_ERROR" | "LARGE_MESSAGE_ERROR" + | "FEED_REQUEST_ERROR" >(err.context.type); if (err.context.type === "ROOM_CONNECTION_ERROR") { expectAssignable(err.context.code); @@ -254,7 +255,7 @@ import { expectAssignable, expectError, expectType } from "tsd"; suspense.useErrorListener((err) => { expectType(err.message); expectType(err.stack); - expectType<-1 | 4001 | 4005 | 4006 | (number & {}) | undefined>( + expectType( err.context.code ); expectAssignable< @@ -280,6 +281,7 @@ import { expectAssignable, expectError, expectType } from "tsd"; | "UPDATE_ROOM_SUBSCRIPTION_SETTINGS_ERROR" | "UPDATE_NOTIFICATION_SETTINGS_ERROR" | "LARGE_MESSAGE_ERROR" + | "FEED_REQUEST_ERROR" >(err.context.type); if (err.context.type === "ROOM_CONNECTION_ERROR") { expectAssignable(err.context.code); diff --git a/packages/liveblocks-redux/package.json b/packages/liveblocks-redux/package.json index 9e256d73f09..428d4ec4ed5 100644 --- a/packages/liveblocks-redux/package.json +++ b/packages/liveblocks-redux/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/redux", - "version": "3.15.5", + "version": "3.16.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.", @@ -35,8 +35,8 @@ "test:watch": "vitest" }, "dependencies": { - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5" + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0" }, "peerDependencies": { "redux": "^4 || ^5" diff --git a/packages/liveblocks-yjs/package.json b/packages/liveblocks-yjs/package.json index e7c8963d8a9..f748736aace 100644 --- a/packages/liveblocks-yjs/package.json +++ b/packages/liveblocks-yjs/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/yjs", - "version": "3.15.5", + "version": "3.16.0", "description": "Integrate your existing or new Yjs documents with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", @@ -35,8 +35,8 @@ "test:watch": "vitest" }, "dependencies": { - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5", + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0", "@noble/hashes": "^1.8.0", "js-base64": "^3.7.7", "y-indexeddb": "^9.0.12" diff --git a/packages/liveblocks-zustand/package.json b/packages/liveblocks-zustand/package.json index 2758e7e92b3..99287632bf8 100644 --- a/packages/liveblocks-zustand/package.json +++ b/packages/liveblocks-zustand/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/zustand", - "version": "3.15.5", + "version": "3.16.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.", @@ -36,8 +36,8 @@ "test:watch": "vitest" }, "dependencies": { - "@liveblocks/client": "3.15.5", - "@liveblocks/core": "3.15.5" + "@liveblocks/client": "3.16.0", + "@liveblocks/core": "3.16.0" }, "peerDependencies": { "zustand": "^5.0.1"