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/battleship/.yarnrc.yml b/sdk/webpubsub-chat-client/examples/battleship/.yarnrc.yml new file mode 100644 index 000000000..3b452cd5a --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/battleship/.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/battleship/README.md b/sdk/webpubsub-chat-client/examples/battleship/README.md new file mode 100644 index 000000000..98ea0f1be --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/battleship/README.md @@ -0,0 +1,37 @@ +# Battleship + +Multiplayer real-time naval combat. Each player gets a grid with randomly placed ships. Attack any opponent's grid — hits and misses appear instantly for all players. Last fleet standing wins. + +## How it works + +| Feature | Chat SDK API | +|---|---| +| Login | `new ChatClient(url)` + `login()` | +| Create game & invite players | `createRoom(title, playerList)` | +| Join game via invitation | `addListenerForNewRoom` | +| Deploy fleet / Fire at opponent | `sendToRoom(roomId, json)` | +| Real-time attack updates | `addListenerForNewMessage` | +| Restore game state on rejoin | `listRoomMessage` | +| See who joined | `addListenerForMemberJoined` | + +## Prerequisites + +1. An Azure Web PubSub resource +2. Node.js 18+ + +## Run + +```bash +cd examples/battleship +yarn install +node server.js "" +``` + +Open **multiple browser tabs** at `http://localhost:3000`, login as different users, and start a game. + +## How to Play + +- Ships are placed **randomly** when you join a game +- **Free-for-all**: no turns — click any cell on an opponent's board to attack +- A player is **eliminated** when all their ship cells are hit +- Last player alive **wins** diff --git a/sdk/webpubsub-chat-client/examples/battleship/package.json b/sdk/webpubsub-chat-client/examples/battleship/package.json new file mode 100644 index 000000000..6e0011b22 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/battleship/package.json @@ -0,0 +1,16 @@ +{ + "name": "battleship-example", + "version": "1.0.0", + "description": "Real-time multiplayer battleship 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/battleship/public/app.js b/sdk/webpubsub-chat-client/examples/battleship/public/app.js new file mode 100644 index 000000000..6d0ef7388 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/battleship/public/app.js @@ -0,0 +1,633 @@ +// 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'; + +// Constants +const GRID = 8; +const SHIPS = [4, 3, 3, 2]; // 12 cells total +const TOTAL_CELLS = SHIPS.reduce((a, b) => a + b, 0); + +// State +let client = null; +let games = new Map(); // roomId → game +let currentGameId = null; + +// DOM +const $ = (id) => document.getElementById(id); + +function escapeHtml(s) { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; +} + +function formatTime(iso) { + if (!iso) return ''; + const d = new Date(iso); + return isNaN(d.getTime()) ? '' : d.toLocaleTimeString(); +} + +function cellLabel(r, c) { + return `${String.fromCharCode(65 + r)}${c + 1}`; +} + +function showError(msg) { + $('login-error').textContent = msg; + $('login-error').classList.remove('hidden'); +} + +function mk(tag, cls, text) { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text) e.textContent = text; + return e; +} + +// Ship Placement (random) +function placeShips() { + const used = new Set(); + const cells = []; + for (const size of SHIPS) { + while (true) { + const h = Math.random() > 0.5; + const r = Math.floor(Math.random() * GRID); + const c = Math.floor(Math.random() * GRID); + if (h && c + size > GRID) continue; + if (!h && r + size > GRID) continue; + const batch = []; + let ok = true; + for (let i = 0; i < size; i++) { + const key = h ? `${r},${c + i}` : `${r + i},${c}`; + if (used.has(key)) { ok = false; break; } + batch.push(key); + } + if (ok) { + batch.forEach(k => used.add(k)); + cells.push(...batch); + break; + } + } + } + return cells; +} + +// Game State +function newGame(roomId, title) { + return { + roomId, title, + myShips: null, // Set<"r,c"> — my ship cells + setups: new Map(), // userId → Set<"r,c"> + attacks: [], // [{attacker, target, row, col, result, time}] + eliminated: new Set(), // announced eliminations + lastActivity: Date.now(),// timestamp for sorting + }; +} + +function hitsOn(g, uid) { + return new Set(g.attacks.filter(a => a.target === uid && a.result === 'hit').map(a => `${a.row},${a.col}`)); +} + +function remaining(g, uid) { + const s = g.setups.get(uid); + if (!s) return 0; + return s.size - hitsOn(g, uid).size; +} + +function isAlive(g, uid) { + const s = g.setups.get(uid); + return s ? remaining(g, uid) > 0 : true; +} + +function alivePlayers(g) { + return [...g.setups.keys()].filter(u => isAlive(g, u)); +} + +function getWinner(g) { + if (g.setups.size < 2) return null; + const ap = alivePlayers(g); + return ap.length === 1 ? ap[0] : null; +} + +// 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('Enter a username'); + + const btn = $('btn-login'); + 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(); + + 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 invite list + const inp = $('input-players'); + inp.value = inp.value.split(',').map(s => s.trim()).filter(s => s.toLowerCase() !== client.userId.toLowerCase()).join(', '); + + setupListeners(); + + // Restore existing games and auto-deploy ships for any game where we haven't yet + for (const room of client.rooms) { + await loadGame(room.roomId, room.title); + const g = games.get(room.roomId); + if (g && !g.myShips) { + const ships = placeShips(); + g.myShips = new Set(ships); + g.setups.set(client.userId, g.myShips); + await client.sendToRoom(room.roomId, JSON.stringify({ type: 'setup', ships })); + } + } + renderGameList(); + } catch (e) { + showError('Login failed: ' + e.message); + btn.textContent = 'Login'; + btn.classList.remove('opacity-70'); + $('input-username').disabled = false; + document.querySelectorAll('.name-btn').forEach(b => b.classList.remove('opacity-50', 'pointer-events-none')); + } +} + +// SDK Listeners +function setupListeners() { + client.addListenerForNewRoom(async (room) => { + await loadGame(room.roomId, room.title); + + // Auto-deploy ships immediately so other players can see our board + const g = games.get(room.roomId); + if (g && !g.myShips) { + const ships = placeShips(); + g.myShips = new Set(ships); + g.setups.set(client.userId, g.myShips); + await client.sendToRoom(room.roomId, JSON.stringify({ type: 'setup', ships })); + } + + renderGameList(); + if (games.size === 1) openGame(room.roomId); + }); + + client.addListenerForMemberJoined((info) => { + if (info.roomId === currentGameId) renderPlayersBar(); + }); + + client.addListenerForNewMessage((n) => { + const msg = n.message; + const roomId = n.conversation?.roomId; + if (!roomId || msg.createdBy === client.userId) return; + + const g = games.get(roomId); + if (!g) return; + + try { + const d = JSON.parse(msg.content.text); + + if (d.type === 'setup') { + g.setups.set(msg.createdBy, new Set(d.ships)); + if (roomId === currentGameId) { + renderPlayersBar(); + renderOpponentBoards(); + } + return; + } + + if (d.type === 'attack') { + g.attacks.push({ + attacker: msg.createdBy, target: d.target, + row: d.row, col: d.col, result: d.result, time: msg.createdAt, + }); + g.lastActivity = Date.now(); + + if (roomId === currentGameId) { + if (d.target === client.userId) { + // Animate incoming attack from attacker's board to my board + const attackerWrapper = findOpponentBoard(msg.createdBy); + const myBoardEl = $('my-board'); + const targetCell = findCellInBoard(myBoardEl, d.row, d.col); + if (attackerWrapper && targetCell) { + const attackerBoard = attackerWrapper.querySelector('.board'); + animateAttack(attackerBoard, targetCell, d.result).catch(() => {}); + } + renderMyBoard(); + // Flash my board red when I'm hit + const myBoard = $('my-board'); + myBoard.classList.remove('board-hit-flash'); + void myBoard.offsetWidth; // reflow to retrigger + myBoard.classList.add('board-hit-flash'); + setTimeout(() => myBoard.classList.remove('board-hit-flash'), 600); + } + renderOpponentBoards(); + renderPlayersBar(); + prependLog(msg.createdBy, d.target, d.row, d.col, d.result, msg.createdAt); + checkElimination(g, d.target); + checkWinner(g); + } + renderGameList(roomId); + } + } catch { /* ignore non-JSON */ } + }); +} + +// Load Game from History +async function loadGame(roomId, title) { + if (games.has(roomId)) return; + const g = newGame(roomId, title); + + try { + const { messages } = await client.listRoomMessage(roomId, null, null); + for (const msg of messages) { + try { + const d = JSON.parse(msg.content.text); + if (d.type === 'setup') { + g.setups.set(msg.createdBy, new Set(d.ships)); + if (msg.createdBy === client.userId) g.myShips = new Set(d.ships); + } + if (d.type === 'attack') { + g.attacks.push({ + attacker: msg.createdBy, target: d.target, + row: d.row, col: d.col, result: d.result, time: msg.createdAt, + }); + } + } catch { /* skip */ } + } + } catch { /* room might be empty */ } + + // Track already-eliminated players + for (const uid of g.setups.keys()) { + if (!isAlive(g, uid)) g.eliminated.add(uid); + } + + // Don't overwrite if another path (e.g. create handler) already set up this game + if (!games.has(roomId)) { + games.set(roomId, g); + } +} + +// Create Game +$('btn-create').addEventListener('click', async () => { + const raw = $('input-players').value.trim(); + const players = raw ? raw.split(',').map(s => s.trim()).filter(Boolean) : []; + + const btn = $('btn-create'); + btn.disabled = true; + btn.textContent = 'Creating...'; + + try { + const room = await client.createRoom('Battle', players); + games.set(room.roomId, newGame(room.roomId, 'Battle')); + renderGameList(); + openGame(room.roomId); + } catch (e) { + alert('Failed: ' + e.message); + } finally { + btn.disabled = false; + btn.textContent = 'Start Game'; + } +}); + +// Open Game +async function openGame(roomId) { + const g = games.get(roomId); + if (!g) return; + + currentGameId = roomId; + $('game-title').textContent = `${g.title} [${roomId.slice(0, 4)}]`; + + // Auto-deploy ships on first open + if (!g.myShips) { + const ships = placeShips(); + g.myShips = new Set(ships); + g.setups.set(client.userId, g.myShips); + await client.sendToRoom(roomId, JSON.stringify({ type: 'setup', ships })); + } + + renderMyBoard(); + renderPlayersBar(); + renderOpponentBoards(); + renderLog(); + checkWinner(g); + + $('game-panel').classList.remove('hidden'); + renderGameList(); +} + +// Find a cell element in a board container by row/col +function findCellInBoard(boardEl, row, col) { + return boardEl.querySelector(`.cell[data-row="${row}"][data-col="${col}"]`); +} + +// Find an opponent's board wrapper by userId +function findOpponentBoard(uid) { + return $('opponent-boards').querySelector(`[data-uid="${uid}"]`); +} + +// Attack Animation +function animateAttack(fromEl, toEl, result) { + return new Promise(resolve => { + const fromRect = fromEl.getBoundingClientRect(); + const targetRect = toEl.getBoundingClientRect(); + + const sx = fromRect.left + fromRect.width / 2; + const sy = fromRect.top + fromRect.height / 2; + const ex = targetRect.left + targetRect.width / 2; + const ey = targetRect.top + targetRect.height / 2; + + // SVG trail line + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'attack-overlay'); + svg.setAttribute('viewBox', `0 0 ${window.innerWidth} ${window.innerHeight}`); + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', sx); line.setAttribute('y1', sy); + line.setAttribute('x2', sx); line.setAttribute('y2', sy); + line.setAttribute('stroke', result === 'hit' ? '#ef4444' : '#60a5fa'); + line.setAttribute('stroke-width', '2'); + line.setAttribute('stroke-linecap', 'round'); + line.setAttribute('opacity', '0.6'); + svg.appendChild(line); + document.body.appendChild(svg); + + // Projectile + const proj = document.createElement('div'); + proj.className = 'projectile'; + proj.textContent = '💣'; + proj.style.left = sx + 'px'; + proj.style.top = sy + 'px'; + document.body.appendChild(proj); + + const duration = 400; + const start = performance.now(); + + function step(now) { + const t = Math.min((now - start) / duration, 1); + const ease = t * (2 - t); // ease-out + const cx = sx + (ex - sx) * ease; + const cy = sy + (ey - sy) * ease; + proj.style.left = cx + 'px'; + proj.style.top = cy + 'px'; + line.setAttribute('x2', cx); + line.setAttribute('y2', cy); + + if (t < 1) { + requestAnimationFrame(step); + } else { + proj.remove(); + line.setAttribute('opacity', '0'); + line.style.transition = 'opacity 0.3s'; + setTimeout(() => svg.remove(), 300); + + // Impact burst + const impact = document.createElement('div'); + impact.className = 'impact-effect'; + impact.textContent = result === 'hit' ? '💥' : '🌊'; + impact.style.left = ex + 'px'; + impact.style.top = ey + 'px'; + document.body.appendChild(impact); + setTimeout(() => { impact.remove(); resolve(); }, 500); + } + } + requestAnimationFrame(step); + }); +} + +// Attack +async function handleAttack(targetUserId, row, col, cellEl) { + const g = games.get(currentGameId); + if (!g || !targetUserId) return; + if (!isAlive(g, client.userId) || !isAlive(g, targetUserId)) return; + + const key = `${row},${col}`; + if (g.attacks.some(a => a.attacker === client.userId && a.target === targetUserId && a.row === row && a.col === col)) return; + + const ships = g.setups.get(targetUserId); + if (!ships) return; + const result = ships.has(key) ? 'hit' : 'miss'; + + try { + // Run animation and network send in parallel + const animP = cellEl ? animateAttack($('my-board'), cellEl, result).catch(() => {}) : Promise.resolve(); + const sendP = client.sendToRoom(currentGameId, JSON.stringify({ type: 'attack', target: targetUserId, row, col, result })); + await Promise.all([animP, sendP]); + + g.attacks.push({ attacker: client.userId, target: targetUserId, row, col, result, time: new Date().toISOString() }); + g.lastActivity = Date.now(); + + renderOpponentBoards(); + renderPlayersBar(); + prependLog(client.userId, targetUserId, row, col, result, new Date().toISOString()); + renderGameList(currentGameId); + checkElimination(g, targetUserId); + checkWinner(g); + } catch (e) { + alert('Attack failed: ' + e.message); + } +} + +// Elimination & Winner +function checkElimination(g, target) { + if (!isAlive(g, target) && !g.eliminated.has(target)) { + g.eliminated.add(target); + const row = mk('div', 'px-3 py-2 text-sm text-red-600 font-semibold', `💀 ${target} eliminated!`); + row.classList.add('hit-anim'); + $('battle-log').prepend(row); + renderOpponentBoards(); + } +} + +function checkWinner(g) { + const w = getWinner(g); + const banner = $('winner-banner'); + if (w) { + banner.textContent = w === client.userId ? '🎉 You win!' : `🎉 ${w} wins!`; + banner.classList.remove('hidden'); + renderOpponentBoards(); + } else { + banner.classList.add('hidden'); + } +} + +// Rendering: Board +function renderBoard(container, cellState, onClick) { + container.innerHTML = ''; + + // Column labels row + container.appendChild(mk('div', 'lbl lbl-col', '')); + for (let c = 0; c < GRID; c++) { + container.appendChild(mk('div', 'lbl lbl-col', String(c + 1))); + } + + // Grid + for (let r = 0; r < GRID; r++) { + container.appendChild(mk('div', 'lbl lbl-row', String.fromCharCode(65 + r))); + for (let c = 0; c < GRID; c++) { + const s = cellState(r, c); + const cell = mk('div', `cell ${s.cls}`, s.txt || ''); + cell.dataset.row = r; + cell.dataset.col = c; + if (s.click && onClick) cell.addEventListener('click', () => onClick(r, c, cell)); + container.appendChild(cell); + } + } +} + +// My Fleet +function renderMyBoard() { + const g = games.get(currentGameId); + if (!g || !g.myShips) return; + + const hits = hitsOn(g, client.userId); + const misses = new Set( + g.attacks.filter(a => a.target === client.userId && a.result === 'miss').map(a => `${a.row},${a.col}`) + ); + + renderBoard($('my-board'), (r, c) => { + const k = `${r},${c}`; + if (g.myShips.has(k) && hits.has(k)) return { cls: 'cell-hit', txt: '💥' }; + if (g.myShips.has(k)) return { cls: 'cell-ship', txt: '🚢' }; + if (misses.has(k)) return { cls: 'cell-miss', txt: '🌊' }; + return { cls: 'cell-water' }; + }); +} + +// All Opponent Boards +function renderOpponentBoards() { + const g = games.get(currentGameId); + const container = $('opponent-boards'); + if (!g) { container.innerHTML = ''; return; } + + const opponents = [...g.setups.keys()].filter(u => u !== client.userId); + container.innerHTML = ''; + + if (opponents.length === 0) { + container.innerHTML = '

