From 446c5fa29a163eb0fa4e4d7c32c0b5d57ab760a7 Mon Sep 17 00:00:00 2001 From: xingsy97 <87063252+xingsy97@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:02:22 +0800 Subject: [PATCH 1/2] unify commits --- sdk/webpubsub-chat-client/.gitignore | 7 + sdk/webpubsub-chat-client/.yarnrc.yml | 7 + sdk/webpubsub-chat-client/README.md | 121 ++ .../examples/quickstart/.yarnrc.yml | 5 + .../examples/quickstart/README.md | 44 + .../examples/quickstart/client.js | 100 ++ .../examples/quickstart/package.json | 17 + .../examples/quickstart/server.js | 30 + sdk/webpubsub-chat-client/package.json | 69 + .../scripts/esbuild.config.mjs | 43 + .../scripts/pack-for-publish.mjs | 43 + sdk/webpubsub-chat-client/src/chatClient.ts | 307 ++++ sdk/webpubsub-chat-client/src/constant.ts | 19 + .../src/generatedTypes.ts | 408 +++++ sdk/webpubsub-chat-client/src/index.ts | 15 + sdk/webpubsub-chat-client/src/logger.ts | 6 + sdk/webpubsub-chat-client/src/utils.ts | 31 + .../swagger/openapi.yaml | 732 ++++++++ .../tests/integration.test.ts | 189 ++ sdk/webpubsub-chat-client/tests/testUtils.ts | 56 + sdk/webpubsub-chat-client/tsconfig.json | 18 + sdk/webpubsub-chat-client/yarn.lock | 1581 +++++++++++++++++ 22 files changed, 3848 insertions(+) create mode 100644 sdk/webpubsub-chat-client/.gitignore create mode 100644 sdk/webpubsub-chat-client/.yarnrc.yml create mode 100644 sdk/webpubsub-chat-client/README.md create mode 100644 sdk/webpubsub-chat-client/examples/quickstart/.yarnrc.yml create mode 100644 sdk/webpubsub-chat-client/examples/quickstart/README.md create mode 100644 sdk/webpubsub-chat-client/examples/quickstart/client.js create mode 100644 sdk/webpubsub-chat-client/examples/quickstart/package.json create mode 100644 sdk/webpubsub-chat-client/examples/quickstart/server.js create mode 100644 sdk/webpubsub-chat-client/package.json create mode 100644 sdk/webpubsub-chat-client/scripts/esbuild.config.mjs create mode 100644 sdk/webpubsub-chat-client/scripts/pack-for-publish.mjs create mode 100644 sdk/webpubsub-chat-client/src/chatClient.ts create mode 100644 sdk/webpubsub-chat-client/src/constant.ts create mode 100644 sdk/webpubsub-chat-client/src/generatedTypes.ts create mode 100644 sdk/webpubsub-chat-client/src/index.ts create mode 100644 sdk/webpubsub-chat-client/src/logger.ts create mode 100644 sdk/webpubsub-chat-client/src/utils.ts create mode 100644 sdk/webpubsub-chat-client/swagger/openapi.yaml create mode 100644 sdk/webpubsub-chat-client/tests/integration.test.ts create mode 100644 sdk/webpubsub-chat-client/tests/testUtils.ts create mode 100644 sdk/webpubsub-chat-client/tsconfig.json create mode 100644 sdk/webpubsub-chat-client/yarn.lock diff --git a/sdk/webpubsub-chat-client/.gitignore b/sdk/webpubsub-chat-client/.gitignore new file mode 100644 index 000000000..89827b2e1 --- /dev/null +++ b/sdk/webpubsub-chat-client/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +types +tsconfig.tsbuildinfo +.env +.yarn +*.tgz diff --git a/sdk/webpubsub-chat-client/.yarnrc.yml b/sdk/webpubsub-chat-client/.yarnrc.yml new file mode 100644 index 000000000..58ee78802 --- /dev/null +++ b/sdk/webpubsub-chat-client/.yarnrc.yml @@ -0,0 +1,7 @@ +nodeLinker: node-modules + +# Temporary: Use MyGet feed for @azure scoped packages during preview. +# This will be removed once the package is published to npm. +npmScopes: + azure: + npmRegistryServer: "https://www.myget.org/F/azure-signalr-dev/npm/" diff --git a/sdk/webpubsub-chat-client/README.md b/sdk/webpubsub-chat-client/README.md new file mode 100644 index 000000000..b566f08cd --- /dev/null +++ b/sdk/webpubsub-chat-client/README.md @@ -0,0 +1,121 @@ +# Azure Web PubSub Chat Client SDK + +A client SDK for building chat applications with Azure Web PubSub. + +> ⚠️ **Internal Preview**: This package is currently for internal use only and is not ready for production. + +## Installation + +```bash +npm install @azure/web-pubsub-chat-client +``` + +## Quick Start + +For a complete example, see [examples/quickstart](./examples/quickstart). + +```javascript +import { ChatClient } from '@azure/web-pubsub-chat-client'; + +// Get client access URL from your server +const url = await fetch('/negotiate?userId=alice').then(r => r.json()).then(d => d.url); + +// Option 1: Login with an existing WebPubSubClient +const wpsClient = new WebPubSubClient(url); +const client = await ChatClient.login(wpsClient); + +// Option 2: Login directly with URL +// const client = await new ChatClient(url).login(); + +console.log(`Logged in as: ${client.userId}`); + +// Listen for events +client.addListenerForNewMessage((notification) => { + const msg = notification.message; + console.log(`${msg.createdBy}: ${msg.content.text}`); +}); + +client.addListenerForNewRoom((room) => { + console.log(`Joined room: ${room.title}`); +}); + +// Create a room and send messages +const room = await client.createRoom('My Room', ['bob']); +await client.sendToRoom(room.roomId, 'Hello!'); + +// Get message history +const history = await client.listRoomMessage(room.roomId, null, null); + +// Manage room members +await client.addUserToRoom(room.roomId, 'charlie'); +await client.removeUserFromRoom(room.roomId, 'charlie'); + +// Cleanup +client.stop(); +``` + +## API + +### ChatClient + +#### Constructor + +```typescript +// With existing WebPubSubClient +new ChatClient(wpsClient: WebPubSubClient) + +// With client access URL +new ChatClient(clientAccessUrl: string, options?: WebPubSubClientOptions) + +// With credential +new ChatClient(credential: WebPubSubClientCredential, options?: WebPubSubClientOptions) +``` + +#### Static Methods + +| Method | Description | +|--------|-------------| +| `ChatClient.login(wpsClient)` | Create and login using an existing WebPubSubClient | + +#### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `userId` | `string` | Current user's ID (throws if not logged in) | +| `rooms` | `RoomInfo[]` | List of joined rooms | +| `connection` | `WebPubSubClient` | Underlying WebPubSub connection | + +#### Methods + +| Method | Description | +|--------|-------------| +| `login()` | Connect and authenticate, returns `ChatClient` | +| `stop()` | Disconnect | +| `createRoom(title, members, roomId?)` | Create a new room with initial members | +| `getRoom(roomId, withMembers)` | Get room info | +| `addUserToRoom(roomId, userId)` | Add user to room (admin operation) | +| `removeUserFromRoom(roomId, userId)` | Remove user from room (admin operation) | +| `sendToRoom(roomId, message)` | Send text message to room, returns message ID | +| `listRoomMessage(roomId, startId, endId, maxCount?)` | Get room message history | +| `getUserInfo(userId)` | Get user profile | + +#### Event Listeners + +| Method | Callback Parameter | Description | +|--------|-------------------|-------------| +| `addListenerForNewMessage(callback)` | `NewMessageNotificationBody` | New message received | +| `addListenerForNewRoom(callback)` | `RoomInfo` | Joined a new room | +| `addListenerForMemberJoined(callback)` | `MemberJoinedNotificationBody` | Member joined a room | +| `addListenerForMemberLeft(callback)` | `MemberLeftNotificationBody` | Member left a room | +| `addListenerForRoomLeft(callback)` | `RoomLeftNotificationBody` | Self left a room | +| `onConnected(callback)` | `OnConnectedArgs` | Connection established | +| `onDisconnected(callback)` | `OnDisconnectedArgs` | Connection lost | +| `onStopped(callback)` | `OnStoppedArgs` | Connection stopped | + +## Examples + +See the [examples](./examples) directory for complete working examples. + +## License + +MIT diff --git a/sdk/webpubsub-chat-client/examples/quickstart/.yarnrc.yml b/sdk/webpubsub-chat-client/examples/quickstart/.yarnrc.yml new file mode 100644 index 000000000..3b452cd5a --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/quickstart/.yarnrc.yml @@ -0,0 +1,5 @@ +nodeLinker: node-modules + +npmScopes: + azure: + npmRegistryServer: "https://www.myget.org/F/azure-signalr-dev/npm/" diff --git a/sdk/webpubsub-chat-client/examples/quickstart/README.md b/sdk/webpubsub-chat-client/examples/quickstart/README.md new file mode 100644 index 000000000..ca8064c62 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/quickstart/README.md @@ -0,0 +1,44 @@ +# Minimal Example + +A minimal example demonstrating the basic usage of Web PubSub Chat SDK. + +## Prerequisites + +1. An Azure Web PubSub resource with: + - A Persistent Storage configured (Storage Account with Table enabled) + - A Chat hub created (with Chat feature enabled, using the Persistent Storage above) + +## Quick Start + +```bash +yarn install +``` + +### 1. Start the server + +```bash +yarn server -- "" +``` + +Or set the environment variable: + +```bash +export WebPubSubConnectionString="" +yarn server +``` + +### 2. Run the client + +In a new terminal: + +```bash +yarn client +``` + +## What this example does + +1. Creates two chat clients (Alice and Bob) +2. Alice creates a room and invites Bob +3. Alice sends messages to the room +4. Bob receives notifications for new room and messages +5. Lists message history from the room diff --git a/sdk/webpubsub-chat-client/examples/quickstart/client.js b/sdk/webpubsub-chat-client/examples/quickstart/client.js new file mode 100644 index 000000000..24c7aa827 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/quickstart/client.js @@ -0,0 +1,100 @@ +import { ChatClient } from '@azure/web-pubsub-chat-client'; +import { WebPubSubClient } from '@azure/web-pubsub-client'; + +const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000'; + +const getClientAccessUrl = (userId) => + fetch(`${SERVER_URL}/negotiate?userId=${userId}`).then(r => r.json()).then(d => d.url); + +function setupListeners(client) { + // chat event listeners + client.addListenerForNewRoom((room) => { + console.log(`[${client.userId}] joined room "${room.title}" (${room.roomId})`); + }); + client.addListenerForNewMessage((notification) => { + const msg = notification.message; + console.log(`[${client.userId}] received message from ${msg.createdBy}: ${msg.content.text}`); + }); + client.addListenerForMemberJoined((info) => { + console.log(`[${client.userId}] saw ${info.userId} joined room ${info.roomId}`); + }); + client.addListenerForMemberLeft((info) => { + console.log(`[${client.userId}] saw ${info.userId} left room ${info.roomId}`); + }); + client.addListenerForRoomLeft((info) => { + console.log(`[${client.userId}] left room ${info.roomId}`); + }); + // chat connection listeners + client.onStopped((e) => { + console.log(`connection used by ${client.userId} stopped`); + }); + client.onDisconnected((e) => { + console.log(`connection used by ${client.userId} disconnected`); + }); +} + +async function main() { + // Create chat clients for Alice, Bob, and Mike + + // Option 1: create a chat client with a existing WebPubSubClient + const url1 = await getClientAccessUrl('alice'); + const webPubSubClient = new WebPubSubClient(url1); + const alice = await ChatClient.login(webPubSubClient); + console.log(`Alice logged in as: ${alice.userId}`); + + // Option 2: create a chat client directly with client access URL + const url2 = await getClientAccessUrl('bob'), url3 = await getClientAccessUrl('mike'); + const bob = await new ChatClient(url2).login(); + const mike = await new ChatClient(url3).login(); + + console.log(`Bob logged in as: ${bob.userId}`); + console.log(`Mike logged in as: ${mike.userId}`); + + // Setup event listeners + + setupListeners(alice); + setupListeners(bob); + setupListeners(mike); + + // Alice creates a room and invites Bob + console.log('\n--- Alice creates a room ---'); + const room = await alice.createRoom('Hello World Room', [bob.userId]); + + // Alice sends messages to the room + console.log('\n--- Alice sends messages ---'); + for (let i = 1; i <= 3; i++) { + console.log(`[Alice] will send message #${i}`); + const msgId = await alice.sendToRoom(room.roomId, `Hello from Alice #${i}`); + } + + // Bob replies to the room + console.log('\n--- Bob replies ---'); + for (let i = 1; i <= 2; i++) { + console.log(`[Bob] will send message #${i}`); + const msgId = await bob.sendToRoom(room.roomId, `Hi Alice, this is Bob #${i}`); + } + + // List message history + console.log('\n--- Message History ---'); + const history = await alice.listRoomMessage(room.roomId, null, null); + for (const msg of history.messages) { + console.log(` [${msg.createdBy}] [${msg.createdAt}] ${msg.content.text}`); + } + + // Alice manages room members + console.log('\n--- Alice manages room members ---'); + + + // Alice adds mike to the room + await alice.addUserToRoom(room.roomId, mike.userId); + + // Alice removes bob and mike from the room + await alice.removeUserFromRoom(room.roomId, bob.userId); + await alice.removeUserFromRoom(room.roomId, mike.userId); + + // Cleanup + console.log('\n--- Cleanup ---'); + [alice, bob, mike].forEach(client => client.stop()); +} + +main().catch(console.error); diff --git a/sdk/webpubsub-chat-client/examples/quickstart/package.json b/sdk/webpubsub-chat-client/examples/quickstart/package.json new file mode 100644 index 000000000..1e534bacd --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/quickstart/package.json @@ -0,0 +1,17 @@ +{ + "name": "quickstart-example", + "version": "1.0.0", + "description": "Quickstart example for Web PubSub Chat SDK", + "type": "module", + "scripts": { + "server": "node server.js", + "client": "node client.js" + }, + "author": "Microsoft", + "license": "MIT", + "dependencies": { + "@azure/web-pubsub": "^1.2.0", + "@azure/web-pubsub-chat-client": "1.0.0-beta.1", + "express": "^5.2.1" + } +} diff --git a/sdk/webpubsub-chat-client/examples/quickstart/server.js b/sdk/webpubsub-chat-client/examples/quickstart/server.js new file mode 100644 index 000000000..899ec0a49 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/quickstart/server.js @@ -0,0 +1,30 @@ +import express from 'express'; +import { WebPubSubServiceClient } from '@azure/web-pubsub'; + +const hubName = 'chat'; +const port = 3000 || process.env.PORT + +// Get connection string from environment variable or command line argument +const connectionString = process.env.WebPubSubConnectionString || process.argv[2]; +if (!connectionString) { + console.error('Please provide WebPubSubConnectionString via environment variable or command line argument'); + process.exit(1); +} + +const app = express(); +const serviceClient = new WebPubSubServiceClient(connectionString, hubName, { allowInsecureConnection: true }); + +// Negotiate endpoint for client to get access token +app.get('/negotiate', async (req, res) => { + console.log(`received negotiate request: ${JSON.stringify(req.query)}`); + const userId = req.query.userId; + if (!userId) { + return res.status(500).json({ error: 'userId is required' }); + } + const token = await serviceClient.getClientAccessToken({ userId }); + res.json({ url: token.url }); +}); + +app.listen(port, () => { + console.log(`Server listening at http://localhost:${port}`); +}); diff --git a/sdk/webpubsub-chat-client/package.json b/sdk/webpubsub-chat-client/package.json new file mode 100644 index 000000000..a31a56a97 --- /dev/null +++ b/sdk/webpubsub-chat-client/package.json @@ -0,0 +1,69 @@ +{ + "name": "@azure/web-pubsub-chat-client", + "version": "1.0.0-beta.1", + "description": "Client SDK for building chat applications with Azure Web PubSub", + "author": "Microsoft", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Azure/azure-webpubsub/" + }, + "homepage": "https://github.com/Azure/azure-webpubsub/", + "bugs": { + "url": "https://github.com/Azure/azure-webpubsub/issues" + }, + "keywords": [ + "azure", + "webpubsub", + "chat", + "realtime", + "messaging" + ], + "type": "module", + "main": "dist/index.js", + "browser": "dist/browser/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "browser": { + "types": "./dist/index.d.ts", + "default": "./dist/browser/index.js" + }, + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "build:bundle": "node scripts/esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly", + "build:watch": "tsc -p tsconfig.json --watch", + "pack:publish": "node scripts/pack-for-publish.mjs", + "generate:types": "openapi-typescript swagger/openapi.yaml -o src/generatedTypes.ts && node -e \"const fs=require('fs');const c=fs.readFileSync('src/generatedTypes.ts','utf8');const schemas=[...c.matchAll(/^\\s{8}(\\w+):/gm)].map(m=>m[1]);const exports='\\n// Flattened schema exports\\nexport type Schemas = components[\\\"schemas\\\"];\\n'+schemas.map(s=>'export type '+s+' = Schemas[\\\"'+s+'\\\"];').join('\\n')+'\\n';fs.appendFileSync('src/generatedTypes.ts',exports)\"", + "test:start-server": "node .\\examples\\quickstart\\server.js", + "test": "npx tsx --test tests/**/*.ts", + "test:integration": "tsx --test tests/integration.test.ts", + "test:one": "tsx --test --test-name-pattern" + }, + "dependencies": { + "@azure/logger": "^1.3.0", + "@azure/web-pubsub-client": "1.0.5-beta.1", + "events": "^3.3.0", + "ws": "^8.0.0" + }, + "devDependencies": { + "@types/events": "^3.0.3", + "@types/node": "^25.0.3", + "esbuild": "^0.27.3", + "esbuild-plugin-polyfill-node": "^0.3.0", + "openapi-typescript": "^7.10.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/sdk/webpubsub-chat-client/scripts/esbuild.config.mjs b/sdk/webpubsub-chat-client/scripts/esbuild.config.mjs new file mode 100644 index 000000000..b9af61099 --- /dev/null +++ b/sdk/webpubsub-chat-client/scripts/esbuild.config.mjs @@ -0,0 +1,43 @@ +import * as esbuild from 'esbuild'; +import { polyfillNode } from 'esbuild-plugin-polyfill-node'; + +// Node.js build +await esbuild.build({ + entryPoints: ['src/index.ts'], + bundle: true, + outfile: 'dist/index.js', + format: 'esm', + platform: 'node', + target: 'es2020', + sourcemap: true, + external: ['ws'], +}); + +// Browser build — Node.js built-ins are automatically polyfilled +await esbuild.build({ + entryPoints: ['src/index.ts'], + bundle: true, + outfile: 'dist/browser/index.js', + format: 'esm', + platform: 'browser', + target: 'es2020', + sourcemap: true, + plugins: [ + polyfillNode({ + globals: { process: true, Buffer: true, global: true }, + }), + // 'ws' is not needed in browser — stub it to use native WebSocket + { + name: 'browser-ws-stub', + setup(build) { + build.onResolve({ filter: /^ws$/ }, () => ({ path: 'ws', namespace: 'browser-ws' })); + build.onLoad({ filter: /.*/, namespace: 'browser-ws' }, () => ({ + contents: 'export default globalThis.WebSocket;', + })); + }, + }, + ], + alias: undefined, +}); + +console.log('Build complete: dist/index.js (node) + dist/browser/index.js (browser)'); diff --git a/sdk/webpubsub-chat-client/scripts/pack-for-publish.mjs b/sdk/webpubsub-chat-client/scripts/pack-for-publish.mjs new file mode 100644 index 000000000..d097b7419 --- /dev/null +++ b/sdk/webpubsub-chat-client/scripts/pack-for-publish.mjs @@ -0,0 +1,43 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, '..'); +const packageJsonPath = path.join(rootDir, 'package.json'); + +// 1. Build bundle +console.log('📦 Building bundle with esbuild...'); +execSync('node scripts/esbuild.config.mjs', { cwd: rootDir, stdio: 'inherit' }); + +// 2. Generate type declarations +console.log('📝 Generating type declarations...'); +execSync('yarn tsc -p tsconfig.json --emitDeclarationOnly', { cwd: rootDir, stdio: 'inherit' }); + +// 3. Backup and modify package.json +console.log('🔧 Preparing package.json for publish...'); +const originalPackageJson = fs.readFileSync(packageJsonPath, 'utf8'); +const pkg = JSON.parse(originalPackageJson); + +// Remove local file dependencies and dev-only dependencies +delete pkg.dependencies['@azure/web-pubsub-client']; +delete pkg.dependencies['@azure/logger']; +delete pkg.dependencies['events']; + +// Keep ws as it's external in esbuild config +// pkg.dependencies should now only have: { "ws": "^8.0.0" } + +fs.writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n'); + +try { + // 4. Pack + console.log('📦 Creating package tarball...'); + execSync('yarn pack -o azure-web-pubsub-chat-client-%v.tgz', { cwd: rootDir, stdio: 'inherit' }); + + console.log('✅ Done! Package ready for upload to MyGet.'); +} finally { + // 5. Restore original package.json + console.log('🔄 Restoring package.json...'); + fs.writeFileSync(packageJsonPath, originalPackageJson); +} diff --git a/sdk/webpubsub-chat-client/src/chatClient.ts b/sdk/webpubsub-chat-client/src/chatClient.ts new file mode 100644 index 000000000..344b48627 --- /dev/null +++ b/sdk/webpubsub-chat-client/src/chatClient.ts @@ -0,0 +1,307 @@ +import { OnConnectedArgs, OnDisconnectedArgs, OnStoppedArgs, WebPubSubClient, WebPubSubClientCredential, WebPubSubClientOptions, WebPubSubDataType } from "@azure/web-pubsub-client"; +import { EventEmitter } from "events"; +import { + MessageInfo, + MessageRangeQuery, + RoomInfo, + UserProfile, + RoomInfoWithMembers, + Notification, + NewMessageNotificationBody, + NewRoomNotificationBody, + SendMessageResponse, + ManageRoomMemberRequest, + MemberJoinedNotificationBody, + NotificationType, + MemberLeftNotificationBody, + RoomLeftNotification, + RoomLeftNotificationBody, +} from "./generatedTypes.js"; +import { ERRORS, INVOCATION_NAME } from "./constant.js"; +import { logger } from "./logger.js"; +import { isWebPubSubClient } from "./utils.js"; + +class ChatClient { + public readonly connection: WebPubSubClient; + + private readonly _emitter = new EventEmitter(); + private readonly _rooms = new Map(); + protected _conversationIds = new Set(); + private _userId: string | undefined; + private _isLoggedIn = false; + + constructor(clientAccessUrl: string, options?: WebPubSubClientOptions); + constructor(credential: WebPubSubClientCredential, options?: WebPubSubClientOptions); + constructor(credential: string | WebPubSubClientCredential, options?: WebPubSubClientOptions); + constructor(wpsClient: WebPubSubClient); + + constructor(arg1: string | WebPubSubClientCredential | WebPubSubClient, options?: WebPubSubClientOptions) { + if (isWebPubSubClient(arg1)) { + this.connection = arg1; + } else { + this.connection = new WebPubSubClient(arg1 as any, options); + } + this.connection.on("group-message", (e) => { + this._handleNotification(e.message.data as Notification); + }); + this.connection.on("server-message", (e) => { + this._handleNotification(e.message.data as Notification); + }); + } + + private async _handleNotification(data: Notification): Promise { + logger.info("Received notification:", data); + try { + const type = data.notificationType; + switch (type) { + case "MessageCreated": + const notificationBody = data.body as NewMessageNotificationBody; + this._emitter.emit(type, notificationBody); + break; + case "RoomJoined": + const roomInfo = data.body as NewRoomNotificationBody as RoomInfo; + this._rooms.set(roomInfo.roomId, roomInfo); // Add to _rooms first so listeners can use listRoomMessage + this._emitter.emit(type, roomInfo); + break; + case "RoomMemberJoined": + const memberJoinedInfo = data.body as MemberJoinedNotificationBody; + this._emitter.emit(type, memberJoinedInfo); + break; + // someone (not self) left a specific room + case "RoomMemberLeft": + const memberLeftInfo = data.body as MemberLeftNotificationBody; + this._emitter.emit(type, memberLeftInfo); + break; + // self left a specific room + case "RoomLeft": + const roomLeftInfo = data.body as RoomLeftNotificationBody; + this._emitter.emit(type, roomLeftInfo); + this._rooms.delete(roomLeftInfo.roomId); + break; + case "MessageUpdated": + case "MessageDeleted": + case "RoomClosed": + case "AddContact": + logger.warning(`Known notification type ${type} received but not implemented yet.`); + break; + default: + logger.warning(`Unknown notification type received: ${type}`); + } + } + catch (err) { + logger.error(`Error processing notification, error = ${err}, data: `, data); + } + } + + /** Invoke server event and return typed data */ + private async invokeWithReturnType(eventName: string, payload: any, dataType: WebPubSubDataType): Promise { + logger.verbose(`invoke event: '${eventName}', dataType: ${dataType}, payload:`, payload); + + const rawResponse = await this.connection.invokeEvent(eventName, payload, dataType); + + logger.verbose(`invoke response for '${eventName}':`, rawResponse); + + const dataString = JSON.stringify(rawResponse); + if (dataString?.indexOf("InvalidRequest") !== -1) { + throw new Error(`Invocation of event "${eventName}" failed: ${dataString || "Unknown error"}`); + } + // todo: handle rawResponse.success + return rawResponse.data as T; + } + + /** create a chat client based on an existing WebPubSubClient. */ + public static async login(wpsClient: WebPubSubClient): Promise { + const chatClient = new ChatClient(wpsClient); + return await chatClient.login(); + } + + /** create a chat client based on an existing WebPubSubClient. */ + public async login(): Promise { + await this.connection.start(); + const loginResponse = await this.invokeWithReturnType(INVOCATION_NAME.LOGIN, "", "text"); + logger.info("loginResponse", loginResponse); + this._userId = loginResponse.userId; + this._isLoggedIn = true; + this._conversationIds = new Set(loginResponse.conversationIds || []); + // Use Promise.all to wait for all room info to be fetched + const roomInfos = await Promise.all( + (loginResponse.roomIds || []).map(async (roomId) => { + const roomInfo = await this.getRoom(roomId, false); + return { roomId, roomInfo }; + }) + ); + roomInfos.forEach(({ roomId, roomInfo }) => { + this._rooms.set(roomId, roomInfo); + }); + return this; + } + + private ensureLoggedIn(): void { + if (!this._isLoggedIn) { + throw new Error("Not logged in. Please call login() first."); + } + } + + public async getUserInfo(userId: string): Promise { + this.ensureLoggedIn(); + return this.invokeWithReturnType(INVOCATION_NAME.GET_USER_PROPERTIES, { userId: userId }, "json"); + } + + public async sendToConversation(conversationId: string, message: string): Promise { + this.ensureLoggedIn(); + const payload = { + conversation: { conversationId: conversationId }, + content: message, + }; + const resp = await this.invokeWithReturnType(INVOCATION_NAME.SEND_TEXT_MESSAGE, payload, "json"); + if (!resp || !resp.id) { + throw new Error(`Failed to send message to conversation ${conversationId}, got invalid invoke response: ${JSON.stringify(resp)}`); + } + const msgId = resp.id; + // sender won't receive conversation message via notification mechanism, so emit event here + const roomId = Array.from(this._rooms.values()).find((r) => r.defaultConversationId === conversationId)?.roomId; + if (!roomId) { + logger.warning(`Failed to find roomId for conversationId ${conversationId} when sending message.`); + } + this._emitter.emit("MessageCreated" as NotificationType, { + conversation: { conversationId: conversationId, roomId: roomId || "" }, + message: { + messageId: msgId, + createdBy: this.userId, + content: { + text: message, + binary: null, + }, + } as MessageInfo, + } as NewMessageNotificationBody); + return msgId; + } + + public async sendToRoom(roomId: string, message: string): Promise { + this.ensureLoggedIn(); + const conversationId = this._rooms.get(roomId)?.defaultConversationId; + if (!conversationId) { + throw Error(`Failed to sendToRoom, not found roomId ${roomId}`); + } + return await this.sendToConversation(conversationId, message); + } + + public async getRoom(roomId: string, withMembers: boolean): Promise { + this.ensureLoggedIn(); + return this.invokeWithReturnType(INVOCATION_NAME.GET_ROOM, { id: roomId, withMembers: withMembers }, "json"); + } + + /** Create a room and its initial members. If `roomId` is not set, the service will create a random one. */ + public async createRoom(title: string, members: string[], roomId?: string): Promise { + this.ensureLoggedIn(); + let roomDetails = { + title: title, + members: [...new Set([...members, this.userId])], // deduplicate and add self + } as any; + if (roomId) { + roomDetails = { ...roomDetails, roomId: roomId }; + } + const roomInfo = await this.invokeWithReturnType(INVOCATION_NAME.CREATE_ROOM, roomDetails, "json"); + if ((roomInfo as any).code === ERRORS.ROOM_ALREADY_EXISTS) { + throw new Error(ERRORS.ROOM_ALREADY_EXISTS); + } + this._rooms.set(roomInfo.roomId, roomInfo); + this._emitter.emit("RoomJoined" as NotificationType, roomInfo); + return roomInfo; + } + + private async manageRoomMember(request: ManageRoomMemberRequest): Promise { + const ret = await this.invokeWithReturnType(INVOCATION_NAME.MANAGE_ROOM_MEMBER, request, "json"); + if ((ret as any).code === ERRORS.NO_PERMISSION_IN_ROOM) { + throw new Error(ERRORS.NO_PERMISSION_IN_ROOM); + } + } + + /** Add a user to a room. This is an admin operation where one user adds another user to a room. */ + public async addUserToRoom(roomId: string, userId: string): Promise { + this.ensureLoggedIn(); + const payload: ManageRoomMemberRequest = { roomId: roomId, operation: "Add", userId: userId }; + await this.manageRoomMember(payload); + } + + /** Remove a user from a room. This is an admin operation where one user removes another user from a room. */ + public async removeUserFromRoom(roomId: string, userId: string): Promise { + this.ensureLoggedIn(); + const payload: ManageRoomMemberRequest = { roomId: roomId, operation: "Delete", userId: userId }; + await this.manageRoomMember(payload); + } + + /** List messages in a conversation. It returns messages and a query for the next query parameter. */ + public async listMessage(conversationId: string, startId: string | null, endId: string | null, maxCount: number = 100): Promise<{ messages: MessageInfo[]; nextQuery: MessageRangeQuery }> { + this.ensureLoggedIn(); + const query: MessageRangeQuery = { + conversation: { conversationId: conversationId }, + start: startId, + end: endId, + maxCount: maxCount, + }; + const result = await this.invokeWithReturnType<{ messages: MessageInfo[]; nextQuery: MessageRangeQuery }>(INVOCATION_NAME.LIST_MESSAGES, query, "json"); + return result; + } + + /** List messages in a room. It returns messages and a query for the next query parameter. */ + public async listRoomMessage(roomId: string, startId: string | null, endId: string | null, maxCount: number = 100): Promise<{ messages: MessageInfo[]; nextQuery: MessageRangeQuery }> { + this.ensureLoggedIn(); + const conversationId = this._rooms.get(roomId)?.defaultConversationId; + if (!conversationId) { + throw Error(`Failed to listRoomMessage, not found roomId ${roomId}`); + } + const query: MessageRangeQuery = { + conversation: { conversationId: conversationId }, + start: startId, + end: endId, + maxCount: maxCount, + }; + const result = await this.invokeWithReturnType<{ messages: MessageInfo[]; nextQuery: MessageRangeQuery }>(INVOCATION_NAME.LIST_MESSAGES, query, "json"); + return result; + } + + /** Cached rooms known to the client. */ + public get rooms(): RoomInfo[] { + return Array.from(this._rooms.values()); + } + + public get userId(): string { + if (!this._userId) { + throw new Error("User ID is not set. Please login first."); + } + return this._userId; + } + /** add callback for new message events. */ + public addListenerForNewMessage = (callback: (message: NewMessageNotificationBody) => void) => this._emitter.on("MessageCreated" as NotificationType, callback); + + /** add callback for new room events. */ + public addListenerForNewRoom = (callback: (room: RoomInfo) => void) => this._emitter.on("RoomJoined" as NotificationType, callback); + + /** add callback for new member joined room events */ + public addListenerForMemberJoined = (callback: (info: MemberJoinedNotificationBody) => void) => this._emitter.on("RoomMemberJoined" as NotificationType, callback); + + /** add callback for member left room events */ + public addListenerForMemberLeft = (callback: (info: MemberLeftNotificationBody) => void) => this._emitter.on("RoomMemberLeft" as NotificationType, callback); + + /** add callback for user self left room events */ + public addListenerForRoomLeft = (callback: (info: RoomLeftNotificationBody) => void) => this._emitter.on("RoomLeft" as NotificationType, callback); + + public stop = (): void => { + this.connection.stop(); + }; + + public onConnected = (callback: (e: OnConnectedArgs) => void): void => { + return this.connection.on("connected", callback); + }; + + public onDisconnected = (callback: (e: OnDisconnectedArgs) => void): void => { + return this.connection.on("disconnected", callback); + }; + + public onStopped = (callback: (e: OnStoppedArgs) => void): void => { + return this.connection.on("stopped", callback); + }; +} + +export { ChatClient }; diff --git a/sdk/webpubsub-chat-client/src/constant.ts b/sdk/webpubsub-chat-client/src/constant.ts new file mode 100644 index 000000000..ad8273db3 --- /dev/null +++ b/sdk/webpubsub-chat-client/src/constant.ts @@ -0,0 +1,19 @@ +const INVOCATION_NAME = { + LOGIN: "chat.login", + LIST_USER_CONVERSATION: "chat.listUserConversation", + GET_USER_PROPERTIES: "chat.getUserProperties", + GET_ROOM: "chat.getRoom", + LIST_MESSAGES: "chat.queryMessageHistory", + SEND_TEXT_MESSAGE: "chat.sendTextMessage", + CREATE_ROOM: "chat.createRoom", + JOIN_ROOM: "chat.joinRoom", + MANAGE_ROOM_MEMBER: "chat.manageRoomMember", +} as const; + +const ERRORS = { + ROOM_ALREADY_EXISTS: "RoomAlreadyExists", + USER_ALREADY_IN_ROOM: "UserAlreadyInRoom", + NO_PERMISSION_IN_ROOM: "NoPermissionInRoom", +} as const; + +export { INVOCATION_NAME, ERRORS }; diff --git a/sdk/webpubsub-chat-client/src/generatedTypes.ts b/sdk/webpubsub-chat-client/src/generatedTypes.ts new file mode 100644 index 000000000..56a9dc233 --- /dev/null +++ b/sdk/webpubsub-chat-client/src/generatedTypes.ts @@ -0,0 +1,408 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export type paths = Record; +export type webhooks = Record; +export interface components { + schemas: { + /** @enum {string} */ + NotificationType: "MessageCreated" | "MessageUpdated" | "MessageDeleted" | "RoomJoined" | "RoomLeft" | "RoomClosed" | "RoomMemberJoined" | "RoomMemberLeft" | "AddContact"; + Notification: { + notificationType: components["schemas"]["NotificationType"]; + body: components["schemas"]["NewMessageNotificationBody"] | components["schemas"]["NewRoomNotificationBody"] | components["schemas"]["UpdateMessageNotificationBody"] | components["schemas"]["AddContactNotificationBody"] | components["schemas"]["MemberJoinedNotificationBody"] | components["schemas"]["MemberLeftNotificationBody"] | components["schemas"]["RoomLeftNotificationBody"]; + }; + NewMessageNotificationBody: { + conversation: components["schemas"]["ChatConversation"]; + message: components["schemas"]["MessageInfo"]; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + notificationType: "MessageCreated"; + }; + NewMessageNotification: components["schemas"]["Notification"] & { + /** @enum {string} */ + notificationType?: "NewMessage"; + }; + NewRoomNotificationBody: { + roomId: string; + title: string; + defaultConversationId?: string; + /** @description null for now */ + properties?: Record | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + notificationType: "RoomJoined"; + }; + NewRoomNotification: components["schemas"]["Notification"] & { + /** @enum {string} */ + notificationType?: "NewRoom"; + body?: components["schemas"]["NewRoomNotificationBody"]; + }; + UpdateMessageNotificationBody: { + conversation: components["schemas"]["ChatConversation"]; + message: components["schemas"]["MessageInfo"]; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + notificationType: "MessageUpdated"; + }; + UpdateMessageNotification: components["schemas"]["Notification"] & { + /** @enum {string} */ + notificationType?: "UpdateMessage"; + body?: components["schemas"]["UpdateMessageNotificationBody"]; + }; + AddContactNotificationBody: { + userId: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + notificationType: "AddContact"; + }; + AddContactNotification: components["schemas"]["Notification"] & { + /** @enum {string} */ + notificationType?: "AddContact"; + body?: components["schemas"]["AddContactNotificationBody"]; + }; + MemberJoinedNotificationBody: { + roomId: string; + title: string; + userId: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + notificationType: "RoomMemberJoined"; + }; + MemberJoinedNotification: components["schemas"]["Notification"] & { + /** @enum {string} */ + notificationType?: "MemberJoined"; + body?: components["schemas"]["MemberJoinedNotificationBody"]; + }; + MemberLeftNotificationBody: { + roomId: string; + title: string; + userId: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + notificationType: "RoomMemberLeft"; + }; + MemberLeftNotification: components["schemas"]["Notification"] & { + /** @enum {string} */ + notificationType?: "MemberLeft"; + body?: components["schemas"]["MemberLeftNotificationBody"]; + }; + RoomLeftNotificationBody: { + roomId: string; + title: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + notificationType: "RoomLeft"; + }; + RoomLeftNotification: components["schemas"]["Notification"] & { + /** @enum {string} */ + notificationType?: "RoomLeft"; + body?: components["schemas"]["RoomLeftNotificationBody"]; + }; + RoomClosedNotificationBody: { + roomId: string; + title: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + notificationType: "RoomClosed"; + }; + RoomClosedNotification: components["schemas"]["Notification"] & { + /** @enum {string} */ + notificationType?: "RoomClosed"; + body?: components["schemas"]["RoomClosedNotificationBody"]; + }; + MessageDeletedNotificationBody: { + conversation: components["schemas"]["ChatConversation"]; + messageId: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + notificationType: "MessageDeleted"; + }; + MessageDeletedNotification: components["schemas"]["Notification"] & { + /** @enum {string} */ + notificationType?: "MessageDeleted"; + body?: components["schemas"]["MessageDeletedNotificationBody"]; + }; + UserProfile: { + /** + * @description Unique identifier for the user + * @example user123 + */ + userId: string; + /** + * @description Array of room IDs the user is in + * @example [ + * "room1", + * "room2", + * "room3" + * ] + */ + roomIds?: string[]; + /** + * @description Array of conversation IDs the user is in + * @example [ + * "id1", + * "id2", + * "id3" + * ] + */ + conversationIds?: string[]; + }; + ListUserConversationRequest: { + continuationToken?: string | null; + /** @default 1000 */ + maxCount: number | null; + }; + ListUserConversationResponse: { + conversations: components["schemas"]["ChatConversation"][]; + continuationToken?: string | null; + }; + ChatConversation: { + roomId?: string | null; + /** @description null for now */ + topicId?: string | null; + conversationId?: string | null; + }; + /** @enum {string} */ + ApprovalEnum: "AutoApprove" | "ManualApprove" | "AutoDeny"; + UserPolicy: { + addContact: components["schemas"]["ApprovalEnum"]; + /** + * @example [ + * "Nickname" + * ] + */ + publicProperties?: string[]; + friendProperties?: string[]; + privateProperties?: string[]; + }; + /** @enum {string} */ + ContactResultState: "OK" | "Pending" | "Failed"; + AddContactResult: { + userId: string; + state: components["schemas"]["ContactResultState"]; + message: string; + }; + ContactRequest: { + userId: string; + message: string; + }; + /** @enum {string} */ + ContactOperation: "Approve" | "Deny" | "Block"; + ContactRequestOperation: { + operation: components["schemas"]["ContactOperation"]; + userId: string; + }; + RoomInfo: { + roomId: string; + title: string; + defaultConversationId: string; + /** @description null for now */ + properties?: Record | null; + }; + RoomInfoWithMembers: components["schemas"]["RoomInfo"] & { + /** @description List of user id */ + members: string[]; + }; + /** @enum {string} */ + RoomMemberJoinEnum: "AutoApprove" | "ManualApprove" | "InviteOnly"; + /** @enum {string} */ + RoomMessagePermissionEnum: "Allow" | "AdminOnly" | "Deny"; + /** @enum {string} */ + RoomReactPermissionEnum: "Allow" | "Deny"; + RoomPolicy: { + memberJoin: components["schemas"]["RoomMemberJoinEnum"]; + messageTypeText?: components["schemas"]["RoomMessagePermissionEnum"]; + messageTypeImage?: components["schemas"]["RoomMessagePermissionEnum"]; + react?: components["schemas"]["RoomReactPermissionEnum"]; + }; + MessageRangeQuery: { + conversation: components["schemas"]["ChatConversation"]; + /** @description MessageId */ + start?: string | null; + /** @description MessageId */ + end?: string | null; + /** @default 100 */ + maxCount: number | null; + }; + MessageInfo: { + messageId: string; + /** @description UserId */ + createdBy?: string; + /** Format: date-time */ + createdAt?: string; + /** @example Join/Leave/Text/Emoji/File/Image/Voice/... */ + bodyType?: string; + /** @example Inline/Reference/External/... */ + messageBodyType: string; + content: { + text?: string | null; + /** Format: binary */ + binary?: string | null; + }; + refMessageId?: string | null; + }; + CreateTextMessage: { + conversation: components["schemas"]["ChatConversation"]; + message: string; + refMessageId?: string | null; + /** @description Array of UserId */ + extMentions?: string[] | null; + extDeleteAfterRead?: boolean | null; + /** Format: date-time */ + extScheduled?: string | null; + }; + CreateMessage: { + conversation: components["schemas"]["ChatConversation"]; + /** @example Text/Emoji/File/Image/Voice/... */ + messageType: string; + content: { + text?: string | null; + /** Format: binary */ + binary?: string | null; + }; + refMessageId?: string | null; + /** @description Array of UserId */ + extMentions?: string[] | null; + extDeleteAfterRead?: boolean | null; + /** Format: date-time */ + extScheduled?: string | null; + }; + MessageBody: { + conversation: components["schemas"]["ChatConversation"]; + messageId: string; + /** @example Join/Leave/Text/Emoji/File/Image/Voice/... */ + messageType: string; + /** @example Inline/Reference/External/... */ + messageBodyType: string; + content: { + text?: string | null; + /** Format: binary */ + binary?: string | null; + }; + refMessageId?: string | null; + }; + /** @enum {string} */ + JoinRoomState: "OK" | "Pending" | "Failed"; + JoinRoomResult: { + /** @description RoomId */ + room: string; + state: components["schemas"]["JoinRoomState"]; + message: string; + }; + JoinRoomRequest: { + userId: string; + message: string; + }; + /** @enum {string} */ + JoinRoomOperationEnum: "Approve" | "Deny" | "Block"; + JoinRoomOperation: { + /** @description RoomId */ + room: string; + userId: string; + operation: components["schemas"]["JoinRoomOperationEnum"]; + }; + RoomMember: { + userId: string; + role: string; + }; + /** @enum {string} */ + RoomMemberOperationEnum: "Add" | "Delete" | "Update"; + RoomMemberOperation: { + operation: components["schemas"]["RoomMemberOperationEnum"]; + member: components["schemas"]["RoomMember"]; + }; + /** @enum {string} */ + RoomMemberOperationType: "Add" | "Delete"; + ManageRoomMemberRequest: { + roomId: string; + operation: components["schemas"]["RoomMemberOperationType"]; + userId: string; + }; + SendMessageResponse: { + id: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; + +// Flattened schema exports +export type Schemas = components["schemas"]; +export type NotificationType = Schemas["NotificationType"]; +export type Notification = Schemas["Notification"]; +export type NewMessageNotificationBody = Schemas["NewMessageNotificationBody"]; +export type NewMessageNotification = Schemas["NewMessageNotification"]; +export type NewRoomNotificationBody = Schemas["NewRoomNotificationBody"]; +export type NewRoomNotification = Schemas["NewRoomNotification"]; +export type UpdateMessageNotificationBody = Schemas["UpdateMessageNotificationBody"]; +export type UpdateMessageNotification = Schemas["UpdateMessageNotification"]; +export type AddContactNotificationBody = Schemas["AddContactNotificationBody"]; +export type AddContactNotification = Schemas["AddContactNotification"]; +export type MemberJoinedNotificationBody = Schemas["MemberJoinedNotificationBody"]; +export type MemberJoinedNotification = Schemas["MemberJoinedNotification"]; +export type MemberLeftNotificationBody = Schemas["MemberLeftNotificationBody"]; +export type MemberLeftNotification = Schemas["MemberLeftNotification"]; +export type RoomLeftNotificationBody = Schemas["RoomLeftNotificationBody"]; +export type RoomLeftNotification = Schemas["RoomLeftNotification"]; +export type RoomClosedNotificationBody = Schemas["RoomClosedNotificationBody"]; +export type RoomClosedNotification = Schemas["RoomClosedNotification"]; +export type MessageDeletedNotificationBody = Schemas["MessageDeletedNotificationBody"]; +export type MessageDeletedNotification = Schemas["MessageDeletedNotification"]; +export type UserProfile = Schemas["UserProfile"]; +export type ListUserConversationRequest = Schemas["ListUserConversationRequest"]; +export type ListUserConversationResponse = Schemas["ListUserConversationResponse"]; +export type ChatConversation = Schemas["ChatConversation"]; +export type ApprovalEnum = Schemas["ApprovalEnum"]; +export type UserPolicy = Schemas["UserPolicy"]; +export type ContactResultState = Schemas["ContactResultState"]; +export type AddContactResult = Schemas["AddContactResult"]; +export type ContactRequest = Schemas["ContactRequest"]; +export type ContactOperation = Schemas["ContactOperation"]; +export type ContactRequestOperation = Schemas["ContactRequestOperation"]; +export type RoomInfo = Schemas["RoomInfo"]; +export type RoomInfoWithMembers = Schemas["RoomInfoWithMembers"]; +export type RoomMemberJoinEnum = Schemas["RoomMemberJoinEnum"]; +export type RoomMessagePermissionEnum = Schemas["RoomMessagePermissionEnum"]; +export type RoomReactPermissionEnum = Schemas["RoomReactPermissionEnum"]; +export type RoomPolicy = Schemas["RoomPolicy"]; +export type MessageRangeQuery = Schemas["MessageRangeQuery"]; +export type MessageInfo = Schemas["MessageInfo"]; +export type CreateTextMessage = Schemas["CreateTextMessage"]; +export type CreateMessage = Schemas["CreateMessage"]; +export type MessageBody = Schemas["MessageBody"]; +export type JoinRoomState = Schemas["JoinRoomState"]; +export type JoinRoomResult = Schemas["JoinRoomResult"]; +export type JoinRoomRequest = Schemas["JoinRoomRequest"]; +export type JoinRoomOperationEnum = Schemas["JoinRoomOperationEnum"]; +export type JoinRoomOperation = Schemas["JoinRoomOperation"]; +export type RoomMember = Schemas["RoomMember"]; +export type RoomMemberOperationEnum = Schemas["RoomMemberOperationEnum"]; +export type RoomMemberOperation = Schemas["RoomMemberOperation"]; +export type RoomMemberOperationType = Schemas["RoomMemberOperationType"]; +export type ManageRoomMemberRequest = Schemas["ManageRoomMemberRequest"]; +export type SendMessageResponse = Schemas["SendMessageResponse"]; diff --git a/sdk/webpubsub-chat-client/src/index.ts b/sdk/webpubsub-chat-client/src/index.ts new file mode 100644 index 000000000..8a89ebbe1 --- /dev/null +++ b/sdk/webpubsub-chat-client/src/index.ts @@ -0,0 +1,15 @@ +import { ChatClient } from './chatClient.js'; + +export type { + MessageInfo, + MessageRangeQuery, + RoomInfo, + RoomInfoWithMembers, + UserProfile, + Notification, + NewMessageNotificationBody, + NewRoomNotificationBody, + SendMessageResponse, +} from './generatedTypes.js'; + +export { ChatClient }; diff --git a/sdk/webpubsub-chat-client/src/logger.ts b/sdk/webpubsub-chat-client/src/logger.ts new file mode 100644 index 000000000..6f3b5c1a9 --- /dev/null +++ b/sdk/webpubsub-chat-client/src/logger.ts @@ -0,0 +1,6 @@ +import { createClientLogger } from "@azure/logger"; + +/** + * The \@azure\/logger configuration for this package. + */ +export const logger = createClientLogger("web-pubsub-chat-client:*"); diff --git a/sdk/webpubsub-chat-client/src/utils.ts b/sdk/webpubsub-chat-client/src/utils.ts new file mode 100644 index 000000000..d0d04be02 --- /dev/null +++ b/sdk/webpubsub-chat-client/src/utils.ts @@ -0,0 +1,31 @@ +import { WebPubSubClient } from "@azure/web-pubsub-client"; + +export function decodeMessageBody(base64: string | null | undefined): string { + if (!base64) return ""; + if (typeof Buffer !== 'undefined') { + return Buffer.from(base64, 'base64').toString('utf-8'); + } + // compatibility for browser environment + return decodeURIComponent(escape(atob(base64))); +} + + +/** + * Type guard for WebPubSubClient. + * We avoid using `instanceof` because it can fail in scenarios with multiple + * dependency copies (e.g., monorepo with yarn/pnpm link) or across different + * execution contexts (iframe, worker). Instead, we check for stable public + * methods to ensure reliable detection. + */ +export function isWebPubSubClient(obj: unknown): obj is WebPubSubClient { + if (typeof obj !== "object" || obj === null) return false; + const anyObj = obj as any; + return ( + typeof anyObj === "object" && + ( + typeof anyObj.start === "function" || + typeof anyObj.stop === "function" || + typeof anyObj.on === "function" + ) + ); +} \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/swagger/openapi.yaml b/sdk/webpubsub-chat-client/swagger/openapi.yaml new file mode 100644 index 000000000..36b69a867 --- /dev/null +++ b/sdk/webpubsub-chat-client/swagger/openapi.yaml @@ -0,0 +1,732 @@ +openapi: 3.0.0 +info: + title: Web PubSub Chat API + version: 1.0.0 + description: API for Web PubSub Chat SDK + +components: + schemas: + NotificationType: + type: string + enum: + - MessageCreated + - MessageUpdated + - MessageDeleted + - RoomJoined + - RoomLeft + - RoomClosed + - RoomMemberJoined + - RoomMemberLeft + - AddContact + + Notification: + type: object + properties: + notificationType: + $ref: '#/components/schemas/NotificationType' + body: + oneOf: + - $ref: '#/components/schemas/NewMessageNotificationBody' + - $ref: '#/components/schemas/NewRoomNotificationBody' + - $ref: '#/components/schemas/UpdateMessageNotificationBody' + - $ref: '#/components/schemas/AddContactNotificationBody' + - $ref: '#/components/schemas/MemberJoinedNotificationBody' + - $ref: '#/components/schemas/MemberLeftNotificationBody' + - $ref: '#/components/schemas/RoomLeftNotificationBody' + discriminator: + propertyName: notificationType + mapping: + MessageCreated: '#/components/schemas/NewMessageNotificationBody' + MessageUpdated: '#/components/schemas/UpdateMessageNotificationBody' + MessageDeleted: '#/components/schemas/MessageDeletedNotificationBody' + RoomJoined: '#/components/schemas/NewRoomNotificationBody' + RoomLeft: '#/components/schemas/RoomLeftNotificationBody' + RoomClosed: '#/components/schemas/RoomClosedNotificationBody' + RoomMemberJoined: '#/components/schemas/MemberJoinedNotificationBody' + RoomMemberLeft: '#/components/schemas/MemberLeftNotificationBody' + AddContact: '#/components/schemas/AddContactNotificationBody' + required: + - notificationType + - body + + NewMessageNotificationBody: + type: object + properties: + conversation: + $ref: '#/components/schemas/ChatConversation' + message: + $ref: '#/components/schemas/MessageInfo' + required: + - conversation + - message + + NewMessageNotification: + allOf: + - $ref: '#/components/schemas/Notification' + - type: object + properties: + notificationType: + type: string + enum: [NewMessage] + NewRoomNotificationBody: + type: object + properties: + roomId: + type: string + title: + type: string + defaultConversationId: + type: string + properties: + type: object + nullable: true + description: null for now + required: + - roomId + - title + + NewRoomNotification: + allOf: + - $ref: '#/components/schemas/Notification' + - type: object + properties: + notificationType: + type: string + enum: [NewRoom] + body: + $ref: '#/components/schemas/NewRoomNotificationBody' + required: + - roomId + - title + + UpdateMessageNotificationBody: + type: object + properties: + conversation: + $ref: '#/components/schemas/ChatConversation' + message: + $ref: '#/components/schemas/MessageInfo' + required: + - conversation + - message + + UpdateMessageNotification: + allOf: + - $ref: '#/components/schemas/Notification' + - type: object + properties: + notificationType: + type: string + enum: [UpdateMessage] + body: + $ref: '#/components/schemas/UpdateMessageNotificationBody' + + AddContactNotificationBody: + type: object + properties: + userId: + type: string + required: + - userId + + AddContactNotification: + allOf: + - $ref: '#/components/schemas/Notification' + - type: object + properties: + notificationType: + type: string + enum: [AddContact] + body: + $ref: '#/components/schemas/AddContactNotificationBody' + + MemberJoinedNotificationBody: + type: object + properties: + roomId: + type: string + title: + type: string + userId: + type: string + required: + - roomId + - title + - userId + + MemberJoinedNotification: + allOf: + - $ref: '#/components/schemas/Notification' + - type: object + properties: + notificationType: + type: string + enum: [MemberJoined] + body: + $ref: '#/components/schemas/MemberJoinedNotificationBody' + + MemberLeftNotificationBody: + type: object + properties: + roomId: + type: string + title: + type: string + userId: + type: string + required: + - roomId + - title + - userId + + MemberLeftNotification: + allOf: + - $ref: '#/components/schemas/Notification' + - type: object + properties: + notificationType: + type: string + enum: [MemberLeft] + body: + $ref: '#/components/schemas/MemberLeftNotificationBody' + + RoomLeftNotificationBody: + type: object + properties: + roomId: + type: string + title: + type: string + required: + - roomId + - title + + RoomLeftNotification: + allOf: + - $ref: '#/components/schemas/Notification' + - type: object + properties: + notificationType: + type: string + enum: [RoomLeft] + body: + $ref: '#/components/schemas/RoomLeftNotificationBody' + + RoomClosedNotificationBody: + type: object + properties: + roomId: + type: string + title: + type: string + required: + - roomId + - title + + RoomClosedNotification: + allOf: + - $ref: '#/components/schemas/Notification' + - type: object + properties: + notificationType: + type: string + enum: [RoomClosed] + body: + $ref: '#/components/schemas/RoomClosedNotificationBody' + + MessageDeletedNotificationBody: + type: object + properties: + conversation: + $ref: '#/components/schemas/ChatConversation' + messageId: + type: string + required: + - conversation + - messageId + + MessageDeletedNotification: + allOf: + - $ref: '#/components/schemas/Notification' + - type: object + properties: + notificationType: + type: string + enum: [MessageDeleted] + body: + $ref: '#/components/schemas/MessageDeletedNotificationBody' + + UserProfile: + type: object + properties: + userId: + type: string + description: Unique identifier for the user + example: "user123" + roomIds: + type: array + items: + type: string + description: Array of room IDs the user is in + example: ["room1", "room2", "room3"] + conversationIds: + type: array + items: + type: string + description: Array of conversation IDs the user is in + example: ["id1", "id2", "id3"] + required: + - userId + - rooms + + ListUserConversationRequest: + type: object + properties: + continuationToken: + type: string + nullable: true + maxCount: + type: integer + minimum: 1 + maximum: 1000 + default: 1000 + nullable: true + + ListUserConversationResponse: + type: object + properties: + conversations: + type: array + items: + $ref: '#/components/schemas/ChatConversation' + continuationToken: + type: string + nullable: true + required: + - conversations + + ChatConversation: + type: object + properties: + roomId: + type: string + nullable: true + topicId: + type: string + nullable: true + description: null for now + conversationId: + type: string + nullable: true + + ApprovalEnum: + type: string + enum: + - AutoApprove + - ManualApprove + - AutoDeny + + UserPolicy: + type: object + properties: + addContact: + $ref: '#/components/schemas/ApprovalEnum' + publicProperties: + type: array + items: + type: string + example: ["Nickname"] + friendProperties: + type: array + items: + type: string + privateProperties: + type: array + items: + type: string + required: + - addContact + + ContactResultState: + type: string + enum: + - OK + - Pending + - Failed + + AddContactResult: + type: object + properties: + userId: + type: string + state: + $ref: '#/components/schemas/ContactResultState' + message: + type: string + required: + - userId + - state + - message + + ContactRequest: + type: object + properties: + userId: + type: string + message: + type: string + required: + - userId + - message + + ContactOperation: + type: string + enum: + - Approve + - Deny + - Block + + ContactRequestOperation: + type: object + properties: + operation: + $ref: '#/components/schemas/ContactOperation' + userId: + type: string + required: + - operation + - userId + + RoomInfo: + type: object + properties: + roomId: + type: string + title: + type: string + defaultConversationId: + type: string + properties: + type: object + nullable: true + description: null for now + required: + - roomId + - title + - defaultConversationId + + RoomInfoWithMembers: + allOf: + - $ref: '#/components/schemas/RoomInfo' + - type: object + properties: + members: + type: array + items: + type: string + description: List of user id + required: + - members + + RoomMemberJoinEnum: + type: string + enum: + - AutoApprove + - ManualApprove + - InviteOnly + + RoomMessagePermissionEnum: + type: string + enum: + - Allow + - AdminOnly + - Deny + + RoomReactPermissionEnum: + type: string + enum: + - Allow + - Deny + + RoomPolicy: + type: object + properties: + memberJoin: + $ref: '#/components/schemas/RoomMemberJoinEnum' + messageTypeText: + $ref: '#/components/schemas/RoomMessagePermissionEnum' + messageTypeImage: + $ref: '#/components/schemas/RoomMessagePermissionEnum' + react: + $ref: '#/components/schemas/RoomReactPermissionEnum' + required: + - memberJoin + + MessageRangeQuery: + type: object + properties: + conversation: + $ref: '#/components/schemas/ChatConversation' + start: + type: string + nullable: true + description: MessageId + end: + type: string + nullable: true + description: MessageId + maxCount: + type: integer + default: 100 + nullable: true + required: + - conversation + + MessageInfo: + type: object + properties: + messageId: + type: string + createdBy: + type: string + description: UserId + createdAt: + type: string + format: date-time + bodyType: + type: string + example: Join/Leave/Text/Emoji/File/Image/Voice/... + messageBodyType: + type: string + example: Inline/Reference/External/... + content: + type: object + properties: + text: + type: string + nullable: true + binary: + type: string + format: binary + nullable: true + refMessageId: + type: string + nullable: true + required: + - messageId + - sender + - sentAt + - messageType + - messageBodyType + - content + + CreateTextMessage: + type: object + properties: + conversation: + $ref: '#/components/schemas/ChatConversation' + message: + type: string + refMessageId: + type: string + nullable: true + extMentions: + type: array + items: + type: string + nullable: true + description: Array of UserId + extDeleteAfterRead: + type: boolean + nullable: true + extScheduled: + type: string + format: date-time + nullable: true + required: + - conversation + - message + + CreateMessage: + type: object + properties: + conversation: + $ref: '#/components/schemas/ChatConversation' + messageType: + type: string + example: Text/Emoji/File/Image/Voice/... + content: + type: object + properties: + text: + type: string + nullable: true + binary: + type: string + format: binary + nullable: true + refMessageId: + type: string + nullable: true + extMentions: + type: array + items: + type: string + nullable: true + description: Array of UserId + extDeleteAfterRead: + type: boolean + nullable: true + extScheduled: + type: string + format: date-time + nullable: true + required: + - conversation + - messageType + - content + + MessageBody: + type: object + properties: + conversation: + $ref: '#/components/schemas/ChatConversation' + messageId: + type: string + messageType: + type: string + example: Join/Leave/Text/Emoji/File/Image/Voice/... + messageBodyType: + type: string + example: Inline/Reference/External/... + content: + type: object + properties: + text: + type: string + nullable: true + binary: + type: string + format: binary + nullable: true + refMessageId: + type: string + nullable: true + required: + - conversation + - messageId + - messageType + - messageBodyType + - content + + JoinRoomState: + type: string + enum: + - OK + - Pending + - Failed + + JoinRoomResult: + type: object + properties: + room: + type: string + description: RoomId + state: + $ref: '#/components/schemas/JoinRoomState' + message: + type: string + required: + - room + - state + - message + + JoinRoomRequest: + type: object + properties: + userId: + type: string + message: + type: string + required: + - userId + - message + + JoinRoomOperationEnum: + type: string + enum: + - Approve + - Deny + - Block + + JoinRoomOperation: + type: object + properties: + room: + type: string + description: RoomId + userId: + type: string + operation: + $ref: '#/components/schemas/JoinRoomOperationEnum' + required: + - room + - userId + - operation + + RoomMember: + type: object + properties: + userId: + type: string + role: + type: string + required: + - userId + - role + + RoomMemberOperationEnum: + type: string + enum: + - Add + - Delete + - Update + + RoomMemberOperation: + type: object + properties: + operation: + $ref: '#/components/schemas/RoomMemberOperationEnum' + member: + $ref: '#/components/schemas/RoomMember' + required: + - operation + - member + + RoomMemberOperationType: + type: string + enum: + - Add + - Delete + + ManageRoomMemberRequest: + type: object + properties: + roomId: + type: string + operation: + $ref: '#/components/schemas/RoomMemberOperationType' + userId: + type: string + required: + - roomId + - operation + - userId + + SendMessageResponse: + type: object + properties: + id: + type: string + required: + - id diff --git a/sdk/webpubsub-chat-client/tests/integration.test.ts b/sdk/webpubsub-chat-client/tests/integration.test.ts new file mode 100644 index 000000000..f1652e496 --- /dev/null +++ b/sdk/webpubsub-chat-client/tests/integration.test.ts @@ -0,0 +1,189 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { ChatClient } from "../src/chatClient.js"; +import { + SHORT_TEST_TIMEOUT, + LONG_TEST_TIMEOUT, + createTestClient, + getMultipleClients, + stopClients, + forceExitAfterTests, +} from "./testUtils.js"; + +test("same user id login twice", { timeout: LONG_TEST_TIMEOUT }, async (t) => { + let chat0, chat1; + try { + chat0 = await createTestClient(); + + // first login + chat1 = await createTestClient(); + const chat1UserId = chat1.userId; + let messageReceived = 0; + chat1.addListenerForNewMessage((notification) => { + messageReceived++; + }); + assert.equal(chat1.userId, chat1UserId, `chat1 userId should be '${chat1UserId}'`); + + const roomName = `room-${Math.floor(Math.random() * 10000)}`; + const createdRoom = await chat0.createRoom(roomName, [chat1.userId], `uid_${roomName}`); + await chat0.sendToRoom(createdRoom.roomId, `Hello from chat0`); + // sleep 100ms + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(messageReceived, 1, `chat1 should receive 1 message at first login`); + + chat1.stop(); + + // second login with same userId + chat1 = await createTestClient(chat1UserId); // login again with same userId + messageReceived = 0; + chat1.addListenerForNewMessage((notification) => { + messageReceived++; + }); + assert.equal(chat1.userId, chat1UserId, `chat1 userId should still be '${chat1UserId}' after re-login`); + + const sentMsgId = await chat0.sendToRoom(createdRoom.roomId, `Hello from chat0`); + + // sleep 100ms + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(messageReceived, 1, `chat1 should receive 1 message at second login`); + } catch (e) { + t.diagnostic((e as any).toString()); + throw e; + } finally { + const clientsToStop = [chat0, chat1].filter(Boolean) as ChatClient[]; + stopClients(clientsToStop); + } +}); + +test("single client", { timeout: SHORT_TEST_TIMEOUT }, async (t) => { + let chat1; + try { + chat1 = await createTestClient(); + + assert.ok(chat1.userId && typeof chat1.userId === "string"); + + const roomId = `room-id-${randomUUID().substring(0, 3)}`; + const created = await chat1.createRoom("ut-single-room", [], roomId); + assert.equal(created.roomId, roomId, "roomId should match"); + assert.equal(created.title, "ut-single-room", "room title should match"); + assert.ok(Array.isArray(created.members), "members should be an array"); + assert.deepEqual(created.members, [chat1.userId], "members should contain only the creator"); + assert.ok(created.members.includes(chat1.userId), "members should include the creator"); + + const fetched = await chat1.getRoom(created.roomId, true); + assert.equal(fetched.roomId, created.roomId, "fetched roomId should match created"); + assert.equal(fetched.title, created.title, "fetched title should match created"); + assert.ok(Array.isArray(fetched.members), "fetched members should be an array"); + } catch (e) { + t.diagnostic((e as any).toString()); + throw e; + } finally { + if (chat1) stopClients([chat1]); + } +}); + +test("create room with multiple users", { timeout: LONG_TEST_TIMEOUT }, async (t) => { + let chats; + try { + chats = await getMultipleClients(3); + + var joinedRoomCounts = [0, 0, 0], + receivedMsgCounts = [0, 0, 0]; + + for (let i = 0; i < 3; i++) { + chats[i].addListenerForNewRoom((room) => { + joinedRoomCounts[i]++; + }); + chats[i].addListenerForNewMessage((message) => { + receivedMsgCounts[i]++; + }); + } + + const createdRoom = await chats[0].createRoom("test-room", [chats[1].userId, chats[2].userId]); + + for (let i = 1; i <= 5; i++) { + const msgId = await chats[0].sendToRoom(createdRoom.roomId, `HelloMessage,#${i}`); + assert.equal(msgId, i.toString(), `sent message id should be ${i} but got ${msgId}`); + } + + const listedMsgs = await chats[0].listMessage(createdRoom.defaultConversationId, "0", null, 100); + let listedMsgCount = 0; + for (const message of listedMsgs.messages) { + assert.equal(message.messageId, (5 - listedMsgCount).toString(), `message id should match expected order, expect ${5 - listedMsgCount} but got ${message.messageId}`); + assert.equal(message.content.text, `HelloMessage,#${5 - listedMsgCount}`, `message body should match expected content, expect 'HelloMessage,#${5 - listedMsgCount}' but got '${message.content.text}'`); + listedMsgCount++; + } + + await new Promise((resolve) => setTimeout(resolve, 100)); // wait for events + assert.equal(listedMsgCount, 5, "should list 5 messages"); + assert.deepEqual(joinedRoomCounts, [1, 1, 1], "chat2 should receive new room event"); + assert.deepEqual(receivedMsgCounts, [5, 5, 5], "chat2 should receive 5 new messages"); + } catch (e) { + t.diagnostic((e as any).toString()); + throw e; + } finally { + if (chats && Array.isArray(chats)) { + stopClients(chats); + } + } +}); + +test("admin adds multiple users to a group", { timeout: LONG_TEST_TIMEOUT }, async (t) => { + let chats; + try { + chats = await getMultipleClients(3); + const createdRoom = await chats[0].createRoom("ut-room", []); + // Admin (chats[0]) adds other users to the room + for (let i = 1; i < chats.length; i++) { + await chats[0].addUserToRoom(createdRoom.roomId, chats[i].userId); + } + + let messageReceivedCounts = new Array(chats.length).fill(0); + + chats.forEach((chat, index) => { + chat.addListenerForNewMessage((notification) => { + messageReceivedCounts[index]++; + }); + }); + + + // client 0..n-1 send message, should be received by all others + for (let i = 1; i < chats.length; i++) { + const sentMsgId = await chats[i].sendToRoom(createdRoom.roomId, `Hello from chat${i}`); + assert.equal(sentMsgId, i.toString(), `sent message id should be ${i} but got ${sentMsgId}`); + } + + // sleep 100ms + await new Promise((resolve) => setTimeout(resolve, 1000)); + + for (let i = 1; i < chats.length; i++) { + assert.equal(messageReceivedCounts[i], chats.length - 1, `chat${i} should receive ${chats.length - 1} messages`); + } + assert.equal(messageReceivedCounts[0], chats.length - 1, `creator should receive ${chats.length - 1} messages`); + + // client 0 send message + const finalMsgId = await chats[0].sendToRoom(createdRoom.roomId, "final message"); + assert.equal(finalMsgId, chats.length.toString(), `sent message id should be ${chats.length} but got ${finalMsgId}`); + + // sleep 100ms + await new Promise((resolve) => setTimeout(resolve, 100)); + + for (let i = 1; i < chats.length; i++) { + assert.equal(messageReceivedCounts[i], chats.length, `chat${i} should receive ${chats.length} messages`); + } + assert.equal(messageReceivedCounts[0], chats.length, `creator should receive ${chats.length} messages`); + } catch (e) { + t.diagnostic((e as any).toString()); + throw e; + } finally { + if (chats && Array.isArray(chats)) { + stopClients(chats); + } + } +}); + +// Force exit after all tests to avoid hanging on open connections +after(() => { + forceExitAfterTests(); +}); \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/tests/testUtils.ts b/sdk/webpubsub-chat-client/tests/testUtils.ts new file mode 100644 index 000000000..472fad721 --- /dev/null +++ b/sdk/webpubsub-chat-client/tests/testUtils.ts @@ -0,0 +1,56 @@ +import { WebPubSubClient } from "@azure/web-pubsub-client"; +import { ChatClient } from "../src/chatClient.js"; +import { randomInt as secureRandomInt } from "crypto"; + +// Test configuration +export const negotiateUrl = "http://localhost:3000/negotiate"; +export const SHORT_TEST_TIMEOUT = 5 * 1000; +export const LONG_TEST_TIMEOUT = 10 * 1000; + +// Helper functions +export const randomInt = () => secureRandomInt(0, 10000000); + +export const getUserIds = (count: number): string[] => { + const userIds: string[] = []; + for (let i = 0; i < count; i++) { + userIds.push(`user-${i}-${randomInt()}`); + } + return userIds; +}; + +export async function createTestClient(userId?: string): Promise { + if (!userId) { + userId = `uid-${randomInt()}`; + } + const wpsClient = new WebPubSubClient({ + getClientAccessUrl: async () => { + const res = await fetch(negotiateUrl + (userId ? `?userId=${encodeURIComponent(userId)}` : "")); + const value = (await res.json()) as { url?: string }; + if (!value?.url) throw new Error("Failed to get negotiate url"); + return value.url; + }, + }); + return await ChatClient.login(wpsClient); +} + +export async function getMultipleClients(count: number): Promise { + const userIds = getUserIds(count); + const clients: ChatClient[] = []; + for (const userId of userIds) { + clients.push(await createTestClient(userId)); + } + return clients; +} + +// Helper to stop multiple clients immediately +export function stopClients(clients: ChatClient[]): void { + clients.forEach((c) => c.stop()); +} + +// Force exit after all tests complete (call this at the end of test file) +export function forceExitAfterTests(): void { + // Use setImmediate to allow the test runner to finish reporting + setImmediate(() => { + process.exit(0); + }); +} diff --git a/sdk/webpubsub-chat-client/tsconfig.json b/sdk/webpubsub-chat-client/tsconfig.json new file mode 100644 index 000000000..840efc83d --- /dev/null +++ b/sdk/webpubsub-chat-client/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "composite": true /* Enable constraints that allow a TypeScript project to be used with project references. */, + "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, + "declarationMap": true /* Create sourcemaps for d.ts files. */, + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + "strict": true /* Enable all strict type-checking options. */, + "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "moduleResolution": "NodeNext", + "rootDir": "src", + "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + "module": "NodeNext" /* Specify what module code is generated. */, + "outDir": "./dist" /* Specify an output folder for all emitted files. */ + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "dist-esm", "types", "types-esm"] +} \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/yarn.lock b/sdk/webpubsub-chat-client/yarn.lock new file mode 100644 index 000000000..c0837b608 --- /dev/null +++ b/sdk/webpubsub-chat-client/yarn.lock @@ -0,0 +1,1581 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"@azure/abort-controller@npm:^2.1.2": + version: 2.1.2 + resolution: "@azure/abort-controller@npm:2.1.2" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/3771b6820e33ebb56e79c7c68e2288296b8c2529556fbd29cf4cf2fbff7776e7ce1120072972d8df9f1bf50e2c3224d71a7565362b589595563f710b8c3d7b79 + languageName: node + linkType: hard + +"@azure/core-util@npm:^1.11.0": + version: 1.13.1 + resolution: "@azure/core-util@npm:1.13.1" + dependencies: + "@azure/abort-controller": "npm:^2.1.2" + "@typespec/ts-http-runtime": "npm:^0.3.0" + tslib: "npm:^2.6.2" + checksum: 10c0/37067621cdac933c51775c26648fdcea315f07b08bd875cff4610e403eabf9c12532525f0bf094e258dadc03a55d35f12c9242f662526847b32c85cdcc2d6603 + languageName: node + linkType: hard + +"@azure/logger@npm:^1.1.4, @azure/logger@npm:^1.3.0": + version: 1.3.0 + resolution: "@azure/logger@npm:1.3.0" + dependencies: + "@typespec/ts-http-runtime": "npm:^0.3.0" + tslib: "npm:^2.6.2" + checksum: 10c0/aaa6a88fd4f26d41100865ff2c53b400347f632d315d9ae8ffa28db03974d35461e743031bdca40cad617ace172d1ba598ffdd18c345ebc564f63a51c32c4a29 + languageName: node + linkType: hard + +"@azure/web-pubsub-chat-client@workspace:.": + version: 0.0.0-use.local + resolution: "@azure/web-pubsub-chat-client@workspace:." + dependencies: + "@azure/logger": "npm:^1.3.0" + "@azure/web-pubsub-client": "npm:1.0.5-beta.1" + "@types/events": "npm:^3.0.3" + "@types/node": "npm:^25.0.3" + esbuild: "npm:^0.27.3" + esbuild-plugin-polyfill-node: "npm:^0.3.0" + events: "npm:^3.3.0" + openapi-typescript: "npm:^7.10.1" + tsx: "npm:^4.21.0" + typescript: "npm:^5.9.3" + ws: "npm:^8.0.0" + languageName: unknown + linkType: soft + +"@azure/web-pubsub-client@npm:1.0.5-beta.1": + version: 1.0.5-beta.1 + resolution: "@azure/web-pubsub-client@npm:1.0.5-beta.1::__archiveUrl=https%3A%2F%2Fwww.myget.org%2FF%2Fazure-signalr-dev%2Fnpm%2F%40azure%2Fweb-pubsub-client%2F-%2F%40azure%2Fweb-pubsub-client-1.0.5-beta.1.tgz" + dependencies: + "@azure/abort-controller": "npm:^2.1.2" + "@azure/core-util": "npm:^1.11.0" + "@azure/logger": "npm:^1.1.4" + buffer: "npm:^6.0.0" + events: "npm:^3.3.0" + tslib: "npm:^2.8.1" + ws: "npm:^8.18.0" + checksum: 10c0/aa67daf0657101e51b59ce68716e021f120aae88c2906e16e4b814dc1fbdada890833f1d43128311f251a01bbeebf791f42b51390f9d30e0393ed588a627bcde + languageName: node + linkType: hard + +"@babel/code-frame@npm:^7.26.2": + version: 7.27.1 + resolution: "@babel/code-frame@npm:7.27.1" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.27.1" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10c0/5dd9a18baa5fce4741ba729acc3a3272c49c25cb8736c4b18e113099520e7ef7b545a4096a26d600e4416157e63e87d66db46aa3fbf0a5f2286da2705c12da00 + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.27.1": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 10c0/42aaebed91f739a41f3d80b72752d1f95fd7c72394e8e4bd7cdd88817e0774d80a432451bcba17c2c642c257c483bf1d409dd4548883429ea9493a3bc4ab0847 + languageName: node + linkType: hard + +"@esbuild/aix-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/aix-ppc64@npm:0.27.2" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/aix-ppc64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/aix-ppc64@npm:0.27.3" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm64@npm:0.27.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/android-arm64@npm:0.27.3" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm@npm:0.27.2" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/android-arm@npm:0.27.3" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-x64@npm:0.27.2" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/android-x64@npm:0.27.3" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-arm64@npm:0.27.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/darwin-arm64@npm:0.27.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-x64@npm:0.27.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/darwin-x64@npm:0.27.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-arm64@npm:0.27.2" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/freebsd-arm64@npm:0.27.3" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-x64@npm:0.27.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/freebsd-x64@npm:0.27.3" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm64@npm:0.27.2" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-arm64@npm:0.27.3" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm@npm:0.27.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-arm@npm:0.27.3" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ia32@npm:0.27.2" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-ia32@npm:0.27.3" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-loong64@npm:0.27.2" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-loong64@npm:0.27.3" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-mips64el@npm:0.27.2" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-mips64el@npm:0.27.3" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ppc64@npm:0.27.2" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-ppc64@npm:0.27.3" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-riscv64@npm:0.27.2" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-riscv64@npm:0.27.3" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-s390x@npm:0.27.2" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-s390x@npm:0.27.3" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-x64@npm:0.27.2" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-x64@npm:0.27.3" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-arm64@npm:0.27.2" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/netbsd-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/netbsd-arm64@npm:0.27.3" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-x64@npm:0.27.2" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/netbsd-x64@npm:0.27.3" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-arm64@npm:0.27.2" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/openbsd-arm64@npm:0.27.3" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-x64@npm:0.27.2" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/openbsd-x64@npm:0.27.3" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openharmony-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openharmony-arm64@npm:0.27.2" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openharmony-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/openharmony-arm64@npm:0.27.3" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/sunos-x64@npm:0.27.2" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/sunos-x64@npm:0.27.3" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-arm64@npm:0.27.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/win32-arm64@npm:0.27.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-ia32@npm:0.27.2" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/win32-ia32@npm:0.27.3" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-x64@npm:0.27.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/win32-x64@npm:0.27.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@isaacs/balanced-match@npm:^4.0.1": + version: 4.0.1 + resolution: "@isaacs/balanced-match@npm:4.0.1" + checksum: 10c0/7da011805b259ec5c955f01cee903da72ad97c5e6f01ca96197267d3f33103d5b2f8a1af192140f3aa64526c593c8d098ae366c2b11f7f17645d12387c2fd420 + languageName: node + linkType: hard + +"@isaacs/brace-expansion@npm:^5.0.1": + version: 5.0.1 + resolution: "@isaacs/brace-expansion@npm:5.0.1" + dependencies: + "@isaacs/balanced-match": "npm:^4.0.1" + checksum: 10c0/e5d67c7bbf1f17b88132a35bc638af306d48acbb72810d48fa6e6edd8ab375854773108e8bf70f021f7ef6a8273455a6d1f0c3b5aa2aff06ce7894049ab77fb8 + languageName: node + linkType: hard + +"@isaacs/fs-minipass@npm:^4.0.0": + version: 4.0.1 + resolution: "@isaacs/fs-minipass@npm:4.0.1" + dependencies: + minipass: "npm:^7.0.4" + checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 + languageName: node + linkType: hard + +"@jspm/core@npm:^2.0.1": + version: 2.1.0 + resolution: "@jspm/core@npm:2.1.0" + checksum: 10c0/4e10f912b60f33d216a68f46351dd430f10a2024ce5b149ac93e4d19f85d0dbf0b929cbb90397ea0e8cef28f1723ea1f94c88b7c5d16ecf1f62e391ea072bc33 + languageName: node + linkType: hard + +"@npmcli/agent@npm:^4.0.0": + version: 4.0.0 + resolution: "@npmcli/agent@npm:4.0.0" + dependencies: + agent-base: "npm:^7.1.0" + http-proxy-agent: "npm:^7.0.0" + https-proxy-agent: "npm:^7.0.1" + lru-cache: "npm:^11.2.1" + socks-proxy-agent: "npm:^8.0.3" + checksum: 10c0/f7b5ce0f3dd42c3f8c6546e8433573d8049f67ef11ec22aa4704bc41483122f68bf97752e06302c455ead667af5cb753e6a09bff06632bc465c1cfd4c4b75a53 + languageName: node + linkType: hard + +"@npmcli/fs@npm:^5.0.0": + version: 5.0.0 + resolution: "@npmcli/fs@npm:5.0.0" + dependencies: + semver: "npm:^7.3.5" + checksum: 10c0/26e376d780f60ff16e874a0ac9bc3399186846baae0b6e1352286385ac134d900cc5dafaded77f38d77f86898fc923ae1cee9d7399f0275b1aa24878915d722b + languageName: node + linkType: hard + +"@redocly/ajv@npm:^8.11.2": + version: 8.17.1 + resolution: "@redocly/ajv@npm:8.17.1" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: 10c0/f6fd7fc0455ab1f465fd1138b5e33732c177b357b992fa0a474902aff749aef3353d1c9785203870b460aad0752276751eaa6dfa5b57f357e6de7ac926684061 + languageName: node + linkType: hard + +"@redocly/config@npm:^0.22.0": + version: 0.22.2 + resolution: "@redocly/config@npm:0.22.2" + checksum: 10c0/625e947e7939e2d59bd83f516af5a581411167e3fc83adf7322bddf9bc69038fc601ed4ee8abae44d298ed367a16a1a09e7cdbe8b5dde172b4ce53c88d8717f4 + languageName: node + linkType: hard + +"@redocly/openapi-core@npm:^1.34.5": + version: 1.34.6 + resolution: "@redocly/openapi-core@npm:1.34.6" + dependencies: + "@redocly/ajv": "npm:^8.11.2" + "@redocly/config": "npm:^0.22.0" + colorette: "npm:^1.2.0" + https-proxy-agent: "npm:^7.0.5" + js-levenshtein: "npm:^1.1.6" + js-yaml: "npm:^4.1.0" + minimatch: "npm:^5.0.1" + pluralize: "npm:^8.0.0" + yaml-ast-parser: "npm:0.0.43" + checksum: 10c0/3033dc0810fd7d25f033f90f921ce625672851948c264c51426908fe17b363b12a6ddf0aebc231a16befed9396658fa122de9f25fee82fe087b7d4d01291d4e8 + languageName: node + linkType: hard + +"@types/events@npm:^3.0.3": + version: 3.0.3 + resolution: "@types/events@npm:3.0.3" + checksum: 10c0/3a56f8c51eb4ebc0d05dcadca0c6636c816acc10216ce36c976fad11e54a01f4bb979a07211355686015884753b37f17d74bfdc7aaf4ebb027c1e8a501c7b21d + languageName: node + linkType: hard + +"@types/node@npm:^25.0.3": + version: 25.0.3 + resolution: "@types/node@npm:25.0.3" + dependencies: + undici-types: "npm:~7.16.0" + checksum: 10c0/b7568f0d765d9469621615e2bb257c7fd1953d95e9acbdb58dffb6627a2c4150d405a4600aa1ad8a40182a94fe5f903cafd3c0a2f5132814debd0e3bfd61f835 + languageName: node + linkType: hard + +"@typespec/ts-http-runtime@npm:^0.3.0": + version: 0.3.2 + resolution: "@typespec/ts-http-runtime@npm:0.3.2" + dependencies: + http-proxy-agent: "npm:^7.0.0" + https-proxy-agent: "npm:^7.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/33cb3d7a38851a4144b8370c39d6ad9e77b722213cb0eafef9a9e52e35f611d88ebdfd264bb3b51147a2fa4de8d78c8cff294af9e58ebb39ab6c439588771d2b + languageName: node + linkType: hard + +"abbrev@npm:^4.0.0": + version: 4.0.0 + resolution: "abbrev@npm:4.0.0" + checksum: 10c0/b4cc16935235e80702fc90192e349e32f8ef0ed151ef506aa78c81a7c455ec18375c4125414b99f84b2e055199d66383e787675f0bcd87da7a4dbd59f9eac1d5 + languageName: node + linkType: hard + +"agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": + version: 7.1.4 + resolution: "agent-base@npm:7.1.4" + checksum: 10c0/c2c9ab7599692d594b6a161559ada307b7a624fa4c7b03e3afdb5a5e31cd0e53269115b620fcab024c5ac6a6f37fa5eb2e004f076ad30f5f7e6b8b671f7b35fe + languageName: node + linkType: hard + +"ansi-colors@npm:^4.1.3": + version: 4.1.3 + resolution: "ansi-colors@npm:4.1.3" + checksum: 10c0/ec87a2f59902f74e61eada7f6e6fe20094a628dab765cfdbd03c3477599368768cffccdb5d3bb19a1b6c99126783a143b1fee31aab729b31ffe5836c7e5e28b9 + languageName: node + linkType: hard + +"argparse@npm:^2.0.1": + version: 2.0.1 + resolution: "argparse@npm:2.0.1" + checksum: 10c0/c5640c2d89045371c7cedd6a70212a04e360fd34d6edeae32f6952c63949e3525ea77dbec0289d8213a99bbaeab5abfa860b5c12cf88a2e6cf8106e90dd27a7e + languageName: node + linkType: hard + +"balanced-match@npm:^1.0.0": + version: 1.0.2 + resolution: "balanced-match@npm:1.0.2" + checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee + languageName: node + linkType: hard + +"base64-js@npm:^1.3.1": + version: 1.5.1 + resolution: "base64-js@npm:1.5.1" + checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf + languageName: node + linkType: hard + +"brace-expansion@npm:^2.0.1": + version: 2.0.2 + resolution: "brace-expansion@npm:2.0.2" + dependencies: + balanced-match: "npm:^1.0.0" + checksum: 10c0/6d117a4c793488af86b83172deb6af143e94c17bc53b0b3cec259733923b4ca84679d506ac261f4ba3c7ed37c46018e2ff442f9ce453af8643ecd64f4a54e6cf + languageName: node + linkType: hard + +"buffer@npm:^6.0.0": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.2.1" + checksum: 10c0/2a905fbbcde73cc5d8bd18d1caa23715d5f83a5935867c2329f0ac06104204ba7947be098fe1317fbd8830e26090ff8e764f08cd14fefc977bb248c3487bcbd0 + languageName: node + linkType: hard + +"cacache@npm:^20.0.1": + version: 20.0.3 + resolution: "cacache@npm:20.0.3" + dependencies: + "@npmcli/fs": "npm:^5.0.0" + fs-minipass: "npm:^3.0.0" + glob: "npm:^13.0.0" + lru-cache: "npm:^11.1.0" + minipass: "npm:^7.0.3" + minipass-collect: "npm:^2.0.1" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + p-map: "npm:^7.0.2" + ssri: "npm:^13.0.0" + unique-filename: "npm:^5.0.0" + checksum: 10c0/c7da1ca694d20e8f8aedabd21dc11518f809a7d2b59aa76a1fc655db5a9e62379e465c157ddd2afe34b19230808882288effa6911b2de26a088a6d5645123462 + languageName: node + linkType: hard + +"change-case@npm:^5.4.4": + version: 5.4.4 + resolution: "change-case@npm:5.4.4" + checksum: 10c0/2a9c2b9c9ad6ab2491105aaf506db1a9acaf543a18967798dcce20926c6a173aa63266cb6189f3086e3c14bf7ae1f8ea4f96ecc466fcd582310efa00372f3734 + languageName: node + linkType: hard + +"chownr@npm:^3.0.0": + version: 3.0.0 + resolution: "chownr@npm:3.0.0" + checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10 + languageName: node + linkType: hard + +"colorette@npm:^1.2.0": + version: 1.4.0 + resolution: "colorette@npm:1.4.0" + checksum: 10c0/4955c8f7daafca8ae7081d672e4bd89d553bd5782b5846d5a7e05effe93c2f15f7e9c0cb46f341b59f579a39fcf436241ff79594899d75d5f3460c03d607fe9e + languageName: node + linkType: hard + +"debug@npm:4, debug@npm:^4.3.4": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + languageName: node + linkType: hard + +"encoding@npm:^0.1.13": + version: 0.1.13 + resolution: "encoding@npm:0.1.13" + dependencies: + iconv-lite: "npm:^0.6.2" + checksum: 10c0/36d938712ff00fe1f4bac88b43bcffb5930c1efa57bbcdca9d67e1d9d6c57cfb1200fb01efe0f3109b2ce99b231f90779532814a81370a1bd3274a0f58585039 + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 + languageName: node + linkType: hard + +"err-code@npm:^2.0.2": + version: 2.0.3 + resolution: "err-code@npm:2.0.3" + checksum: 10c0/b642f7b4dd4a376e954947550a3065a9ece6733ab8e51ad80db727aaae0817c2e99b02a97a3d6cecc648a97848305e728289cf312d09af395403a90c9d4d8a66 + languageName: node + linkType: hard + +"esbuild-plugin-polyfill-node@npm:^0.3.0": + version: 0.3.0 + resolution: "esbuild-plugin-polyfill-node@npm:0.3.0" + dependencies: + "@jspm/core": "npm:^2.0.1" + import-meta-resolve: "npm:^3.0.0" + peerDependencies: + esbuild: "*" + checksum: 10c0/8e7e7ee7034a11995f99eefbb75b56b162f5b43b849f7d199cbc7b1089867a8595f48a640a1487f496d2af27986b467ccf3a34bf81cbf9961928c8b8d8cbd3ac + languageName: node + linkType: hard + +"esbuild@npm:^0.27.3": + version: 0.27.3 + resolution: "esbuild@npm:0.27.3" + dependencies: + "@esbuild/aix-ppc64": "npm:0.27.3" + "@esbuild/android-arm": "npm:0.27.3" + "@esbuild/android-arm64": "npm:0.27.3" + "@esbuild/android-x64": "npm:0.27.3" + "@esbuild/darwin-arm64": "npm:0.27.3" + "@esbuild/darwin-x64": "npm:0.27.3" + "@esbuild/freebsd-arm64": "npm:0.27.3" + "@esbuild/freebsd-x64": "npm:0.27.3" + "@esbuild/linux-arm": "npm:0.27.3" + "@esbuild/linux-arm64": "npm:0.27.3" + "@esbuild/linux-ia32": "npm:0.27.3" + "@esbuild/linux-loong64": "npm:0.27.3" + "@esbuild/linux-mips64el": "npm:0.27.3" + "@esbuild/linux-ppc64": "npm:0.27.3" + "@esbuild/linux-riscv64": "npm:0.27.3" + "@esbuild/linux-s390x": "npm:0.27.3" + "@esbuild/linux-x64": "npm:0.27.3" + "@esbuild/netbsd-arm64": "npm:0.27.3" + "@esbuild/netbsd-x64": "npm:0.27.3" + "@esbuild/openbsd-arm64": "npm:0.27.3" + "@esbuild/openbsd-x64": "npm:0.27.3" + "@esbuild/openharmony-arm64": "npm:0.27.3" + "@esbuild/sunos-x64": "npm:0.27.3" + "@esbuild/win32-arm64": "npm:0.27.3" + "@esbuild/win32-ia32": "npm:0.27.3" + "@esbuild/win32-x64": "npm:0.27.3" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/fdc3f87a3f08b3ef98362f37377136c389a0d180fda4b8d073b26ba930cf245521db0a368f119cc7624bc619248fff1439f5811f062d853576f8ffa3df8ee5f1 + languageName: node + linkType: hard + +"esbuild@npm:~0.27.0": + version: 0.27.2 + resolution: "esbuild@npm:0.27.2" + dependencies: + "@esbuild/aix-ppc64": "npm:0.27.2" + "@esbuild/android-arm": "npm:0.27.2" + "@esbuild/android-arm64": "npm:0.27.2" + "@esbuild/android-x64": "npm:0.27.2" + "@esbuild/darwin-arm64": "npm:0.27.2" + "@esbuild/darwin-x64": "npm:0.27.2" + "@esbuild/freebsd-arm64": "npm:0.27.2" + "@esbuild/freebsd-x64": "npm:0.27.2" + "@esbuild/linux-arm": "npm:0.27.2" + "@esbuild/linux-arm64": "npm:0.27.2" + "@esbuild/linux-ia32": "npm:0.27.2" + "@esbuild/linux-loong64": "npm:0.27.2" + "@esbuild/linux-mips64el": "npm:0.27.2" + "@esbuild/linux-ppc64": "npm:0.27.2" + "@esbuild/linux-riscv64": "npm:0.27.2" + "@esbuild/linux-s390x": "npm:0.27.2" + "@esbuild/linux-x64": "npm:0.27.2" + "@esbuild/netbsd-arm64": "npm:0.27.2" + "@esbuild/netbsd-x64": "npm:0.27.2" + "@esbuild/openbsd-arm64": "npm:0.27.2" + "@esbuild/openbsd-x64": "npm:0.27.2" + "@esbuild/openharmony-arm64": "npm:0.27.2" + "@esbuild/sunos-x64": "npm:0.27.2" + "@esbuild/win32-arm64": "npm:0.27.2" + "@esbuild/win32-ia32": "npm:0.27.2" + "@esbuild/win32-x64": "npm:0.27.2" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/cf83f626f55500f521d5fe7f4bc5871bec240d3deb2a01fbd379edc43b3664d1167428738a5aad8794b35d1cca985c44c375b1cd38a2ca613c77ced2c83aafcd + languageName: node + linkType: hard + +"events@npm:^3.3.0": + version: 3.3.0 + resolution: "events@npm:3.3.0" + checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 + languageName: node + linkType: hard + +"exponential-backoff@npm:^3.1.1": + version: 3.1.3 + resolution: "exponential-backoff@npm:3.1.3" + checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267 + languageName: node + linkType: hard + +"fast-deep-equal@npm:^3.1.3": + version: 3.1.3 + resolution: "fast-deep-equal@npm:3.1.3" + checksum: 10c0/40dedc862eb8992c54579c66d914635afbec43350afbbe991235fdcb4e3a8d5af1b23ae7e79bef7d4882d0ecee06c3197488026998fb19f72dc95acff1d1b1d0 + languageName: node + linkType: hard + +"fast-uri@npm:^3.0.1": + version: 3.1.0 + resolution: "fast-uri@npm:3.1.0" + checksum: 10c0/44364adca566f70f40d1e9b772c923138d47efeac2ae9732a872baafd77061f26b097ba2f68f0892885ad177becd065520412b8ffeec34b16c99433c5b9e2de7 + languageName: node + linkType: hard + +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + +"fs-minipass@npm:^3.0.0": + version: 3.0.3 + resolution: "fs-minipass@npm:3.0.3" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/63e80da2ff9b621e2cb1596abcb9207f1cf82b968b116ccd7b959e3323144cce7fb141462200971c38bbf2ecca51695069db45265705bed09a7cd93ae5b89f94 + languageName: node + linkType: hard + +"fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + +"get-tsconfig@npm:^4.7.5": + version: 4.13.0 + resolution: "get-tsconfig@npm:4.13.0" + dependencies: + resolve-pkg-maps: "npm:^1.0.0" + checksum: 10c0/2c49ef8d3907047a107f229fd610386fe3b7fe9e42dfd6b42e7406499493cdda8c62e83e57e8d7a98125610774b9f604d3a0ff308d7f9de5c7ac6d1b07cb6036 + languageName: node + linkType: hard + +"glob@npm:^13.0.0": + version: 13.0.1 + resolution: "glob@npm:13.0.1" + dependencies: + minimatch: "npm:^10.1.2" + minipass: "npm:^7.1.2" + path-scurry: "npm:^2.0.0" + checksum: 10c0/af7b863dec8dff74f61d7d6e53104e1f6bbdd482157a196cade8ed857481e876ec35181b38a059b2a7b93ea3b08248f4ff0792fef6dc91814fd5097a716f48e4 + languageName: node + linkType: hard + +"graceful-fs@npm:^4.2.6": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"http-cache-semantics@npm:^4.1.1": + version: 4.2.0 + resolution: "http-cache-semantics@npm:4.2.0" + checksum: 10c0/45b66a945cf13ec2d1f29432277201313babf4a01d9e52f44b31ca923434083afeca03f18417f599c9ab3d0e7b618ceb21257542338b57c54b710463b4a53e37 + languageName: node + linkType: hard + +"http-proxy-agent@npm:^7.0.0": + version: 7.0.2 + resolution: "http-proxy-agent@npm:7.0.2" + dependencies: + agent-base: "npm:^7.1.0" + debug: "npm:^4.3.4" + checksum: 10c0/4207b06a4580fb85dd6dff521f0abf6db517489e70863dca1a0291daa7f2d3d2d6015a57bd702af068ea5cf9f1f6ff72314f5f5b4228d299c0904135d2aef921 + languageName: node + linkType: hard + +"https-proxy-agent@npm:^7.0.0, https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.5": + version: 7.0.6 + resolution: "https-proxy-agent@npm:7.0.6" + dependencies: + agent-base: "npm:^7.1.2" + debug: "npm:4" + checksum: 10c0/f729219bc735edb621fa30e6e84e60ee5d00802b8247aac0d7b79b0bd6d4b3294737a337b93b86a0bd9e68099d031858a39260c976dc14cdbba238ba1f8779ac + languageName: node + linkType: hard + +"iconv-lite@npm:^0.6.2": + version: 0.6.3 + resolution: "iconv-lite@npm:0.6.3" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10c0/98102bc66b33fcf5ac044099d1257ba0b7ad5e3ccd3221f34dd508ab4070edff183276221684e1e0555b145fce0850c9f7d2b60a9fcac50fbb4ea0d6e845a3b1 + languageName: node + linkType: hard + +"ieee754@npm:^1.2.1": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb + languageName: node + linkType: hard + +"import-meta-resolve@npm:^3.0.0": + version: 3.1.1 + resolution: "import-meta-resolve@npm:3.1.1" + checksum: 10c0/75545f3f0f4f789f15b91a541b2d3e9d5b25fc9e8c60e8423cbdef4fff226f45520bd040219c63eee001878f075e82b52e436ca0d7d05e6c4fdc0348b7f251dd + languageName: node + linkType: hard + +"imurmurhash@npm:^0.1.4": + version: 0.1.4 + resolution: "imurmurhash@npm:0.1.4" + checksum: 10c0/8b51313850dd33605c6c9d3fd9638b714f4c4c40250cff658209f30d40da60f78992fb2df5dabee4acf589a6a82bbc79ad5486550754bd9ec4e3fc0d4a57d6a6 + languageName: node + linkType: hard + +"index-to-position@npm:^1.1.0": + version: 1.2.0 + resolution: "index-to-position@npm:1.2.0" + checksum: 10c0/d7ac9fae9fad1d7fbeb7bd92e1553b26e8b10522c2d80af5c362828428a41360e21fc5915d7b8c8227eb0f0d37b12099846ac77381a04d6c0059eb81749e374d + languageName: node + linkType: hard + +"ip-address@npm:^10.0.1": + version: 10.1.0 + resolution: "ip-address@npm:10.1.0" + checksum: 10c0/0103516cfa93f6433b3bd7333fa876eb21263912329bfa47010af5e16934eeeff86f3d2ae700a3744a137839ddfad62b900c7a445607884a49b5d1e32a3d7566 + languageName: node + linkType: hard + +"isexe@npm:^3.1.1": + version: 3.1.2 + resolution: "isexe@npm:3.1.2" + checksum: 10c0/1081adb0e9d8dd6d313916e39c81b683ab0e304bcec388b7bb400da988180dc576be7b298e6cd55d89fc5e98f32c1d73c2e04d2454c6115f58b28a8040d421ed + languageName: node + linkType: hard + +"js-levenshtein@npm:^1.1.6": + version: 1.1.6 + resolution: "js-levenshtein@npm:1.1.6" + checksum: 10c0/14045735325ea1fd87f434a74b11d8a14380f090f154747e613529c7cff68b5ee607f5230fa40665d5fb6125a3791f4c223f73b9feca754f989b059f5c05864f + languageName: node + linkType: hard + +"js-tokens@npm:^4.0.0": + version: 4.0.0 + resolution: "js-tokens@npm:4.0.0" + checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed + languageName: node + linkType: hard + +"js-yaml@npm:^4.1.0": + version: 4.1.1 + resolution: "js-yaml@npm:4.1.1" + dependencies: + argparse: "npm:^2.0.1" + bin: + js-yaml: bin/js-yaml.js + checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7 + languageName: node + linkType: hard + +"json-schema-traverse@npm:^1.0.0": + version: 1.0.0 + resolution: "json-schema-traverse@npm:1.0.0" + checksum: 10c0/71e30015d7f3d6dc1c316d6298047c8ef98a06d31ad064919976583eb61e1018a60a0067338f0f79cabc00d84af3fcc489bd48ce8a46ea165d9541ba17fb30c6 + languageName: node + linkType: hard + +"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1": + version: 11.2.5 + resolution: "lru-cache@npm:11.2.5" + checksum: 10c0/cc98958d25dddf1c8a8cbdc49588bd3b24450e8dfa78f32168fd188a20d4a0331c7406d0f3250c86a46619ee288056fd7a1195e8df56dc8a9592397f4fbd8e1d + languageName: node + linkType: hard + +"make-fetch-happen@npm:^15.0.0": + version: 15.0.3 + resolution: "make-fetch-happen@npm:15.0.3" + dependencies: + "@npmcli/agent": "npm:^4.0.0" + cacache: "npm:^20.0.1" + http-cache-semantics: "npm:^4.1.1" + minipass: "npm:^7.0.2" + minipass-fetch: "npm:^5.0.0" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + negotiator: "npm:^1.0.0" + proc-log: "npm:^6.0.0" + promise-retry: "npm:^2.0.1" + ssri: "npm:^13.0.0" + checksum: 10c0/525f74915660be60b616bcbd267c4a5b59481b073ba125e45c9c3a041bb1a47a2bd0ae79d028eb6f5f95bf9851a4158423f5068539c3093621abb64027e8e461 + languageName: node + linkType: hard + +"minimatch@npm:^10.1.2": + version: 10.1.2 + resolution: "minimatch@npm:10.1.2" + dependencies: + "@isaacs/brace-expansion": "npm:^5.0.1" + checksum: 10c0/0cccef3622201703de6ecf9d772c0be1d5513dcc038ed9feb866c20cf798243e678ac35605dac3f1a054650c28037486713fe9e9a34b184b9097959114daf086 + languageName: node + linkType: hard + +"minimatch@npm:^5.0.1": + version: 5.1.6 + resolution: "minimatch@npm:5.1.6" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10c0/3defdfd230914f22a8da203747c42ee3c405c39d4d37ffda284dac5e45b7e1f6c49aa8be606509002898e73091ff2a3bbfc59c2c6c71d4660609f63aa92f98e3 + languageName: node + linkType: hard + +"minipass-collect@npm:^2.0.1": + version: 2.0.1 + resolution: "minipass-collect@npm:2.0.1" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/5167e73f62bb74cc5019594709c77e6a742051a647fe9499abf03c71dca75515b7959d67a764bdc4f8b361cf897fbf25e2d9869ee039203ed45240f48b9aa06e + languageName: node + linkType: hard + +"minipass-fetch@npm:^5.0.0": + version: 5.0.1 + resolution: "minipass-fetch@npm:5.0.1" + dependencies: + encoding: "npm:^0.1.13" + minipass: "npm:^7.0.3" + minipass-sized: "npm:^2.0.0" + minizlib: "npm:^3.0.1" + dependenciesMeta: + encoding: + optional: true + checksum: 10c0/50bcf48c9841ebb25e29a2817468595219c72cfffc7c175a1d7327843c8bef9b72cb01778f46df7eca695dfe47ab98e6167af4cb026ddd80f660842919a5193c + languageName: node + linkType: hard + +"minipass-flush@npm:^1.0.5": + version: 1.0.5 + resolution: "minipass-flush@npm:1.0.5" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/2a51b63feb799d2bb34669205eee7c0eaf9dce01883261a5b77410c9408aa447e478efd191b4de6fc1101e796ff5892f8443ef20d9544385819093dbb32d36bd + languageName: node + linkType: hard + +"minipass-pipeline@npm:^1.2.4": + version: 1.2.4 + resolution: "minipass-pipeline@npm:1.2.4" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10c0/cbda57cea20b140b797505dc2cac71581a70b3247b84480c1fed5ca5ba46c25ecc25f68bfc9e6dcb1a6e9017dab5c7ada5eab73ad4f0a49d84e35093e0c643f2 + languageName: node + linkType: hard + +"minipass-sized@npm:^2.0.0": + version: 2.0.0 + resolution: "minipass-sized@npm:2.0.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/f9201696a6f6d68610d04c9c83e3d2e5cb9c026aae1c8cbf7e17f386105cb79c1bb088dbc21bf0b1eb4f3fb5df384fd1e7aa3bf1f33868c416ae8c8a92679db8 + languageName: node + linkType: hard + +"minipass@npm:^3.0.0": + version: 3.3.6 + resolution: "minipass@npm:3.3.6" + dependencies: + yallist: "npm:^4.0.0" + checksum: 10c0/a114746943afa1dbbca8249e706d1d38b85ed1298b530f5808ce51f8e9e941962e2a5ad2e00eae7dd21d8a4aae6586a66d4216d1a259385e9d0358f0c1eba16c + languageName: node + linkType: hard + +"minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": + version: 7.1.2 + resolution: "minipass@npm:7.1.2" + checksum: 10c0/b0fd20bb9fb56e5fa9a8bfac539e8915ae07430a619e4b86ff71f5fc757ef3924b23b2c4230393af1eda647ed3d75739e4e0acb250a6b1eb277cf7f8fe449557 + languageName: node + linkType: hard + +"minizlib@npm:^3.0.1, minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec + languageName: node + linkType: hard + +"ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 + languageName: node + linkType: hard + +"negotiator@npm:^1.0.0": + version: 1.0.0 + resolution: "negotiator@npm:1.0.0" + checksum: 10c0/4c559dd52669ea48e1914f9d634227c561221dd54734070791f999c52ed0ff36e437b2e07d5c1f6e32909fc625fe46491c16e4a8f0572567d4dd15c3a4fda04b + languageName: node + linkType: hard + +"node-gyp@npm:latest": + version: 12.2.0 + resolution: "node-gyp@npm:12.2.0" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + graceful-fs: "npm:^4.2.6" + make-fetch-happen: "npm:^15.0.0" + nopt: "npm:^9.0.0" + proc-log: "npm:^6.0.0" + semver: "npm:^7.3.5" + tar: "npm:^7.5.4" + tinyglobby: "npm:^0.2.12" + which: "npm:^6.0.0" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/3ed046746a5a7d90950cd8b0547332b06598443f31fe213ef4332a7174c7b7d259e1704835feda79b87d3f02e59d7791842aac60642ede4396ab25fdf0f8f759 + languageName: node + linkType: hard + +"nopt@npm:^9.0.0": + version: 9.0.0 + resolution: "nopt@npm:9.0.0" + dependencies: + abbrev: "npm:^4.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/1822eb6f9b020ef6f7a7516d7b64a8036e09666ea55ac40416c36e4b2b343122c3cff0e2f085675f53de1d2db99a2a89a60ccea1d120bcd6a5347bf6ceb4a7fd + languageName: node + linkType: hard + +"openapi-typescript@npm:^7.10.1": + version: 7.10.1 + resolution: "openapi-typescript@npm:7.10.1" + dependencies: + "@redocly/openapi-core": "npm:^1.34.5" + ansi-colors: "npm:^4.1.3" + change-case: "npm:^5.4.4" + parse-json: "npm:^8.3.0" + supports-color: "npm:^10.2.2" + yargs-parser: "npm:^21.1.1" + peerDependencies: + typescript: ^5.x + bin: + openapi-typescript: bin/cli.js + checksum: 10c0/85d2075c27a3802cca50838d4ead52d6a3443f964b0d1601e82cdea5dc317c62a2452f23caafd11991101c8420f326f2977cda2fa126e517c59f57b73017ccff + languageName: node + linkType: hard + +"p-map@npm:^7.0.2": + version: 7.0.4 + resolution: "p-map@npm:7.0.4" + checksum: 10c0/a5030935d3cb2919d7e89454d1ce82141e6f9955413658b8c9403cfe379283770ed3048146b44cde168aa9e8c716505f196d5689db0ae3ce9a71521a2fef3abd + languageName: node + linkType: hard + +"parse-json@npm:^8.3.0": + version: 8.3.0 + resolution: "parse-json@npm:8.3.0" + dependencies: + "@babel/code-frame": "npm:^7.26.2" + index-to-position: "npm:^1.1.0" + type-fest: "npm:^4.39.1" + checksum: 10c0/0eb5a50f88b8428c8f7a9cf021636c16664f0c62190323652d39e7bdf62953e7c50f9957e55e17dc2d74fc05c89c11f5553f381dbc686735b537ea9b101c7153 + languageName: node + linkType: hard + +"path-scurry@npm:^2.0.0": + version: 2.0.1 + resolution: "path-scurry@npm:2.0.1" + dependencies: + lru-cache: "npm:^11.0.0" + minipass: "npm:^7.1.2" + checksum: 10c0/2a16ed0e81fbc43513e245aa5763354e25e787dab0d539581a6c3f0f967461a159ed6236b2559de23aa5b88e7dc32b469b6c47568833dd142a4b24b4f5cd2620 + languageName: node + linkType: hard + +"picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + +"picomatch@npm:^4.0.3": + version: 4.0.3 + resolution: "picomatch@npm:4.0.3" + checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 + languageName: node + linkType: hard + +"pluralize@npm:^8.0.0": + version: 8.0.0 + resolution: "pluralize@npm:8.0.0" + checksum: 10c0/2044cfc34b2e8c88b73379ea4a36fc577db04f651c2909041b054c981cd863dd5373ebd030123ab058d194ae615d3a97cfdac653991e499d10caf592e8b3dc33 + languageName: node + linkType: hard + +"proc-log@npm:^6.0.0": + version: 6.1.0 + resolution: "proc-log@npm:6.1.0" + checksum: 10c0/4f178d4062733ead9d71a9b1ab24ebcecdfe2250916a5b1555f04fe2eda972a0ec76fbaa8df1ad9c02707add6749219d118a4fc46dc56bdfe4dde4b47d80bb82 + languageName: node + linkType: hard + +"promise-retry@npm:^2.0.1": + version: 2.0.1 + resolution: "promise-retry@npm:2.0.1" + dependencies: + err-code: "npm:^2.0.2" + retry: "npm:^0.12.0" + checksum: 10c0/9c7045a1a2928094b5b9b15336dcd2a7b1c052f674550df63cc3f36cd44028e5080448175b6f6ca32b642de81150f5e7b1a98b728f15cb069f2dd60ac2616b96 + languageName: node + linkType: hard + +"require-from-string@npm:^2.0.2": + version: 2.0.2 + resolution: "require-from-string@npm:2.0.2" + checksum: 10c0/aaa267e0c5b022fc5fd4eef49d8285086b15f2a1c54b28240fdf03599cbd9c26049fee3eab894f2e1f6ca65e513b030a7c264201e3f005601e80c49fb2937ce2 + languageName: node + linkType: hard + +"resolve-pkg-maps@npm:^1.0.0": + version: 1.0.0 + resolution: "resolve-pkg-maps@npm:1.0.0" + checksum: 10c0/fb8f7bbe2ca281a73b7ef423a1cbc786fb244bd7a95cbe5c3fba25b27d327150beca8ba02f622baea65919a57e061eb5005204daa5f93ed590d9b77463a567ab + languageName: node + linkType: hard + +"retry@npm:^0.12.0": + version: 0.12.0 + resolution: "retry@npm:0.12.0" + checksum: 10c0/59933e8501727ba13ad73ef4a04d5280b3717fd650408460c987392efe9d7be2040778ed8ebe933c5cbd63da3dcc37919c141ef8af0a54a6e4fca5a2af177bfe + languageName: node + linkType: hard + +"safer-buffer@npm:>= 2.1.2 < 3.0.0": + version: 2.1.2 + resolution: "safer-buffer@npm:2.1.2" + checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 + languageName: node + linkType: hard + +"semver@npm:^7.3.5": + version: 7.7.4 + resolution: "semver@npm:7.7.4" + bin: + semver: bin/semver.js + checksum: 10c0/5215ad0234e2845d4ea5bb9d836d42b03499546ddafb12075566899fc617f68794bb6f146076b6881d755de17d6c6cc73372555879ec7dce2c2feee947866ad2 + languageName: node + linkType: hard + +"smart-buffer@npm:^4.2.0": + version: 4.2.0 + resolution: "smart-buffer@npm:4.2.0" + checksum: 10c0/a16775323e1404dd43fabafe7460be13a471e021637bc7889468eb45ce6a6b207261f454e4e530a19500cc962c4cc5348583520843b363f4193cee5c00e1e539 + languageName: node + linkType: hard + +"socks-proxy-agent@npm:^8.0.3": + version: 8.0.5 + resolution: "socks-proxy-agent@npm:8.0.5" + dependencies: + agent-base: "npm:^7.1.2" + debug: "npm:^4.3.4" + socks: "npm:^2.8.3" + checksum: 10c0/5d2c6cecba6821389aabf18728325730504bf9bb1d9e342e7987a5d13badd7a98838cc9a55b8ed3cb866ad37cc23e1086f09c4d72d93105ce9dfe76330e9d2a6 + languageName: node + linkType: hard + +"socks@npm:^2.8.3": + version: 2.8.7 + resolution: "socks@npm:2.8.7" + dependencies: + ip-address: "npm:^10.0.1" + smart-buffer: "npm:^4.2.0" + checksum: 10c0/2805a43a1c4bcf9ebf6e018268d87b32b32b06fbbc1f9282573583acc155860dc361500f89c73bfbb157caa1b4ac78059eac0ef15d1811eb0ca75e0bdadbc9d2 + languageName: node + linkType: hard + +"ssri@npm:^13.0.0": + version: 13.0.0 + resolution: "ssri@npm:13.0.0" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/405f3a531cd98b013cecb355d63555dca42fd12c7bc6671738aaa9a82882ff41cdf0ef9a2b734ca4f9a760338f114c29d01d9238a65db3ccac27929bd6e6d4b2 + languageName: node + linkType: hard + +"supports-color@npm:^10.2.2": + version: 10.2.2 + resolution: "supports-color@npm:10.2.2" + checksum: 10c0/fb28dd7e0cdf80afb3f2a41df5e068d60c8b4f97f7140de2eaed5b42e075d82a0e980b20a2c0efd2b6d73cfacb55555285d8cc719fa0472220715aefeaa1da7c + languageName: node + linkType: hard + +"tar@npm:^7.5.4": + version: 7.5.7 + resolution: "tar@npm:7.5.7" + dependencies: + "@isaacs/fs-minipass": "npm:^4.0.0" + chownr: "npm:^3.0.0" + minipass: "npm:^7.1.2" + minizlib: "npm:^3.1.0" + yallist: "npm:^5.0.0" + checksum: 10c0/51f261afc437e1112c3e7919478d6176ea83f7f7727864d8c2cce10f0b03a631d1911644a567348c3063c45abdae39718ba97abb073d22aa3538b9a53ae1e31c + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.12": + version: 0.2.15 + resolution: "tinyglobby@npm:0.2.15" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.3" + checksum: 10c0/869c31490d0d88eedb8305d178d4c75e7463e820df5a9b9d388291daf93e8b1eb5de1dad1c1e139767e4269fe75f3b10d5009b2cc14db96ff98986920a186844 + languageName: node + linkType: hard + +"tslib@npm:^2.6.2, tslib@npm:^2.8.1": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 + languageName: node + linkType: hard + +"tsx@npm:^4.21.0": + version: 4.21.0 + resolution: "tsx@npm:4.21.0" + dependencies: + esbuild: "npm:~0.27.0" + fsevents: "npm:~2.3.3" + get-tsconfig: "npm:^4.7.5" + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 10c0/f5072923cd8459a1f9a26df87823a2ab5754641739d69df2a20b415f61814322b751fa6be85db7c6ec73cf68ba8fac2fd1cfc76bdb0aa86ded984d84d5d2126b + languageName: node + linkType: hard + +"type-fest@npm:^4.39.1": + version: 4.41.0 + resolution: "type-fest@npm:4.41.0" + checksum: 10c0/f5ca697797ed5e88d33ac8f1fec21921839871f808dc59345c9cf67345bfb958ce41bd821165dbf3ae591cedec2bf6fe8882098dfdd8dc54320b859711a2c1e4 + languageName: node + linkType: hard + +"typescript@npm:^5.9.3": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A^5.9.3#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 + languageName: node + linkType: hard + +"undici-types@npm:~7.16.0": + version: 7.16.0 + resolution: "undici-types@npm:7.16.0" + checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a + languageName: node + linkType: hard + +"unique-filename@npm:^5.0.0": + version: 5.0.0 + resolution: "unique-filename@npm:5.0.0" + dependencies: + unique-slug: "npm:^6.0.0" + checksum: 10c0/afb897e9cf4c2fb622ea716f7c2bb462001928fc5f437972213afdf1cc32101a230c0f1e9d96fc91ee5185eca0f2feb34127145874975f347be52eb91d6ccc2c + languageName: node + linkType: hard + +"unique-slug@npm:^6.0.0": + version: 6.0.0 + resolution: "unique-slug@npm:6.0.0" + dependencies: + imurmurhash: "npm:^0.1.4" + checksum: 10c0/da7ade4cb04eb33ad0499861f82fe95ce9c7c878b7139dc54d140ecfb6a6541c18a5c8dac16188b8b379fe62c0c1f1b710814baac910cde5f4fec06212126c6a + languageName: node + linkType: hard + +"which@npm:^6.0.0": + version: 6.0.0 + resolution: "which@npm:6.0.0" + dependencies: + isexe: "npm:^3.1.1" + bin: + node-which: bin/which.js + checksum: 10c0/fe9d6463fe44a76232bb6e3b3181922c87510a5b250a98f1e43a69c99c079b3f42ddeca7e03d3e5f2241bf2d334f5a7657cfa868b97c109f3870625842f4cc15 + languageName: node + linkType: hard + +"ws@npm:^8.0.0, ws@npm:^8.18.0": + version: 8.19.0 + resolution: "ws@npm:8.19.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/4741d9b9bc3f9c791880882414f96e36b8b254e34d4b503279d6400d9a4b87a033834856dbdd94ee4b637944df17ea8afc4bce0ff4a1560d2166be8855da5b04 + languageName: node + linkType: hard + +"yallist@npm:^4.0.0": + version: 4.0.0 + resolution: "yallist@npm:4.0.0" + checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a + languageName: node + linkType: hard + +"yallist@npm:^5.0.0": + version: 5.0.0 + resolution: "yallist@npm:5.0.0" + checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416 + languageName: node + linkType: hard + +"yaml-ast-parser@npm:0.0.43": + version: 0.0.43 + resolution: "yaml-ast-parser@npm:0.0.43" + checksum: 10c0/4d2f1e761067b2c6abdd882279a406f879258787af470a6d4a659cb79cb2ab056b870b25f1f80f46ed556e8b499d611d247806376f53edf3412f72c0a8ea2e98 + languageName: node + linkType: hard + +"yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 + languageName: node + linkType: hard From 27f1771899f216b8c3adb35db1aad9a4c0a9664d Mon Sep 17 00:00:00 2001 From: xingsy97 <87063252+xingsy97@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:03:50 +0800 Subject: [PATCH 2/2] add demos --- .../examples/live-auction/.yarnrc.yml | 5 + .../examples/live-auction/README.md | 48 + .../examples/live-auction/package.json | 16 + .../examples/live-auction/public/app.js | 352 ++ .../examples/live-auction/public/index.html | 134 + .../examples/live-auction/server.js | 41 + .../examples/live-auction/yarn.lock | 942 ++++ .../examples/teams-lite/.gitignore | 10 + .../examples/teams-lite/README.md | 57 + .../examples/teams-lite/client/.gitignore | 24 + .../examples/teams-lite/client/.yarnrc.yml | 5 + .../examples/teams-lite/client/README.md | 145 + .../teams-lite/client/eslint.config.js | 23 + .../examples/teams-lite/client/index.html | 12 + .../examples/teams-lite/client/package.json | 56 + .../teams-lite/client/postcss.config.js | 0 .../client/public/microsoft_teams.svg | 51 + .../teams-lite/client/src/assets/react.svg | 1 + .../client/src/components/AddToRoomDialog.tsx | 156 + .../src/components/AvatarWithOnlineStatus.tsx | 60 + .../client/src/components/ChatApp.tsx | 83 + .../client/src/components/ChatFooter.tsx | 9 + .../client/src/components/ChatHeader.tsx | 509 ++ .../client/src/components/ChatInput.tsx | 64 + .../client/src/components/ChatMessages.tsx | 39 + .../src/components/ChatStatusBanner.tsx | 33 + .../client/src/components/ChatWindow.tsx | 30 + .../src/components/CreateRoomDialog.tsx | 184 + .../client/src/components/JoinRoomDialog.tsx | 83 + .../client/src/components/LoginDialog.tsx | 63 + .../src/components/MessageComponent.tsx | 106 + .../src/components/OnlineStatusIndicator.tsx | 21 + .../src/components/RemoveFromRoomDialog.tsx | 147 + .../src/components/RichTextEditor.teams.css | 180 + .../client/src/components/RichTextEditor.tsx | 184 + .../client/src/components/Sidebar.tsx | 536 ++ .../client/src/components/TopSearchBar.tsx | 208 + .../client/src/components/TypingIndicator.tsx | 30 + .../client/src/components/UserProfileCard.tsx | 60 + .../client/src/contexts/ChatClientContext.ts | 63 + .../client/src/contexts/ChatRoomContext.ts | 12 + .../src/contexts/ChatSettingsContext.ts | 26 + .../client/src/hooks/useChatClient.ts | 10 + .../client/src/hooks/usePrivateChat.ts | 51 + .../client/src/hooks/useTextareaAutosize.ts | 18 + .../examples/teams-lite/client/src/index.css | 2768 ++++++++++ .../teams-lite/client/src/lib/constants.ts | 4 + .../examples/teams-lite/client/src/main.tsx | 16 + .../src/providers/ChatClientProvider.tsx | 802 +++ .../client/src/providers/ChatRoomProvider.tsx | 28 + .../src/providers/ChatSettingsProvider.tsx | 133 + .../client/src/reducers/messagesReducer.ts | 160 + .../client/src/utils/avatarUtils.ts | 113 + .../client/src/utils/messageFormatting.ts | 51 + .../client/src/utils/sharedComponents.tsx | 174 + .../teams-lite/client/src/utils/storage.ts | 29 + .../client/src/utils/timeFormatting.ts | 74 + .../teams-lite/client/src/vite-env.d.ts | 1 + .../teams-lite/client/tailwind.config.js | 0 .../teams-lite/client/tsconfig.app.json | 27 + .../examples/teams-lite/client/tsconfig.json | 7 + .../teams-lite/client/tsconfig.node.json | 20 + .../teams-lite/client/tsconfig.test.json | 8 + .../examples/teams-lite/client/vite.config.ts | 38 + .../teams-lite/client/vitest.config.ts | 14 + .../teams-lite/client/vitest.setup.ts | 1 + .../examples/teams-lite/client/yarn.lock | 4854 +++++++++++++++++ .../examples/teams-lite/deploy/README.md | 63 + .../examples/teams-lite/deploy/config.json | 7 + .../examples/teams-lite/deploy/deploy.ps1 | 230 + .../examples/teams-lite/deploy/main.bicep | 71 + .../examples/teams-lite/package.json | 18 + .../examples/teams-lite/server/.npmrc | 1 + .../examples/teams-lite/server/.yarnrc.yml | 5 + .../examples/teams-lite/server/README.md | 84 + .../examples/teams-lite/server/index.js | 93 + .../examples/teams-lite/server/package.json | 24 + .../examples/teams-lite/server/yarn.lock | 1188 ++++ 78 files changed, 15993 insertions(+) create mode 100644 sdk/webpubsub-chat-client/examples/live-auction/.yarnrc.yml create mode 100644 sdk/webpubsub-chat-client/examples/live-auction/README.md create mode 100644 sdk/webpubsub-chat-client/examples/live-auction/package.json create mode 100644 sdk/webpubsub-chat-client/examples/live-auction/public/app.js create mode 100644 sdk/webpubsub-chat-client/examples/live-auction/public/index.html create mode 100644 sdk/webpubsub-chat-client/examples/live-auction/server.js create mode 100644 sdk/webpubsub-chat-client/examples/live-auction/yarn.lock create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/.gitignore create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/README.md create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/.gitignore create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/.yarnrc.yml create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/README.md create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/eslint.config.js create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/index.html create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/package.json create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/postcss.config.js create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/public/microsoft_teams.svg create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/assets/react.svg create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/AddToRoomDialog.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/AvatarWithOnlineStatus.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatApp.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatFooter.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatHeader.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatInput.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatMessages.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatStatusBanner.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatWindow.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/CreateRoomDialog.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/JoinRoomDialog.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/LoginDialog.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/MessageComponent.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/OnlineStatusIndicator.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/RemoveFromRoomDialog.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/RichTextEditor.teams.css create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/RichTextEditor.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/Sidebar.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/TopSearchBar.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/TypingIndicator.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/UserProfileCard.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/contexts/ChatClientContext.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/contexts/ChatRoomContext.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/contexts/ChatSettingsContext.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/hooks/useChatClient.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/hooks/usePrivateChat.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/hooks/useTextareaAutosize.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/index.css create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/lib/constants.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/main.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/providers/ChatClientProvider.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/providers/ChatRoomProvider.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/providers/ChatSettingsProvider.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/reducers/messagesReducer.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/utils/avatarUtils.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/utils/messageFormatting.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/utils/sharedComponents.tsx create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/utils/storage.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/utils/timeFormatting.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/src/vite-env.d.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/tailwind.config.js create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/tsconfig.app.json create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/tsconfig.json create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/tsconfig.node.json create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/tsconfig.test.json create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/vite.config.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/vitest.config.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/vitest.setup.ts create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/client/yarn.lock create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/deploy/README.md create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/deploy/config.json create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/deploy/deploy.ps1 create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/deploy/main.bicep create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/package.json create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/server/.npmrc create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/server/.yarnrc.yml create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/server/README.md create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/server/index.js create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/server/package.json create mode 100644 sdk/webpubsub-chat-client/examples/teams-lite/server/yarn.lock diff --git a/sdk/webpubsub-chat-client/examples/live-auction/.yarnrc.yml b/sdk/webpubsub-chat-client/examples/live-auction/.yarnrc.yml new file mode 100644 index 000000000..3b452cd5a --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/live-auction/.yarnrc.yml @@ -0,0 +1,5 @@ +nodeLinker: node-modules + +npmScopes: + azure: + npmRegistryServer: "https://www.myget.org/F/azure-signalr-dev/npm/" diff --git a/sdk/webpubsub-chat-client/examples/live-auction/README.md b/sdk/webpubsub-chat-client/examples/live-auction/README.md new file mode 100644 index 000000000..5b7d26b5e --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/live-auction/README.md @@ -0,0 +1,48 @@ +# Live Auction Example + +A real-time auction app built with the **Web PubSub Chat SDK**. Bids appear instantly across all participants — every millisecond counts. + +## How it works + +| Action | SDK API used | +|---|---| +| User login | `new ChatClient(url)` → `login()` | +| Create an auction (invite bidders) | `createRoom(itemName, bidders)` | +| Broadcast starting price | `sendToRoom(roomId, configJson)` | +| Receive auction invitation | `addListenerForNewRoom` | +| Place a bid | `sendToRoom(roomId, bidJson)` — returns message ID as ACK | +| Real-time bid updates | `addListenerForNewMessage` | +| Load bid history | `listRoomMessage` | +| See who joined | `addListenerForMemberJoined` | + +## Prerequisites + +1. An Azure Web PubSub resource (**PPE portal**, region `CentralUSEUAP` or `EastUS2EUAP`) +2. Enable **Persistent Storage** (Table) and create a **Chat Hub** with chat feature enabled on the resource +3. Copy the connection string + +## Quick Start + +```bash +# Install dependencies +yarn install + +# Start the server (pass your connection string) +export WebPubSubConnectionString="" +yarn start +``` + +Open `http://localhost:3000` in multiple browser tabs. + +## Walkthrough + +1. Open **two or more** browser tabs and log in with different usernames (e.g. `alice`, `bob`) +2. In Alice's tab, create an auction: + - Item: `Vintage Watch` + - Starting price: `100` + - Bidders: `bob` +3. Bob's tab will instantly show the new auction in "Active Auctions" +4. Click the auction — the highest bid panel shows the starting price +5. Bob places a bid of `$150` → Alice sees it update **instantly** (green flash) +6. Alice outbids with `$200` → Bob sees it in real time +7. The bid history shows every bid with timestamps, newest first diff --git a/sdk/webpubsub-chat-client/examples/live-auction/package.json b/sdk/webpubsub-chat-client/examples/live-auction/package.json new file mode 100644 index 000000000..6984dadaf --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/live-auction/package.json @@ -0,0 +1,16 @@ +{ + "name": "live-auction-example", + "version": "1.0.0", + "description": "Real-time live auction example using Web PubSub Chat SDK", + "type": "module", + "scripts": { + "start": "node server.js" + }, + "author": "Microsoft", + "license": "MIT", + "dependencies": { + "@azure/web-pubsub": "^1.2.0", + "@azure/web-pubsub-chat-client": "1.0.0-beta.1", + "express": "^5.2.1" + } +} diff --git a/sdk/webpubsub-chat-client/examples/live-auction/public/app.js b/sdk/webpubsub-chat-client/examples/live-auction/public/app.js new file mode 100644 index 000000000..2e40e53c9 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/live-auction/public/app.js @@ -0,0 +1,352 @@ +// TODO: Once published to npm, replace with: import { ChatClient } from 'https://unpkg.com/@azure/web-pubsub-chat-client/dist/browser/index.js' +import { ChatClient } from '/@azure/web-pubsub-chat-client/index.js'; + +// State +let client = null; +let auctions = new Map(); // roomId -> { item, startingPrice, roomId, highestBid, highestBidder } +let currentAuctionId = null; + +// DOM refs +const $ = (id) => document.getElementById(id); + +// Helpers +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +function formatTime(isoStr) { + if (!isoStr) return ''; + const d = new Date(isoStr); + return isNaN(d.getTime()) ? '' : d.toLocaleTimeString(); +} + +function showError(msg) { + const el = $('login-error'); + el.textContent = msg; + el.classList.remove('hidden'); +} + +// Login + +// Click a preset name button → fill input and auto-login +document.querySelectorAll('.name-btn').forEach(btn => { + btn.addEventListener('click', () => { + $('input-username').value = btn.dataset.name; + doLogin(); + }); +}); + +$('btn-login').addEventListener('click', doLogin); +$('input-username').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); + +async function doLogin() { + const username = $('input-username').value.trim(); + if (!username) return showError('Please enter a username'); + + const btn = $('btn-login'); + const prevText = btn.textContent; + btn.textContent = 'Logging in...'; + btn.classList.add('opacity-70'); + $('input-username').disabled = true; + document.querySelectorAll('.name-btn').forEach(b => b.classList.add('opacity-50', 'pointer-events-none')); + + try { + const resp = await fetch(`/negotiate?userId=${encodeURIComponent(username)}`); + const { url } = await resp.json(); + + client = new ChatClient(url); + await client.login(); + + // Show logged-in state + btn.textContent = 'Logged in'; + $('display-username').textContent = `✓ Logged in as ${client.userId}`; + $('login-bar').classList.add('opacity-50', 'pointer-events-none'); + $('main-content').classList.remove('hidden'); + + // Remove self from the default bidders input + const biddersInput = $('input-bidders'); + const others = biddersInput.value.split(',').map(s => s.trim()).filter(s => s.toLowerCase() !== client.userId.toLowerCase()); + biddersInput.value = others.join(', '); + + setupListeners(); + + // Restore auctions the user already belongs to (re-login) + for (const room of client.rooms) { + await loadAuctionFromRoom(room.roomId, room.title); + } + renderAuctionList(); + } catch (e) { + showError('Login failed: ' + e.message); + btn.textContent = prevText; + btn.classList.remove('opacity-70'); + $('input-username').disabled = false; + document.querySelectorAll('.name-btn').forEach(b => b.classList.remove('opacity-50', 'pointer-events-none')); + } +} + +// Item presets +document.querySelectorAll('.item-preset').forEach(btn => { + btn.addEventListener('click', () => { + $('input-item').value = btn.dataset.item; + $('input-starting-price').value = btn.dataset.price; + }); +}); + +// Chat SDK listeners +function setupListeners() { + client.addListenerForNewRoom(async (room) => { + await loadAuctionFromRoom(room.roomId, room.title); + renderAuctionList(); + // Auto-open if it's the only auction + if (auctions.size === 1) openAuction(room.roomId); + }); + + // New message arrives in real time (skip self — already handled locally) + client.addListenerForNewMessage((notification) => { + const msg = notification.message; + const roomId = notification.conversation?.roomId; + if (!roomId || msg.createdBy === client.userId) return; + + try { + const data = JSON.parse(msg.content.text); + + // Config message (may arrive after newRoom due to race condition) + if (data.type === 'config') { + const auction = auctions.get(roomId); + if (auction && auction.startingPrice === 0) { + auction.startingPrice = data.startingPrice; + auction.highestBid = Math.max(auction.highestBid, data.startingPrice); + renderAuctionList(); + if (roomId === currentAuctionId) { + $('auction-meta').textContent = `Starting price: $${auction.startingPrice}`; + renderHighestBid(auction); + } + } + return; + } + + if (data.type === 'bid') { + const auction = auctions.get(roomId); + if (auction && data.amount > auction.highestBid) { + auction.highestBid = data.amount; + auction.highestBidder = msg.createdBy; + } + if (roomId === currentAuctionId) { + renderHighestBid(auction); + prependBidEntry(msg.createdBy, data.amount, msg.createdAt); + } + renderAuctionList(); + } + } catch { /* ignore non-JSON */ } + }); +} + +// Load auction from room messages +async function loadAuctionFromRoom(roomId, title) { + if (auctions.has(roomId)) return; + + const auction = { item: title, startingPrice: 0, roomId, highestBid: 0, highestBidder: null }; + + try { + const { messages } = await client.listRoomMessage(roomId, null, null); + for (const msg of messages) { + try { + const data = JSON.parse(msg.content.text); + if (data.type === 'config') { + auction.startingPrice = data.startingPrice; + auction.highestBid = data.startingPrice; + } + if (data.type === 'bid' && data.amount > auction.highestBid) { + auction.highestBid = data.amount; + auction.highestBidder = msg.createdBy; + } + } catch { /* skip */ } + } + } catch { /* room might be empty */ } + + auctions.set(roomId, auction); +} + +// Create auction +$('btn-create').addEventListener('click', async () => { + const item = $('input-item').value.trim(); + const startingPrice = parseInt($('input-starting-price').value, 10); + const biddersRaw = $('input-bidders').value.trim(); + + if (!item || !startingPrice) { + alert('Please enter an item name and starting price'); + return; + } + + const bidders = biddersRaw ? biddersRaw.split(',').map(s => s.trim()).filter(Boolean) : []; + + const btn = $('btn-create'); + btn.disabled = true; + btn.textContent = 'Creating...'; + + try { + const room = await client.createRoom(item, bidders); + await client.sendToRoom(room.roomId, JSON.stringify({ type: 'config', startingPrice })); + + auctions.set(room.roomId, { + item, startingPrice, roomId: room.roomId, + highestBid: startingPrice, highestBidder: null + }); + renderAuctionList(); + openAuction(room.roomId); // auto-open the auction we just created + } catch (e) { + alert('Failed to create auction: ' + e.message); + } finally { + btn.disabled = false; + btn.textContent = 'Start Auction'; + } +}); + +function itemLabel(auction) { + const tag = auction.roomId ? auction.roomId.slice(0, 4) : ''; + return tag ? `${auction.item} [${tag}]` : auction.item; +} + +// Auction list +function renderAuctionList() { + const container = $('auction-list'); + if (auctions.size === 0) { + container.innerHTML = '

No auctions yet. Create one or wait for an invitation.

'; + return; + } + + container.innerHTML = ''; + for (const [roomId, auction] of auctions) { + const isActive = roomId === currentAuctionId; + const div = document.createElement('div'); + div.className = `border rounded p-3 cursor-pointer hover:bg-gray-50 flex justify-between items-center ${isActive ? 'border-blue-400 bg-blue-50' : ''}`; + div.innerHTML = ` + ${escapeHtml(itemLabel(auction))} + $${auction.highestBid} + `; + div.addEventListener('click', () => openAuction(roomId)); + container.appendChild(div); + } +} + +// Open auction +async function openAuction(roomId) { + const auction = auctions.get(roomId); + if (!auction) return; + + currentAuctionId = roomId; + $('auction-title').textContent = itemLabel(auction); + $('auction-meta').textContent = `Starting price: $${auction.startingPrice}`; + $('bid-error').classList.add('hidden'); + + renderHighestBid(auction); + await renderBidHistory(roomId); + + $('auction-panel').classList.remove('hidden'); + renderAuctionList(); // highlight the active one +} + +function renderHighestBid(auction) { + $('highest-bid-amount').textContent = `$${auction.highestBid}`; + $('highest-bid-user').textContent = auction.highestBidder ? `by ${auction.highestBidder}` : '—'; + + // Flash effect + const panel = $('highest-bid-panel'); + panel.classList.remove('bid-flash'); + void panel.offsetWidth; + panel.classList.add('bid-flash'); +} + +// Quick bid +document.querySelectorAll('.quick-bid').forEach(btn => { + btn.addEventListener('click', () => { + const raise = parseInt(btn.dataset.raise, 10); + const auction = auctions.get(currentAuctionId); + if (!auction) return; + placeBid(auction.highestBid + raise); + }); +}); + +async function placeBid(amount) { + const auction = auctions.get(currentAuctionId); + if (!auction) return; + + if (amount <= auction.highestBid) { + const el = $('bid-error'); + el.textContent = `Bid must be higher than $${auction.highestBid}`; + el.classList.remove('hidden'); + return; + } + + $('bid-error').classList.add('hidden'); + document.querySelectorAll('.quick-bid').forEach(b => b.disabled = true); + + try { + await client.sendToRoom(currentAuctionId, JSON.stringify({ type: 'bid', amount })); + + auction.highestBid = amount; + auction.highestBidder = client.userId; + + renderHighestBid(auction); + prependBidEntry(client.userId, amount, new Date().toISOString()); + renderAuctionList(); + } catch (e) { + const el = $('bid-error'); + el.textContent = 'Bid failed: ' + e.message; + el.classList.remove('hidden'); + } finally { + document.querySelectorAll('.quick-bid').forEach(b => b.disabled = false); + } +} + +// Bid history +async function renderBidHistory(roomId) { + const container = $('bid-history'); + container.innerHTML = ''; + + try { + const { messages } = await client.listRoomMessage(roomId, null, null); + + const bids = []; + for (const msg of messages) { + try { + const data = JSON.parse(msg.content.text); + if (data.type === 'bid') { + bids.push({ user: msg.createdBy, amount: data.amount, time: msg.createdAt }); + } + } catch { /* skip */ } + } + + bids.reverse(); + for (const bid of bids) { + container.appendChild(createBidRow(bid.user, bid.amount, bid.time)); + } + + $('bid-count').textContent = `${bids.length} bid${bids.length !== 1 ? 's' : ''} total`; + } catch { + container.innerHTML = '

Could not load bid history

'; + } +} + +function prependBidEntry(user, amount, time) { + const container = $('bid-history'); + const row = createBidRow(user, amount, time); + row.classList.add('bid-flash'); + container.prepend(row); + + const countEl = $('bid-count'); + const current = parseInt(countEl.textContent) || 0; + countEl.textContent = `${current + 1} bid${current + 1 !== 1 ? 's' : ''} total`; +} + +function createBidRow(user, amount, time) { + const row = document.createElement('div'); + row.className = 'flex justify-between items-center px-3 py-2'; + row.innerHTML = ` + ${escapeHtml(user)} bid $${amount} + ${formatTime(time)} + `; + return row; +} diff --git a/sdk/webpubsub-chat-client/examples/live-auction/public/index.html b/sdk/webpubsub-chat-client/examples/live-auction/public/index.html new file mode 100644 index 000000000..cfec81da5 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/live-auction/public/index.html @@ -0,0 +1,134 @@ + + + + + + Live Auction + + + + + +
+

💰 Live Auction

+

Powered by Web PubSub Chat

+ + +
+ Login as: +
+ + + + +
+
+ + +
+ +
+ + + +
+ + + + diff --git a/sdk/webpubsub-chat-client/examples/live-auction/server.js b/sdk/webpubsub-chat-client/examples/live-auction/server.js new file mode 100644 index 000000000..215f3d8b1 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/live-auction/server.js @@ -0,0 +1,41 @@ +import express from 'express'; +import { WebPubSubServiceClient } from '@azure/web-pubsub'; +import { createRequire } from 'module'; +import path from 'path'; + +const require = createRequire(import.meta.url); +const hubName = 'chat'; +const port = 3000; + +const connectionString = process.env.WebPubSubConnectionString || process.argv[2]; +if (!connectionString) { + console.error('Usage: node server.js '); + console.error(' or set environment variable WebPubSubConnectionString'); + process.exit(1); +} + +const app = express(); +const serviceClient = new WebPubSubServiceClient(connectionString, hubName, { allowInsecureConnection: true }); + +// Negotiate endpoint +app.get('/negotiate', async (req, res) => { + const userId = req.query.userId; + if (!userId) { + return res.status(400).json({ error: 'userId is required' }); + } + const token = await serviceClient.getClientAccessToken({ userId }); + res.json({ url: token.url }); +}); + +// Serve the SDK browser bundle from the installed package +// TODO: Once published to npm, the client can load the SDK directly from unpkg CDN and this block can be removed. +const sdkPkgDir = path.dirname(require.resolve('@azure/web-pubsub-chat-client/package.json')); +const sdkBrowserDir = path.join(sdkPkgDir, 'dist', 'browser'); +app.use('/@azure/web-pubsub-chat-client', express.static(sdkBrowserDir)); + +// Serve static files +app.use(express.static('public')); + +app.listen(port, () => { + console.log(`Live Auction server running at http://localhost:${port}`); +}); diff --git a/sdk/webpubsub-chat-client/examples/live-auction/yarn.lock b/sdk/webpubsub-chat-client/examples/live-auction/yarn.lock new file mode 100644 index 000000000..45f9e83b3 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/live-auction/yarn.lock @@ -0,0 +1,942 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"@azure/abort-controller@npm:^2.1.2": + version: 2.1.2 + resolution: "@azure/abort-controller@npm:2.1.2::__archiveUrl=https%3A%2F%2Fwww.myget.org%2FF%2Fazure-signalr-dev%2Fnpm%2F%40azure%2Fabort-controller%2F-%2F%40azure%2Fabort-controller-2.1.2.tgz" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/3771b6820e33ebb56e79c7c68e2288296b8c2529556fbd29cf4cf2fbff7776e7ce1120072972d8df9f1bf50e2c3224d71a7565362b589595563f710b8c3d7b79 + languageName: node + linkType: hard + +"@azure/core-auth@npm:^1.10.0, @azure/core-auth@npm:^1.9.0": + version: 1.10.1 + resolution: "@azure/core-auth@npm:1.10.1::__archiveUrl=https%3A%2F%2Fwww.myget.org%2FF%2Fazure-signalr-dev%2Fnpm%2F%40azure%2Fcore-auth%2F-%2F%40azure%2Fcore-auth-1.10.1.tgz" + dependencies: + "@azure/abort-controller": "npm:^2.1.2" + "@azure/core-util": "npm:^1.13.0" + tslib: "npm:^2.6.2" + checksum: 10c0/83fd96e43cf8ca3e1cf6c7677915ca1433d6e331cb7352b64a3f93d9fd71dcddf77e8b46f2bb2a5db49ce87016ed30ebaca88034a0acf321e86ba17c0eb3329e + languageName: node + linkType: hard + +"@azure/core-client@npm:^1.9.2": + version: 1.10.1 + resolution: "@azure/core-client@npm:1.10.1::__archiveUrl=https%3A%2F%2Fwww.myget.org%2FF%2Fazure-signalr-dev%2Fnpm%2F%40azure%2Fcore-client%2F-%2F%40azure%2Fcore-client-1.10.1.tgz" + dependencies: + "@azure/abort-controller": "npm:^2.1.2" + "@azure/core-auth": "npm:^1.10.0" + "@azure/core-rest-pipeline": "npm:^1.22.0" + "@azure/core-tracing": "npm:^1.3.0" + "@azure/core-util": "npm:^1.13.0" + "@azure/logger": "npm:^1.3.0" + tslib: "npm:^2.6.2" + checksum: 10c0/f88b3df77e50c07eccc1a4bc1c12e626620be12027dd100682116664c4cc676ee1f78427e55ce8750a311762f75fdd41f99ce289c06b78a3b18e491d622d0579 + languageName: node + linkType: hard + +"@azure/core-paging@npm:^1.6.2": + version: 1.6.2 + resolution: "@azure/core-paging@npm:1.6.2::__archiveUrl=https%3A%2F%2Fwww.myget.org%2FF%2Fazure-signalr-dev%2Fnpm%2F%40azure%2Fcore-paging%2F-%2F%40azure%2Fcore-paging-1.6.2.tgz" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/c727782f8dc66eff50c03421af2ca55f497f33e14ec845f5918d76661c57bc8e3a7ca9fa3d39181287bfbfa45f28cb3d18b67c31fd36bbe34146387dbd07b440 + languageName: node + linkType: hard + +"@azure/core-rest-pipeline@npm:^1.19.0, @azure/core-rest-pipeline@npm:^1.22.0": + version: 1.22.2 + resolution: "@azure/core-rest-pipeline@npm:1.22.2::__archiveUrl=https%3A%2F%2Fwww.myget.org%2FF%2Fazure-signalr-dev%2Fnpm%2F%40azure%2Fcore-rest-pipeline%2F-%2F%40azure%2Fcore-rest-pipeline-1.22.2.tgz" + dependencies: + "@azure/abort-controller": "npm:^2.1.2" + "@azure/core-auth": "npm:^1.10.0" + "@azure/core-tracing": "npm:^1.3.0" + "@azure/core-util": "npm:^1.13.0" + "@azure/logger": "npm:^1.3.0" + "@typespec/ts-http-runtime": "npm:^0.3.0" + tslib: "npm:^2.6.2" + checksum: 10c0/b5767a09ab8a944237e52523173fd2d6746f156962d368255bd66c5f328c2aee49e9b85a0898734c27e54ac8ee8b0a0f29d6044557fe077bf47946fada388fa2 + languageName: node + linkType: hard + +"@azure/core-tracing@npm:^1.2.0, @azure/core-tracing@npm:^1.3.0": + version: 1.3.1 + resolution: "@azure/core-tracing@npm:1.3.1::__archiveUrl=https%3A%2F%2Fwww.myget.org%2FF%2Fazure-signalr-dev%2Fnpm%2F%40azure%2Fcore-tracing%2F-%2F%40azure%2Fcore-tracing-1.3.1.tgz" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/0cb26db9ab5336a1867cc9cd0bd42b1702406d0f76420385789d1a96c8702a38cb081838ea73cd707bb7b340c4386499cf6e77538cacfda4467c251fe2ffa32b + languageName: node + linkType: hard + +"@azure/core-util@npm:^1.13.0": + version: 1.13.1 + resolution: "@azure/core-util@npm:1.13.1::__archiveUrl=https%3A%2F%2Fwww.myget.org%2FF%2Fazure-signalr-dev%2Fnpm%2F%40azure%2Fcore-util%2F-%2F%40azure%2Fcore-util-1.13.1.tgz" + dependencies: + "@azure/abort-controller": "npm:^2.1.2" + "@typespec/ts-http-runtime": "npm:^0.3.0" + tslib: "npm:^2.6.2" + checksum: 10c0/37067621cdac933c51775c26648fdcea315f07b08bd875cff4610e403eabf9c12532525f0bf094e258dadc03a55d35f12c9242f662526847b32c85cdcc2d6603 + languageName: node + linkType: hard + +"@azure/logger@npm:^1.1.4, @azure/logger@npm:^1.3.0": + version: 1.3.0 + resolution: "@azure/logger@npm:1.3.0::__archiveUrl=https%3A%2F%2Fwww.myget.org%2FF%2Fazure-signalr-dev%2Fnpm%2F%40azure%2Flogger%2F-%2F%40azure%2Flogger-1.3.0.tgz" + dependencies: + "@typespec/ts-http-runtime": "npm:^0.3.0" + tslib: "npm:^2.6.2" + checksum: 10c0/aaa6a88fd4f26d41100865ff2c53b400347f632d315d9ae8ffa28db03974d35461e743031bdca40cad617ace172d1ba598ffdd18c345ebc564f63a51c32c4a29 + languageName: node + linkType: hard + +"@azure/web-pubsub-chat-client@npm:1.0.0-beta.1": + version: 1.0.0-beta.1 + resolution: "@azure/web-pubsub-chat-client@npm:1.0.0-beta.1::__archiveUrl=https%3A%2F%2Fwww.myget.org%2FF%2Fazure-signalr-dev%2Fnpm%2F%40azure%2Fweb-pubsub-chat-client%2F-%2F%40azure%2Fweb-pubsub-chat-client-1.0.0-beta.1.tgz" + dependencies: + ws: "npm:^8.0.0" + checksum: 10c0/d036ff3aeba8a4deae6886b236c66f765e1c5d149c540a07e483ccd11e3a5c2a6419767a327f170f338cdf695e787876411f6f488b142ee1a2645817b7b288a9 + languageName: node + linkType: hard + +"@azure/web-pubsub@npm:^1.2.0": + version: 1.2.0 + resolution: "@azure/web-pubsub@npm:1.2.0::__archiveUrl=https%3A%2F%2Fwww.myget.org%2FF%2Fazure-signalr-dev%2Fnpm%2F%40azure%2Fweb-pubsub%2F-%2F%40azure%2Fweb-pubsub-1.2.0.tgz" + dependencies: + "@azure/core-auth": "npm:^1.9.0" + "@azure/core-client": "npm:^1.9.2" + "@azure/core-paging": "npm:^1.6.2" + "@azure/core-rest-pipeline": "npm:^1.19.0" + "@azure/core-tracing": "npm:^1.2.0" + "@azure/logger": "npm:^1.1.4" + jsonwebtoken: "npm:^9.0.2" + tslib: "npm:^2.8.1" + checksum: 10c0/17fe119732680142846fc3023b4b5696cd3c32cd0f9c5c95184616e0b237ceb7c81a64595100dbeb44038327b3d015ff2369f9968bae1703aeb64f0394e44413 + languageName: node + linkType: hard + +"@typespec/ts-http-runtime@npm:^0.3.0": + version: 0.3.3 + resolution: "@typespec/ts-http-runtime@npm:0.3.3" + dependencies: + http-proxy-agent: "npm:^7.0.0" + https-proxy-agent: "npm:^7.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/84cc402c6f5467e9b4e68eec1339bad91b39bcd42641d48c460a60015edf515ae252c2de32c65500f34ac84f55510dd337294b1f0d6a0ca59cb4c3b1c103e81f + languageName: node + linkType: hard + +"accepts@npm:^2.0.0": + version: 2.0.0 + resolution: "accepts@npm:2.0.0" + dependencies: + mime-types: "npm:^3.0.0" + negotiator: "npm:^1.0.0" + checksum: 10c0/98374742097e140891546076215f90c32644feacf652db48412329de4c2a529178a81aa500fbb13dd3e6cbf6e68d829037b123ac037fc9a08bcec4b87b358eef + languageName: node + linkType: hard + +"agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": + version: 7.1.4 + resolution: "agent-base@npm:7.1.4" + checksum: 10c0/c2c9ab7599692d594b6a161559ada307b7a624fa4c7b03e3afdb5a5e31cd0e53269115b620fcab024c5ac6a6f37fa5eb2e004f076ad30f5f7e6b8b671f7b35fe + languageName: node + linkType: hard + +"async-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-function@npm:1.0.0" + checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73 + languageName: node + linkType: hard + +"async-generator-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-generator-function@npm:1.0.0" + checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186 + languageName: node + linkType: hard + +"body-parser@npm:^2.2.1": + version: 2.2.2 + resolution: "body-parser@npm:2.2.2" + dependencies: + bytes: "npm:^3.1.2" + content-type: "npm:^1.0.5" + debug: "npm:^4.4.3" + http-errors: "npm:^2.0.0" + iconv-lite: "npm:^0.7.0" + on-finished: "npm:^2.4.1" + qs: "npm:^6.14.1" + raw-body: "npm:^3.0.1" + type-is: "npm:^2.0.1" + checksum: 10c0/95a830a003b38654b75166ca765358aa92ee3d561bf0e41d6ccdde0e1a0c9783cab6b90b20eb635d23172c010b59d3563a137a738e74da4ba714463510d05137 + languageName: node + linkType: hard + +"buffer-equal-constant-time@npm:^1.0.1": + version: 1.0.1 + resolution: "buffer-equal-constant-time@npm:1.0.1" + checksum: 10c0/fb2294e64d23c573d0dd1f1e7a466c3e978fe94a4e0f8183937912ca374619773bef8e2aceb854129d2efecbbc515bbd0cc78d2734a3e3031edb0888531bbc8e + languageName: node + linkType: hard + +"bytes@npm:^3.1.2, bytes@npm:~3.1.2": + version: 3.1.2 + resolution: "bytes@npm:3.1.2" + checksum: 10c0/76d1c43cbd602794ad8ad2ae94095cddeb1de78c5dddaa7005c51af10b0176c69971a6d88e805a90c2b6550d76636e43c40d8427a808b8645ede885de4a0358e + languageName: node + linkType: hard + +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938 + languageName: node + linkType: hard + +"call-bound@npm:^1.0.2": + version: 1.0.4 + resolution: "call-bound@npm:1.0.4" + dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + get-intrinsic: "npm:^1.3.0" + checksum: 10c0/f4796a6a0941e71c766aea672f63b72bc61234c4f4964dc6d7606e3664c307e7d77845328a8f3359ce39ddb377fed67318f9ee203dea1d47e46165dcf2917644 + languageName: node + linkType: hard + +"content-disposition@npm:^1.0.0": + version: 1.0.1 + resolution: "content-disposition@npm:1.0.1" + checksum: 10c0/bd7ff1fe8d2542d3a2b9a29428cc3591f6ac27bb5595bba2c69664408a68f9538b14cbd92479796ea835b317a09a527c8c7209c4200381dedb0c34d3b658849e + languageName: node + linkType: hard + +"content-type@npm:^1.0.5": + version: 1.0.5 + resolution: "content-type@npm:1.0.5" + checksum: 10c0/b76ebed15c000aee4678c3707e0860cb6abd4e680a598c0a26e17f0bfae723ec9cc2802f0ff1bc6e4d80603719010431d2231018373d4dde10f9ccff9dadf5af + languageName: node + linkType: hard + +"cookie-signature@npm:^1.2.1": + version: 1.2.2 + resolution: "cookie-signature@npm:1.2.2" + checksum: 10c0/54e05df1a293b3ce81589b27dddc445f462f6fa6812147c033350cd3561a42bc14481674e05ed14c7bd0ce1e8bb3dc0e40851bad75415733711294ddce0b7bc6 + languageName: node + linkType: hard + +"cookie@npm:^0.7.1": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 + languageName: node + linkType: hard + +"debug@npm:4, debug@npm:^4.3.4, debug@npm:^4.4.0, debug@npm:^4.4.3": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + languageName: node + linkType: hard + +"depd@npm:^2.0.0, depd@npm:~2.0.0": + version: 2.0.0 + resolution: "depd@npm:2.0.0" + checksum: 10c0/58bd06ec20e19529b06f7ad07ddab60e504d9e0faca4bd23079fac2d279c3594334d736508dc350e06e510aba5e22e4594483b3a6562ce7c17dd797f4cc4ad2c + languageName: node + linkType: hard + +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.2.0" + checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031 + languageName: node + linkType: hard + +"ecdsa-sig-formatter@npm:1.0.11": + version: 1.0.11 + resolution: "ecdsa-sig-formatter@npm:1.0.11" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10c0/ebfbf19d4b8be938f4dd4a83b8788385da353d63307ede301a9252f9f7f88672e76f2191618fd8edfc2f24679236064176fab0b78131b161ee73daa37125408c + languageName: node + linkType: hard + +"ee-first@npm:1.1.1": + version: 1.1.1 + resolution: "ee-first@npm:1.1.1" + checksum: 10c0/b5bb125ee93161bc16bfe6e56c6b04de5ad2aa44234d8f644813cc95d861a6910903132b05093706de2b706599367c4130eb6d170f6b46895686b95f87d017b7 + languageName: node + linkType: hard + +"encodeurl@npm:^2.0.0": + version: 2.0.0 + resolution: "encodeurl@npm:2.0.0" + checksum: 10c0/5d317306acb13e6590e28e27924c754163946a2480de11865c991a3a7eed4315cd3fba378b543ca145829569eefe9b899f3d84bb09870f675ae60bc924b01ceb + languageName: node + linkType: hard + +"es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85 + languageName: node + linkType: hard + +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.1 + resolution: "es-object-atoms@npm:1.1.1" + dependencies: + es-errors: "npm:^1.3.0" + checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c + languageName: node + linkType: hard + +"escape-html@npm:^1.0.3": + version: 1.0.3 + resolution: "escape-html@npm:1.0.3" + checksum: 10c0/524c739d776b36c3d29fa08a22e03e8824e3b2fd57500e5e44ecf3cc4707c34c60f9ca0781c0e33d191f2991161504c295e98f68c78fe7baa6e57081ec6ac0a3 + languageName: node + linkType: hard + +"etag@npm:^1.8.1": + version: 1.8.1 + resolution: "etag@npm:1.8.1" + checksum: 10c0/12be11ef62fb9817314d790089a0a49fae4e1b50594135dcb8076312b7d7e470884b5100d249b28c18581b7fd52f8b485689ffae22a11ed9ec17377a33a08f84 + languageName: node + linkType: hard + +"express@npm:^5.2.1": + version: 5.2.1 + resolution: "express@npm:5.2.1" + dependencies: + accepts: "npm:^2.0.0" + body-parser: "npm:^2.2.1" + content-disposition: "npm:^1.0.0" + content-type: "npm:^1.0.5" + cookie: "npm:^0.7.1" + cookie-signature: "npm:^1.2.1" + debug: "npm:^4.4.0" + depd: "npm:^2.0.0" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + etag: "npm:^1.8.1" + finalhandler: "npm:^2.1.0" + fresh: "npm:^2.0.0" + http-errors: "npm:^2.0.0" + merge-descriptors: "npm:^2.0.0" + mime-types: "npm:^3.0.0" + on-finished: "npm:^2.4.1" + once: "npm:^1.4.0" + parseurl: "npm:^1.3.3" + proxy-addr: "npm:^2.0.7" + qs: "npm:^6.14.0" + range-parser: "npm:^1.2.1" + router: "npm:^2.2.0" + send: "npm:^1.1.0" + serve-static: "npm:^2.2.0" + statuses: "npm:^2.0.1" + type-is: "npm:^2.0.1" + vary: "npm:^1.1.2" + checksum: 10c0/45e8c841ad188a41402ddcd1294901e861ee0819f632fb494f2ed344ef9c43315d294d443fb48d594e6586a3b779785120f43321417adaef8567316a55072949 + languageName: node + linkType: hard + +"finalhandler@npm:^2.1.0": + version: 2.1.1 + resolution: "finalhandler@npm:2.1.1" + dependencies: + debug: "npm:^4.4.0" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + on-finished: "npm:^2.4.1" + parseurl: "npm:^1.3.3" + statuses: "npm:^2.0.1" + checksum: 10c0/6bd664e21b7b2e79efcaace7d1a427169f61cce048fae68eb56290e6934e676b78e55d89f5998c5508871345bc59a61f47002dc505dc7288be68cceac1b701e2 + languageName: node + linkType: hard + +"forwarded@npm:0.2.0": + version: 0.2.0 + resolution: "forwarded@npm:0.2.0" + checksum: 10c0/9b67c3fac86acdbc9ae47ba1ddd5f2f81526fa4c8226863ede5600a3f7c7416ef451f6f1e240a3cc32d0fd79fcfe6beb08fd0da454f360032bde70bf80afbb33 + languageName: node + linkType: hard + +"fresh@npm:^2.0.0": + version: 2.0.0 + resolution: "fresh@npm:2.0.0" + checksum: 10c0/0557548194cb9a809a435bf92bcfbc20c89e8b5eb38861b73ced36750437251e39a111fc3a18b98531be9dd91fe1411e4969f229dc579ec0251ce6c5d4900bbc + languageName: node + linkType: hard + +"function-bind@npm:^1.1.2": + version: 1.1.2 + resolution: "function-bind@npm:1.1.2" + checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5 + languageName: node + linkType: hard + +"generator-function@npm:^2.0.0": + version: 2.0.1 + resolution: "generator-function@npm:2.0.1" + checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8 + languageName: node + linkType: hard + +"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.3.0": + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" + dependencies: + async-function: "npm:^1.0.0" + async-generator-function: "npm:^1.0.0" + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" + function-bind: "npm:^1.1.2" + generator-function: "npm:^2.0.0" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d + languageName: node + linkType: hard + +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: "npm:^1.0.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c + languageName: node + linkType: hard + +"gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead + languageName: node + linkType: hard + +"has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e + languageName: node + linkType: hard + +"hasown@npm:^2.0.2": + version: 2.0.2 + resolution: "hasown@npm:2.0.2" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9 + languageName: node + linkType: hard + +"http-errors@npm:^2.0.0, http-errors@npm:^2.0.1, http-errors@npm:~2.0.1": + version: 2.0.1 + resolution: "http-errors@npm:2.0.1" + dependencies: + depd: "npm:~2.0.0" + inherits: "npm:~2.0.4" + setprototypeof: "npm:~1.2.0" + statuses: "npm:~2.0.2" + toidentifier: "npm:~1.0.1" + checksum: 10c0/fb38906cef4f5c83952d97661fe14dc156cb59fe54812a42cd448fa57b5c5dfcb38a40a916957737bd6b87aab257c0648d63eb5b6a9ca9f548e105b6072712d4 + languageName: node + linkType: hard + +"http-proxy-agent@npm:^7.0.0": + version: 7.0.2 + resolution: "http-proxy-agent@npm:7.0.2" + dependencies: + agent-base: "npm:^7.1.0" + debug: "npm:^4.3.4" + checksum: 10c0/4207b06a4580fb85dd6dff521f0abf6db517489e70863dca1a0291daa7f2d3d2d6015a57bd702af068ea5cf9f1f6ff72314f5f5b4228d299c0904135d2aef921 + languageName: node + linkType: hard + +"https-proxy-agent@npm:^7.0.0": + version: 7.0.6 + resolution: "https-proxy-agent@npm:7.0.6" + dependencies: + agent-base: "npm:^7.1.2" + debug: "npm:4" + checksum: 10c0/f729219bc735edb621fa30e6e84e60ee5d00802b8247aac0d7b79b0bd6d4b3294737a337b93b86a0bd9e68099d031858a39260c976dc14cdbba238ba1f8779ac + languageName: node + linkType: hard + +"iconv-lite@npm:^0.7.0, iconv-lite@npm:~0.7.0": + version: 0.7.2 + resolution: "iconv-lite@npm:0.7.2" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10c0/3c228920f3bd307f56bf8363706a776f4a060eb042f131cd23855ceca962951b264d0997ab38a1ad340e1c5df8499ed26e1f4f0db6b2a2ad9befaff22f14b722 + languageName: node + linkType: hard + +"inherits@npm:~2.0.4": + version: 2.0.4 + resolution: "inherits@npm:2.0.4" + checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 + languageName: node + linkType: hard + +"ipaddr.js@npm:1.9.1": + version: 1.9.1 + resolution: "ipaddr.js@npm:1.9.1" + checksum: 10c0/0486e775047971d3fdb5fb4f063829bac45af299ae0b82dcf3afa2145338e08290563a2a70f34b732d795ecc8311902e541a8530eeb30d75860a78ff4e94ce2a + languageName: node + linkType: hard + +"is-promise@npm:^4.0.0": + version: 4.0.0 + resolution: "is-promise@npm:4.0.0" + checksum: 10c0/ebd5c672d73db781ab33ccb155fb9969d6028e37414d609b115cc534654c91ccd061821d5b987eefaa97cf4c62f0b909bb2f04db88306de26e91bfe8ddc01503 + languageName: node + linkType: hard + +"jsonwebtoken@npm:^9.0.2": + version: 9.0.3 + resolution: "jsonwebtoken@npm:9.0.3" + dependencies: + jws: "npm:^4.0.1" + lodash.includes: "npm:^4.3.0" + lodash.isboolean: "npm:^3.0.3" + lodash.isinteger: "npm:^4.0.4" + lodash.isnumber: "npm:^3.0.3" + lodash.isplainobject: "npm:^4.0.6" + lodash.isstring: "npm:^4.0.1" + lodash.once: "npm:^4.0.0" + ms: "npm:^2.1.1" + semver: "npm:^7.5.4" + checksum: 10c0/6ca7f1e54886ea3bde7146a5a22b53847c46e25453c7f7307a69818b9a6ad48c390b2e59d5690fcfd03c529b01960060cc4bb0c686991d6edae2285dfd30f4ba + languageName: node + linkType: hard + +"jwa@npm:^2.0.1": + version: 2.0.1 + resolution: "jwa@npm:2.0.1" + dependencies: + buffer-equal-constant-time: "npm:^1.0.1" + ecdsa-sig-formatter: "npm:1.0.11" + safe-buffer: "npm:^5.0.1" + checksum: 10c0/ab3ebc6598e10dc11419d4ed675c9ca714a387481466b10e8a6f3f65d8d9c9237e2826f2505280a739cf4cbcf511cb288eeec22b5c9c63286fc5a2e4f97e78cf + languageName: node + linkType: hard + +"jws@npm:^4.0.1": + version: 4.0.1 + resolution: "jws@npm:4.0.1" + dependencies: + jwa: "npm:^2.0.1" + safe-buffer: "npm:^5.0.1" + checksum: 10c0/6be1ed93023aef570ccc5ea8d162b065840f3ef12f0d1bb3114cade844de7a357d5dc558201d9a65101e70885a6fa56b17462f520e6b0d426195510618a154d0 + languageName: node + linkType: hard + +"live-auction-example@workspace:.": + version: 0.0.0-use.local + resolution: "live-auction-example@workspace:." + dependencies: + "@azure/web-pubsub": "npm:^1.2.0" + "@azure/web-pubsub-chat-client": "npm:1.0.0-beta.1" + express: "npm:^5.2.1" + languageName: unknown + linkType: soft + +"lodash.includes@npm:^4.3.0": + version: 4.3.0 + resolution: "lodash.includes@npm:4.3.0" + checksum: 10c0/7ca498b9b75bf602d04e48c0adb842dfc7d90f77bcb2a91a2b2be34a723ad24bc1c8b3683ec6b2552a90f216c723cdea530ddb11a3320e08fa38265703978f4b + languageName: node + linkType: hard + +"lodash.isboolean@npm:^3.0.3": + version: 3.0.3 + resolution: "lodash.isboolean@npm:3.0.3" + checksum: 10c0/0aac604c1ef7e72f9a6b798e5b676606042401dd58e49f051df3cc1e3adb497b3d7695635a5cbec4ae5f66456b951fdabe7d6b387055f13267cde521f10ec7f7 + languageName: node + linkType: hard + +"lodash.isinteger@npm:^4.0.4": + version: 4.0.4 + resolution: "lodash.isinteger@npm:4.0.4" + checksum: 10c0/4c3e023a2373bf65bf366d3b8605b97ec830bca702a926939bcaa53f8e02789b6a176e7f166b082f9365bfec4121bfeb52e86e9040cb8d450e64c858583f61b7 + languageName: node + linkType: hard + +"lodash.isnumber@npm:^3.0.3": + version: 3.0.3 + resolution: "lodash.isnumber@npm:3.0.3" + checksum: 10c0/2d01530513a1ee4f72dd79528444db4e6360588adcb0e2ff663db2b3f642d4bb3d687051ae1115751ca9082db4fdef675160071226ca6bbf5f0c123dbf0aa12d + languageName: node + linkType: hard + +"lodash.isplainobject@npm:^4.0.6": + version: 4.0.6 + resolution: "lodash.isplainobject@npm:4.0.6" + checksum: 10c0/afd70b5c450d1e09f32a737bed06ff85b873ecd3d3d3400458725283e3f2e0bb6bf48e67dbe7a309eb371a822b16a26cca4a63c8c52db3fc7dc9d5f9dd324cbb + languageName: node + linkType: hard + +"lodash.isstring@npm:^4.0.1": + version: 4.0.1 + resolution: "lodash.isstring@npm:4.0.1" + checksum: 10c0/09eaf980a283f9eef58ef95b30ec7fee61df4d6bf4aba3b5f096869cc58f24c9da17900febc8ffd67819b4e29de29793190e88dc96983db92d84c95fa85d1c92 + languageName: node + linkType: hard + +"lodash.once@npm:^4.0.0": + version: 4.1.1 + resolution: "lodash.once@npm:4.1.1" + checksum: 10c0/46a9a0a66c45dd812fcc016e46605d85ad599fe87d71a02f6736220554b52ffbe82e79a483ad40f52a8a95755b0d1077fba259da8bfb6694a7abbf4a48f1fc04 + languageName: node + linkType: hard + +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f + languageName: node + linkType: hard + +"media-typer@npm:^1.1.0": + version: 1.1.0 + resolution: "media-typer@npm:1.1.0" + checksum: 10c0/7b4baa40b25964bb90e2121ee489ec38642127e48d0cc2b6baa442688d3fde6262bfdca86d6bbf6ba708784afcac168c06840c71facac70e390f5f759ac121b9 + languageName: node + linkType: hard + +"merge-descriptors@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-descriptors@npm:2.0.0" + checksum: 10c0/95389b7ced3f9b36fbdcf32eb946dc3dd1774c2fdf164609e55b18d03aa499b12bd3aae3a76c1c7185b96279e9803525550d3eb292b5224866060a288f335cb3 + languageName: node + linkType: hard + +"mime-db@npm:^1.54.0": + version: 1.54.0 + resolution: "mime-db@npm:1.54.0" + checksum: 10c0/8d907917bc2a90fa2df842cdf5dfeaf509adc15fe0531e07bb2f6ab15992416479015828d6a74200041c492e42cce3ebf78e5ce714388a0a538ea9c53eece284 + languageName: node + linkType: hard + +"mime-types@npm:^3.0.0, mime-types@npm:^3.0.2": + version: 3.0.2 + resolution: "mime-types@npm:3.0.2" + dependencies: + mime-db: "npm:^1.54.0" + checksum: 10c0/35a0dd1035d14d185664f346efcdb72e93ef7a9b6e9ae808bd1f6358227010267fab52657b37562c80fc888ff76becb2b2938deb5e730818b7983bf8bd359767 + languageName: node + linkType: hard + +"ms@npm:^2.1.1, ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 + languageName: node + linkType: hard + +"negotiator@npm:^1.0.0": + version: 1.0.0 + resolution: "negotiator@npm:1.0.0" + checksum: 10c0/4c559dd52669ea48e1914f9d634227c561221dd54734070791f999c52ed0ff36e437b2e07d5c1f6e32909fc625fe46491c16e4a8f0572567d4dd15c3a4fda04b + languageName: node + linkType: hard + +"object-inspect@npm:^1.13.3": + version: 1.13.4 + resolution: "object-inspect@npm:1.13.4" + checksum: 10c0/d7f8711e803b96ea3191c745d6f8056ce1f2496e530e6a19a0e92d89b0fa3c76d910c31f0aa270432db6bd3b2f85500a376a83aaba849a8d518c8845b3211692 + languageName: node + linkType: hard + +"on-finished@npm:^2.4.1": + version: 2.4.1 + resolution: "on-finished@npm:2.4.1" + dependencies: + ee-first: "npm:1.1.1" + checksum: 10c0/46fb11b9063782f2d9968863d9cbba33d77aa13c17f895f56129c274318b86500b22af3a160fe9995aa41317efcd22941b6eba747f718ced08d9a73afdb087b4 + languageName: node + linkType: hard + +"once@npm:^1.4.0": + version: 1.4.0 + resolution: "once@npm:1.4.0" + dependencies: + wrappy: "npm:1" + checksum: 10c0/5d48aca287dfefabd756621c5dfce5c91a549a93e9fdb7b8246bc4c4790aa2ec17b34a260530474635147aeb631a2dcc8b32c613df0675f96041cbb8244517d0 + languageName: node + linkType: hard + +"parseurl@npm:^1.3.3": + version: 1.3.3 + resolution: "parseurl@npm:1.3.3" + checksum: 10c0/90dd4760d6f6174adb9f20cf0965ae12e23879b5f5464f38e92fce8073354341e4b3b76fa3d878351efe7d01e617121955284cfd002ab087fba1a0726ec0b4f5 + languageName: node + linkType: hard + +"path-to-regexp@npm:^8.0.0": + version: 8.3.0 + resolution: "path-to-regexp@npm:8.3.0" + checksum: 10c0/ee1544a73a3f294a97a4c663b0ce71bbf1621d732d80c9c9ed201b3e911a86cb628ebad691b9d40f40a3742fe22011e5a059d8eed2cf63ec2cb94f6fb4efe67c + languageName: node + linkType: hard + +"proxy-addr@npm:^2.0.7": + version: 2.0.7 + resolution: "proxy-addr@npm:2.0.7" + dependencies: + forwarded: "npm:0.2.0" + ipaddr.js: "npm:1.9.1" + checksum: 10c0/c3eed999781a35f7fd935f398b6d8920b6fb00bbc14287bc6de78128ccc1a02c89b95b56742bf7cf0362cc333c61d138532049c7dedc7a328ef13343eff81210 + languageName: node + linkType: hard + +"qs@npm:^6.14.0, qs@npm:^6.14.1": + version: 6.14.1 + resolution: "qs@npm:6.14.1" + dependencies: + side-channel: "npm:^1.1.0" + checksum: 10c0/0e3b22dc451f48ce5940cbbc7c7d9068d895074f8c969c0801ac15c1313d1859c4d738e46dc4da2f498f41a9ffd8c201bd9fb12df67799b827db94cc373d2613 + languageName: node + linkType: hard + +"range-parser@npm:^1.2.1": + version: 1.2.1 + resolution: "range-parser@npm:1.2.1" + checksum: 10c0/96c032ac2475c8027b7a4e9fe22dc0dfe0f6d90b85e496e0f016fbdb99d6d066de0112e680805075bd989905e2123b3b3d002765149294dce0c1f7f01fcc2ea0 + languageName: node + linkType: hard + +"raw-body@npm:^3.0.1": + version: 3.0.2 + resolution: "raw-body@npm:3.0.2" + dependencies: + bytes: "npm:~3.1.2" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.7.0" + unpipe: "npm:~1.0.0" + checksum: 10c0/d266678d08e1e7abea62c0ce5864344e980fa81c64f6b481e9842c5beaed2cdcf975f658a3ccd67ad35fc919c1f6664ccc106067801850286a6cbe101de89f29 + languageName: node + linkType: hard + +"router@npm:^2.2.0": + version: 2.2.0 + resolution: "router@npm:2.2.0" + dependencies: + debug: "npm:^4.4.0" + depd: "npm:^2.0.0" + is-promise: "npm:^4.0.0" + parseurl: "npm:^1.3.3" + path-to-regexp: "npm:^8.0.0" + checksum: 10c0/3279de7450c8eae2f6e095e9edacbdeec0abb5cb7249c6e719faa0db2dba43574b4fff5892d9220631c9abaff52dd3cad648cfea2aaace845e1a071915ac8867 + languageName: node + linkType: hard + +"safe-buffer@npm:^5.0.1": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 + languageName: node + linkType: hard + +"safer-buffer@npm:>= 2.1.2 < 3.0.0": + version: 2.1.2 + resolution: "safer-buffer@npm:2.1.2" + checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 + languageName: node + linkType: hard + +"semver@npm:^7.5.4": + version: 7.7.4 + resolution: "semver@npm:7.7.4" + bin: + semver: bin/semver.js + checksum: 10c0/5215ad0234e2845d4ea5bb9d836d42b03499546ddafb12075566899fc617f68794bb6f146076b6881d755de17d6c6cc73372555879ec7dce2c2feee947866ad2 + languageName: node + linkType: hard + +"send@npm:^1.1.0, send@npm:^1.2.0": + version: 1.2.1 + resolution: "send@npm:1.2.1" + dependencies: + debug: "npm:^4.4.3" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + etag: "npm:^1.8.1" + fresh: "npm:^2.0.0" + http-errors: "npm:^2.0.1" + mime-types: "npm:^3.0.2" + ms: "npm:^2.1.3" + on-finished: "npm:^2.4.1" + range-parser: "npm:^1.2.1" + statuses: "npm:^2.0.2" + checksum: 10c0/fbbbbdc902a913d65605274be23f3d604065cfc3ee3d78bf9fc8af1dc9fc82667c50d3d657f5e601ac657bac9b396b50ee97bd29cd55436320cf1cddebdcec72 + languageName: node + linkType: hard + +"serve-static@npm:^2.2.0": + version: 2.2.1 + resolution: "serve-static@npm:2.2.1" + dependencies: + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + parseurl: "npm:^1.3.3" + send: "npm:^1.2.0" + checksum: 10c0/37986096e8572e2dfaad35a3925fa8da0c0969f8814fd7788e84d4d388bc068cf0c06d1658509788e55bed942a6b6d040a8a267fa92bb9ffb1179f8bacde5fd7 + languageName: node + linkType: hard + +"setprototypeof@npm:~1.2.0": + version: 1.2.0 + resolution: "setprototypeof@npm:1.2.0" + checksum: 10c0/68733173026766fa0d9ecaeb07f0483f4c2dc70ca376b3b7c40b7cda909f94b0918f6c5ad5ce27a9160bdfb475efaa9d5e705a11d8eaae18f9835d20976028bc + languageName: node + linkType: hard + +"side-channel-list@npm:^1.0.0": + version: 1.0.0 + resolution: "side-channel-list@npm:1.0.0" + dependencies: + es-errors: "npm:^1.3.0" + object-inspect: "npm:^1.13.3" + checksum: 10c0/644f4ac893456c9490ff388bf78aea9d333d5e5bfc64cfb84be8f04bf31ddc111a8d4b83b85d7e7e8a7b845bc185a9ad02c052d20e086983cf59f0be517d9b3d + languageName: node + linkType: hard + +"side-channel-map@npm:^1.0.1": + version: 1.0.1 + resolution: "side-channel-map@npm:1.0.1" + dependencies: + call-bound: "npm:^1.0.2" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.5" + object-inspect: "npm:^1.13.3" + checksum: 10c0/010584e6444dd8a20b85bc926d934424bd809e1a3af941cace229f7fdcb751aada0fb7164f60c2e22292b7fa3c0ff0bce237081fd4cdbc80de1dc68e95430672 + languageName: node + linkType: hard + +"side-channel-weakmap@npm:^1.0.2": + version: 1.0.2 + resolution: "side-channel-weakmap@npm:1.0.2" + dependencies: + call-bound: "npm:^1.0.2" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.5" + object-inspect: "npm:^1.13.3" + side-channel-map: "npm:^1.0.1" + checksum: 10c0/71362709ac233e08807ccd980101c3e2d7efe849edc51455030327b059f6c4d292c237f94dc0685031dd11c07dd17a68afde235d6cf2102d949567f98ab58185 + languageName: node + linkType: hard + +"side-channel@npm:^1.1.0": + version: 1.1.0 + resolution: "side-channel@npm:1.1.0" + dependencies: + es-errors: "npm:^1.3.0" + object-inspect: "npm:^1.13.3" + side-channel-list: "npm:^1.0.0" + side-channel-map: "npm:^1.0.1" + side-channel-weakmap: "npm:^1.0.2" + checksum: 10c0/cb20dad41eb032e6c24c0982e1e5a24963a28aa6122b4f05b3f3d6bf8ae7fd5474ef382c8f54a6a3ab86e0cac4d41a23bd64ede3970e5bfb50326ba02a7996e6 + languageName: node + linkType: hard + +"statuses@npm:^2.0.1, statuses@npm:^2.0.2, statuses@npm:~2.0.2": + version: 2.0.2 + resolution: "statuses@npm:2.0.2" + checksum: 10c0/a9947d98ad60d01f6b26727570f3bcceb6c8fa789da64fe6889908fe2e294d57503b14bf2b5af7605c2d36647259e856635cd4c49eab41667658ec9d0080ec3f + languageName: node + linkType: hard + +"toidentifier@npm:~1.0.1": + version: 1.0.1 + resolution: "toidentifier@npm:1.0.1" + checksum: 10c0/93937279934bd66cc3270016dd8d0afec14fb7c94a05c72dc57321f8bd1fa97e5bea6d1f7c89e728d077ca31ea125b78320a616a6c6cd0e6b9cb94cb864381c1 + languageName: node + linkType: hard + +"tslib@npm:^2.6.2, tslib@npm:^2.8.1": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 + languageName: node + linkType: hard + +"type-is@npm:^2.0.1": + version: 2.0.1 + resolution: "type-is@npm:2.0.1" + dependencies: + content-type: "npm:^1.0.5" + media-typer: "npm:^1.1.0" + mime-types: "npm:^3.0.0" + checksum: 10c0/7f7ec0a060b16880bdad36824ab37c26019454b67d73e8a465ed5a3587440fbe158bc765f0da68344498235c877e7dbbb1600beccc94628ed05599d667951b99 + languageName: node + linkType: hard + +"unpipe@npm:~1.0.0": + version: 1.0.0 + resolution: "unpipe@npm:1.0.0" + checksum: 10c0/193400255bd48968e5c5383730344fbb4fa114cdedfab26e329e50dd2d81b134244bb8a72c6ac1b10ab0281a58b363d06405632c9d49ca9dfd5e90cbd7d0f32c + languageName: node + linkType: hard + +"vary@npm:^1.1.2": + version: 1.1.2 + resolution: "vary@npm:1.1.2" + checksum: 10c0/f15d588d79f3675135ba783c91a4083dcd290a2a5be9fcb6514220a1634e23df116847b1cc51f66bfb0644cf9353b2abb7815ae499bab06e46dd33c1a6bf1f4f + languageName: node + linkType: hard + +"wrappy@npm:1": + version: 1.0.2 + resolution: "wrappy@npm:1.0.2" + checksum: 10c0/56fece1a4018c6a6c8e28fbc88c87e0fbf4ea8fd64fc6c63b18f4acc4bd13e0ad2515189786dd2c30d3eec9663d70f4ecf699330002f8ccb547e4a18231fc9f0 + languageName: node + linkType: hard + +"ws@npm:^8.0.0": + version: 8.19.0 + resolution: "ws@npm:8.19.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/4741d9b9bc3f9c791880882414f96e36b8b254e34d4b503279d6400d9a4b87a033834856dbdd94ee4b637944df17ea8afc4bce0ff4a1560d2166be8855da5b04 + languageName: node + linkType: hard diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/.gitignore b/sdk/webpubsub-chat-client/examples/teams-lite/.gitignore new file mode 100644 index 000000000..d6a221e12 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/.gitignore @@ -0,0 +1,10 @@ +.env +__pycache__/ +.vscode/ +python_server/static/ +.azure/ +.venv +.yarn +app.zip +.package +server/dist/ \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/README.md b/sdk/webpubsub-chat-client/examples/teams-lite/README.md new file mode 100644 index 000000000..aed990565 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/README.md @@ -0,0 +1,57 @@ +# Teams-Lite Demo + +A full-featured web chat application with a Teams-like UI, built with React + TypeScript + Vite, powered by Azure Web PubSub Chat SDK. + +## Features + +- Multi-room chat with sidebar for room switching +- Create / join / leave rooms +- Add / remove room members +- Real-time message notifications +- User profiles with avatars +- Message history +- Markdown support +- Online status indicators +- Typing indicators + +## Prerequisites + +- Node.js 18+ +- An Azure Web PubSub resource with a Chat hub configured + +## Quick Start + +**Terminal 1 - Start the server:** + +```bash +cd server +yarn install +``` + +Set your connection string via environment variable or `.env` file: + +```bash +# Option 1: environment variable +export WebPubSubConnectionString="" + +# Option 2: create a .env file in the server directory +echo 'WebPubSubConnectionString=' > .env +``` + +```bash +yarn start +``` + +**Terminal 2 - Start the client:** + +```bash +cd client +yarn install +yarn dev +``` + +Open http://localhost:5173 in your browser. + +## Deploy to Azure + +See [deploy/README.md](./deploy/README.md) for instructions on deploying to Azure App Service. diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/.gitignore b/sdk/webpubsub-chat-client/examples/teams-lite/client/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/.yarnrc.yml b/sdk/webpubsub-chat-client/examples/teams-lite/client/.yarnrc.yml new file mode 100644 index 000000000..3b452cd5a --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/.yarnrc.yml @@ -0,0 +1,5 @@ +nodeLinker: node-modules + +npmScopes: + azure: + npmRegistryServer: "https://www.myget.org/F/azure-signalr-dev/npm/" diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/README.md b/sdk/webpubsub-chat-client/examples/teams-lite/client/README.md new file mode 100644 index 000000000..7c3c7a7c8 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/README.md @@ -0,0 +1,145 @@ +## Chat Demo Web Client + +Lightweight React + TypeScript + Vite frontend for the AI chat demo. Connects to the backend through Azure Web PubSub client protocol for real‑time, streaming AI responses and multi‑room chat. + +### Features +* Streaming AI responses with placeholder while thinking +* Multiple rooms (default: `public`) – create / switch / remove (except `public`) +* Instant room switching with cached history +* Basic connection + error banner +* Markdown (sanitized) + inline formatting +* TailwindCSS utility styling + +### Dependencies + +- **React 19**: Core React library +- **@azure/web-pubsub-client**: Azure Web PubSub client for real-time messaging +- **marked**: Markdown parsing library +- **dompurify**: HTML sanitization for security +- **TypeScript**: Type safety and development experience + +### Quick Start +Prereqs: Node.js 18+ and the server running at http://localhost:5000 by default. + +```bash +cd client +npm install +npm run dev +``` +Open http://localhost:5173 + +### Scripts +* `npm run dev` – Vite dev server +* `npm run build` – Type check + production build +* `npm run preview` – Preview built assets +* `npm run lint` – ESLint (TS + React hooks rules) +* `npm test` – Vitest + React Testing Library + +### Configuration +* Backend base URL: `BACKEND_URL` in `src/lib/constants.ts` (defaults to `http://localhost:5000`) +* Persistent connection negotiate endpoint: `GET /negotiate?roomId={roomId}` (handled by backend) +* History fetch: `GET /api/rooms/{roomId}/messages?limit=50` + +To change the backend target (e.g. when deployed), update `BACKEND_URL` or expose it as an environment variable and import it similarly. + +### Component Structure + +This React app uses context providers and modular components for real-time chat with AI. + +#### Provider Hierarchy + +```jsx + + + + + + + +``` + +#### Components + +##### `ChatApp` +- Renders `` and wraps `` inside `` + + ```jsx +
+ + + + +
+ ``` + +##### Core Components + +- **`Sidebar`**: Room list and add/join controls +- **`ChatWindow`**: Chat area + - **`ChatHeader`**: Title and connection status + - **`ChatMessages`**: Message stream with auto-scroll + - **`MessageComponent`**: Renders each message + - **`TypingIndicator`**: Shows AI typing + - **`ChatInput`**: Textarea and send button + +##### Context Providers + +- **ChatSettingsProvider** (`ChatSettingsContext`): manages current `roomId` and list of rooms +- **ChatClientProvider** (`ChatClientContext`): handles WebSocket connection, messages state, streaming +- **ChatRoomProvider** (`ChatRoomContext`): maintains active room context (used in `ChatApp`) + +#### Key Features + +##### Message Handling +- **Streaming Messages**: Real-time message streaming with cursor animation +- **Markdown Support**: Full markdown rendering with DOMPurify sanitization +- **Message Types**: User messages, bot messages, system messages +- **Animations**: Smooth fade-in animations and completion effects + +##### Connection Management +- **Auto-connection**: Automatic WebPubSub connection on app load +- **Status Indicators**: Visual connection status with error handling +- **Reconnection**: Graceful handling of connection failures + +##### User Experience +- **Typing Indicators**: Animated typing indicators during message streaming +- **Auto-scroll**: Automatic scrolling to new messages +- **Responsive Design**: Mobile-friendly interface +- **Keyboard Support**: Enter to send, Shift+Enter for line breaks + +### Project Structure +``` +src/ + components/ UI pieces (messages list, input, sidebar, headers) + contexts/ React contexts (settings, client, theme, etc.) + providers/ Higher-level state + WebPubSub wiring + reducers/ Message state reducer + hooks/ Reusable hooks (auto-scroll, theme, chat client) + utils/ Formatting + room helpers + storage + lib/ Constants & generic helpers +public/ Static assets +``` + +### How It Works (Brief) +1. `ChatClientProvider` negotiates a Web PubSub client connection per browser session. +2. Rooms are joined (as groups) dynamically; server broadcasts streaming chunks. +3. Messages are cached per room for instant switching; history fetched once per room per connection. +4. Markdown is parsed (short messages fast‑path) then sanitized before render. + +### Tests +```bash +npm test +``` + +### Production Build +```bash +npm run build +``` +Serve the built assets (done automatically in Azure deployment) or locally via: +```bash +npm run preview +``` + +### Next Steps +* Point `BACKEND_URL` (or env) to Azure deployment +* Add more room / message tests in `src/__tests__` diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/eslint.config.js b/sdk/webpubsub-chat-client/examples/teams-lite/client/eslint.config.js new file mode 100644 index 000000000..d94e7deb7 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { globalIgnores } from 'eslint/config' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/index.html b/sdk/webpubsub-chat-client/examples/teams-lite/client/index.html new file mode 100644 index 000000000..087049f52 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/index.html @@ -0,0 +1,12 @@ + + + + + + Teams Lite + + +
+ + + diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/package.json b/sdk/webpubsub-chat-client/examples/teams-lite/client/package.json new file mode 100644 index 000000000..b7eca1bd3 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/package.json @@ -0,0 +1,56 @@ +{ + "name": "teams-lite-client", + "private": true, + "version": "1.0.0", + "description": "Client UI for Teams-Lite Chat Demo", + "author": "Microsoft", + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.app.json && vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@azure/web-pubsub-chat-client": "link:G:\\\\azure-webpubsub\\\\sdk\\\\webpubsub-chat-sdk", + "@fluentui/react": "^8.125.4", + "@tailwindcss/vite": "^4.1.11", + "@types/dompurify": "^3.0.5", + "dompurify": "^3.2.6", + "marked": "^16.1.2", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "roosterjs": "^9.45.2", + "roosterjs-content-model-api": "^9.45.2", + "roosterjs-content-model-core": "^9.45.2", + "roosterjs-content-model-plugins": "^9.45.2", + "roosterjs-react": "^9.0.4" + }, + "devDependencies": { + "@eslint/js": "^9.30.1", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^22.7.5", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "autoprefixer": "^10.4.21", + "eslint": "^9.30.1", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "jsdom": "^25.0.1", + "os-browserify": "^0.3.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.11", + "typescript": "~5.8.3", + "typescript-eslint": "^8.35.1", + "vite": "^7.0.4", + "vitest": "^2.1.4" + } +} diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/postcss.config.js b/sdk/webpubsub-chat-client/examples/teams-lite/client/postcss.config.js new file mode 100644 index 000000000..e69de29bb diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/public/microsoft_teams.svg b/sdk/webpubsub-chat-client/examples/teams-lite/client/public/microsoft_teams.svg new file mode 100644 index 000000000..90d741227 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/public/microsoft_teams.svg @@ -0,0 +1,51 @@ + \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/assets/react.svg b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/AddToRoomDialog.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/AddToRoomDialog.tsx new file mode 100644 index 000000000..01a563ce1 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/AddToRoomDialog.tsx @@ -0,0 +1,156 @@ +import React, { useState, useEffect, type KeyboardEvent } from 'react'; +import { createPortal } from 'react-dom'; +import { Modal, Button } from '../utils/sharedComponents'; + +interface AddToRoomDialogProps { + isOpen: boolean; + onAddToRoom: (userIds: string[]) => void; + onClose: () => void; + isLoading?: boolean; + roomName?: string; +} + +export const AddToRoomDialog: React.FC = ({ + isOpen, + onAddToRoom, + onClose, + isLoading = false, + roomName +}) => { + const [userIds, setUserIds] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [error, setError] = useState(''); + + // Clear form when dialog opens + useEffect(() => { + if (isOpen) { + setUserIds([]); + setInputValue(''); + setError(''); + } + }, [isOpen]); + + const handleInputChange = (value: string) => { + setInputValue(value); + + // Check if user typed a comma + if (value.includes(',')) { + const newIds = value.split(',').map(id => id.trim()).filter(id => id.length > 0); + if (newIds.length > 0) { + const lastId = newIds[newIds.length - 1]; + const idsToAdd = newIds.slice(0, -1); + + // Add all complete IDs (before the last comma) + if (idsToAdd.length > 0) { + const updatedIds = [...userIds, ...idsToAdd.filter(id => !userIds.includes(id))]; + setUserIds(updatedIds); + } + + // Keep the remaining text after the last comma + setInputValue(lastId); + } else { + setInputValue(''); + } + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + const trimmedValue = inputValue.trim(); + if (trimmedValue && !userIds.includes(trimmedValue)) { + setUserIds([...userIds, trimmedValue]); + } + setInputValue(''); + } else if (e.key === 'Backspace' && !inputValue && userIds.length > 0) { + // Remove last ID when backspace is pressed and input is empty + setUserIds(userIds.slice(0, -1)); + } + }; + + const removeUserId = (indexToRemove: number) => { + setUserIds(userIds.filter((_, index) => index !== indexToRemove)); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + // Basic validation + if (userIds.length === 0) { + setError('At least one user ID is required'); + return; + } + + setError(''); + onAddToRoom(userIds); + }; + + const handleClose = () => { + setUserIds([]); + setInputValue(''); + setError(''); + onClose(); + }; + + return createPortal( + +
+
+ +
+ {userIds.map((userId, index) => ( + + {userId} + + + ))} + handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={userIds.length === 0 ? 'Enter user IDs separated by comma or press Enter' : ''} + className="user-id-input" + /> +
+ {error &&

{error}

} +
+ +
+ + + +
+
+
, + document.body + ); +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/AvatarWithOnlineStatus.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/AvatarWithOnlineStatus.tsx new file mode 100644 index 000000000..b51b24cd2 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/AvatarWithOnlineStatus.tsx @@ -0,0 +1,60 @@ +import React, { useContext } from 'react'; +import { getAvatarStyle, getAvatarInitials } from '../utils/avatarUtils'; +import type { AvatarStyleOptions } from '../utils/avatarUtils'; +import { OnlineStatusIndicator } from './OnlineStatusIndicator'; +import { ChatClientContext } from '../contexts/ChatClientContext'; + +interface AvatarWithOnlineStatusProps extends AvatarStyleOptions { + userOrRoomId: string; + title?: string; + onClick?: () => void; + onMouseEnter?: (e: React.MouseEvent) => void; + onMouseLeave?: (e: React.MouseEvent) => void; + isUser?: boolean; // Is this the current user's avatar + isPrivateChat?: boolean; // Is this avatar in a private chat context +} + +export const AvatarWithOnlineStatus: React.FC = ({ + userOrRoomId, + title, + onClick, + onMouseEnter, + onMouseLeave, + // isUser = false, // unused parameter + isPrivateChat = false, + ...styleOptions +}) => { + const chatContext = useContext(ChatClientContext); + const style = getAvatarStyle(userOrRoomId, styleOptions); + const initials = getAvatarInitials(userOrRoomId); + + // Show online status in private chat context, including personal rooms (private-user-user) + // In personal rooms, we want to show the user's own online status + const shouldShowOnlineStatus = isPrivateChat; + // When ephemeral messages are disabled, all users appear online by default + const isOnline = shouldShowOnlineStatus && ( + !chatContext?.ephemeralMessagesEnabled || chatContext?.onlineStatus[userOrRoomId]?.isOnline || false + ); + + // Determine if avatar is clickable based on cursor style or onClick handler + const isClickable = onClick || styleOptions.cursor === 'pointer'; + + return ( +
+ {initials} + {shouldShowOnlineStatus && ( + + )} +
+ ); +}; \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatApp.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatApp.tsx new file mode 100644 index 000000000..63c81e193 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatApp.tsx @@ -0,0 +1,83 @@ +import React, { useContext, useEffect, useRef } from 'react'; +import { ChatRoomProvider } from '../providers/ChatRoomProvider'; +import { ChatWindow } from './ChatWindow'; +import { ChatSettingsContext } from '../contexts/ChatSettingsContext'; +import { ChatClientContext } from '../contexts/ChatClientContext'; +import { Sidebar } from './Sidebar'; +import { ChatFooter } from './ChatFooter'; + +// Auto-close timeout for notifications +const NOTIFICATION_AUTO_CLOSE_MS = 8000; + +export const ChatApp: React.FC = () => { + const settingsContext = useContext(ChatSettingsContext); + const clientContext = useContext(ChatClientContext); + const autoCloseTimerRef = useRef(null); + + if (!settingsContext) { + throw new Error('ChatApp must be used within ChatSettingsProvider'); + } + + const successNotification = clientContext?.successNotification; + const setSuccessNotification = clientContext?.setSuccessNotification; + + // Debug: log notification changes + useEffect(() => { + console.log('[ChatApp] successNotification changed to:', successNotification); + }, [successNotification]); + + // Auto-close success notification after timeout + useEffect(() => { + if (successNotification && setSuccessNotification) { + console.log('[ChatApp] Setting up 5s timer for notification:', successNotification); + // Clear any existing timer + if (autoCloseTimerRef.current) { + console.log('[ChatApp] Clearing existing timer'); + clearTimeout(autoCloseTimerRef.current); + } + + // Set new auto-close timer + autoCloseTimerRef.current = setTimeout(() => { + console.log('[ChatApp] Timer fired, clearing notification'); + setSuccessNotification(""); + }, NOTIFICATION_AUTO_CLOSE_MS); + + // Cleanup on unmount or when notification changes + return () => { + console.log('[ChatApp] Cleanup: clearing timer'); + if (autoCloseTimerRef.current) { + clearTimeout(autoCloseTimerRef.current); + } + }; + } + }, [successNotification, setSuccessNotification]); + + return ( + <> +
+ {/* Success Notification Banner */} + {successNotification && setSuccessNotification && ( +
+ + {successNotification} + +
+ )} + +
+ + + + +
+
+ + + ); +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatFooter.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatFooter.tsx new file mode 100644 index 000000000..2d4bbce33 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatFooter.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export const ChatFooter: React.FC = () => { + return ( +
+

Powered by Azure Web PubSub

+
+ ); +}; \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatHeader.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatHeader.tsx new file mode 100644 index 000000000..903e6a9eb --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatHeader.tsx @@ -0,0 +1,509 @@ +import React, { useContext, useState, useEffect } from "react"; +import { useChatClient } from '../hooks/useChatClient'; +import { ChatRoomContext } from "../contexts/ChatRoomContext"; +import { ChatSettingsContext } from '../contexts/ChatSettingsContext'; +import { ChatClientContext } from '../contexts/ChatClientContext'; +import { usePrivateChat } from '../hooks/usePrivateChat'; +import { AvatarWithOnlineStatus } from './AvatarWithOnlineStatus'; +import { UserProfileCard } from './UserProfileCard'; +import { AddToRoomDialog } from './AddToRoomDialog'; + +export const ChatHeader: React.FC = () => { + const { connectionStatus } = useChatClient(); + const settingsContext = useContext(ChatSettingsContext); + const clientContext = useContext(ChatClientContext); + const [roomMembersInfo, setRoomMembersInfo] = useState<{ count: number; members: string[] } | null>(null); + const [showMembersList, setShowMembersList] = useState(false); + const [showProfileCard, setShowProfileCard] = useState(false); + const [isAddToRoomDialogOpen, setIsAddToRoomDialogOpen] = useState(false); + const [isAddingUsers, setIsAddingUsers] = useState(false); + const [removingUserId, setRemovingUserId] = useState(null); + const [isLeavingRoom, setIsLeavingRoom] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const [showMoreMenu, setShowMoreMenu] = useState(false); + const [showRoomInfo, setShowRoomInfo] = useState(false); + const { createOrJoinPrivateChat } = usePrivateChat(); + const chatRoom = useContext(ChatRoomContext); + const roomId = chatRoom?.room ? chatRoom.room.id : undefined; + const roomName = chatRoom?.room ? chatRoom.room.name : undefined; + const roomMembersUpdateTrigger = clientContext?.roomMembersUpdateTrigger || 0; + + // Fetch member info for current room + useEffect(() => { + const fetchRoomMembers = async () => { + if (!clientContext?.client || !roomId) { + setRoomMembersInfo(null); + return; + } + + try { + console.log("trying to fetch room members for room:", roomId); + const roomInfo = await clientContext.client.getRoom(roomId, true); + console.log("fetched room member info:", roomInfo); + const members = (roomInfo as any).members || []; + setRoomMembersInfo({ + count: members.length, + members: members + }); + } catch (error) { + console.log(`Failed to get member info for room ${roomId}:`, error); + setRoomMembersInfo({ count: 0, members: [] }); + } + }; + + fetchRoomMembers(); + }, [clientContext?.client, roomId, roomMembersUpdateTrigger]); + + // Close members list when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (showMembersList && !target.closest('[data-members-dropdown]')) { + setShowMembersList(false); + } + if (showMoreMenu && !target.closest('[data-more-menu]')) { + setShowMoreMenu(false); + } + if (showRoomInfo && !target.closest('[data-room-info]')) { + setShowRoomInfo(false); + } + }; + + if (showMembersList || showMoreMenu || showRoomInfo) { + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + } + }, [showMembersList, showMoreMenu, showRoomInfo]); + + const copyRoomId = async () => { + if (roomId) { + try { + await navigator.clipboard.writeText(roomId); + console.log('Room ID copied to clipboard:', roomId); + // You could add a toast notification here + } catch (err) { + console.error('Failed to copy room ID:', err); + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = roomId; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + } + } + }; + + const handleLogout = async () => { + // Disconnect the chat client + if (clientContext?.client) { + try { + await clientContext.client.stop(); + } catch (error) { + console.error('Error disconnecting client:', error); + } + } + // Refresh the page to reset all state + window.location.reload(); + }; + + const handleAddToRoom = async (userIds: string[]) => { + if (!roomId || !clientContext?.client) return; + + setIsAddingUsers(true); + setErrorMessage(""); + try { + // Add users to room using the client + for (const userId of userIds) { + await (clientContext.client as any).addUserToRoom(roomId, userId); + } + setIsAddToRoomDialogOpen(false); + // Refresh room members info + const roomInfo = await clientContext.client.getRoom(roomId, true); + const members = (roomInfo as any).members || []; + setRoomMembersInfo({ + count: members.length, + members: members + }); + } catch (error) { + console.error('Error adding users to room:', error); + const errorMsg = error instanceof Error ? error.message : 'Failed to add users to room'; + setErrorMessage(errorMsg); + } finally { + setIsAddingUsers(false); + } + }; + + const handleRemoveUser = async (userId: string) => { + console.log("Removing user from room:", userId, roomId); + if (!roomId || !clientContext?.client) return; + + setRemovingUserId(userId); + setErrorMessage(""); + try { + // Remove user from room using the client + await (clientContext.client as any).removeUserFromRoom(roomId, userId); + + // Refresh room members info + const roomInfo = await clientContext.client.getRoom(roomId, true); + const members = (roomInfo as any).members || []; + setRoomMembersInfo({ + count: members.length, + members: members + }); + + // Show success notification with room name + clientContext.setSuccessNotification(`Removed user ${userId} from room: ${roomName || roomId}`); + } catch (error) { + console.error('Error removing user from room:', error); + const errorMsg = error instanceof Error ? error.message : 'Failed to remove user from room'; + setErrorMessage(errorMsg); + } finally { + setRemovingUserId(null); + } + }; + + const handleLeaveRoom = async () => { + if (!clientContext?.client || !roomId || !settingsContext) return; + + const leavingRoomName = roomName || roomId; + setIsLeavingRoom(true); + setErrorMessage(""); + try { + await settingsContext.removeRoom(clientContext.client, roomId); + setShowMembersList(false); + + // Show success notification + clientContext.setSuccessNotification(`You have left the room: ${leavingRoomName}`); + } catch (error) { + console.error('Error leaving room:', error); + const errorMsg = error instanceof Error ? error.message : 'Failed to leave room'; + setErrorMessage(errorMsg); + } finally { + setIsLeavingRoom(false); + } + }; + + const renderUserAvatar = () => { + const userId = connectionStatus.userId || settingsContext?.userId; + if (!userId) return null; + + return ( +
+ + {userId} + + setShowProfileCard(true)} + cursor="pointer" + /> +
+ ); + }; + + const renderMemberAvatar = (userId: string, size: number = 32) => { + const currentUserId = connectionStatus.userId || settingsContext?.userId; + const isCurrentUser = userId === currentUserId; + + return ( + + ); + }; + + const roomTitle = () => { + if (roomId?.startsWith("private-")) { + // Private chat room - extract other user from room ID + const parts = roomId.split("-"); + if (parts.length >= 3) { + const currentUserId = connectionStatus.userId || settingsContext?.userId; + const otherUser = parts[1] === currentUserId ? parts[2] : parts[1]; + // Check if chatting with self + const isSelfChat = otherUser === currentUserId; + const displayName = isSelfChat ? `${otherUser} (You)` : otherUser; + return ( + <> +
+ + {showRoomInfo && ( +
+
+ Room Name: + {roomName || displayName} +
+
+ Room ID: + {roomId} +
+ +
+ )} +
+ {displayName} + + ); + } + } + if (roomName) { + return ( + <> +
+ + {showRoomInfo && ( +
+
+ Room Name: + {roomName} +
+
+ Room ID: + {roomId} +
+ +
+ )} +
+ {roomName} + + ); + } + return No Room Selected; + } + + const currentUserId = connectionStatus.userId || settingsContext?.userId; + + return ( + <> + {/* Dialogs */} + { + setIsAddToRoomDialogOpen(false); + setErrorMessage(""); + }} + isLoading={isAddingUsers} + roomName={roomName} + /> + + {/* Error Message Banner */} + {errorMessage && ( +
+ ⚠️ + {errorMessage} + +
+ )} + + {/* Combined header bar - Room info on left, User avatar on right */} +
+
+
+
+ {/* if the room name contains '<->, then its a private chat, otherwise its a room */} + {/* if its a private chat, extract the other user name */} +

{ roomTitle() }

+ {roomMembersInfo && !roomId?.startsWith('private-') && ( +
+ + + {showMembersList && ( +
+
+
+ Members ({roomMembersInfo.count}) +
+
+ {roomMembersInfo.members.map((member) => { + const currentUserId = connectionStatus.userId || settingsContext?.userId; + const isCurrentUser = member === currentUserId; + const isRemoving = removingUserId === member; + + return ( +
+
{ + if (!isCurrentUser) { + await createOrJoinPrivateChat(member); + setShowMembersList(false); + } + }} + className="member-item-content" + > + {renderMemberAvatar(member, 24)} + {member} {isCurrentUser && '(You)'} +
+ {!isCurrentUser && ( + + )} +
+ ); + })} +
+ + {/* Room Management Actions */} +
+
+ + +
+
+
+ )} +
+ )} +
+
+
+ + {/* User avatar on the right */} + {currentUserId && ( +
+ {renderUserAvatar()} + {showProfileCard && ( + <> +
setShowProfileCard(false)} /> + setShowProfileCard(false)} + onLogout={handleLogout} + /> + + )} +
+ )} +
+ + ); +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatInput.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatInput.tsx new file mode 100644 index 000000000..6c65fcf35 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatInput.tsx @@ -0,0 +1,64 @@ +import React, { useRef, useState, useCallback, useContext } from "react"; +import { useChatClient } from "../hooks/useChatClient"; +import { ChatRoomContext } from "../contexts/ChatRoomContext"; +import { RichTextEditor } from "./RichTextEditor"; +import type { RichTextEditorHandle } from "./RichTextEditor"; + +export const ChatInput: React.FC = () => { + const { sendMessage, connectionStatus, isStreaming, sendTypingIndicator } = useChatClient(); + const chatRoom = useContext(ChatRoomContext); + const roomId = chatRoom?.room?.id; + const [hasContent, setHasContent] = useState(false); + const editorRef = useRef(null); + const lastTypingSentRef = useRef(0); + + const isConnected = connectionStatus.status === 'connected'; + // Lock sending while AI is streaming a response to avoid overlapping questions + const canSend = hasContent && isConnected && !isStreaming; + + // Handle content change for typing indicator + const handleContentChange = useCallback((hasContent: boolean) => { + setHasContent(hasContent); + + // Send typing indicator when user is typing (throttled to every 2 seconds) + if (hasContent && roomId && isConnected) { + const now = Date.now(); + if (now - lastTypingSentRef.current > 2000) { + sendTypingIndicator(roomId); + lastTypingSentRef.current = now; + } + } + }, [roomId, isConnected, sendTypingIndicator]); + + const handleSubmit = useCallback(() => { + if (!canSend || !editorRef.current) return; + + const html = editorRef.current.getHtml(); + const text = editorRef.current.getText().trim(); + + if (!text) return; + + // Send HTML content (will be rendered on the receiving end) + editorRef.current.clear(); + setHasContent(false); + void sendMessage(html); + }, [canSend, sendMessage]); + + const onSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + handleSubmit(); + }, [handleSubmit]); + + return ( +
+ + + ); +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatMessages.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatMessages.tsx new file mode 100644 index 000000000..b2d59a6f1 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatMessages.tsx @@ -0,0 +1,39 @@ +import React, { useRef, useEffect, useContext } from 'react'; +import { useChatClient } from '../hooks/useChatClient'; +import { MessageComponent } from './MessageComponent'; +import { ChatSettingsContext } from '../contexts/ChatSettingsContext'; + +export const ChatMessages: React.FC = () => { + const { messages } = useChatClient(); + const settings = useContext(ChatSettingsContext); + + if (!settings) throw new Error('ChatMessages must be used within ChatSettingsProvider'); + const messagesEndRef = useRef(null); + + // Auto-scroll to bottom when messages change + useEffect(() => { + const anchor = messagesEndRef.current; + if (!anchor) return; + // On large list swaps (e.g., room switch), instant jump is smoother than smooth animation + const behavior: ScrollBehavior = messages.length > 30 ? 'auto' : 'smooth'; + type ScrollIntoViewFn = (arg?: unknown) => void; + const maybeFn = (anchor as HTMLElement & { scrollIntoView?: unknown }).scrollIntoView; + if (typeof maybeFn === 'function') { + const fn = maybeFn as ScrollIntoViewFn; + try { + fn.call(anchor, { behavior }); + } catch { + try { fn.call(anchor); } catch { /* ignore */ } + } + } + }, [messages]); + + return ( +
+ {messages.map((message) => ( + + ))} +
+
+ ); +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatStatusBanner.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatStatusBanner.tsx new file mode 100644 index 000000000..ef95f7bc5 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatStatusBanner.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { useChatClient } from '../hooks/useChatClient'; + +export const ChatStatusBanner: React.FC = () => { + const { connectionStatus, uiNotice } = useChatClient(); + + // Prefer explicit UI notices; otherwise reflect connection lifecycle succinctly. + const fallback = (() => { + if (connectionStatus.status === 'error' || connectionStatus.status === 'disconnected') { + return { type: 'error' as const, text: connectionStatus.message }; + } + if (connectionStatus.status === 'connecting') { + return { type: 'info' as const, text: connectionStatus.message }; + } + return undefined; + })(); + const active = uiNotice ?? fallback; + + if (!active) return null; + + const isError = active.type === 'error'; + const variant = isError ? 'error' : 'info'; + + return ( +
+
+
+ {active.text} +
+
+
+ ); +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatWindow.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatWindow.tsx new file mode 100644 index 000000000..19f92e572 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/ChatWindow.tsx @@ -0,0 +1,30 @@ +import React, { useContext } from 'react'; +import { ChatHeader } from './ChatHeader'; +import { ChatMessages } from './ChatMessages'; +import { ChatStatusBanner } from './ChatStatusBanner'; +import { ChatInput } from './ChatInput'; +import { TypingIndicator } from './TypingIndicator'; +import { useChatClient } from '../hooks/useChatClient'; +import { ChatRoomContext } from '../contexts/ChatRoomContext'; + +export const ChatWindow: React.FC = () => { + const { getTypingUsersForRoom } = useChatClient(); + const chatRoom = useContext(ChatRoomContext); + const roomId = chatRoom?.room?.id; + const typingUsers = roomId ? getTypingUsersForRoom(roomId) : []; + + return ( +
+
+ +
+
+ + + + +
+
+
+ ); +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/CreateRoomDialog.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/CreateRoomDialog.tsx new file mode 100644 index 000000000..e7638b60d --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/CreateRoomDialog.tsx @@ -0,0 +1,184 @@ +import React, { useState, type KeyboardEvent } from 'react'; +import { createPortal } from 'react-dom'; +import { Modal, Button, FormField } from '../utils/sharedComponents'; + +interface CreateRoomDialogProps { + isOpen: boolean; + onCreateRoom: (roomName: string, memberIds: string[]) => void; + onClose: () => void; + isLoading?: boolean; +} + +interface TagInputProps { + label: string; + tags: string[]; + onTagsChange: (tags: string[]) => void; + placeholder?: string; +} + +const TagInput: React.FC = ({ label, tags, onTagsChange, placeholder }) => { + const [inputValue, setInputValue] = useState(''); + + const handleInputChange = (value: string) => { + setInputValue(value); + + // Check if user typed a comma + if (value.includes(',')) { + const newTags = value.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0); + if (newTags.length > 0) { + const lastTag = newTags[newTags.length - 1]; + const tagsToAdd = newTags.slice(0, -1); + + // Add all complete tags (before the last comma) + if (tagsToAdd.length > 0) { + const updatedTags = [...tags, ...tagsToAdd.filter(tag => !tags.includes(tag))]; + onTagsChange(updatedTags); + } + + // Keep the remaining text after the last comma + setInputValue(lastTag); + } else { + setInputValue(''); + } + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + const trimmedValue = inputValue.trim(); + if (trimmedValue && !tags.includes(trimmedValue)) { + onTagsChange([...tags, trimmedValue]); + } + setInputValue(''); + } else if (e.key === 'Backspace' && !inputValue && tags.length > 0) { + // Remove last tag when backspace is pressed and input is empty + onTagsChange(tags.slice(0, -1)); + } + }; + + const removeTag = (indexToRemove: number) => { + onTagsChange(tags.filter((_, index) => index !== indexToRemove)); + }; + + return ( +
+ +
+ {tags.map((tag, index) => ( + + {tag} + + + ))} + handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={tags.length === 0 ? placeholder : ''} + className="user-id-input" + /> +
+
+ ); +}; + +export const CreateRoomDialog: React.FC = ({ + isOpen, + onCreateRoom, + onClose, + isLoading = false +}) => { + const [roomName, setRoomName] = useState(''); + const [memberIds, setMemberIds] = useState([]); + const [error, setError] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + // Basic validation + if (!roomName.trim()) { + setError('Room name is required'); + return; + } + + setError(''); + // memberIds is already an array of strings + onCreateRoom(roomName.trim(), memberIds); + + // Clear the form after successful submission + setRoomName(''); + setMemberIds([]); + }; + + const handleClose = () => { + setRoomName(''); + setMemberIds([]); + setError(''); + onClose(); + }; + + return createPortal( + +
+ { + setRoomName(value); + if (error) setError(''); + }} + error={error} + placeholder="Enter room name (required)" + /> + + + +

+ Click × to remove memeber User ID. +

+ +
+ + + +
+ +
, + document.body + ); +}; \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/JoinRoomDialog.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/JoinRoomDialog.tsx new file mode 100644 index 000000000..2625c662f --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/JoinRoomDialog.tsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Modal, Button, FormField } from '../utils/sharedComponents'; + +interface JoinRoomDialogProps { + isOpen: boolean; + onJoinRoom: (roomId: string) => void; + onClose: () => void; + isLoading?: boolean; +} + +export const JoinRoomDialog: React.FC = ({ + isOpen, + onJoinRoom, + onClose, + isLoading = false +}) => { + const [roomId, setRoomId] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + // Basic validation + if (!roomId.trim()) { + setError('Room ID is required'); + return; + } + + setError(''); + onJoinRoom(roomId.trim()); + }; + + const handleClose = () => { + setRoomId(''); + setError(''); + onClose(); + }; + + return createPortal( + + + +
+ { + setRoomId(value); + if (error) setError(''); + }} + error={error} + placeholder="Enter room ID" + /> + +
+ + + +
+ +
, + document.body + ); +}; \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/LoginDialog.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/LoginDialog.tsx new file mode 100644 index 000000000..639a19aa6 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/LoginDialog.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Modal, Button, FormField, ErrorDisplay } from '../utils/sharedComponents'; + +interface LoginDialogProps { + isOpen: boolean; + onLogin: (userId: string, password: string) => void; + isLoading?: boolean; +} + +export const LoginDialog: React.FC = ({ isOpen, onLogin, isLoading = false }) => { + const [userId, setUserId] = useState(''); + const [password, setPassword] = useState('88888888'); + const [error, setError] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + // Basic validation + if (!userId.trim()) { + setError('User ID is required'); + return; + } + + onLogin(userId.trim(), password); + }; + + return createPortal( + {}} title="Welcome to TeamsLite"> + {error && } + +
+ { + setUserId(value); + if (error) setError(''); + }} + placeholder="Enter your user ID" + /> + + + + + +
, + document.body + ); +}; \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/MessageComponent.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/MessageComponent.tsx new file mode 100644 index 000000000..35373b6d4 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/MessageComponent.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import type { ChatMessage } from '../contexts/ChatClientContext'; +import { formatMessageContent } from '../utils/messageFormatting'; +import { formatMessageTime, formatFullMessageTime } from '../utils/timeFormatting'; +import { usePrivateChat } from '../hooks/usePrivateChat'; +import { AvatarWithOnlineStatus } from './AvatarWithOnlineStatus'; + +interface MessageComponentProps { + message: ChatMessage; +} + +export const MessageComponent: React.FC = ({ message }) => { + const { createOrJoinPrivateChat } = usePrivateChat(); + + const messageClasses = [ + 'message', + message.isFromCurrentUser ? 'user-message' : 'bot-message', + message.streaming ? 'streaming' : '', + message.isPlaceholder ? 'thinking' : '', + ].filter(Boolean).join(' '); + + // Render simple avatar for non-current user messages + const renderAvatar = () => { + if (message.isFromCurrentUser) return null; + + return ( +
+ createOrJoinPrivateChat(message.sender || '')} + isUser={false} + isPrivateChat={true} // Messages are in chat context, show online status + /> +
+ ); + }; + + // Render acknowledgment status icon for user messages + const renderAckIcon = () => { + if (!message.isFromCurrentUser) return null; + + if (message.isAcked) { + // Acknowledged - circle with checkmark + return ( +
+ ✓ +
+ ); + } else { + // Not acknowledged - empty circle + return ( +
+ ); + } + }; + + // Render system message with special styling + if (message.isSystemMessage) { + return ( +
+
+
+ {message.content} +
+
+
+ ); + } + + return ( +
+ {/* User ID and timestamp above message bubble */} +
+ {!message.isFromCurrentUser && {message.sender}} + {formatMessageTime(message.timestamp)} +
+ + {/* Message bubble with avatar and ack icon */} +
+ {renderAvatar()} +
+
+
+
+
+ {renderAckIcon()} +
+
+ ); +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/OnlineStatusIndicator.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/OnlineStatusIndicator.tsx new file mode 100644 index 000000000..9c94e0581 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/OnlineStatusIndicator.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +interface OnlineStatusIndicatorProps { + isOnline: boolean; + size?: number; +} + +export const OnlineStatusIndicator: React.FC = ({ + isOnline, + size = 16 // Increased default size further +}) => { + return ( +
+ ); +}; \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/RemoveFromRoomDialog.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/RemoveFromRoomDialog.tsx new file mode 100644 index 000000000..7b24c4609 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/RemoveFromRoomDialog.tsx @@ -0,0 +1,147 @@ +import React, { useState, type KeyboardEvent } from 'react'; +import { createPortal } from 'react-dom'; +import { Modal, Button } from '../utils/sharedComponents'; + +interface RemoveFromRoomDialogProps { + isOpen: boolean; + onRemoveFromRoom: (userIds: string[]) => void; + onClose: () => void; + isLoading?: boolean; + roomName?: string; +} + +export const RemoveFromRoomDialog: React.FC = ({ + isOpen, + onRemoveFromRoom, + onClose, + isLoading = false, + roomName +}) => { + const [userIds, setUserIds] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [error, setError] = useState(''); + + const handleInputChange = (value: string) => { + setInputValue(value); + + // Check if user typed a comma + if (value.includes(',')) { + const newIds = value.split(',').map(id => id.trim()).filter(id => id.length > 0); + if (newIds.length > 0) { + const lastId = newIds[newIds.length - 1]; + const idsToAdd = newIds.slice(0, -1); + + // Add all complete IDs (before the last comma) + if (idsToAdd.length > 0) { + const updatedIds = [...userIds, ...idsToAdd.filter(id => !userIds.includes(id))]; + setUserIds(updatedIds); + } + + // Keep the remaining text after the last comma + setInputValue(lastId); + } else { + setInputValue(''); + } + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + const trimmedValue = inputValue.trim(); + if (trimmedValue && !userIds.includes(trimmedValue)) { + setUserIds([...userIds, trimmedValue]); + } + setInputValue(''); + } else if (e.key === 'Backspace' && !inputValue && userIds.length > 0) { + // Remove last ID when backspace is pressed and input is empty + setUserIds(userIds.slice(0, -1)); + } + }; + + const removeUserId = (indexToRemove: number) => { + setUserIds(userIds.filter((_, index) => index !== indexToRemove)); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + // Basic validation + if (userIds.length === 0) { + setError('At least one user ID is required'); + return; + } + + setError(''); + onRemoveFromRoom(userIds); + }; + + const handleClose = () => { + setUserIds([]); + setInputValue(''); + setError(''); + onClose(); + }; + + return createPortal( + +
+
+ +
+ {userIds.map((userId, index) => ( +
+ {userId} + +
+ ))} + handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={userIds.length === 0 ? 'Enter user IDs separated by comma or press Enter' : ''} + className="border-none outline-none flex-1 min-w-[120px] text-[14px] p-[4px]" + /> +
+ {error &&

{error}

} +
+ +
+ + + +
+
+
, + document.body + ); +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/RichTextEditor.teams.css b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/RichTextEditor.teams.css new file mode 100644 index 000000000..b7eb19169 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/RichTextEditor.teams.css @@ -0,0 +1,180 @@ +/* Teams-like styling for chat input editor */ +.rich-text-editor { + background: #fafbfc; + border-radius: 8px; + border: none; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06); + width: 100%; + max-width: 100%; + display: flex; + flex-direction: column; + overflow: visible; + transition: box-shadow 0.15s ease; + box-sizing: border-box; + flex-shrink: 0; + position: relative; +} + +.rich-text-editor:focus-within { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.rich-text-editor.disabled { + opacity: 0.6; + pointer-events: none; +} + +/* Ribbon toolbar - Teams style formatting buttons */ +.rich-text-editor .roosterjs-ribbon { + display: flex; + align-items: center; + padding: 2px 8px; + gap: 1px; + background: #ffffff; + border-bottom: 1px solid #edebe9; +} + +/* Editor content area */ +.rich-text-editor [contenteditable="true"] { + padding: 4px 12px 8px 12px; + min-height: 52px; + max-height: 200px; + overflow-y: auto; + overflow-x: hidden; + background: #ffffff !important; + font-size: 14px; + font-family: "Segoe UI", sans-serif; + color: #242424; + border: none !important; + outline: none !important; + box-shadow: none !important; + line-height: 1.5; + word-wrap: break-word; + word-break: break-word; +} + +/* Fix list items overflow */ +.rich-text-editor [contenteditable="true"] ul, +.rich-text-editor [contenteditable="true"] ol { + margin: 0; + padding-left: 20px; +} + +.rich-text-editor [contenteditable="true"] li { + margin-left: 0; +} + +/* Code formatting styles in editor */ +.rich-text-editor [contenteditable="true"] code { + background-color: #f3f2f1; + color: #c7254e; + padding: 2px 6px; + border-radius: 3px; + font-family: "Consolas", "Monaco", "Courier New", monospace; + font-size: 13px; +} + +.rich-text-editor [contenteditable="true"]:focus { + outline: none !important; + box-shadow: none !important; + border: none !important; +} + +/* Placeholder via data attribute */ +.rich-text-editor [contenteditable="true"]:empty:before { + content: attr(data-placeholder); + color: #999; + pointer-events: none; +} + +/* Fix TooltipHost wrapper taking extra space */ +.rich-text-editor .roosterjs-ribbon .ms-TooltipHost { + display: inline-flex !important; + align-items: center !important; + line-height: 1 !important; +} + +/* Rooster Ribbon buttons - minimal Teams style */ +.rich-text-editor .roosterjs-ribbon button, +.rich-text-editor button[role="button"] { + min-width: 28px !important; + height: 28px !important; + padding: 2px 6px !important; + border-radius: 4px !important; + background: transparent !important; + color: #605e5c !important; + border: none !important; + font-size: 14px !important; + cursor: pointer !important; + transition: background 0.1s !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; +} + +.rich-text-editor .roosterjs-ribbon button:hover, +.rich-text-editor button[role="button"]:hover { + background: #f3f2f1 !important; + color: #323130 !important; +} + +.rich-text-editor .roosterjs-ribbon button[aria-pressed="true"], +.rich-text-editor button[role="button"][aria-pressed="true"] { + background: #edebe9 !important; + color: #201f1e !important; +} + +/* Bottom action bar - inside editor */ +.rich-text-actions { + display: flex; + justify-content: flex-end; + align-items: center; + padding: 8px 12px; + background: transparent; +} + +/* Editor content wrapper with send button */ +.editor-content-wrapper { + position: relative; + display: flex; + flex-direction: column; +} + +.editor-content-wrapper .send-button { + position: absolute; + right: 12px; + bottom: 12px; + background: #6264a7; + color: #ffffff; + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: not-allowed; + transition: all 0.15s ease; + opacity: 0.4; + z-index: 10; +} + +.editor-content-wrapper .send-button.enabled { + opacity: 1; + cursor: pointer; +} + +.editor-content-wrapper .send-button.enabled:hover { + background: #464775; + transform: scale(1.05); +} + +.editor-content-wrapper .send-button:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.editor-content-wrapper .send-button svg { + width: 14px; + height: 14px; +} diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/RichTextEditor.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/RichTextEditor.tsx new file mode 100644 index 000000000..ee132ed84 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/RichTextEditor.tsx @@ -0,0 +1,184 @@ +import React, { useCallback, useImperativeHandle, forwardRef, useMemo } from "react"; +import "./RichTextEditor.teams.css"; +import { Editor } from "roosterjs-content-model-core"; +import type { EditorOptions } from "roosterjs-content-model-types"; +import { ThemeProvider } from "@fluentui/react/lib/Theme"; +import type { PartialTheme } from "@fluentui/react/lib/Theme"; +import { + Rooster, + Ribbon, + createRibbonPlugin, + createEmojiPlugin, + boldButton, + italicButton, + underlineButton, + strikethroughButton, + bulletedListButton, + numberedListButton, + blockQuoteButton, + codeButton, + clearFormatButton, +} from "roosterjs-react"; +import { ShortcutPlugin } from "roosterjs-content-model-plugins"; + +export interface RichTextEditorHandle { + getHtml: () => string; + getText: () => string; + clear: () => void; + focus: () => void; + isEmpty: () => boolean; +} + +interface RichTextEditorProps { + placeholder?: string; + disabled?: boolean; + canSend?: boolean; + onSubmit?: () => void; + onChange?: (hasContent: boolean) => void; +} + +// Teams light theme for Fluent UI +const teamsLightTheme: PartialTheme = { + palette: { + themePrimary: "#6264a7", + themeLighterAlt: "#f7f7fb", + themeLighter: "#e1e1f1", + themeLight: "#c8c9e4", + themeTertiary: "#9496c8", + themeSecondary: "#6769ae", + themeDarkAlt: "#585a95", + themeDark: "#4a4c7e", + themeDarker: "#37385c", + neutralLighterAlt: "#faf9f8", + neutralLighter: "#f3f2f1", + neutralLight: "#edebe9", + neutralQuaternaryAlt: "#e1dfdd", + neutralQuaternary: "#d0d0d0", + neutralTertiaryAlt: "#c8c6c4", + neutralTertiary: "#a19f9d", + neutralSecondary: "#605e5c", + neutralPrimaryAlt: "#3b3a39", + neutralPrimary: "#323130", + neutralDark: "#201f1e", + black: "#000000", + white: "#ffffff", + }, +}; + +// Ribbon buttons configuration - Teams-like layout +const ribbonButtons = [ + boldButton, + italicButton, + underlineButton, + strikethroughButton, + bulletedListButton, + numberedListButton, + blockQuoteButton, + codeButton, + clearFormatButton, +]; + + +export const RichTextEditor = forwardRef( + ({ disabled = false, canSend = false, placeholder, onSubmit, onChange }, ref) => { + // Plugins + const ribbonPlugin = useMemo(() => createRibbonPlugin(), []); + const emojiPlugin = useMemo(() => createEmojiPlugin(), []); + const shortcutPlugin = useMemo(() => new ShortcutPlugin(), []); + const plugins = useMemo(() => [ribbonPlugin, emojiPlugin, shortcutPlugin], [ribbonPlugin, emojiPlugin, shortcutPlugin]); + + // Rooster imperative handle + + const [editor, setEditor] = React.useState(null); + const editorDivRef = React.useRef(null); + + // Use editorCreator to capture editor instance + const editorCreator = useCallback((div: HTMLDivElement, options?: EditorOptions) => { + editorDivRef.current = div; + const ed = new Editor(div, options); + setEditor(ed); + return ed; + }, []); + + // Update placeholder when it changes + React.useEffect(() => { + if (editorDivRef.current && placeholder) { + editorDivRef.current.setAttribute('data-placeholder', placeholder); + } + }, [placeholder]); + + useImperativeHandle(ref, () => ({ + getHtml: () => editorDivRef.current?.innerHTML || "", + getText: () => editorDivRef.current?.innerText || "", + clear: () => { + if (editorDivRef.current) { + editorDivRef.current.innerHTML = ""; + } + }, + focus: () => editor?.focus?.(), + isEmpty: () => !(editorDivRef.current?.innerText?.trim()), + }), [editor]); + + // Watch for editor content changes + React.useEffect(() => { + if (!editorDivRef.current) return; + + const checkContent = () => { + if (onChange && editorDivRef.current) { + const text = editorDivRef.current.innerText?.trim() || ""; + onChange(text.length > 0); + } + }; + + const div = editorDivRef.current; + div.addEventListener('input', checkContent); + + return () => { + div.removeEventListener('input', checkContent); + }; + }, [onChange]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (editorDivRef.current && editorDivRef.current.innerText?.trim() && onSubmit) { + onSubmit(); + } + } + }, [onSubmit]); + + // Teams-like theme + const theme = teamsLightTheme; + + return ( + +
+
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + []} /> +
+
+ + +
+
+
+ ); + } +); + +RichTextEditor.displayName = "RichTextEditor"; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/Sidebar.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/Sidebar.tsx new file mode 100644 index 000000000..0133a6f6b --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/Sidebar.tsx @@ -0,0 +1,536 @@ +import React, { useContext, useState, useMemo, useEffect } from 'react'; +import { ChatSettingsContext } from '../contexts/ChatSettingsContext'; +import type { RoomMetadata } from '../contexts/ChatSettingsContext'; +import { ChatClientContext } from '../contexts/ChatClientContext'; +import { CreateRoomDialog } from './CreateRoomDialog'; +import { useChatClient } from '../hooks/useChatClient'; +import { usePrivateChat } from '../hooks/usePrivateChat'; +import { AvatarWithOnlineStatus } from './AvatarWithOnlineStatus'; +import { GLOBAL_METADATA_ROOM_ID } from '../lib/constants'; + +export const Sidebar: React.FC = () => { + const settings = useContext(ChatSettingsContext); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [showSearchResults, setShowSearchResults] = useState(false); + const [showSearchBox, setShowSearchBox] = useState(false); + const [showConnectionInfo, setShowConnectionInfo] = useState(false); + const [allMembers, setAllMembers] = useState>>(new Map()); + const [membersFetchTrigger, setMembersFetchTrigger] = useState(0); + const clientContext = useContext(ChatClientContext); + const { connectionStatus } = useChatClient(); + const { createOrJoinPrivateChat } = usePrivateChat(); + const unreadCounts = clientContext?.unreadCounts || {}; + const getLastMessageForRoom = clientContext?.getLastMessageForRoom; + const roomMessagesUpdateTrigger = clientContext?.roomMessagesUpdateTrigger || 0; + + // Get current user ID + const currentUserId = connectionStatus.userId || settings?.userId; + + // Helper function to get the identifier for avatar display + const getAvatarIdentifier = React.useCallback((room: RoomMetadata): string => { + // Check if it's a private chat (starts with "private-") + if (room.roomId && room.roomId.startsWith('private-')) { + // Extract user IDs from room ID: private-user1-user2 + const parts = room.roomId.split('-'); + if (parts.length >= 3 && currentUserId) { + // Return the other user's ID (not current user) + const user1 = parts[1]; + const user2 = parts[2]; + return user1 === currentUserId ? user2 : user1; + } + } + + // For regular rooms, use room name for avatar + return room.roomName || room.roomId || 'Unknown'; + }, [currentUserId]); + + // Helper function to get display name for room + const getRoomDisplayName = React.useCallback((room: RoomMetadata): string => { + // Check if it's a private chat (contains "<->") + if (room.roomName && room.roomName.includes('<->')) { + // Parse the two user IDs and return the other user's ID + const userIds = room.roomName.split(' <-> '); + if (userIds.length === 2 && currentUserId) { + return userIds[0] === currentUserId ? userIds[1] : userIds[0]; + } + } + + // For regular rooms, return the original room name + return room.roomName; + }, [currentUserId]); + + // Helper function to format message preview - memoized to avoid recreation on each render + const formatMessagePreview = React.useCallback((roomId: string): string => { + if (!roomId) return 'No room ID'; + if (!getLastMessageForRoom) return roomId; + + const lastMessage = getLastMessageForRoom(roomId); + if (!lastMessage) return 'No messages yet'; + + const sender = lastMessage.sender || 'Unknown'; + // Strip HTML tags to get plain text + const rawContent = lastMessage.content; + const content = rawContent.replace(/<[^>]*>/g, '').replace(/ /g, ' ').trim(); + const maxLength = 20; // Adjust based on your UI needs + const isPrivateChat = roomId.startsWith('private-'); + + // For private chats, don't show sender prefix + if (isPrivateChat) { + if (content.length > maxLength) { + return `${content.substring(0, maxLength)}...`; + } + return content || 'No messages yet'; + } + + // For group chats, show sender prefix + if (content.length > maxLength) { + return `${sender}: ${content.substring(0, maxLength)}...`; + } + return content ? `${sender}: ${content}` : 'No messages yet'; + }, [getLastMessageForRoom]); + + // Helper function to format timestamp for display + const formatMessageTime = React.useCallback((roomId: string): string => { + if (!roomId || !getLastMessageForRoom) return ''; + + const lastMessage = getLastMessageForRoom(roomId); + if (!lastMessage) return ''; + + // Handle case where timestamp might be missing or invalid + if (!lastMessage.timestamp) { + console.warn(`Missing timestamp for room ${roomId}:`, lastMessage); + return ''; + } + + const msgDate = new Date(lastMessage.timestamp); + + // Check if date is valid + if (isNaN(msgDate.getTime())) { + console.warn(`Invalid timestamp for room ${roomId}:`, lastMessage.timestamp); + return ''; + } + + const now = new Date(); + const isToday = msgDate.toDateString() === now.toDateString(); + + if (isToday) { + // Show time only for today's messages (e.g., "12:17 PM") + return msgDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + } + + // Check if yesterday + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + if (msgDate.toDateString() === yesterday.toDateString()) { + return 'Yesterday'; + } + + // Check if within this week (last 7 days) + const weekAgo = new Date(now); + weekAgo.setDate(weekAgo.getDate() - 7); + if (msgDate > weekAgo) { + return msgDate.toLocaleDateString([], { weekday: 'short' }); + } + + // Older messages show date + return msgDate.toLocaleDateString([], { month: 'short', day: 'numeric' }); + }, [getLastMessageForRoom]); + + const statusClass = useMemo(() => { + switch (connectionStatus.status) { + case "connected": return "header-status connected"; + case "connecting": return "header-status connecting"; + case "error": return "header-status error"; + default: return "header-status disconnected"; + } + }, [connectionStatus.status]); + + // Fetch members for all rooms for search + useEffect(() => { + const fetchAllMembers = async () => { + if (!clientContext?.client || !settings?.rooms) return; + + const membersMap = new Map>(); + + for (const room of settings.rooms) { + if (room.roomId.startsWith('private-')) continue; + + try { + const roomInfo = await clientContext.client.getRoom(room.roomId, true); + const members = (roomInfo as any).members || []; + membersMap.set(room.roomId, new Set(members)); + } catch (error) { + console.error(`Failed to fetch members for room ${room.roomId}:`, error); + } + } + + setAllMembers(membersMap); + }; + + if (settings?.rooms && settings.rooms.length > 0) { + fetchAllMembers(); + } + }, [clientContext?.client, settings?.rooms, membersFetchTrigger]); + + // Filter rooms based on search query + const searchResults = useMemo(() => { + if (!searchQuery.trim()) return { rooms: [], members: [] }; + + const query = searchQuery.toLowerCase(); + + const results: { + rooms: RoomMetadata[]; + members: { userId: string; roomId: string; roomName: string }[] + } = { + rooms: [], + members: [] + }; + + // Search rooms (exclude private chats) - only match room name + if (settings?.rooms) { + results.rooms = settings.rooms.filter(room => { + if (room.roomId.startsWith('private-')) return false; + return room.roomName?.toLowerCase().includes(query); + }).slice(0, 5); + } + + // Search members from cached data + const memberSet = new Set(); + allMembers.forEach((members, roomId) => { + const room = settings?.rooms?.find(r => r.roomId === roomId); + if (!room) return; + + members.forEach(userId => { + if (userId.toLowerCase().includes(query) && !memberSet.has(userId)) { + memberSet.add(userId); + results.members.push({ userId, roomId, roomName: room.roomName }); + } + }); + }); + + // Also search users from private chats + // Private room format: private-{sortedUserIds} + if (settings?.rooms) { + const currentUserId = settings.userId; + settings.rooms.forEach(room => { + if (!room.roomId.startsWith('private-')) return; + + // Extract user IDs from private room ID + const userIdsPart = room.roomId.replace('private-', ''); + const userIds = userIdsPart.split('-'); + + // Find the other user (not current user) + const otherUserId = userIds.find(id => id !== currentUserId); + if (otherUserId && otherUserId.toLowerCase().includes(query) && !memberSet.has(otherUserId)) { + memberSet.add(otherUserId); + // For private chats, clicking on the user should open the private chat + results.members.push({ userId: otherUserId, roomId: room.roomId, roomName: `Chat with ${otherUserId}` }); + } + }); + } + + results.members = results.members.slice(0, 5); + + return results; + }, [searchQuery, settings?.rooms, allMembers, settings?.userId]); + + // Close search results when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (showSearchResults && !target.closest('[data-sidebar-search]')) { + setShowSearchResults(false); + } + if (showConnectionInfo && !target.closest('[data-connection-status]')) { + setShowConnectionInfo(false); + } + }; + + if (showSearchResults || showConnectionInfo) { + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + } + }, [showSearchResults, showConnectionInfo]); + + const handleSearchSelect = (selectedRoomId: string) => { + if (settings?.setRoomId) { + settings.setRoomId(selectedRoomId); + } + setSearchQuery(""); + setShowSearchResults(false); + }; + + // Sort rooms by last message timestamp - most recent activity first + // Also filter out global metadata room and deduplicate by roomId + const sortedRooms = React.useMemo(() => { + if (!settings?.rooms) return []; + + // Filter out global metadata room and deduplicate by roomId + const seenIds = new Set(); + const filteredRooms = settings.rooms.filter(room => { + // Skip rooms with undefined roomId + if (!room.roomId) return false; + // Skip global metadata room + if (room.roomId === GLOBAL_METADATA_ROOM_ID) return false; + // Skip duplicates + if (seenIds.has(room.roomId)) return false; + seenIds.add(room.roomId); + return true; + }); + + return filteredRooms.sort((a, b) => { + // Use includeSystemMessages=true for sorting so new rooms with only system messages appear at top + const aLastMsg = getLastMessageForRoom?.(a.roomId, true); + const bLastMsg = getLastMessageForRoom?.(b.roomId, true); + + // Rooms without messages go to the bottom + if (!aLastMsg && !bLastMsg) return 0; + if (!aLastMsg) return 1; + if (!bLastMsg) return -1; + + // Sort by most recent message timestamp + return new Date(bLastMsg.timestamp).getTime() - new Date(aLastMsg.timestamp).getTime(); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settings?.rooms, getLastMessageForRoom, roomMessagesUpdateTrigger]); + + if (!settings) return null; + const { roomId, setRoomId, addRoom } = settings; + + const handleCreateRoom = async (roomName: string, memberIds: string[]) => { + setIsCreating(true); + try { + const id = await addRoom(clientContext!.client!, roomName, memberIds); + setIsCreateDialogOpen(false); + setRoomId(id); + } catch (error) { + console.error('HandleCreateRoomError:', error); + // Error will be handled by the dialog if needed + } finally { + setIsCreating(false); + } + }; + + return ( + <> + setIsCreateDialogOpen(false)} + isLoading={isCreating} + /> + + ); +}; \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/TopSearchBar.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/TopSearchBar.tsx new file mode 100644 index 000000000..51377eef5 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/TopSearchBar.tsx @@ -0,0 +1,208 @@ +import React, { useState, useContext, useEffect, useMemo } from 'react'; +import { ChatSettingsContext } from '../contexts/ChatSettingsContext'; +import { ChatClientContext } from '../contexts/ChatClientContext'; +import { usePrivateChat } from '../hooks/usePrivateChat'; + +export const TopSearchBar: React.FC = () => { + const [searchQuery, setSearchQuery] = useState(''); + const [showResults, setShowResults] = useState(false); + const settingsContext = useContext(ChatSettingsContext); + const clientContext = useContext(ChatClientContext); + const { createOrJoinPrivateChat } = usePrivateChat(); + + const rooms = settingsContext?.rooms || []; + const setRoomId = settingsContext?.setRoomId; + + // Get all members from all non-private rooms + const [allMembers, setAllMembers] = useState>>(new Map()); + + // Fetch members for all rooms + useEffect(() => { + const fetchAllMembers = async () => { + if (!clientContext?.client) return; + + const membersMap = new Map>(); + + for (const room of rooms) { + // Skip private chat rooms + if (room.roomId.startsWith('private-')) continue; + + try { + const roomInfo = await clientContext.client.getRoom(room.roomId, true); + const members = (roomInfo as any).members || []; + membersMap.set(room.roomId, new Set(members)); + } catch (error) { + console.error(`Failed to fetch members for room ${room.roomId}:`, error); + } + } + + setAllMembers(membersMap); + }; + + if (rooms.length > 0) { + fetchAllMembers(); + } + }, [clientContext?.client, rooms]); + + // Search results + const searchResults = useMemo(() => { + if (!searchQuery.trim()) return { rooms: [], members: [] }; + + const query = searchQuery.toLowerCase(); + const results: { rooms: typeof rooms; members: { userId: string; roomId: string; roomName: string }[] } = { + rooms: [], + members: [] + }; + + // Search rooms (exclude private chats) + results.rooms = rooms.filter(room => { + // Filter out private chat rooms + if (room.roomId.startsWith('private-')) return false; + + return room.roomName.toLowerCase().includes(query) || + room.roomId.toLowerCase().includes(query); + }); + + // Search members + const memberSet = new Set(); + allMembers.forEach((members, roomId) => { + const room = rooms.find(r => r.roomId === roomId); + if (!room) return; + + members.forEach(userId => { + if (userId.toLowerCase().includes(query) && !memberSet.has(userId)) { + memberSet.add(userId); + results.members.push({ + userId, + roomId, + roomName: room.roomName + }); + } + }); + }); + + return results; + }, [searchQuery, rooms, allMembers]); + + // Close results when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (!target.closest('.search-box-container')) { + setShowResults(false); + } + }; + + if (showResults) { + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + } + }, [showResults]); + + const handleRoomClick = (roomId: string) => { + if (setRoomId) { + setRoomId(roomId); + } + setSearchQuery(''); + setShowResults(false); + }; + + const handleMemberClick = async (userId: string) => { + await createOrJoinPrivateChat(userId); + setSearchQuery(''); + setShowResults(false); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setShowResults(true); + }; + + const handleClear = () => { + setSearchQuery(''); + setShowResults(false); + }; + + const totalResults = searchResults.rooms.length + searchResults.members.length; + const hasResults = searchQuery.trim() && totalResults > 0; + const hasNoResults = searchQuery.trim() && totalResults === 0; + + return ( +
+
+ + + + Teams Lite +
+ +
+
+ + + + + setShowResults(true)} + /> + {searchQuery && ( + + )} +
+ + {showResults && (hasResults || hasNoResults) && ( +
+ {hasNoResults && ( +
+ No rooms or people found +
+ )} + + {searchResults.rooms.length > 0 && ( + <> +
Rooms
+ {searchResults.rooms.map(room => ( + + ))} + + )} + + {searchResults.members.length > 0 && ( + <> +
People
+ {searchResults.members.map((member, index) => ( + + ))} + + )} +
+ )} +
+ +
+ {/* Placeholder for future actions */} +
+
+ ); +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/TypingIndicator.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/TypingIndicator.tsx new file mode 100644 index 000000000..849347691 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/TypingIndicator.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +interface TypingIndicatorProps { + typingUsers: string[]; +} + +export const TypingIndicator: React.FC = ({ typingUsers }) => { + if (typingUsers.length === 0) return null; + + const getTypingText = () => { + if (typingUsers.length === 1) { + return `${typingUsers[0]} is typing`; + } else if (typingUsers.length === 2) { + return `${typingUsers[0]} and ${typingUsers[1]} are typing`; + } else { + return `${typingUsers[0]} and ${typingUsers.length - 1} others are typing`; + } + }; + + return ( +
+ + + + + + {getTypingText()} +
+ ); +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/UserProfileCard.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/UserProfileCard.tsx new file mode 100644 index 000000000..510422d44 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/components/UserProfileCard.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { AvatarWithOnlineStatus } from './AvatarWithOnlineStatus'; + +interface UserProfileCardProps { + userId: string; + onClose: () => void; + onLogout: () => void; +} + +export const UserProfileCard: React.FC = ({ + userId, + onClose: _onClose, + onLogout, +}) => { + return ( +
+
+ +
+

{userId}

+

+ + Online +

+
+
+ +
+ +
+ +
+
+ ); +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/contexts/ChatClientContext.ts b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/contexts/ChatClientContext.ts new file mode 100644 index 000000000..1e0f37cf5 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/contexts/ChatClientContext.ts @@ -0,0 +1,63 @@ +import { createContext } from 'react'; +// import type { WebPubSubClient } from '@azure/web-pubsub-client'; +import { ChatClient } from '@azure/web-pubsub-chat-client'; + +export interface ChatMessage { + id: string; + content: string; + sender?: string; + timestamp: string; + isFromCurrentUser: boolean; + isAcked?: boolean; // Whether the message has been acknowledged by the server (only meaningful for isFromCurrentUser=true) + streaming?: boolean; + streamingEnd?: boolean; + isPlaceholder?: boolean; // New flag for placeholder messages + isSystemMessage?: boolean; // Flag for system notifications (e.g., "You joined this room") +} + +export interface ConnectionStatus { + status: 'connecting' | 'connected' | 'disconnected' | 'error'; + message: string; + connectionId?: string; + userId?: string; +} + +// Online status related types +export interface OnlineStatus { + [userId: string]: { + isOnline: boolean; + lastSeen: number; // timestamp + }; +} + +// Typing status related types +export interface TypingStatus { + [visitorKey: string]: { + // visitorKey format: "roomId:userId" + isTyping: boolean; + lastTyping: number; // timestamp + }; +} + +export interface ChatClientContextType { + client: ChatClient | null; + connectionStatus: ConnectionStatus; + messages: ChatMessage[]; + isStreaming: boolean; + sendMessage: (message: string) => Promise; + clearMessages: () => void; + uiNotice?: { type: 'info' | 'error'; text: string }; + unreadCounts: Record; + getLastMessageForRoom: (roomId: string, includeSystemMessages?: boolean) => ChatMessage | null; + roomMessagesUpdateTrigger: number; + roomMembersUpdateTrigger: number; + onlineStatus: OnlineStatus; + typingStatus: TypingStatus; + sendTypingIndicator: (roomId: string) => void; + getTypingUsersForRoom: (roomId: string) => string[]; + successNotification: string; + setSuccessNotification: (message: string) => void; + ephemeralMessagesEnabled: boolean; // When false, all users appear online and typing/ping are disabled +} + +export const ChatClientContext = createContext(undefined); diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/contexts/ChatRoomContext.ts b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/contexts/ChatRoomContext.ts new file mode 100644 index 000000000..25fb11e61 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/contexts/ChatRoomContext.ts @@ -0,0 +1,12 @@ +import { createContext } from 'react'; + +export interface ChatRoom { + id: string; + name: string; +} + +export interface ChatRoomContextType { + room: ChatRoom | null; +} + +export const ChatRoomContext = createContext(undefined); diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/contexts/ChatSettingsContext.ts b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/contexts/ChatSettingsContext.ts new file mode 100644 index 000000000..a416d8d4a --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/contexts/ChatSettingsContext.ts @@ -0,0 +1,26 @@ +import { createContext } from 'react'; +import type { ChatClient } from '@azure/web-pubsub-chat-client'; + +export interface RoomMetadata { + roomId: string; + roomName: string; + userId: string; + createdAt?: string; + updatedAt?: string; + description?: string; +} + +export interface ChatSettingsContextType { + roomId: string; + setRoomId: (roomId: string) => void; + rooms: RoomMetadata[]; + setRooms: (rooms: RoomMetadata[]) => void; + addRoom: (client: ChatClient, roomName: string, memberIds?: string[]) => Promise; // returns the created room id (server generates id) + addUserToRoom: (client: ChatClient, roomId: string, userId: string) => Promise; // admin adds a user to a room + removeRoom: (client: ChatClient, roomId: string) => Promise; // leave a room (remove self from room) + updateRoom: (roomId: string, roomName: string, description?: string) => Promise; + userId?: string; + setUserId: (userId: string) => void; +} + +export const ChatSettingsContext = createContext(undefined); diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/hooks/useChatClient.ts b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/hooks/useChatClient.ts new file mode 100644 index 000000000..52ebe7a17 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/hooks/useChatClient.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { ChatClientContext } from '../contexts/ChatClientContext'; + +export const useChatClient = () => { + const context = useContext(ChatClientContext); + if (context === undefined) { + throw new Error('useChatClient must be used within a ChatClientProvider'); + } + return context; +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/hooks/usePrivateChat.ts b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/hooks/usePrivateChat.ts new file mode 100644 index 000000000..c8ccd39e5 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/hooks/usePrivateChat.ts @@ -0,0 +1,51 @@ +import { useContext, useCallback } from 'react'; +import { ChatClientContext } from '../contexts/ChatClientContext'; +import { ChatSettingsContext } from '../contexts/ChatSettingsContext'; +import { useChatClient } from './useChatClient'; + +export const usePrivateChat = () => { + const clientContext = useContext(ChatClientContext); + const settingsContext = useContext(ChatSettingsContext); + const { connectionStatus } = useChatClient(); + + const createOrJoinPrivateChat = useCallback(async (targetUserId: string) => { + const currentUserId = connectionStatus.userId || settingsContext?.userId; + if (!currentUserId || !clientContext?.client || !settingsContext) { + console.error('Missing required data for private chat'); + return; + } + + // Don't allow clicking on self + if (targetUserId === currentUserId) { + return; + } + + // Generate room ID for private chat, use alphabetical order to ensure consistency + const [uid0, uid1] = [currentUserId, targetUserId].sort(); + const privateRoomId = `private-${uid0}-${uid1}`; + const privateRoomName = `${uid0} <-> ${uid1}`; + + try { + // Check if room already exists in client's rooms + const existingRoom = clientContext.client.rooms.find(r => r.roomId === privateRoomId); + + if (existingRoom) { + // Room exists, just switch to it + console.log('Switching to existing private room:', privateRoomId); + settingsContext.setRoomId(privateRoomId); + } else { + // Room doesn't exist, create it + console.log('Creating new private room:', privateRoomId); + const newRoom = await clientContext.client.createRoom(privateRoomName, [targetUserId], privateRoomId); + console.log('Created private room:', newRoom); + + // Switch to new room + settingsContext.setRoomId(privateRoomId); + } + } catch (error) { + console.error('Failed to create/join private room:', error); + } + }, [connectionStatus.userId, settingsContext, clientContext?.client]); + + return { createOrJoinPrivateChat }; +}; \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/hooks/useTextareaAutosize.ts b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/hooks/useTextareaAutosize.ts new file mode 100644 index 000000000..fc6bcd721 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/hooks/useTextareaAutosize.ts @@ -0,0 +1,18 @@ +import { useLayoutEffect } from "react"; +import type { RefObject } from "react"; + +export function useTextareaAutosize( + ref: RefObject, + value: string, + options?: { maxHeight?: number } // optional tweak +) { + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + + // reset then grow + el.style.height = "auto"; + const max = options?.maxHeight ?? 180; // default cap + el.style.height = `${Math.min(el.scrollHeight, max)}px`; + }, [ref, value, options?.maxHeight]); +} diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/index.css b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/index.css new file mode 100644 index 000000000..a4e9b1fdd --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/index.css @@ -0,0 +1,2768 @@ +@import "tailwindcss"; + +/* ========== Shared Component Styles ========== */ + +/* Modal Overlay */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +/* Modal Container */ +.modal-container { + background-color: white; + border-radius: 12px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + max-width: 480px; + width: 90%; + max-height: 90vh; + overflow: auto; +} + +/* Modal Header */ +.modal-header { + padding: 24px 32px 16px; + border-bottom: 1px solid #f3f4f6; +} + +/* Modal Title */ +.modal-title { + font-size: 24px; + font-weight: 600; + color: #111827; + margin: 0; + text-align: center; +} + +/* Modal Content */ +.modal-content { + padding: 32px; +} + +/* User ID Tag */ +.user-id-tag { + display: inline-flex; + align-items: center; + background-color: #e3f2fd; + color: #1976d2; + padding: 4px 8px 4px 12px; + border-radius: 16px; + font-size: 14px; + border: 1px solid #bbdefb; + white-space: nowrap; + flex-shrink: 0; + gap: 4px; +} + +.user-id-tag-remove { + background: transparent; + border: none; + cursor: pointer; + color: #1976d2; + font-size: 16px; + line-height: 1; + padding: 0 2px; + display: flex; + align-items: center; + justify-content: center; +} + +.user-id-tag-remove:hover { + color: #0d47a1; +} + +.user-id-input { + border: none; + outline: none; + flex: 1 1 60px; + min-width: 60px; + font-size: 14px; + padding: 4px; + background: transparent; +} + +/* Button Base */ +.btn { + padding: 12px 24px; + font-size: 16px; + font-weight: 500; + border-radius: 8px; + border: none; + cursor: pointer; + transition: background-color 0.2s ease-in-out; + outline: none; + width: 100%; +} + +.btn-primary { + background-color: #3b82f6; + color: white; +} + +.btn-primary:hover:not(:disabled) { + background-color: #2563eb; +} + +.btn-secondary { + background-color: #e5e7eb; + color: #374151; + border: 1px solid #d1d5db; +} + +.btn-secondary:hover:not(:disabled) { + background-color: #d1d5db; +} + +.btn-success { + background-color: #059669; + color: white; +} + +.btn-success:hover:not(:disabled) { + background-color: #047857; +} + +.btn:disabled { + background-color: #9ca3af; + color: white; + cursor: not-allowed; +} + +/* Form Field */ +.form-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-label { + font-size: 14px; + font-weight: 500; + color: #374151; +} + +.form-input { + width: 100%; + padding: 12px 16px; + font-size: 16px; + border: 2px solid #e5e7eb; + border-radius: 8px; + background-color: #f9fafb; + transition: border-color 0.2s ease-in-out, background-color 0.2s ease-in-out; + outline: none; + box-sizing: border-box; +} + +.form-input:focus { + border-color: #3b82f6; + background-color: white; +} + +.form-input-error { + border-color: #ef4444; + background-color: #fef2f2; +} + +.form-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.form-textarea { + resize: vertical; + min-height: 100px; +} + +.form-error-text { + font-size: 14px; + color: #ef4444; + font-weight: 500; +} + +/* Error Display */ +.error-display { + background-color: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + padding: 12px; +} + +.error-display-text { + font-size: 14px; + color: #ef4444; + font-weight: 500; +} + +/* Error Banner */ +.error-banner { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + background-color: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + max-width: 90%; + animation: slideDown 0.3s ease-out; +} + +/* Success Banner */ +.success-banner { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + background-color: #f0fdf4; + border: 1px solid #86efac; + border-radius: 8px; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + max-width: 90%; + animation: slideDown 0.3s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateX(-50%) translateY(-20px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +.error-banner-icon { + font-size: 18px; + flex-shrink: 0; +} + +.error-banner-text { + font-size: 14px; + color: #ef4444; + font-weight: 500; + flex: 1; +} + +.success-banner-icon { + font-size: 18px; + flex-shrink: 0; + color: #22c55e; + font-weight: bold; +} + +.success-banner-text { + font-size: 14px; + color: #16a34a; + font-weight: 500; + flex: 1; +} + +.success-banner-close { + background: none; + border: none; + color: #16a34a; + font-size: 24px; + line-height: 1; + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: opacity 0.2s; +} + +.success-banner-close:hover { + opacity: 0.7; +} + +.error-banner-close { + background: none; + border: none; + color: #ef4444; + font-size: 24px; + line-height: 1; + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: opacity 0.2s; +} + +.error-banner-close:hover { + opacity: 0.7; +} + +.form-error-text { + font-size: 14px; + color: #ef4444; + font-weight: 500; +} + + +/* Spinner */ +.spinner { + width: var(--spinner-size, 24px); + height: var(--spinner-size, 24px); + border: 3px solid var(--spinner-bg-color, rgba(59, 130, 246, 0.125)); + border-top-color: var(--spinner-color, #3b82f6); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Form Container */ +.form-container { + display: flex; + flex-direction: column; + gap: 24px; +} + +/* ========== Chat Header Styles ========== */ +.header-user-avatar { + display: flex; + align-items: center; + gap: 8px; +} + +.header-user-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.header-user-position { + position: absolute; + top: 8px; + right: 8px; + z-index: 10; +} + +/* ========== User Profile Card Styles ========== */ +.profile-card-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100; +} + +.profile-card-wrapper { + position: relative; +} + +.profile-card { + position: absolute; + top: 48px; + right: 0; + width: 220px; + background-color: white; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.1); + z-index: 101; + overflow: hidden; + animation: profile-card-slide-in 0.2s ease-out; +} + +@keyframes profile-card-slide-in { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.profile-card-header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; +} + +.profile-card-info { + flex: 1; + min-width: 0; +} + +.profile-card-name { + font-size: 14px; + font-weight: 600; + color: #242424; + margin: 0 0 2px 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-card-status { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: #616161; + margin: 0; +} + +.profile-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #a4a4a4; +} + +.profile-status-dot.online { + background-color: #92c353; +} + +.profile-status-dot.away { + background-color: #f8d22a; +} + +.profile-status-dot.busy { + background-color: #c4314b; +} + +.profile-status-dot.offline { + background-color: #a4a4a4; +} + +.profile-card-divider { + height: 1px; + background-color: #e0e0e0; + margin: 0; +} + +.profile-card-actions { + padding: 6px; +} + +.profile-card-action-btn { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border: none; + background: transparent; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + color: #424242; + text-align: left; + transition: background-color 0.15s ease; +} + +.profile-card-action-btn:hover { + background-color: #f5f5f5; +} + +.profile-card-action-btn.logout-btn { + color: #c4314b; +} + +.profile-card-action-btn.logout-btn:hover { + background-color: #fef0f2; +} + +.profile-card-action-btn svg { + flex-shrink: 0; +} + +.header-actions-row { + display: flex; + align-items: center; + gap: 12px; +} + +/* Three-dot more menu */ +.header-more-menu { + position: relative; + margin-left: auto; +} + +.header-more-menu-left { + margin-left: 0; + margin-right: 4px; +} + +.more-menu-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s ease; +} + +.more-menu-btn:hover, +.more-menu-btn.active { + background: rgba(0, 0, 0, 0.06); + color: var(--text-primary); +} + +.more-menu-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 200px; + background: #ffffff; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08); + z-index: 100; + overflow: hidden; + animation: fadeIn 0.15s ease; +} + +.header-more-menu:not(.header-more-menu-left) .more-menu-dropdown { + left: auto; + right: 0; +} + +.more-menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 14px; + border: none; + background: transparent; + color: var(--text-primary); + font-size: 14px; + text-align: left; + cursor: pointer; + transition: background 0.1s ease; +} + +.more-menu-item:hover { + background: #f3f2f1; +} + +.more-menu-item svg { + flex-shrink: 0; + color: var(--text-secondary); +} + +.more-menu-info { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 14px; + border-top: 1px solid #edebe9; + background: #fafafa; +} + +.more-menu-label { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.more-menu-value { + font-size: 12px; + color: var(--text-primary); + font-family: 'Consolas', 'Monaco', monospace; + word-break: break-all; +} + +.members-dropdown-container { + position: relative; +} + +.members-btn { + display: flex; + align-items: center; + gap: 4px; + font-size: 14px; + color: white; + padding: 4px 8px; + background-color: #3b82f6; + border-radius: 4px; + border: none; + cursor: pointer; + transition: background-color 0.2s ease-in-out; +} + +.members-btn:hover, +.members-btn.active { + background-color: #2563eb; +} + +.members-dropdown { + position: absolute; + top: 100%; + left: 0; + margin-top: 8px; + background-color: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + z-index: 50; + min-width: 200px; + max-height: 400px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.members-dropdown-content { + padding: 8px 0; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.members-dropdown-header { + padding: 8px 16px; + font-size: 14px; + font-weight: 600; + color: #374151; + border-bottom: 1px solid #f3f4f6; + flex-shrink: 0; +} + +.members-list-scrollable { + flex: 1; + overflow-y: auto; + min-height: 0; + max-height: 200px; +} + +.member-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px 8px 16px; + font-size: 14px; + color: #374151; + border-bottom: 1px solid #f9fafb; + transition: background-color 0.2s ease-in-out; +} + +.member-item-content { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + cursor: pointer; + min-width: 0; +} + +.member-item-content span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.member-item:last-child { + border-bottom: none; +} + +.member-item:hover:not(.current-user) { + background-color: #f9fafb; +} + +.member-item.current-user { + opacity: 0.6; +} + +.member-item.current-user .member-item-content { + cursor: default; +} + +.member-remove-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background-color: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + cursor: pointer; + color: #dc2626; + transition: all 0.2s ease-in-out; + flex-shrink: 0; +} + +.member-remove-btn:hover:not(:disabled) { + background-color: #fee2e2; + border-color: #fca5a5; + transform: scale(1.05); +} + +.member-remove-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.member-remove-btn svg { + flex-shrink: 0; +} + +.members-dropdown-divider { + height: 1px; + background-color: #e5e7eb; + margin: 8px 0; + flex-shrink: 0; +} + +.members-dropdown-actions { + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; +} + +.member-action-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background-color: #f3f4f6; + color: #374151; + border: 1px solid #e5e7eb; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.2s ease-in-out; + width: 100%; + text-align: left; +} + +.member-action-btn:hover { + background-color: #e5e7eb; + border-color: #d1d5db; +} + +.member-action-btn svg { + flex-shrink: 0; +} + +.member-action-btn.leave-room-btn { + background-color: #fef2f2; + color: #dc2626; + border-color: #fecaca; +} + +.member-action-btn.leave-room-btn:hover { + background-color: #fee2e2; + border-color: #fca5a5; +} + +.member-action-btn.leave-room-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + + +.copy-room-btn { + padding: 4px 8px; + background-color: #059669; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: background-color 0.2s ease-in-out; +} + +.copy-room-btn:hover { + background-color: #047857; +} + +/* ========== Message Component Styles ========== */ +.message-wrapper { + display: flex; + flex-direction: column; + width: 100%; + margin-bottom: 16px; +} + +.message-wrapper.from-user { + align-items: flex-end; +} + +.message-wrapper.from-other { + align-items: flex-start; +} + +.message-avatar { + margin: 0 10px 0 0; + align-self: flex-start; +} + +.message-meta { + font-size: 0.8rem; + color: #6b7280; + font-weight: 500; + margin-bottom: 4px; + display: flex; + align-items: center; + gap: 12px; +} + +.message-meta.from-other { + padding-left: 42px; +} + +.message-meta.from-user { + padding-right: 24px; +} + +.message-bubble-row { + display: flex; + align-items: flex-end; + width: 100%; +} + +.message-bubble-row.from-user { + justify-content: flex-end; +} + +.message-bubble-row.from-other { + justify-content: flex-start; +} + +.ack-icon { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + margin-left: 8px; + margin-bottom: 8px; + flex-shrink: 0; + align-self: flex-end; + border-radius: 50%; +} + +.ack-icon.acked { + border: 1px solid #22c55e; + background-color: #22c55e; + font-size: 10px; + color: white; +} + +.ack-icon.pending { + border: 1px solid #9ca3af; + background-color: transparent; +} + +/* ========== Avatar Styles ========== */ +.avatar-container { + position: relative; + width: var(--avatar-size, 32px); + height: var(--avatar-size, 32px); + border-radius: 50%; + background-color: var(--avatar-bg-color, #6b7280); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: var(--avatar-font-size, 14px); + font-weight: 500; + cursor: var(--avatar-cursor, default); + margin: var(--avatar-margin, 0); + flex-shrink: var(--avatar-flex-shrink, 0); + transition: transform 0.2s ease-in-out; +} + +.avatar-container:hover { + transform: var(--avatar-hover-transform, none); +} + +.avatar-container.clickable:hover { + transform: scale(1.1); +} + +/* ========== Online Status Indicator Styles ========== */ +.online-status-indicator { + width: var(--indicator-size, 16px); + height: var(--indicator-size, 16px); + border-radius: 50%; + border: 2px solid white; + position: absolute; + bottom: -2px; + right: -2px; + transition: background-color 0.2s ease-in-out; + z-index: 1; +} + +.online-status-indicator.online { + background-color: #22c55e; +} + +.online-status-indicator.offline { + background-color: #6b7280; +} + +/* ========== Sidebar Styles ========== */ +.sidebar-title { + display: flex; + align-items: center; + gap: 8px; +} + +.sidebar-logo { + width: 24px; + height: 24px; +} + +.sidebar-actions-container { + display: flex; + gap: 6px; + padding: 8px 16px 12px 16px; +} + +/* Sidebar Search Box */ +.sidebar-search-container { + position: relative; + padding: 0 0 8px 0; + animation: slideDown 0.15s ease; + overflow: visible; +} + +.sidebar-search-container .search-results { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 4px; + background: var(--chat-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + max-height: 300px; + overflow-y: auto; + z-index: 100; +} + +@keyframes slideDown { + from { + opacity: 0; + max-height: 0; + padding-bottom: 0; + } + to { + opacity: 1; + max-height: 100px; + padding-bottom: 8px; + } +} + +.sidebar-search-box { + display: flex; + align-items: center; + background: rgba(0, 0, 0, 0.02); + border: none; + border-radius: 4px; + padding: 6px 10px; + transition: background-color 0.2s; +} + +.sidebar-search-box:hover { + background: rgba(0, 0, 0, 0.03); +} + +.sidebar-search-box:focus-within { + background: rgba(0, 0, 0, 0.04); +} + +.sidebar-search-box .search-icon { + color: var(--text-secondary); + flex-shrink: 0; + margin-right: 8px; +} + +.sidebar-search-input { + flex: 1; + border: none !important; + background: transparent !important; + outline: none !important; + box-shadow: none !important; + font-size: 13px; + color: var(--text-primary); + padding: 0 !important; +} + +.sidebar-search-input::placeholder { + color: var(--text-secondary); +} + +.sidebar-action-btn { + padding: 6px 12px; + background-color: #f8fafc; + color: #374151; + border: 1px solid #d0d5dd; + border-radius: 8px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.2s ease-in-out; + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + box-shadow: none; +} + +.sidebar-action-btn:hover { + background-color: #f1f5f9; + border-color: #bcc2cc; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); +} + +.sidebar-action-btn-icon { + font-size: 14px; +} + +.room-label-unread { + font-weight: bold; +} + +.room-preview { + font-size: 0.85em; + opacity: 0.7; + color: #555; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; +} + +.room-preview.unread { + font-weight: bold; +} + +.unread-badge { + margin-left: auto; + background-color: #f59e0b; + color: white; + font-size: 0.75em; + font-weight: 600; + padding: 2px 8px; + border-radius: 12px; + min-width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +/* ========== End Shared Component Styles ========== */ + +:root { + --body-bg: #f0f2f5; + --chat-bg: #ffffff; + --sidebar-bg: #f5f5f5; + --header-bar-bg: #fafafa; + --messages-bg: #f5f5f5; + --user-msg-bg: #0078d4; + --bot-msg-bg: #ffffff; + --text-primary: #1f2937; + --text-secondary: #6b7280; + --border-color: #e0e0e0; + --primary-color: #0078d4; + --primary-hover: #0056a3; + --header-bg: linear-gradient(135deg, #0078d4, #0056a3); + --footer-bg: #ffffff; + --input-bg: #ffffff; + --shadow-color: rgba(0, 0, 0, 0.08); + --ui-shadow: 0 4px 12px var(--shadow-color); + --status-success: #10b981; + --status-error: #ef4444; + --message-radius: 12px; + --container-max-width: 900px; + /* Surface and hover states */ + --surface-subtle: #fafbfc; + --hover-bg: rgba(0, 0, 0, 0.05); + --active-bg: rgba(0, 0, 0, 0.1); + /* Animation speed controls */ + --animation-speed-fast: 0.15s; + --animation-speed-normal: 0.2s; + --animation-speed-slow: 0.3s; + --animation-timing-function: cubic-bezier(0.165, 0.84, 0.44, 1); + --typing-animation-duration: 0.8s; + --cursor-blink-speed: 0.7s; + --fade-transition: transform var(--animation-speed-normal) var(--animation-timing-function), + opacity var(--animation-speed-normal) var(--animation-timing-function); + --message-fade-in-duration: 0.2s; + --message-fade-in-delay: 0.03s; + --completion-animation-duration: 0.5s; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; +} + +body { + background-color: var(--body-bg); + color: var(--text-primary); + line-height: 1.6; + padding: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + font-size: 16px; + height: 100vh; + padding: 20px; +} + +.app-container { + display: flex; + flex-direction: column; + height: calc(100vh - 40px); + overflow: hidden; + max-width: var(--container-max-width); + margin: 0 auto; + width: 100%; + box-shadow: var(--ui-shadow); + border-radius: 12px; +} + +.layout { + display: grid; + grid-template-columns: 260px 1fr; + height: 100%; + background: var(--chat-bg); + border-radius: 12px; + overflow: hidden; +} + +.sidebar { + background: var(--sidebar-bg); + color: var(--text-primary); + padding: 16px; + border-right: 1px solid #e0e0e0; + box-shadow: none; + position: relative; + display: flex; + flex-direction: column; + z-index: 2; +} + +.sidebar-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.sidebar-header h2 { + font-size: 1rem; + font-weight: 600; + margin-right: auto; +} + +/* Sidebar icon button (search) */ +.sidebar-icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s ease; +} + +.sidebar-icon-btn:hover { + background: rgba(0, 0, 0, 0.06); + color: var(--text-primary); +} + +.sidebar-icon-btn.active { + background: rgba(0, 0, 0, 0.1); + color: var(--primary-color); +} + +/* Sidebar footer with connection status */ +.sidebar-footer { + margin-top: auto; + padding-top: 8px; + position: relative; +} + +/* Connection status dot - circular */ +.connection-status-dot { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; + border: none; + padding: 0; + background: transparent; +} + +.connection-status-dot:hover { + filter: brightness(0.95); +} + +.connection-status-dot .status-icon { + font-size: 12px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + text-align: center; +} + +.connection-status-dot.connected { + background-color: rgba(16, 185, 129, 0.2); +} + +.connection-status-dot.connected .status-icon { + color: #10b981; +} + +.connection-status-dot.connecting { + background-color: rgba(59, 130, 246, 0.2); + animation: pulse-connecting 1.5s infinite; +} + +.connection-status-dot.connecting .status-icon { + color: #3b82f6; +} + +.connection-status-dot.disconnected { + background-color: rgba(239, 68, 68, 0.2); +} + +.connection-status-dot.disconnected .status-icon { + color: #ef4444; +} + +.connection-status-dot.error { + background-color: rgba(107, 114, 128, 0.2); +} + +.connection-status-dot.error .status-icon { + color: #6b7280; +} + +/* Connection info dropdown */ +.connection-info-dropdown { + position: absolute; + bottom: calc(100% + 8px); + left: 0; + min-width: 220px; + background: #ffffff; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08); + z-index: 100; + overflow: hidden; + animation: slideUp 0.15s ease; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.connection-info-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid var(--border-color); + background: #fafafa; +} + +.connection-info-header .status-icon.connected { + color: #10b981; +} + +.connection-info-header .status-icon.connecting { + color: #3b82f6; +} + +.connection-info-header .status-icon.disconnected { + color: #ef4444; +} + +.connection-info-header .status-icon.error { + color: #6b7280; +} + +.status-dot-large { + font-size: 12px; +} + +.status-dot-large.connected { + color: #10b981; +} + +.status-dot-large.connecting { + color: #3b82f6; +} + +.status-dot-large.disconnected { + color: #ef4444; +} + +.status-dot-large.error { + color: #6b7280; +} + +.connection-status-text { + font-size: 13px; + font-weight: 500; + text-transform: capitalize; +} + +.connection-status-text.connected { + color: #10b981; +} + +.connection-status-text.connecting { + color: #3b82f6; +} + +.connection-status-text.disconnected { + color: #ef4444; +} + +.connection-status-text.error { + color: #6b7280; +} + +.connection-info-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 14px; +} + +.connection-info-label { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.connection-info-value { + font-size: 12px; + color: var(--text-primary); + font-family: 'Consolas', 'Monaco', monospace; + word-break: break-all; +} + +.sidebar-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.sidebar-input { + flex: 1; + min-width: 0; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--input-bg); + color: var(--text-primary); +} + +.sidebar-input::placeholder { color: var(--text-secondary); } + +.sidebar-add { + background: var(--primary-color); + color: #fff; + border: none; + border-radius: 8px; + padding: 8px 10px; + cursor: pointer; + transition: background 0.2s ease, transform 0.1s ease; +} +.sidebar-add:hover { background: var(--primary-hover); } +.sidebar-add:active { transform: translateY(1px); } + +.room-list { + list-style: none; + margin: 8px 0 0 0; + padding: 0; +} + +.room-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; + border-radius: 12px; + margin-bottom: 4px; + background: transparent; + border: 1px solid transparent; + transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94); + position: relative; +} + +.room-item.active { + background: linear-gradient(135deg, rgba(0, 120, 212, 0.08) 0%, rgba(0, 120, 212, 0.12) 100%); + border-color: rgba(0, 120, 212, 0.2); + box-shadow: 0 2px 8px rgba(0, 120, 212, 0.15), 0 1px 3px rgba(0, 0, 0, 0.1); + transform: translateY(-1px); +} + +.room-item:hover { + background: #ffffff; + border-color: rgba(0, 0, 0, 0.08); + box-shadow: 0 3px 12px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.06); + transform: translateY(-2px) scale(1.01); +} + +.room-item.active:hover { + background: linear-gradient(135deg, rgba(0, 120, 212, 0.12) 0%, rgba(0, 120, 212, 0.16) 100%); + box-shadow: 0 4px 16px rgba(0, 120, 212, 0.2), 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.room-button { + flex: 1; + display: flex; + align-items: flex-start; + gap: 10px; + background: transparent; + color: inherit; + border: none; + text-align: left; + cursor: pointer; + padding: 2px; + border-radius: 8px; + transition: all 0.15s ease; + min-width: 0; + width: 100%; +} + +.room-button:focus { + outline: none; + background: rgba(0, 120, 212, 0.05); +} + +.room-dot { + color: var(--primary-color); + opacity: 0.8; +} + +.room-info { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; + gap: 1px; +} + +.room-label { + font-size: 0.9rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.3; +} + +.room-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: flex-start; + gap: 4px; + flex-shrink: 0; + margin-left: 4px; +} + +.room-time { + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; + line-height: 1.3; + margin-top: 2px; +} + +.room-time.unread { + color: var(--primary); + font-weight: 600; +} + +.room-id { + font-size: 0.75rem; + color: var(--text-secondary); + opacity: 0.7; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.2; +} + +.room-remove { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + border-radius: 6px; + padding: 6px; + opacity: 0.6; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.room-remove:hover { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + opacity: 1; + transform: scale(1.05); +} + +@media (max-width: 900px) { + .layout { grid-template-columns: 240px 1fr; } + .sidebar { + min-width: 200px; + } +} + +@media (max-width: 600px) { + .layout { grid-template-columns: 200px 1fr; } + .sidebar { + min-width: 180px; + font-size: 0.9rem; + } +} + +.chat-container { + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; + position: relative; +} + +.connection-form { + background: var(--chat-bg); + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 10px var(--shadow-color); + margin-bottom: 20px; +} + +label { + display: block; + margin-bottom: 8px; + font-weight: 500; + color: var(--text-primary); +} + +input[type="text"] { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 0.95rem; + background-color: var(--input-bg); + color: var(--text-primary); + transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; +} + +input[type="text"]:focus { + border-color: var(--primary-color); + outline: none; + box-shadow: 0 0 0 0.2rem rgba(0, 120, 212, 0.25); +} + +.main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + width: 100%; + min-height: 0; +} + +/* Top user bar - first row */ +.top-user-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 16px; + background: var(--header-bar-bg); + border-bottom: 1px solid var(--border-color); +} + +.top-bar-left { + display: flex; + align-items: center; + gap: 10px; +} + +.top-bar-logo { + width: 28px; + height: 28px; +} + +.top-bar-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.3px; +} + +/* Search Box */ +.search-box-container { + position: relative; + flex: 1; + max-width: 500px; + margin: 0 16px; +} + +.search-box { + display: flex; + align-items: center; + background: rgba(0, 0, 0, 0.03); + border: none; + border-radius: 4px; + padding: 6px 12px; + transition: background-color 0.2s; +} + +.search-box:hover { + background: rgba(0, 0, 0, 0.05); +} + +.search-box:focus-within { + background: rgba(0, 0, 0, 0.06); +} + +.search-icon { + color: var(--text-secondary); + flex-shrink: 0; + margin-right: 10px; +} + +.search-input { + flex: 1; + border: none !important; + background: transparent !important; + outline: none !important; + box-shadow: none !important; + font-size: 14px; + color: var(--text-primary); + padding: 0 !important; +} + +.search-input::placeholder { + color: var(--text-secondary); +} + +.search-clear { + background: none; + border: none; + color: var(--text-secondary); + font-size: 18px; + cursor: pointer; + padding: 0 4px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.search-clear:hover { + color: var(--text-primary); +} + +.search-results { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 4px; + background: var(--chat-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + max-height: 300px; + overflow-y: auto; + z-index: 100; +} + +.search-result-item { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + border: none; + background: transparent; + cursor: pointer; + text-align: left; + transition: background-color 0.15s; +} + +.search-result-avatar-wrapper { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1; +} + +.search-result-item:hover { + background: var(--message-hover-bg, #f5f5f5); +} + +.search-result-item.active { + background: rgba(98, 100, 167, 0.1); +} + +.search-result-name { + font-size: 14px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.search-result-tag { + font-size: 11px; + color: var(--primary-color, #6264a7); + background: rgba(98, 100, 167, 0.1); + padding: 2px 8px; + border-radius: 10px; + flex-shrink: 0; + margin-left: 8px; +} + +.search-no-results { + padding: 14px; + text-align: center; + color: var(--text-secondary); + font-size: 14px; +} + +.search-result-section-header { + padding: 8px 14px 4px 14px; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + background: var(--surface-subtle); + border-bottom: 1px solid var(--border-color); +} + +/* Room header - single row with menu, room info, and avatar */ +.room-header { + background: var(--header-bar-bg); + color: var(--text-primary); + padding: 8px 16px; + display: flex; + align-items: center; + gap: 8px; + box-shadow: none; + position: relative; + z-index: 5; + border-radius: 0; + border-bottom: 1px solid var(--border-color); +} + +header { + background: var(--chat-bg); + color: var(--text-primary); + padding: 16px 24px; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: none; + position: relative; + z-index: 5; + border-radius: 0; + border-bottom: 1px solid var(--border-color); +} + +.header-title { + text-align: left; +} + +header h1 { + font-size: 1.3rem; + font-weight: 600; + margin-bottom: 2px; + letter-spacing: -0.5px; + display: flex; + align-items: center; + gap: 8px; +} + +/* Room type tag styling */ +.room-tag-container { + position: relative; + display: inline-flex; + align-items: center; +} + +.room-type-tag { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + border: none; + cursor: default; +} + +.room-type-tag.clickable { + cursor: pointer; + transition: all 0.15s ease; +} + +.room-type-tag.clickable:hover { + filter: brightness(0.95); +} + +.room-type-tag.clickable.active { + filter: brightness(0.9); +} + +.room-type-tag.private { + background-color: #dbeafe; + color: #1d4ed8; +} + +.room-type-tag.room { + background-color: #dcfce7; + color: #15803d; +} + +/* Room info dropdown */ +.room-info-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 220px; + background: #ffffff; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08); + z-index: 100; + overflow: hidden; + animation: fadeIn 0.15s ease; + padding: 8px 0; +} + +.room-info-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 12px; +} + +.room-info-label { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.room-info-value { + font-size: 12px; + color: var(--text-primary); + font-family: 'Consolas', 'Monaco', monospace; + word-break: break-all; +} + +.room-info-copy-btn { + display: flex; + align-items: center; + gap: 6px; + width: calc(100% - 16px); + margin: 4px 8px; + padding: 8px 10px; + border: none; + border-radius: 4px; + background: #f5f5f5; + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + transition: background 0.1s ease; +} + +.room-info-copy-btn:hover { + background: #e8e8e8; +} + +.room-info-copy-btn svg { + color: var(--text-secondary); +} + +.room-title-name { + font-weight: 600; +} + +header p { + opacity: 0.9; + font-size: 0.8rem; + margin: 0; +} + +.header-left { + display: flex; + align-items: center; + flex: 1; +} + +.header-right-avatar { + margin-left: auto; +} + +.header-actions { + display: flex; + align-items: center; +} + +.status { + background-color: var(--bot-msg-bg); + color: var(--text-primary); + padding: 8px 16px; + border-radius: 20px; + margin-bottom: 15px; + font-size: 0.85rem; + font-weight: 500; + display: none; + border-left: none; + transition: var(--fade-transition); + box-shadow: 0 1px 3px var(--shadow-color); + transform: translateY(-10px); + opacity: 0; + animation: statusFade var(--animation-speed-normal) forwards; +} + +@keyframes statusFade { + to { + transform: translateY(0); + opacity: 1; + } +} + +.status.connected { + display: inline-flex; + align-items: center; + background-color: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.3); + color: var(--status-success); +} + +.status.connected:before { + content: "●"; + display: inline-block; + margin-right: 6px; + color: var(--status-success); +} + +.status.connecting { + display: inline-flex; + align-items: center; + background-color: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + color: #3b82f6; +} + +.status.connecting:before { + content: "◐"; + display: inline-block; + margin-right: 6px; + color: #3b82f6; + animation: rotate 1s linear infinite; +} + +.status.disconnected { + display: inline-flex; + align-items: center; + background-color: rgba(107, 114, 128, 0.1); + border: 1px solid rgba(107, 114, 128, 0.3); + color: #6b7280; +} + +.status.disconnected:before { + content: "○"; + display: inline-block; + margin-right: 6px; + color: #6b7280; +} + +.status.error { + display: inline-flex; + align-items: center; + background-color: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + color: var(--status-error); +} + +.status.error:before { + content: "●"; + display: inline-block; + margin-right: 6px; + color: var(--status-error); +} + +@keyframes rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.header-status { + margin-left: 15px; + color: var(--text-primary); + background: transparent; + box-shadow: none; + padding: 5px 12px; + border-radius: 100px; + font-size: 0.75rem; + opacity: 0.85; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 6px; +} + +.status-icon { + font-size: 0.8em; + line-height: 1; +} + +.status-text { + font-weight: 500; +} + +.connection-id { + font-size: 0.65rem; + opacity: 0.7; + background: rgba(255, 255, 255, 0.1); + padding: 2px 6px; + border-radius: 4px; + cursor: help; +} + +.header-status.connected { + background-color: rgba(16, 185, 129, 0.25); + border: 1px solid rgba(16, 185, 129, 0.5); + animation: pulse-success 2s infinite; +} + +.header-status.connecting { + background-color: rgba(59, 130, 246, 0.25); + border: 1px solid rgba(59, 130, 246, 0.5); + animation: pulse-connecting 1.5s infinite; +} + +.header-status.disconnected { + background-color: rgba(107, 114, 128, 0.25); + border: 1px solid rgba(107, 114, 128, 0.5); +} + +.header-status.error { + background-color: rgba(239, 68, 68, 0.25); + border: 1px solid rgba(239, 68, 68, 0.5); + animation: pulse-error 1s infinite; +} + +@keyframes pulse-success { + 0% { + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); + transform: translateZ(0); + } + 70% { + box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); + transform: translateZ(0); + } + 100% { + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); + transform: translateZ(0); + } +} + +@keyframes pulse-connecting { + 0% { + box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4); + opacity: 1; + } + 50% { + box-shadow: 0 0 0 4px rgba(59, 130, 246, 0); + opacity: 0.7; + } + 100% { + box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); + opacity: 1; + } +} + +@keyframes pulse-error { + 0% { + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); + } + 50% { + box-shadow: 0 0 0 4px rgba(239, 68, 68, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); + } +} + +.chat-area { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background-color: var(--messages-bg); + background-image: none; + position: relative; +} + +.chat-background { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + opacity: 0.05; + pointer-events: none; + background-size: cover; + z-index: 0; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 40px 30px 15px 30px; + display: flex; + flex-direction: column; + z-index: 1; + scroll-behavior: smooth; + position: relative; + overscroll-behavior: contain; + will-change: scroll-position; + -webkit-overflow-scrolling: touch; +} + +.messages::-webkit-scrollbar { + width: 6px; +} + +.messages::-webkit-scrollbar-track { + background: transparent; +} + +.messages::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.1); + border-radius: 3px; +} + +.messages::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.2); +} + +/* Inline status banner inside chat window */ +.status-banner { + width: 100%; + margin: 8px 0 4px 0; +} +.status-banner-row { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} +.status-banner-line { + height: 1px; + flex: 1 1 auto; + background: rgba(16, 185, 129, 0.35); /* default for info; overridden for error */ +} +.status-banner-text { + font-size: 0.75rem; /* small */ + font-weight: 600; + color: #047857; /* green-700 */ + text-align: center; +} +.status-banner.error .status-banner-line { background: rgba(239, 68, 68, 0.35); } +.status-banner.error .status-banner-text { color: #b91c1c; /* red-700 */ } + +/* System message in chat (e.g., "You joined this room") */ +.system-message { + width: 100%; + margin: 4px 0; +} + +/* When system message is the first child, reduce top margin */ +.system-message:first-child { + margin-top: auto; +} +.system-message-row { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} +.system-message-line { + height: 1px; + flex: 1 1 auto; + background: rgba(156, 163, 175, 0.4); /* gray-400 with opacity */ +} +.system-message-text { + font-size: 0.8rem; + font-weight: 500; + color: #6b7280; /* gray-500 */ + text-align: center; + padding: 0 8px; + white-space: nowrap; +} + +.message { + max-width: 80%; + padding: 14px 18px; + border-radius: var(--message-radius); + margin-bottom: 8px; + line-height: 1.5; + position: relative; + animation: fadeIn 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94); + word-break: break-word; + transition: transform 0.2s ease, box-shadow 0.2s ease; + will-change: transform, opacity; + transform: translateZ(0); + backface-visibility: hidden; +} + +.message.animate-in { + animation-name: slideInFade; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(12px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes slideInFade { + from { + opacity: 0; + transform: translateY(16px) scale(0.96); + } + 50% { + opacity: 0.8; + transform: translateY(4px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes slideInFromRight { + from { + opacity: 0; + transform: translateX(40px) scale(0.95); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +@keyframes slideInFromLeft { + from { + opacity: 0; + transform: translateX(-40px) scale(0.95); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +.message.user-message { + background: linear-gradient(135deg, #0078d4 0%, #106ba3 100%); + color: white; + align-self: flex-end; + border-radius: 12px 12px 4px 12px; + box-shadow: 0 1px 4px rgba(0, 120, 212, 0.15), 0 1px 2px rgba(0, 0, 0, 0.06); + transform: translateZ(0); + will-change: transform, opacity; + backface-visibility: hidden; + position: relative; + animation: slideInFromRight 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.message.bot-message { + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + color: var(--text-primary); + align-self: flex-start; + border-radius: 12px 12px 12px 4px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05); + border: 1px solid rgba(0, 0, 0, 0.04); + transform: translateZ(0); + will-change: transform, opacity; + backface-visibility: hidden; + position: relative; + animation: slideInFromLeft 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.message-content { + font-size: 1rem; + line-height: 1.5; +} + +.input-area { + border: none; + padding: 10px 28px 20px 28px; + background-color: transparent; + display: flex; + align-items: stretch; + box-shadow: none; + position: relative; + z-index: 3; + border-radius: 0; + width: 100%; + box-sizing: border-box; + flex-shrink: 0; +} + +.message-input { + flex: 1; + padding: 14px 20px; + border: 1px solid var(--border-color); + border-radius: 12px; + background-color: var(--input-bg); + color: var(--text-primary); + font-size: 1rem; + resize: none; + max-height: 120px; + overflow-y: auto; + overflow-x: hidden; + transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); +} + +/* Hide textarea scrollbar visuals while preserving scrollability */ +.message-input::-webkit-scrollbar { + width: 0; + height: 0; +} +.message-input { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + +.message-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 2px 8px rgba(0, 120, 212, 0.15); +} + +.message-input:disabled { + background: linear-gradient(145deg, #f8f9fa 0%, #e9ecef 100%); + cursor: not-allowed; + opacity: 0.8; + transform: scale(0.98); + filter: grayscale(0.3); + border-color: rgba(0, 0, 0, 0.1); +} + +.send-button { + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 50%; + width: 46px; + height: 46px; + margin-left: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.3rem; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +.send-button:hover { + background-color: var(--primary-hover); + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.send-button:active { + transform: scale(0.95); +} + +.send-button:disabled { + background-color: #bdbdbd; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +footer { + text-align: center; + padding: 8px 0; + color: var(--text-secondary); + font-size: 0.75rem; + background-color: transparent; + border: none; + position: relative; + z-index: 1; +} + +@keyframes pulse { + 0% { + transform: translateZ(0) scale(1); + opacity: 0.4; + } + 50% { + transform: translateZ(0) scale(1.25); + opacity: 1; + } + 100% { + transform: translateZ(0) scale(1); + opacity: 0.4; + } +} + +/* Animation for streaming messages */ +.message.streaming .message-content { + position: relative; +} + +/* Prefer attaching cursor to the last child so it sits on the same line */ +.message.streaming .message-content > :last-child::after { + content: "▌"; + display: inline-block; + vertical-align: baseline; + animation: blink-cursor var(--cursor-blink-speed) infinite; + margin-left: 2px; + color: var(--primary-color); +} + +/* Fallback: if content wrapper is the only child, attach to message-text */ +.message.streaming .message-text:only-child::after { + content: "▌"; + display: inline-block; + vertical-align: baseline; + animation: blink-cursor var(--cursor-blink-speed) infinite; + margin-left: 2px; + color: var(--primary-color); +} + +/* Thinking animation */ +.message.thinking { + opacity: 0.75; + transition: opacity var(--animation-speed-normal) var(--animation-timing-function), + filter var(--animation-speed-normal) var(--animation-timing-function); + filter: saturate(0.6); +} + +.message.thinking .message-content { + color: var(--text-secondary); + font-style: italic; + transition: color var(--animation-speed-normal) var(--animation-timing-function), + font-style var(--animation-speed-normal) var(--animation-timing-function); +} + +/* Transition between states */ +.message.thinking.streaming, +.message.thinking.completed { + opacity: 1; + filter: none; + transition: opacity var(--animation-speed-normal) var(--animation-timing-function), + filter var(--animation-speed-normal) var(--animation-timing-function); +} + +.message.thinking.streaming .message-content, +.message.thinking.completed .message-content { + color: var(--text-primary); + font-style: normal; + transition: color var(--animation-speed-normal) var(--animation-timing-function), + font-style var(--animation-speed-normal) var(--animation-timing-function); +} + +/* Completion animation */ +.message.completed .message-content { + position: relative; + transition: transform var(--animation-speed-normal) + var(--animation-timing-function); +} + +/* Enhanced animations for message transitions */ +.message.bot-message { + transform-origin: left top; +} + +.message.user-message { + transform-origin: right bottom; +} + +/* Staggered message appearance */ +.message:nth-child(odd) { + animation-delay: calc(var(--message-fade-in-delay) * 1); +} + +.message:nth-child(even) { + animation-delay: calc(var(--message-fade-in-delay) * 2); +} + +/* Removed completion flash to avoid visual flashing when AI response finishes */ + +@keyframes blink-cursor { + 0%, + 100% { + opacity: 0; + } + 50% { + opacity: 1; + } +} + +/* Media Queries for Responsive Design */ +@media (max-width: 768px) { + .app-container { + max-width: 100%; + } + + header { + padding: 10px 15px; + } + + header h1 { + font-size: 1.1rem; + } + + header p { + font-size: 0.7rem; + } + + .message { + max-width: 90%; + padding: 12px 15px; + } + + .input-area { + padding: 12px 15px; + } + + .message-input { + padding: 12px 15px; + } + + .send-button { + width: 40px; + height: 40px; + } +} + +/* Markdown styling */ +.message-content code { + background-color: rgba(0, 0, 0, 0.05); + padding: 2px 5px; + border-radius: 3px; + font-family: "Consolas", "Monaco", monospace; + font-size: 0.9em; +} + +.message-content pre { + background-color: rgba(0, 0, 0, 0.05); + border-radius: 4px; + padding: 8px 12px; + overflow-x: auto; + margin: 10px 0; + border-left: 3px solid var(--primary-color); +} + +.message-content pre code { + background-color: transparent; + padding: 0; + font-family: "Consolas", "Monaco", monospace; + font-size: 0.85em; + color: #333; + display: block; + line-height: 1.5; +} + +.message-content strong { + font-weight: 600; +} + +.message-content em { + font-style: italic; +} + +.message-content a { + color: var(--primary-color); + text-decoration: underline; + text-decoration-style: dotted; +} + +.message-content a:hover { + text-decoration-style: solid; +} + +/* Spinner animation for loading states */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.message-content li { + margin-left: 20px; + display: list-item; + list-style-type: disc; + margin-bottom: 2px; +} + +/* ========== Typing Indicator Styles ========== */ +.typing-indicator { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 28px 4px 32px; + font-size: 13px; + color: #6b7280; + font-style: italic; +} + +.typing-text { + color: #6b7280; +} + +.typing-dots { + display: inline-flex; + align-items: center; + gap: 3px; + margin-right: 5px; +} + +.typing-dots .dot { + width: 5px; + height: 5px; + border-radius: 50%; + background-color: #6b7280; + animation: typing-bounce 1.4s infinite ease-in-out; +} + +.typing-dots .dot:nth-child(1) { + animation-delay: 0s; +} + +.typing-dots .dot:nth-child(2) { + animation-delay: 0.2s; +} + +.typing-dots .dot:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes typing-bounce { + 0%, 60%, 100% { + transform: translateY(0); + opacity: 0.7; + } + 30% { + transform: translateY(-5px); + opacity: 1; + } +} + +/* ========== Rich Text Editor Container (Teams-like) ========== */ +.rich-text-input-area { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; + box-sizing: border-box; + flex-shrink: 0; +} + +/* All rich text editor styles are now in RichTextEditor.teams.css */ diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/lib/constants.ts b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/lib/constants.ts new file mode 100644 index 000000000..7bcaa91bd --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/lib/constants.ts @@ -0,0 +1,4 @@ +export const DEFAULT_ROOM_ID = "rid_public" as const; +export const DEFAULT_ROOM_NAME = "Public Room" as const; +export const GLOBAL_METADATA_ROOM_NAME = "GLOBAL_METADATA" as const; +export const GLOBAL_METADATA_ROOM_ID = "rid_global_metadata" as const; \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/main.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/main.tsx new file mode 100644 index 000000000..c0593d7ae --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import { ChatSettingsProvider } from './providers/ChatSettingsProvider' +import { ChatClientProvider } from './providers/ChatClientProvider' +import { ChatApp } from './components/ChatApp' + +createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/providers/ChatClientProvider.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/providers/ChatClientProvider.tsx new file mode 100644 index 000000000..89816099b --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/providers/ChatClientProvider.tsx @@ -0,0 +1,802 @@ +import React, { useContext } from "react"; +import type { ReactNode } from "react"; +import { ChatClient } from "@azure/web-pubsub-chat-client" +import { ChatClientContext } from "../contexts/ChatClientContext"; +import type { ChatMessage, ConnectionStatus, OnlineStatus, TypingStatus } from "../contexts/ChatClientContext"; +import { messagesReducer, initialMessagesState } from "../reducers/messagesReducer"; +import type { MessagesAction } from "../reducers/messagesReducer"; +import { ChatSettingsContext, type RoomMetadata } from "../contexts/ChatSettingsContext"; +import { DEFAULT_ROOM_ID, GLOBAL_METADATA_ROOM_ID } from "../lib/constants"; +import { LoginDialog } from "../components/LoginDialog"; + +// Feature flag for ephemeral messages (ping, typing indicators) +// When disabled, all users will appear as online by default +const ENABLE_EPHEMERAL_MESSAGES = true; + +// Online status configuration +const PING_INTERVAL_MS = 2500; // Send ping every X seconds +const OFFLINE_TIMEOUT_MS = 2 * PING_INTERVAL_MS; // Mark as offline if no ping received within 20 seconds + +// Typing status configuration +const TYPING_TIMEOUT_MS = 3000; // Mark as not typing if no typing indicator received within 3 seconds + +interface ChatClientProviderProps { + children: ReactNode; +} +// Using relative paths: negotiate endpoint is /negotiate, API under /api + +export const ChatClientProvider: React.FC = ({ children }) => { + const settingsContext = useContext(ChatSettingsContext); + + const [client, setClient] = React.useState(null); + const clientRef = React.useRef(null); // Add ref for stable reference + const [connectionStatus, setConnectionStatus] = React.useState({ + status: "disconnected", + message: "Not connected", + }); + const [messages, dispatch] = React.useReducer(messagesReducer, initialMessagesState); + // Unified per-room state map (messages, streaming flag, fetch seq + loaded) + interface RoomState { + messages: ChatMessage[]; + isStreaming: boolean; + lastFetchSeq: number; // reconnect sequence when last fetched + loaded: boolean; // whether initial history fetched in this connection + } + const roomStatesRef = React.useRef>(new Map()); + const [uiNotice, setUiNotice] = React.useState<{ type: "info" | "error"; text: string } | undefined>(undefined); + const setUiNoticeRef = React.useRef(setUiNotice); + React.useEffect(() => { + setUiNoticeRef.current = setUiNotice; + }, []); + // Unread message counts per room + const [unreadCounts, setUnreadCounts] = React.useState>({}); + // Force re-render trigger for room message updates + const [roomMessagesUpdateTrigger, setRoomMessagesUpdateTrigger] = React.useState(0); + const [roomMembersUpdateTrigger, setRoomMembersUpdateTrigger] = React.useState(0); + // reconnectSeq increments on each (re)connection so we can trigger refetch logic per roomState + const [reconnectSeq, setReconnectSeq] = React.useState(0); + // Refs to guard against double-initialize within the same tick and across effect re-runs + const initStartedRef = React.useRef(false); + const connectingRef = React.useRef(false); + // Login dialog state + const [isLoginDialogOpen, setIsLoginDialogOpen] = React.useState(false); + const [isLoggingIn, setIsLoggingIn] = React.useState(false); + + // Online status management + const [onlineStatus, setOnlineStatus] = React.useState({}); + const pingIntervalRef = React.useRef(null); + const onlineCheckIntervalRef = React.useRef(null); + + // Typing status management + const [typingStatus, setTypingStatus] = React.useState({}); + const typingCheckIntervalRef = React.useRef(null); + + // Success notification management + const [successNotification, setSuccessNotificationInternal] = React.useState(""); + + // Wrapper to track all calls + const setSuccessNotification = React.useCallback((value: string) => { + console.log('[Provider] setSuccessNotification called with:', value, 'stack:', new Error().stack?.split('\n').slice(1, 4).join(' <- ')); + setSuccessNotificationInternal(value); + }, []); + + if (!settingsContext) { + throw new Error("ChatClientProvider must be used within ChatSettingsProvider"); + } + + const { roomId, rooms, userId, setUserId, setRoomId } = settingsContext; + // Keep setter refs stable to avoid capturing stale closures in event handlers + const setUserIdRef = React.useRef(setUserId); + React.useEffect(() => { + setUserIdRef.current = setUserId; + }, [setUserId]); + const setRoomsRef = React.useRef(settingsContext.setRooms); + React.useEffect(() => { + setRoomsRef.current = settingsContext.setRooms; + }, [settingsContext.setRooms]); + const setRoomIdRef = React.useRef(setRoomId); + React.useEffect(() => { + setRoomIdRef.current = setRoomId; + }, [setRoomId]); + + // Refs for latest values (to avoid reconnections) + const roomIdRef = React.useRef(roomId); + const userIdRef = React.useRef(userId); + const roomsRef = React.useRef(rooms); + + // Update refs when values change + React.useEffect(() => { + roomIdRef.current = roomId; + }, [roomId]); + + React.useEffect(() => { + roomsRef.current = rooms; + }, [rooms]); + + React.useEffect(() => { + userIdRef.current = userId; + }, [userId]); + + // On room change, immediately swap the visible message list to the new room's cache (if any) + // or clear it so messages from the previous room never visually "bleed" into the next room. + React.useEffect(() => { + if (!roomId) return; + const rs = roomStatesRef.current.get(roomId); + if (rs && rs.messages.length > 0) { + dispatch({ type: "setAll", payload: rs.messages }); + } else { + dispatch({ type: "clear" }); + } + if (rs) rs.isStreaming = false; // reset streaming flag when switching + + // Clear unread count for the newly active room + setUnreadCounts(prev => { + if (prev[roomId] > 0) { + const updated = { ...prev }; + delete updated[roomId]; + return updated; + } + return prev; + }); + }, [roomId]); + + // Helper: ensure a room state object exists + const ensureRoomState = React.useCallback((id: string): RoomState => { + let rs = roomStatesRef.current.get(id); + if (!rs) { + rs = { messages: [], isStreaming: false, lastFetchSeq: -1, loaded: false }; + roomStatesRef.current.set(id, rs); + } + return rs; + }, []); + + // Helper: apply a messages action to a specific room (by id/group), + // updating the offscreen cache and, if it's the active room, the UI reducer. + const updateRoomMessages = React.useCallback((targetRoomId: string | undefined, action: MessagesAction) => { + const roomKey = targetRoomId || roomIdRef.current || DEFAULT_ROOM_ID; + const rs = ensureRoomState(roomKey); + const prev = rs.messages; + const next = messagesReducer(prev, action); + rs.messages = next; + + // Trigger re-render for room list sorting when messages are updated + if (action.type === "completeMessage" || action.type === "userMessage" || action.type === "streamEnd") { + setRoomMessagesUpdateTrigger(prev => prev + 1); + } + + // Update unread count if this is not the current active room and it's a new message + // Don't count system messages as unread + const isCurrentRoom = roomKey === roomIdRef.current; + const isSystemMessage = action.type === "completeMessage" && action.payload?.isSystemMessage; + if (!isCurrentRoom && !isSystemMessage && (action.type === "completeMessage" || action.type === "streamEnd")) { + setUnreadCounts(prevCounts => ({ + ...prevCounts, + [roomKey]: (prevCounts[roomKey] || 0) + 1 + })); + } + + // Maintain streaming flag heuristics local to the room + switch (action.type) { + case "streamChunk": + rs.isStreaming = true; + break; + case "addPlaceholder": + rs.isStreaming = true; // lock UI while waiting for first chunk + break; + case "streamEnd": + case "completeMessage": + case "clear": + rs.isStreaming = false; + break; + default: + break; + } + if (roomKey === roomIdRef.current) { + dispatch(action); + } + }, [ensureRoomState]); + + // Helper: fetch history for a specific room + const fetchRoomHistory = React.useCallback(async (client: ChatClient, targetRoomId: string, skipGlobalMetadata: boolean = true) => { + // Skip global metadata room + if (skipGlobalMetadata && targetRoomId === GLOBAL_METADATA_ROOM_ID) return; + + const rs = ensureRoomState(targetRoomId); + if (rs.loaded && rs.lastFetchSeq >= reconnectSeq) { + console.log(`Room ${targetRoomId} history already loaded`); + return; + } + + try { + console.log(`Fetching history for room: ${targetRoomId}`); + const roomHistory = await client.listRoomMessage(targetRoomId, null, null, 100); + console.log("fetchRoomHistory result:", roomHistory); + const mapped: ChatMessage[] = (roomHistory.messages.reverse() ?? []).map((m) => { + const rawFrom = (m.createdBy && String(m.createdBy).trim().length > 0) ? m.createdBy : undefined; + const sender = rawFrom ?? "Unknown sender"; + return { + id: String(m.messageId ?? Date.now() + Math.random()), + content: String(m.content?.text ?? ""), + sender, + timestamp: m.createdAt ?? new Date().toISOString(), + isFromCurrentUser: rawFrom !== undefined && rawFrom === userIdRef.current, + isAcked: true, + } as ChatMessage; + }); + + rs.messages = mapped; + rs.loaded = true; + rs.lastFetchSeq = reconnectSeq; + rs.isStreaming = false; + + // If this is the currently active room, update the UI + if (roomIdRef.current === targetRoomId) { + dispatch({ type: "setAll", payload: mapped }); + } + + console.log(`Loaded ${mapped.length} messages for room ${targetRoomId}`); + } catch (e) { + console.log(`Failed to fetch history for room ${targetRoomId}:`, e); + } + }, [ensureRoomState, reconnectSeq]); + + // Helper: fetch history for all rooms + const fetchAllRoomsHistory = React.useCallback(async (client: ChatClient, rooms: { roomId: string }[]) => { + console.log(`Fetching history for ${rooms.length} rooms`); + const fetchPromises = rooms.map(room => fetchRoomHistory(client, room.roomId)); + await Promise.allSettled(fetchPromises); + console.log('Finished fetching all room histories'); + }, [fetchRoomHistory]); + + // Send message function + const sendMessage = React.useCallback( + async (messageText: string) => { + console.log(`sendMessage for client, message = ${messageText}, roomIdRef = ${roomIdRef}, client = `, client); + if (!client || !messageText.trim()) return; + + // Add user message with isAcked=false + const userMessageId = Date.now().toString(); + updateRoomMessages(roomIdRef.current, { type: "userMessage", payload: { id: userMessageId, content: messageText, userId: userIdRef.current ?? "" } }); + + try { + const sent = await client.sendToRoom(roomIdRef.current, messageText); + console.log(`Successfully sendToRoom, roomId = ${roomIdRef.current}, messageId = ${sent}`); + + // Mark message as acknowledged after successful send + updateRoomMessages(roomIdRef.current, { type: "updateMessageAck", payload: { messageId: userMessageId, isAcked: true } }); + } catch (err: unknown) { + const msg = `Error sending message: ${err instanceof Error ? err.message : "Unknown error"}`; + setUiNoticeRef.current({ type: "error", text: msg }); + // Message remains unacknowledged (isAcked=false) on error + } + }, + [client, updateRoomMessages], + ); // Only depend on client + + const clearMessages = React.useCallback(() => { + const activeRoom = roomIdRef.current || DEFAULT_ROOM_ID; + const rs = ensureRoomState(activeRoom); + rs.messages = []; + rs.isStreaming = false; + dispatch({ type: "clear" }); + }, [ensureRoomState]); + + // ---------------------- message helpers ---------------------- + + // Initialize client ONCE on mount - no reconnections needed + React.useEffect(() => { + const initializeClient = async () => { + // Synchronous + ref guards to avoid re-entry even under StrictMode + if (connectingRef.current || initStartedRef.current) return; + + // Show login dialog if no userId is set + if (!userId) { + setIsLoginDialogOpen(true); + return; + } + + connectingRef.current = true; + // Keep state writes minimal to avoid retriggers + + try { + setConnectionStatus({ status: "connecting", message: "Connecting..." }); + setUiNoticeRef.current(undefined); + + // Stop existing client if any + if (clientRef.current) { + try { + await clientRef.current.stop(); + } catch (err) { + console.error("Error stopping previous client:", err); + } + } + + // Use the userId from context (set via login dialog) + // Create new client with initial roomId; no user id) + const chatClient = new ChatClient({ + getClientAccessUrl: async () => { + const url = `/api/negotiate?userId=${userId}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Negotiation failed: ${response.statusText}`); + } + const body = (await response.json()) as { url?: string }; + if (!body?.url || typeof body.url !== 'string') { + throw new Error('Negotiation failed: invalid response shape'); + } + return body.url; + }, + }); + + // Assign clientRef before starting to prevent parallel starts from racing + // const newChatClient = new ChatClient(newClient); //await ChatClient.login(newClient); + // Set up event listeners using refs for latest values + chatClient.onConnected((e: { connectionId: string; userId?: string }) => { + setConnectionStatus({ + status: "connected", + message: "Connected", + connectionId: e.connectionId, + userId: e.userId, + }); + // If the event includes a userId, store it in settings + const evtUserId = e?.userId; + if (typeof evtUserId === "string" && evtUserId.length > 0) { + setUserIdRef.current?.(evtUserId); + } + // Server auto-joins the negotiated room; no client-side tracking needed + // mark reconnection token to allow one-time refetch for current room + setReconnectSeq((s) => s + 1); + // reset loaded flags per room on new connection + for (const rs of roomStatesRef.current.values()) { + rs.loaded = false; + } + }); + + // No additional listeners needed; userId is set via connected event above if provided + chatClient.onDisconnected(() => { + setConnectionStatus({ + status: "disconnected", + message: `Disconnected: Connection closed`, + }); + setUiNoticeRef.current({ type: "error", text: "Disconnected: Connection closed" }); + }); + + + chatClient.addListenerForNewMessage((notification) => { + console.log("New message notification:", notification); + const message = notification.message; + console.log(`Received new message from ${message.createdBy}, content = ${message.content?.text}, isSelf = ${message.createdBy === chatClient.userId}`); + + // Handle ping messages for online status + if (notification.conversation.roomId === GLOBAL_METADATA_ROOM_ID && message.content?.text === "ping") { + if (message.createdBy) { + setOnlineStatus(prev => { + const updated = { + ...prev, + [message.createdBy!]: { + isOnline: true, + lastSeen: Date.now() + } + }; + return updated; + }); + } + return; // Don't show ping messages in the UI + } + + // Handle typing indicator messages + // Format: "typing:roomId" + if (notification.conversation.roomId === GLOBAL_METADATA_ROOM_ID && message.content?.text?.startsWith("typing:")) { + const targetRoomId = message.content.text.substring(7); // Remove "typing:" prefix + if (message.createdBy && message.createdBy !== chatClient.userId) { + const visitorKey = `${targetRoomId}:${message.createdBy}`; + setTypingStatus(prev => ({ + ...prev, + [visitorKey]: { + isTyping: true, + lastTyping: Date.now() + } + })); + } + return; // Don't show typing messages in the UI + } + + if (message.createdBy === chatClient.userId) return ; + updateRoomMessages(notification.conversation.roomId!, { type: "completeMessage", payload: { + messageId: message.messageId, + content: message.content?.text || "", + sender: message.createdBy || "Unknown Sender", + isFromCurrentUser: false + } }); + }); + + chatClient.addListenerForNewRoom((room) => { + console.log('New room created/joined:', room); + + // Skip global metadata room - it should never appear in the sidebar + if (room.roomId === GLOBAL_METADATA_ROOM_ID) { + return; + } + + // Check if room already exists (means we created it ourselves) + const existingRoom = roomsRef.current.find(r => r.roomId === room.roomId); + + if (!existingRoom) { + // Only add to list if not already present (we were added by someone else) + setRoomsRef.current([...roomsRef.current, { + roomId: room.roomId, + roomName: room.title, + userId: chatClient.userId || "unknown" + }]); + + // Only show notification and system message if we were added by someone else + // (not if we created the room ourselves) + const isPrivateChat = room.roomId.startsWith('private-'); + let notificationText: string; + let systemMessageText: string; + + if (isPrivateChat) { + // Extract other user from private room ID (format: private-user1-user2) + const parts = room.roomId.split('-'); + const otherUser = parts[1] === chatClient.userId ? parts[2] : parts[1]; + notificationText = `You started a private chat with ${otherUser}`; + systemMessageText = `Private chat with ${otherUser}`; + } else { + notificationText = `You have been added to room: ${room.title}`; + systemMessageText = `You joined "${room.title}"`; + } + + // Add a system message to trigger room sorting (new room goes to top) + updateRoomMessages(room.roomId, { + type: "completeMessage", + payload: { + messageId: `system-joined-${room.roomId}-${Date.now()}`, + content: systemMessageText, + sender: "System", + isFromCurrentUser: false, + isSystemMessage: true + } + }); + + // Show UI notification + console.log(`User ${chatClient.userId} has been added to room: ${room.title}`); + setUiNoticeRef.current({ type: "info", text: `🎉 ${notificationText}` }); + setSuccessNotification(notificationText); + } + + // Fetch history for the new room + fetchRoomHistory(chatClient, room.roomId).catch(err => { + console.error(`Failed to fetch history for new room ${room.roomId}:`, err); + }); + }); + + chatClient.addListenerForMemberJoined((notification) => { + console.log('Member joined notification:', notification); + const {roomId, userId, title} = notification; + // Skip notifications for global metadata room + if (roomId === GLOBAL_METADATA_ROOM_ID) return; + // Show success notification banner + setSuccessNotification(`User ${userId} has joined room: ${title}`); + + // Trigger room members refresh (not messages) + setRoomMembersUpdateTrigger(prev => prev + 1); + }); + + chatClient.addListenerForMemberLeft((notification) => { + console.log('Member left notification:', notification); + const {roomId, userId, title} = notification; + // Skip notifications for global metadata room + if (roomId === GLOBAL_METADATA_ROOM_ID) return; + // Show success notification banner + setSuccessNotification(`User ${userId} has left room: ${title}`); + + // Trigger room members refresh (not messages) + setRoomMembersUpdateTrigger(prev => prev + 1); + }); + + chatClient.addListenerForRoomLeft((notification) => { + console.log('Room left notification (you were removed):', notification); + const {roomId, title} = notification; + + // Remove room from the rooms list + setRoomsRef.current(roomsRef.current.filter(r => r.roomId !== roomId)); + + // If currently viewing this room, switch to default room + if (roomIdRef.current === roomId) { + setRoomIdRef.current(DEFAULT_ROOM_ID); + } + + // Show UI notification + setUiNoticeRef.current({ type: "info", text: `🚪 You have been removed from room: ${title}` }); + setSuccessNotification(`You have been removed from room: ${title}`); + }); + + await chatClient.login(); + + // const initRooms = [ + // {id: DEFAULT_ROOM_ID, name: DEFAULT_ROOM_NAME}, + // {id: `private-${userId}-${userId}`, name: `${userId} (You)`}, + // ]; + // for (const r of initRooms) { + // await chatClient.createRoom(r.name, [], r.id) + // .then((room) => { console.log('newly created room:', room); }) + // .catch(async (createErr) => { + // console.log('failed to create roomId: ', r.id, 'error:', createErr); + // console.log("try to add user to existing room", r.id, "userId:", userId); + // // If room already exists, add current user to it + // return await chatClient.addUserToRoom(r.id, userId); + // }) + // .catch((addErr) => { console.log('failed to add user to default room:', addErr); }); + // }; + + // Rooms are already created by server, just use chatClient.rooms from login + const roomMetadatas: RoomMetadata[] = chatClient.rooms + .filter(r => r.roomId !== GLOBAL_METADATA_ROOM_ID) // Hide global metadata room from UI + .map(r => ({ roomId: r.roomId, roomName: r.title, userId: "unknown" })); + setRoomsRef.current(roomMetadatas); + setRoomIdRef.current(DEFAULT_ROOM_ID); + + clientRef.current = chatClient; + + setClient(chatClient); + setUserIdRef.current(chatClient.userId); + + console.log(`chat client connected, userId = ${chatClient.userId}`); + + // Fetch history for all rooms after initialization + fetchAllRoomsHistory(chatClient, chatClient.rooms).catch(err => { + console.error('Failed to fetch all room histories:', err); + }); + + // Mark initialized via ref only + initStartedRef.current = true; + + // Send initial ping immediately to announce user is online (only if ephemeral messages are enabled) + if (ENABLE_EPHEMERAL_MESSAGES) { + chatClient.sendToRoom(GLOBAL_METADATA_ROOM_ID, "ping").catch((err) => { + console.error("Failed to send initial ping:", err); + }); + + // Start ping interval for online status + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + } + pingIntervalRef.current = setInterval(() => { + if (clientRef.current) { + clientRef.current.sendToRoom(GLOBAL_METADATA_ROOM_ID, "ping").catch((err) => { + console.error("Failed to send ping:", err); + }); + } + }, PING_INTERVAL_MS); + } + + // Start online status check interval (every 5 seconds) - only if ephemeral messages are enabled + if (ENABLE_EPHEMERAL_MESSAGES) { + if (onlineCheckIntervalRef.current) { + clearInterval(onlineCheckIntervalRef.current); + } + onlineCheckIntervalRef.current = setInterval(() => { + const now = Date.now(); + setOnlineStatus(prev => { + const updated = { ...prev }; + let hasChanges = false; + + for (const [userId, status] of Object.entries(updated)) { + // Mark as offline if no ping received within configured timeout + if (status.isOnline && now - status.lastSeen > OFFLINE_TIMEOUT_MS) { + updated[userId] = { ...status, isOnline: false }; + hasChanges = true; + } + } + + return hasChanges ? updated : prev; + }); + }, 5000); + + // Start typing status check interval (every 1 second for responsiveness) + if (typingCheckIntervalRef.current) { + clearInterval(typingCheckIntervalRef.current); + } + typingCheckIntervalRef.current = setInterval(() => { + const now = Date.now(); + setTypingStatus(prev => { + const updated = { ...prev }; + let hasChanges = false; + + for (const [visitorKey, status] of Object.entries(updated)) { + // Mark as not typing if no typing indicator received within configured timeout + if (status.isTyping && now - status.lastTyping > TYPING_TIMEOUT_MS) { + updated[visitorKey] = { ...status, isTyping: false }; + hasChanges = true; + } + } + + return hasChanges ? updated : prev; + }); + }, 1000); + } + } catch (err: unknown) { + const msg = `Connection Failed: ${err instanceof Error ? err.message : "Unknown error"}`; + setConnectionStatus({ status: "error", message: msg }); + setUiNoticeRef.current({ type: "error", text: msg }); + // remain not initialized so we can retry later + } finally { + connectingRef.current = false; + } + }; + + // Kick off initialization; guards above ensure single start (even under StrictMode) + initializeClient(); + + // Cleanup function to prevent multiple connections + return () => { + // Clear intervals + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + if (onlineCheckIntervalRef.current) { + clearInterval(onlineCheckIntervalRef.current); + onlineCheckIntervalRef.current = null; + } + if (typingCheckIntervalRef.current) { + clearInterval(typingCheckIntervalRef.current); + typingCheckIntervalRef.current = null; + } + + if (clientRef.current) { + try { + clientRef.current.stop(); + } catch (error) { + console.error("Error stopping client:", error); + } + clientRef.current = null; + } + }; + }, [userId, fetchRoomHistory, fetchAllRoomsHistory, updateRoomMessages]); // Add new dependencies + + // Handle room changes - just switch displayed messages, no need to fetch history + React.useEffect(() => { + if (!roomId) return; + + const rs = roomStatesRef.current.get(roomId); + if (rs && rs.messages.length > 0) { + dispatch({ type: "setAll", payload: rs.messages }); + rs.isStreaming = false; + } else { + dispatch({ type: "clear" }); + } + + // Clear unread count for the newly active room + setUnreadCounts(prev => { + if (prev[roomId] > 0) { + const updated = { ...prev }; + delete updated[roomId]; + return updated; + } + return prev; + }); + }, [roomId]); + + // Inline status banner rules: show info when connected and empty; clear when messages arrive + React.useEffect(() => { + if (connectionStatus.status === "connected" && messages.length === 0) { + const next = { type: "info" as const, text: "You're connected. Say hi to start the conversation." }; + // Only set if it's not already the same notice to prevent render loops + if (!(uiNotice && uiNotice.type === "info" && uiNotice.text === next.text)) { + setUiNotice(next); + } + } else if (messages.length > 0) { + // Clear info notice once we have conversation + if (uiNotice?.type === "info") setUiNotice(undefined); + } + }, [connectionStatus.status, messages.length, uiNotice]); + + // Derive isStreaming from messages if available; fallback to state for transient UI control + const isStreaming = React.useMemo(() => { + const activeRoom = roomIdRef.current || roomId || DEFAULT_ROOM_ID; + const rs = roomStatesRef.current.get(activeRoom); + if (!rs) return false; + if (rs.isStreaming) return true; + return rs.messages.some((m) => m.streaming); + }, [roomId]); + + // Handle login dialog submission + const handleLogin = React.useCallback( + async (inputUserId: string, _password: string) => { + setIsLoggingIn(true); + try { + // Here you can add authentication logic if needed + // For now, we'll just accept the userId + setUserIdRef.current(inputUserId); + setIsLoginDialogOpen(false); + } catch (err: unknown) { + const msg = `Login Failed: ${err instanceof Error ? err.message : "Unknown error"}`; + setUiNoticeRef.current({ type: "error", text: msg }); + } finally { + setIsLoggingIn(false); + } + }, + [], // No dependencies needed since we use refs + ); + + // Helper function to get the last message for a room + // For preview display, skip system messages; for sorting, include all messages + const getLastMessageForRoom = React.useCallback((roomId: string, includeSystemMessages: boolean = false): ChatMessage | null => { + const rs = roomStatesRef.current.get(roomId); + if (!rs || rs.messages.length === 0) { + return null; + } + + // If including system messages, just return the last one + if (includeSystemMessages) { + return rs.messages[rs.messages.length - 1]; + } + + // Otherwise, find the last non-system message for preview + for (let i = rs.messages.length - 1; i >= 0; i--) { + if (!rs.messages[i].isSystemMessage) { + return rs.messages[i]; + } + } + return null; + }, []); + + // Send typing indicator to a specific room (only if ephemeral messages are enabled) + const sendTypingIndicator = React.useCallback((targetRoomId: string) => { + if (!ENABLE_EPHEMERAL_MESSAGES) return; // Skip if ephemeral messages are disabled + if (clientRef.current && targetRoomId) { + clientRef.current.sendToRoom(GLOBAL_METADATA_ROOM_ID, `typing:${targetRoomId}`).catch((err) => { + console.error("Failed to send typing indicator:", err); + }); + } + }, []); + + // Get list of users who are typing in a specific room + const getTypingUsersForRoom = React.useCallback((targetRoomId: string): string[] => { + const typingUsers: string[] = []; + for (const [visitorKey, status] of Object.entries(typingStatus)) { + if (status.isTyping) { + const [roomId, visitorUserId] = visitorKey.split(':'); + if (roomId === targetRoomId) { + typingUsers.push(visitorUserId); + } + } + } + return typingUsers; + }, [typingStatus]); + + const value = React.useMemo( + () => ({ + client, + connectionStatus, + messages, + isStreaming, + sendMessage, + clearMessages, + uiNotice, + unreadCounts, + getLastMessageForRoom, + roomMessagesUpdateTrigger, + roomMembersUpdateTrigger, + onlineStatus, + typingStatus, + sendTypingIndicator, + getTypingUsersForRoom, + successNotification, + setSuccessNotification, + ephemeralMessagesEnabled: ENABLE_EPHEMERAL_MESSAGES, + }), + [client, connectionStatus, messages, isStreaming, sendMessage, clearMessages, uiNotice, unreadCounts, getLastMessageForRoom, roomMessagesUpdateTrigger, roomMembersUpdateTrigger, onlineStatus, typingStatus, sendTypingIndicator, getTypingUsersForRoom, successNotification], + ); + return ( + <> + + {children} + + ); +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/providers/ChatRoomProvider.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/providers/ChatRoomProvider.tsx new file mode 100644 index 000000000..053ea0cb0 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/providers/ChatRoomProvider.tsx @@ -0,0 +1,28 @@ +import React, { useContext, useMemo } from 'react'; +import type { ReactNode } from 'react'; +import { ChatRoomContext, type ChatRoom } from '../contexts/ChatRoomContext'; +import { ChatSettingsContext } from '../contexts/ChatSettingsContext'; + +interface ChatRoomProviderProps { + children: ReactNode; +} + +export const ChatRoomProvider: React.FC = ({ children }) => { + const settings = useContext(ChatSettingsContext); + if (!settings) throw new Error('ChatRoomProvider must be used within ChatSettingsProvider'); + + const { roomId, rooms } = settings; + const room: ChatRoom | null = useMemo(() => { + if (!roomId) return null; + const meta = rooms.find(r => r.roomId === roomId); + return { id: roomId, name: meta?.roomName || roomId }; + }, [roomId, rooms]); + + const value = useMemo(() => ({ room }), [room]); + + return ( + + {children} + + ); +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/providers/ChatSettingsProvider.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/providers/ChatSettingsProvider.tsx new file mode 100644 index 000000000..53e7b8f8d --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/providers/ChatSettingsProvider.tsx @@ -0,0 +1,133 @@ +import React from "react"; +import type { ReactNode } from "react"; +import { ChatSettingsContext } from "../contexts/ChatSettingsContext"; +import type { RoomMetadata } from "../contexts/ChatSettingsContext"; +import { DEFAULT_ROOM_ID } from "../lib/constants"; +import { setSelectedRoom } from "../utils/storage"; +import type { ChatClient } from "@azure/web-pubsub-chat-client"; + +interface ChatSettingsProviderProps { + children: ReactNode; +} + +export const ChatSettingsProvider: React.FC = ({ children }) => { + // const [roomId, setRoomId] = React.useState(() => getSelectedRoom() ?? DEFAULT_ROOM_ID); + const [roomId, setRoomId] = React.useState(() => ""); + const [rooms, setRooms] = React.useState([]); + const [userId, setUserId] = React.useState(""); + + // Persist selected room to localStorage + React.useEffect(() => { + setSelectedRoom(roomId); + }, [roomId]); + + // Add a new room via API + const addRoom = React.useCallback( + async (client: ChatClient, roomName: string, memberIds: string[] = [], roomId: string | undefined = undefined): Promise => { + console.log(`client.createRoom, title: ${roomName}, id: ${roomId}, memberIds: [${memberIds.join(", ")}], client: `, client); + + return await client.createRoom(roomName, memberIds, roomId) + .then((newRoom) => { + setRoomId(newRoom.roomId); + return newRoom.roomId; + }) + .catch((error: Error) => { + console.error('AddRoomError:', error); + throw error; + }); + }, + [userId], + ); + + // Add user to an existing room via API (admin operation) + const addUserToRoom = React.useCallback( + async (client: ChatClient, roomIdToAdd: string, userId: string): Promise => { + try { + console.log(`client.addUserToRoom, roomId: ${roomIdToAdd}, userId: ${userId}, client: `, client); + + await client.addUserToRoom(roomIdToAdd, userId); + + console.log('client.addUserToRoom succeeded'); + + // Switch to the room after adding + setRoomId(roomIdToAdd); + } catch (error) { + console.error('Failed to add user to room:', error); + throw error; + } + }, + [setRoomId], + ); + + // Remove self from a room using the chat client + const removeRoom = React.useCallback( + async (client: ChatClient, roomIdToRemove: string): Promise => { + if (roomIdToRemove === DEFAULT_ROOM_ID) { + return; // Cannot remove default room + } + + try { + // Remove current user from the room + await client.removeUserFromRoom(roomIdToRemove, client.userId); + + setRooms((prev) => prev.filter((r) => r.roomId !== roomIdToRemove)); + if (roomId === roomIdToRemove) { + setRoomId(DEFAULT_ROOM_ID); + } + } catch (error) { + console.error('Failed to remove room:', error); + throw error; + } + }, + [roomId, setRoomId], + ); + + // Update a room via API + const updateRoom = React.useCallback( + async (roomIdToUpdate: string, roomName: string, description?: string): Promise => { + try { + const response = await fetch(`/api/rooms/${encodeURIComponent(roomIdToUpdate)}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-User-Id': userId, + }, + body: JSON.stringify({ + roomName: roomName.trim(), + description: description?.trim() || '', + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to update room'); + } + + const updatedRoom = await response.json() as unknown as RoomMetadata; + if (!updatedRoom || typeof updatedRoom !== 'object' || updatedRoom.roomId !== roomIdToUpdate) { + throw new Error('Invalid updated room metadata received'); + } + setRooms((prev) => prev.map((r) => (r.roomId === roomIdToUpdate ? updatedRoom : r))); + } catch (error) { + console.error('Failed to update room:', error); + throw error; + } + }, + [userId], + ); + + const value = { + roomId, + setRoomId, + rooms, + setRooms, + addRoom, + addUserToRoom, + removeRoom, + updateRoom, + userId, + setUserId, + }; + + return {children}; +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/reducers/messagesReducer.ts b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/reducers/messagesReducer.ts new file mode 100644 index 000000000..95bf8f236 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/reducers/messagesReducer.ts @@ -0,0 +1,160 @@ +import type { ChatMessage } from "../contexts/ChatClientContext"; + +// State is just the list of messages for now +export type MessagesState = ChatMessage[]; + +export type MessagesAction = + | { type: "clear" } + | { type: "welcome" } + | { type: "setAll"; payload: ChatMessage[] } + | { type: "userMessage"; payload: { id: string; content: string; userId: string } } + | { type: "updateMessageAck"; payload: { messageId: string; isAcked: boolean } } + | { type: "addPlaceholder" } + | { type: "streamChunk"; payload: { messageId: string; chunk: string; sender: string } } + | { type: "streamEnd"; payload: { messageId: string } } + | { type: "completeMessage"; payload: { messageId: string; content?: string; sender: string; isFromCurrentUser: boolean; isSystemMessage?: boolean } }; + +const nowIso = () => new Date().toISOString(); + +const findLastPlaceholderIndex = (arr: ChatMessage[]) => { + for (let i = arr.length - 1; i >= 0; i--) { + if (arr[i].isPlaceholder) return i; + } + return -1; +}; + +export const initialMessagesState: MessagesState = []; + +export function messagesReducer(state: MessagesState, action: MessagesAction): MessagesState { + switch (action.type) { + case "clear": + return []; + case "setAll": + return [...action.payload]; + case "welcome": { + if (state.length > 0) return state; + const welcome: ChatMessage = { + id: "welcome", + content: "Hello! I'm your AI assistant. How can I help you today?", + sender: "AI Assistant", + timestamp: nowIso(), + isFromCurrentUser: false, + }; + return [welcome]; + } + case "userMessage": { + const { id, content, userId } = action.payload; + const userMsg: ChatMessage = { + id, + content, + sender: userId, + timestamp: nowIso(), + isFromCurrentUser: true, + isAcked: false, // Initially not acknowledged + }; + return [...state, userMsg]; + } + case "updateMessageAck": { + const { messageId, isAcked } = action.payload; + return state.map(msg => + msg.id === messageId ? { ...msg, isAcked } : msg + ); + } + case "addPlaceholder": { + const thinking: ChatMessage = { + id: `pending-${Date.now()}`, + content: "Thinking...", + sender: "AI Assistant", + timestamp: nowIso(), + isFromCurrentUser: false, + streaming: true, + isPlaceholder: true, + }; + return [...state, thinking]; + } + case "streamChunk": { + const { messageId, chunk, sender } = action.payload; + const existingIndex = state.findIndex((m) => m.id === messageId); + if (existingIndex >= 0) { + const existing = state[existingIndex]; + const next = [...state]; + if (existing.isPlaceholder) { + next[existingIndex] = { ...existing, content: chunk || "", isPlaceholder: false }; + } else { + next[existingIndex] = { ...existing, content: (existing.content || "") + (chunk || "") }; + } + // ensure streaming flag while chunks are arriving + next[existingIndex].streaming = true; + return next; + } + const lastPh = findLastPlaceholderIndex(state); + if (lastPh !== -1) { + const next = [...state]; + next[lastPh] = { + ...next[lastPh], + id: messageId, + content: chunk || "", + isPlaceholder: false, + sender, + streaming: true, + } as ChatMessage; + return next; + } + return [ + ...state, + { + id: messageId || Date.now().toString(), + content: chunk || "", + sender, + timestamp: nowIso(), + isFromCurrentUser: false, + streaming: true, + } as ChatMessage, + ]; + } + case "streamEnd": { + const { messageId } = action.payload; + return state.map((m) => (m.id === messageId ? { ...m, streaming: false } : m)); + } + case "completeMessage": { + const { messageId, content, sender, isFromCurrentUser, isSystemMessage } = action.payload; + const existingIndex = state.findIndex((m) => m.id === messageId); + if (existingIndex >= 0) { + const next = [...state]; + next[existingIndex] = { + ...next[existingIndex], + content: content || "", + streaming: false, + isPlaceholder: false, + }; + return next; + } + const lastPh = findLastPlaceholderIndex(state); + if (lastPh !== -1) { + const next = [...state]; + next[lastPh] = { + ...next[lastPh], + id: messageId, + content: content || "", + isPlaceholder: false, + streaming: false, + sender, + } as ChatMessage; // keep existing isUser on placeholder (AI) + return next; + } + return [ + ...state, + { + id: messageId || Date.now().toString(), + content: content || "", + sender, + timestamp: nowIso(), + isFromCurrentUser, + isSystemMessage, + } as ChatMessage, + ]; + } + default: + return state; + } +} diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/utils/avatarUtils.ts b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/utils/avatarUtils.ts new file mode 100644 index 000000000..dd2a81a9a --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/utils/avatarUtils.ts @@ -0,0 +1,113 @@ +import React from 'react'; + +// Consistent color palette for avatars +const AVATAR_COLORS = [ + '#ef4444', // red + '#f97316', // orange + '#f59e0b', // amber + '#eab308', // yellow + '#84cc16', // lime + '#22c55e', // green + '#10b981', // emerald + '#14b8a6', // teal + '#06b6d4', // cyan + '#0ea5e9', // sky + '#3b82f6', // blue + '#6366f1', // indigo + '#8b5cf6', // violet + '#a855f7', // purple + '#d946ef', // fuchsia + '#ec4899', // pink + '#f43f5e' // rose +]; + +/** + * Generate a consistent color for a user based on their ID + * @param userId - The user ID to generate color for + * @returns The hex color string + */ +export const getAvatarColor = (userId: string): string => { + if (!userId) return '#6b7280'; // gray fallback + + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = userId.charCodeAt(i) + ((hash << 5) - hash); + } + + return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]; +}; + +/** + * Get initials from user ID (first character, uppercase) + * @param userId - The user ID to get initials from + * @returns The initials string + */ +export const getAvatarInitials = (userId: string): string => { + if (!userId) return 'U'; + return userId.charAt(0).toUpperCase(); +}; + +/** + * Avatar style options + */ +export interface AvatarStyleOptions { + size?: number; + fontSize?: number; + cursor?: string; + margin?: string; + flexShrink?: number; +} + +/** + * Generate consistent avatar styles using CSS custom properties + * @param userId - The user ID to generate styles for + * @param options - Style options + * @returns CSS style object with custom properties + */ +export const getAvatarStyle = (userId: string, options: AvatarStyleOptions = {}) => { + const { + size = 32, + fontSize = size * 0.4, + cursor = 'default', + margin = '0', + flexShrink = 0 + } = options; + + return { + '--avatar-size': `${size}px`, + '--avatar-bg-color': getAvatarColor(userId), + '--avatar-font-size': `${fontSize}px`, + '--avatar-cursor': cursor, + '--avatar-margin': margin, + '--avatar-flex-shrink': flexShrink, + } as React.CSSProperties; +}; + +/** + * Create a reusable avatar component + * @param userId - The user ID to create avatar for + * @param options - Style and behavior options + * @returns JSX Element + */ +export const createAvatar = ( + userId: string, + options: AvatarStyleOptions & { + title?: string; + onClick?: () => void; + onMouseEnter?: (e: React.MouseEvent) => void; + onMouseLeave?: (e: React.MouseEvent) => void; + } = {} +) => { + const { title, onClick, onMouseEnter, onMouseLeave, ...styleOptions } = options; + const style = getAvatarStyle(userId, styleOptions); + const initials = getAvatarInitials(userId); + + return React.createElement('div', { + style, + title: title || userId, + onClick, + onMouseEnter, + onMouseLeave, + children: initials + }); +}; \ No newline at end of file diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/utils/messageFormatting.ts b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/utils/messageFormatting.ts new file mode 100644 index 000000000..2d7e7c89e --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/utils/messageFormatting.ts @@ -0,0 +1,51 @@ +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + +// Configure marked options +marked.setOptions({ + breaks: true, + gfm: true, + silent: true +}); + +// Check if content appears to be HTML (from rich text editor) +const isHtmlContent = (content: string): boolean => { + // Check for common HTML tags that would come from the rich text editor + const htmlPattern = /<(div|span|p|br|b|i|u|s|strong|em|code|a|ul|ol|li|blockquote)[^>]*>/i; + return htmlPattern.test(content); +}; + +export const formatMessageContent = (content: string): string => { + if (!content) return ''; + + try { + // If content is HTML (from rich text editor), sanitize and return directly + if (isHtmlContent(content)) { + return DOMPurify.sanitize(content, { + ADD_ATTR: ['target'], + ALLOWED_TAGS: ['div', 'span', 'p', 'br', 'b', 'i', 'u', 's', 'strong', 'em', + 'code', 'pre', 'a', 'ul', 'ol', 'li', 'blockquote', 'h1', 'h2', 'h3'], + ALLOWED_ATTR: ['href', 'target', 'style', 'class'] + }); + } + + // For short content, use simpler processing for better performance + if (content.length < 100 && !content.includes('#') && !content.includes('```')) { + return DOMPurify.sanitize(content.replace(/\n/g, '
'), { + ADD_ATTR: ['target'] + }); + } + + // For longer content, use full markdown processing + const html = marked.parse(content) as string; + const sanitized = DOMPurify.sanitize(html, { + ADD_ATTR: ['target'] // Allow target="_blank" for links + }); + + return sanitized; + } catch (error) { + console.error('Error parsing markdown:', error); + // Fallback to basic text with linebreaks if parsing fails + return content.replace(/\n/g, '
'); + } +}; diff --git a/sdk/webpubsub-chat-client/examples/teams-lite/client/src/utils/sharedComponents.tsx b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/utils/sharedComponents.tsx new file mode 100644 index 000000000..bd0bc6e17 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/src/utils/sharedComponents.tsx @@ -0,0 +1,174 @@ +import React from 'react'; + +// 模态框组件 +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; +} + +export const Modal: React.FC = ({ isOpen, onClose, title, children }) => { + if (!isOpen) return null; + + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+
+
+

{title}

+
+
+ {children} +
+
+
+ ); +}; + +// 按钮组件 +interface ButtonProps { + variant?: 'primary' | 'secondary' | 'success'; + disabled?: boolean; + loading?: boolean; + onClick?: () => void; + children: React.ReactNode; + type?: 'button' | 'submit'; +} + +export const Button: React.FC = ({ + variant = 'primary', + disabled = false, + loading = false, + onClick, + children, + type = 'button' +}) => { + const isDisabled = disabled || loading; + + const getButtonClasses = () => { + const classes = ['btn']; + + switch (variant) { + case 'secondary': + classes.push('btn-secondary'); + break; + case 'success': + classes.push('btn-success'); + break; + case 'primary': + default: + classes.push('btn-primary'); + break; + } + + return classes.join(' '); + }; + + return ( + + ); +}; + +// 表单字段组件 +interface FormFieldProps { + label: string; + type: 'text' | 'password' | 'email' | 'textarea'; + value: string; + onChange: (value: string) => void; + placeholder?: string; + error?: string; + disabled?: boolean; +} + +export const FormField: React.FC = ({ + label, + type, + value, + onChange, + placeholder, + error, + disabled = false +}) => { + const handleChange = (e: React.ChangeEvent) => { + onChange(e.target.value); + }; + + const getInputClasses = () => { + const classes = ['form-input']; + if (error) classes.push('form-input-error'); + if (type === 'textarea') classes.push('form-textarea'); + return classes.join(' '); + }; + + return ( +
+ + {type === 'textarea' ? ( +