Waiting for opponents to deploy...

'; + return; + } + + for (const uid of opponents) { + const wrapper = document.createElement('div'); + wrapper.dataset.uid = uid; + const alive = isAlive(g, uid); + const rem = remaining(g, uid); + + const label = mk('h3', `text-sm font-medium mb-1 ${alive ? 'text-gray-600' : 'text-red-400 line-through'}`, + alive ? `${uid} (${rem}/${TOTAL_CELLS})` : `${uid} ☠️`); + wrapper.appendChild(label); + + const boardEl = document.createElement('div'); + boardEl.className = 'board'; + + const ships = g.setups.get(uid); + const myAtk = new Map(); + for (const a of g.attacks) { + if (a.attacker === client.userId && a.target === uid) { + myAtk.set(`${a.row},${a.col}`, a.result); + } + } + const canAttack = isAlive(g, client.userId) && alive; + + renderBoard(boardEl, (r, c) => { + const k = `${r},${c}`; + const res = myAtk.get(k); + if (res === 'hit') return { cls: 'cell-hit', txt: '💥' }; + if (res === 'miss') return { cls: 'cell-miss', txt: '🌊' }; + if (ships && ships.has(k)) return canAttack ? { cls: 'cell-ship', click: true, txt: '🚢' } : { cls: 'cell-ship', txt: '🚢' }; + return canAttack ? { cls: 'cell-target', click: true } : { cls: 'cell-water' }; + }, (r, c, cellEl) => handleAttack(uid, r, c, cellEl)); + + wrapper.appendChild(boardEl); + container.appendChild(wrapper); + } +} + +// Rendering: Players Bar +function renderPlayersBar() { + const g = games.get(currentGameId); + if (!g) return; + + const c = $('players-bar'); + c.innerHTML = ''; + + for (const [uid] of g.setups) { + const rem = remaining(g, uid); + const alive = rem > 0; + const isMe = uid === client.userId; + const span = mk('span', + `px-2 py-1 rounded-full text-xs font-medium ${ + !alive ? 'bg-red-100 text-red-500 line-through' : + isMe ? 'bg-blue-100 text-blue-700 border border-blue-300' : + 'bg-gray-100 text-gray-600 border border-gray-300' + }`, `${uid} (${rem})`); + c.appendChild(span); + } +} + +// Rendering: Game List +function renderGameList(flashId) { + const c = $('game-list'); + if (games.size === 0) { + c.innerHTML = '

No games yet. Create or wait for an invitation.

'; + return; + } + + c.innerHTML = ''; + const sorted = [...games.entries()].sort((a, b) => b[1].lastActivity - a[1].lastActivity); + for (const [rid, g] of sorted) { + const ap = alivePlayers(g); + const total = g.setups.size; + const isActive = rid === currentGameId; + + const div = mk('div', `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(g.title)} [${rid.slice(0, 4)}] + ${total === 0 ? 'new' : `${ap.length}/${total} alive`} + `; + if (flashId === rid) div.classList.add('flash-red'); + div.addEventListener('click', () => openGame(rid)); + c.appendChild(div); + } +} + +// Rendering: Battle Log +function renderLog() { + const g = games.get(currentGameId); + if (!g) return; + + const c = $('battle-log'); + c.innerHTML = ''; + for (const a of [...g.attacks].reverse()) { + c.appendChild(logRow(a.attacker, a.target, a.row, a.col, a.result, a.time)); + } +} + +function prependLog(attacker, target, row, col, result, time) { + const r = logRow(attacker, target, row, col, result, time); + r.classList.add('hit-anim'); + $('battle-log').prepend(r); +} + +function logRow(attacker, target, row, col, result, time) { + const icon = result === 'hit' ? '💥' : '🌊'; + const color = result === 'hit' ? 'text-red-600' : 'text-blue-400'; + const d = mk('div', 'flex justify-between items-center px-3 py-2', ''); + d.innerHTML = ` + ${escapeHtml(attacker)} → ${escapeHtml(target)} at ${cellLabel(row, col)} ${icon} + ${formatTime(time)} + `; + return d; +} diff --git a/sdk/webpubsub-chat-client/examples/battleship/public/index.html b/sdk/webpubsub-chat-client/examples/battleship/public/index.html new file mode 100644 index 000000000..e9f865fbd --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/battleship/public/index.html @@ -0,0 +1,165 @@ + + + + + + Battleship + + + + + +
+

🚢 Battleship Game

+

Multiplayer real-time naval combat · Powered by Web PubSub Chat

+ + +
+ Login as: +
+ + + + +
+
+ + +
+ +
+ + + +
+ + + + diff --git a/sdk/webpubsub-chat-client/examples/battleship/server.js b/sdk/webpubsub-chat-client/examples/battleship/server.js new file mode 100644 index 000000000..860ea3096 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/battleship/server.js @@ -0,0 +1,40 @@ +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 = process.env.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 }); + console.log(`${userId} logged in`); + 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(`Battleship server running at http://localhost:${port}`); +}); diff --git a/sdk/webpubsub-chat-client/examples/battleship/yarn.lock b/sdk/webpubsub-chat-client/examples/battleship/yarn.lock new file mode 100644 index 000000000..002679abf --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/battleship/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 + +"battleship-example@workspace:.": + version: 0.0.0-use.local + resolution: "battleship-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 + +"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 + +"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/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..fef18cecd --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/live-auction/README.md @@ -0,0 +1,50 @@ +# 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` | +| Show participants | `getRoom(roomId, withMembers: true)` | + +## Prerequisites + +1. An Azure Web PubSub resource +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 +node server.js "" +``` + +Or set the environment variable: +```bash +export WebPubSubConnectionString="" +node server.js +``` + +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, pick any item from the list and create an auction, invite `bob` +3. Bob's tab will instantly show the new auction in "Active Auctions" +4. Click the auction to enter, then click any bid increment button to place a bid +5. Both tabs see bid updates in real time +6. 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..8540fc263 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/live-auction/public/app.js @@ -0,0 +1,385 @@ +// 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); + }); + + client.addListenerForMemberJoined((info) => { + const auction = auctions.get(info.roomId); + if (auction) { + auction.members.add(info.userId); + if (info.roomId === currentAuctionId) renderParticipants(auction); + } + }); + + // 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 (auction) auction.lastActivity = Date.now(); + if (roomId === currentAuctionId) { + renderHighestBid(auction); + prependBidEntry(msg.createdBy, data.amount, msg.createdAt); + } + renderAuctionList(roomId); + } + } 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, members: new Set(), lastActivity: Date.now() }; + + 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, members: new Set(), lastActivity: Date.now() + }); + 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(flashRoomId) { + const container = $('auction-list'); + if (auctions.size === 0) { + container.innerHTML = '

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

'; + return; + } + + container.innerHTML = ''; + const sorted = [...auctions.entries()].sort((a, b) => b[1].lastActivity - a[1].lastActivity); + for (const [roomId, auction] of sorted) { + 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} + `; + if (flashRoomId === roomId) { + div.classList.add('bid-flash'); + } + 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); + + // Load participants + try { + const roomInfo = await client.getRoom(roomId, true); + auction.members = new Set(roomInfo.members); + } catch { /* ignore */ } + renderParticipants(auction); + + $('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; + auction.lastActivity = Date.now(); + + renderHighestBid(auction); + prependBidEntry(client.userId, amount, new Date().toISOString()); + renderAuctionList(currentAuctionId); + } 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 renderParticipants(auction) { + const container = $('participants'); + container.innerHTML = ''; + for (const userId of auction.members) { + const span = document.createElement('span'); + span.className = 'px-2 py-1 rounded-full text-xs font-medium ' + + (userId === client.userId ? 'bg-blue-100 text-blue-700 border border-blue-300' : 'bg-gray-100 text-gray-600 border border-gray-300'); + span.textContent = userId; + container.appendChild(span); + } +} + +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..25213bbcf --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/live-auction/public/index.html @@ -0,0 +1,140 @@ + + + + + + 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..bdf28a475 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/live-auction/server.js @@ -0,0 +1,42 @@ +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 }); + console.log(`${userId} logged in`); + 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/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..c1fbe82fc --- /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 +node server.js "" +``` + +Or set the environment variable: + +```bash +export WebPubSubConnectionString="" +node server.js +``` + +### 2. Run the client + +In a new terminal: + +```bash +node client.js +``` + +## 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..b07cb5d31 --- /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 = process.env.PORT || 3000; + +// 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/examples/quickstart/yarn.lock b/sdk/webpubsub-chat-client/examples/quickstart/yarn.lock new file mode 100644 index 000000000..4a72002c6 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/quickstart/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 + +"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 + +"quickstart-example@workspace:.": + version: 0.0.0-use.local + resolution: "quickstart-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 + +"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..82c568026 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/.gitignore @@ -0,0 +1,5 @@ +.env +.vscode/ +.azure/ +.yarn +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..1eaf02a14 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/README.md @@ -0,0 +1,42 @@ +# 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 + +**1. Install dependencies:** + +```bash +npm run install:all +``` + +**2. Start the server (Terminal 1):** + +```bash +npm run dev:server "" +``` + +**3. Start the client (Terminal 2):** + +```bash +npm run dev:client +``` + +Open http://localhost:5173 in your browser. 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..f244debb5 --- /dev/null +++ b/sdk/webpubsub-chat-client/examples/teams-lite/client/README.md @@ -0,0 +1,23 @@ +# Teams-Lite Client + +React + TypeScript + Vite frontend for the Teams-Lite chat demo, powered by `@azure/web-pubsub-chat-client`. + +## Quick Start + +Prereqs: Node.js 18+ and the server running at http://localhost:3000. + +```bash +yarn install +yarn dev +``` + +Open http://localhost:5173 + +## Scripts + +| Command | Description | +|---------|-------------| +| `yarn dev` | Vite dev server | +| `yarn build` | Type check + production build | +| `yarn test` | Vitest + React Testing Library | +| `yarn lint` | ESLint | 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..e4167ac3a --- /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": "1.0.0-beta.1", + "@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/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' ? ( +