diff --git a/docs/automation-design.md b/docs/automation-design.md new file mode 100644 index 000000000..8c21f9fdc --- /dev/null +++ b/docs/automation-design.md @@ -0,0 +1,109 @@ +# Zoom Host Automation — Design Document + +## Project Goal + +This project provides a lightweight Tampermonkey userscript that runs inside a Zoom Web meeting under a **host or co-host** account. Its primary task is to automatically grant **Multi-Pin** permission to any participant who raises their hand, removing the need for the host to perform this action manually. + +Secondary goals include a spam-detection scaffold for the chat panel and a camera-off reminder scaffold. + +--- + +## Architecture + +The entire solution is a single self-contained JavaScript file (`scripts/zoom-host-tools.user.js`) executed by the [Tampermonkey](https://www.tampermonkey.net/) browser extension. No backend services, build steps, or external dependencies are required. + +``` +browser/ +├── scripts/ +│ └── zoom-host-tools.user.js # Main TamperMonkey userscript +├── selectors/ +│ └── zoom-dom-selectors.json # Configurable CSS selectors (source of truth) +└── docs/ + ├── automation-design.md # This file + └── testing-checklist.md # Manual QA checklist +``` + +### Key Design Decisions + +| Decision | Rationale | +|---|---| +| Single-file userscript | No build pipeline; install and run directly in TamperMonkey | +| Selectors isolated in JSON | Easy to update when Zoom updates its DOM without touching logic | +| `setInterval` polling (2 s) | Zoom's SPA does not expose reliable hooks; polling is the simplest resilient approach | +| `async/await` throughout | Clicking menus requires waiting for DOM transitions; async keeps the code readable | +| No frameworks | Keeps the payload tiny and the code auditable | + +--- + +## Selector Strategy + +CSS selectors for Zoom Web DOM elements are defined in two places: + +1. **`selectors/zoom-dom-selectors.json`** — The canonical, human-editable reference. + Update this file whenever Zoom changes its DOM. + +2. **Embedded `SELECTORS` object in the userscript** — A copy of the JSON embedded directly in the script so TamperMonkey can use them without fetching a separate file. + Keep this in sync with the JSON file. + +Every selector is represented as `{ primary, fallback }`. The `resolve()` helper tries `primary` first; if it returns no element, it tries `fallback`. This two-layer approach provides resilience against minor DOM changes without requiring an immediate selector update. + +--- + +## Multi-Pin Automation Logic + +``` +setInterval (every 2 s) + └─ scanParticipants() + └─ for each participantRow + ├─ read name + ├─ skip if already in processedParticipants Set + ├─ detect raisedHandIcon + │ └─ (skip if absent) + ├─ checkCameraStatus() ← scaffold, no-op for now + ├─ needsMultipin() + │ ├─ open participant menu + │ ├─ look for "Allow to Multi-Pin" option + │ └─ close menu; return boolean + └─ grantMultipin() (only if needsMultipin returned true) + ├─ open participant menu + ├─ click "Allow to Multi-Pin" + ├─ add name to processedParticipants + └─ update debug panel stats +``` + +### Idempotency + +The `processedParticipants` `Set` is maintained in memory for the lifetime of the page. Once a participant has been processed (either Multi-Pin granted, or Multi-Pin was already active), their name is added to the set and they are skipped on all future scans. + +> **Note:** The set is cleared if the page is reloaded. This is acceptable because a page reload resets the meeting UI state as well. + +### Retry Protection + +`grantMultipin()` will attempt to open the menu and find the option up to **two times** before giving up and logging a warning. This guards against transient rendering delays. + +--- + +## Extension Points + +### Camera Check (`checkCameraStatus`) + +A scaffold function is already in place. To complete it: + +1. Implement `sendChatMessage(text)` using the `chatInput` selector. +2. Call `sendChatMessage("Please turn your camera on to use Multi-Pin.")` inside `checkCameraStatus` when `cameraStatusIcon` is detected. + +### Chat Moderation (`monitorChat`) + +The `MutationObserver` attached to the chat container already detects messages containing known spam patterns. To add moderation actions: + +1. Implement `muteParticipant(name)` using the participant menu. +2. Call it from inside the `spamDetected` block in `monitorChat`. + +--- + +## Limitations & Known Risks + +- **DOM Changes:** Zoom updates its web client regularly. If the script stops working, inspect the participant list in DevTools and update the selectors. +- **Host Privileges Required:** The script will silently do nothing if the user does not have host or co-host status, because the "Allow to Multi-Pin" menu item will not appear. +- **In-Memory State:** Reloading the page resets `processedParticipants`. This is acceptable but means participants who raised their hand before the reload may be re-processed. +- **Single-Tab:** The script runs independently in each browser tab. Running it in multiple tabs for the same meeting is not recommended. diff --git a/docs/testing-checklist.md b/docs/testing-checklist.md new file mode 100644 index 000000000..d82f59615 --- /dev/null +++ b/docs/testing-checklist.md @@ -0,0 +1,142 @@ +# Zoom Host Automation — Testing Checklist + +Manual QA checklist for `scripts/zoom-host-tools.user.js`. + +## Prerequisites + +- [ ] Tampermonkey extension is installed in the test browser +- [ ] `scripts/zoom-host-tools.user.js` is installed as a Tampermonkey userscript +- [ ] The test Zoom account has **host** or **co-host** privileges +- [ ] A second Zoom account (participant) is available for testing + +--- + +## Test Cases + +### 1. Script Loads Correctly + +**Steps:** +1. Open a Zoom Web meeting (`https://*.zoom.us/wc/*`) with the host account. +2. Open the browser DevTools console. + +**Expected:** +- `[ZoomHostAuto] Zoom Host Automation initializing…` appears in the console. +- `[ZoomHostAuto] Zoom Host Automation active (interval: 2000ms)` appears in the console. +- A floating debug panel is visible in the bottom-right corner of the page showing "🤖 Zoom Host Automation". + +**Pass / Fail:** ___ + +--- + +### 2. Raised Hand Detected + +**Steps:** +1. Have the participant account raise their hand in the meeting. +2. Wait up to 4 seconds (two scan intervals). + +**Expected:** +- Console shows: `[ZoomHostAuto] ✋ Raised hand detected: ""` +- The "Raised hands" counter in the debug panel increments by 1. + +**Pass / Fail:** ___ + +--- + +### 3. Multi-Pin Granted Automatically + +**Steps:** +1. Continue from Test 2 (participant's hand is raised and Multi-Pin has not been granted yet). +2. Wait up to 4 seconds after the raised-hand log entry. + +**Expected:** +- Console shows: `[ZoomHostAuto] ✅ Granted Multi-Pin to ""` +- "Multi-Pin grants" counter in the debug panel increments by 1. +- "Last action" in the debug panel updates to `Granted Multi-Pin to `. +- In Zoom's participant list the participant now has Multi-Pin enabled (verify via the participant menu — the "Allow to Multi-Pin" option should be absent or replaced by "Disable Multi-Pin"). + +**Pass / Fail:** ___ + +--- + +### 4. Same Participant Not Processed Twice + +**Steps:** +1. After Test 3, have the participant lower and then raise their hand again. +2. Wait for two scan intervals. + +**Expected:** +- Console shows: `[ZoomHostAuto] ℹ️ Multi-Pin already granted for ""; skipping` **OR** the participant is simply skipped silently (because their name is in `processedParticipants`). +- "Multi-Pin grants" counter does **not** increment again. + +**Pass / Fail:** ___ + +--- + +### 5. Script Survives Missing Selectors + +**Steps:** +1. Temporarily change a selector in the embedded `SELECTORS` object in the script to an invalid value (e.g., `participantList.primary = '.does-not-exist'`). +2. Reload the meeting page. +3. Wait for several scan intervals. + +**Expected:** +- No uncaught JavaScript errors or exceptions in the console. +- The script continues to run (polling loop does not crash). +- Selector failures are either silently skipped or logged as warnings. + +**Restore:** Revert the selector change after this test. + +**Pass / Fail:** ___ + +--- + +### 6. Debug Logs Visible in Console + +**Steps:** +1. Ensure `DEBUG_MODE = true` in the script (default). +2. Open the meeting with the host account. +3. Observe the DevTools console. + +**Expected:** +- All `[ZoomHostAuto]` log lines appear in the console throughout the meeting. +- No logs appear when `DEBUG_MODE` is set to `false`. + +**Pass / Fail:** ___ + +--- + +### 7. Chat Monitor Detects Spam (Scaffold) + +**Steps:** +1. Open the chat panel in the meeting. +2. From the participant account, send a message containing a URL (e.g., `http://example.com`). +3. Observe the DevTools console on the host account's browser. + +**Expected:** +- Console shows: `[ZoomHostAuto] ⚠️ Possible spam detected | user: "" | message: ""` +- No automatic moderation action is taken (this is a scaffold — action hooks are not yet implemented). + +**Pass / Fail:** ___ + +--- + +### 8. Camera-Off Detection Logged (Scaffold) + +**Steps:** +1. Ensure the participant's camera is **off**. +2. Have the participant raise their hand. +3. Observe the console after the scan picks up the raised hand. + +**Expected:** +- Console shows: `[ZoomHostAuto] 📷 Camera is OFF for ""` +- No chat message is sent automatically (full implementation is a TODO). + +**Pass / Fail:** ___ + +--- + +## Regression Notes + +| Date | Tester | Zoom Web Version | Overall Result | Notes | +|------|--------|-----------------|---------------|-------| +| | | | | | diff --git a/scripts/zoom-host-tools.user.js b/scripts/zoom-host-tools.user.js new file mode 100644 index 000000000..a549c844d --- /dev/null +++ b/scripts/zoom-host-tools.user.js @@ -0,0 +1,454 @@ +// ==UserScript== +// @name Zoom Host Automation +// @namespace https://github.com/FriskyDevelopments/browser +// @version 1.0.0 +// @description Automatically grants Multi-Pin permission to participants who raise their hand in Zoom Web meetings. Requires host or co-host privileges. +// @author FriskyDevelopments +// @match https://*.zoom.us/wc/* +// @grant none +// ==/UserScript== + +(function () { + 'use strict'; + + // ───────────────────────────────────────────────────────────────────────── + // CONFIGURATION + // ───────────────────────────────────────────────────────────────────────── + + const DEBUG_MODE = true; + const SCAN_INTERVAL = 2000; // milliseconds between participant scans + + // Spam patterns detected by the chat monitor + const SPAM_PATTERNS = [ + 'http://', + 'https://', + 't.me', + 'bit.ly', + 'discord.gg', + ]; + + // ───────────────────────────────────────────────────────────────────────── + // EMBEDDED SELECTOR CONFIGURATION + // + // These selectors mirror selectors/zoom-dom-selectors.json. + // If Zoom updates its DOM, change the values in that file and update + // the corresponding entries here to keep both in sync. + // ───────────────────────────────────────────────────────────────────────── + + const SELECTORS = { + participantList: { primary: '.participants-list__list', fallback: "[aria-label='Participants panel'] ul" }, + participantRow: { primary: '.participants-item', fallback: '.participants-list__item' }, + participantName: { primary: '.participants-item__name', fallback: '.participants-list__item-name' }, + raisedHandIcon: { primary: '.participants-item__raised-hand-icon', fallback: "[aria-label='Raise Hand'][class*='active']" }, + participantMenuButton:{ primary: '.participants-item__more-btn', fallback: "[aria-label='More options for participant']" }, + multipinMenuOption: { primary: "[aria-label='Allow to Multi-Pin']", fallback: '[role="menuitem"]' }, + chatSender: { primary: '.chat-message__sender', fallback: '.chat-list__item-sender' }, + cameraStatusIcon: { primary: '.participants-item__camera-icon--off', fallback: "[aria-label='Video off']" }, + chatContainer: { primary: '.chat-list__chat-virtualized', fallback: '.chat-message-list' }, + chatMessage: { primary: '.chat-message__text', fallback: '.chat-list__item-content' }, + chatInput: { primary: '.chat-box__chat-input', fallback: "[aria-label='Type message here']" }, + }; + + // ───────────────────────────────────────────────────────────────────────── + // INTERNAL STATE + // ───────────────────────────────────────────────────────────────────────── + + // Tracks participants that have already been processed. + // Keyed by display name; note that if two participants share an identical + // display name only the first will be processed. Zoom's DOM does not expose + // a stable unique ID in all views, so name is used as the best available key. + const processedParticipants = new Set(); + + const stats = { + scanned: 0, + hands: 0, + grants: 0, + lastAction: 'None', + }; + + // ───────────────────────────────────────────────────────────────────────── + // MODULE 1 — SELECTOR RESOLVER + // ───────────────────────────────────────────────────────────────────────── + + /** + * Returns the first DOM element matching the primary selector for the given + * key. Falls back to the secondary selector when the primary returns nothing. + * Optionally scoped to a given root element. + * + * @param {string} key - Key from the SELECTORS configuration object. + * @param {Element} [root=document] - Element to search within. + * @returns {Element|null} + */ + function resolve(key, root = document) { + const config = SELECTORS[key]; + if (!config) { + log(`warn: unknown selector key "${key}"`); + return null; + } + const el = root.querySelector(config.primary); + if (el) return el; + if (config.fallback) { + return root.querySelector(config.fallback) || null; + } + return null; + } + + /** + * Same as resolve() but returns ALL matching elements as a NodeList / + * Array, trying the primary selector first and falling back if needed. + * + * @param {string} key + * @param {Element} [root=document] + * @returns {Element[]} + */ + function resolveAll(key, root = document) { + const config = SELECTORS[key]; + if (!config) { + log(`warn: unknown selector key "${key}"`); + return []; + } + let nodes = Array.from(root.querySelectorAll(config.primary)); + if (nodes.length === 0 && config.fallback) { + nodes = Array.from(root.querySelectorAll(config.fallback)); + } + return nodes; + } + + // ───────────────────────────────────────────────────────────────────────── + // UTILITY — DEBUG LOGGING + // ───────────────────────────────────────────────────────────────────────── + + function log(message) { + if (DEBUG_MODE) { + console.log(`[ZoomHostAuto] ${message}`); + } + } + + function updateDebugPanel(key, value) { + const panel = document.getElementById('zha-debug-panel'); + if (!panel) return; + const el = panel.querySelector(`[data-key="${key}"]`); + if (el) el.textContent = value; + } + + // ───────────────────────────────────────────────────────────────────────── + // MODULE 2 — MULTI-PIN GRANT + // ───────────────────────────────────────────────────────────────────────── + + /** + * Checks whether a participant already has Multi-Pin by inspecting their + * context menu. Opens the menu, looks for the "Allow to Multi-Pin" option, + * and closes the menu immediately after checking. + * + * @param {Element} row - Participant row element. + * @returns {Promise} true if the option is present (i.e. not yet granted). + */ + async function needsMultipin(row) { + const menuButton = resolve('participantMenuButton', row); + if (!menuButton) return false; + + menuButton.click(); + + // Allow a short moment for the menu to render + await sleep(300); + + const option = resolveMultipinOption(); + const found = !!option; + + // Dismiss the menu + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await sleep(200); + + return found; + } + + /** + * Resolves the "Allow to Multi-Pin" menu option from the currently open + * participant context menu. Tries the primary aria-label selector; if the + * result does not contain the expected text (i.e. a generic fallback matched) + * it falls back to a full text-content search. + * + * @returns {Element|null} + */ + function resolveMultipinOption() { + const option = resolve('multipinMenuOption'); + if (option && option.textContent && option.textContent.toLowerCase().includes('multi-pin')) { + return option; + } + return findMenuItemByText('Allow to Multi-Pin'); + } + + /** + * Searches open menu items for one whose visible text matches a given string. + * + * @param {string} text + * @returns {Element|null} + */ + function findMenuItemByText(text) { + const items = document.querySelectorAll('.menu-item, [role="menuitem"]'); + for (const item of items) { + if (item.textContent && item.textContent.trim().toLowerCase().includes(text.toLowerCase())) { + return item; + } + } + return null; + } + + /** + * Opens the participant's context menu and clicks "Allow to Multi-Pin". + * Marks the participant as processed on success. + * Retries once if the menu fails to open on the first attempt. + * + * @param {Element} row - Participant row element. + * @param {string} name - Participant display name (for logging). + */ + async function grantMultipin(row, name) { + for (let attempt = 1; attempt <= 2; attempt++) { + const menuButton = resolve('participantMenuButton', row); + if (!menuButton) { + log(`grantMultipin: no menu button found for "${name}" (attempt ${attempt})`); + break; + } + + menuButton.click(); + await sleep(400); + + const option = resolveMultipinOption(); + + if (option) { + option.click(); + processedParticipants.add(name); + stats.grants++; + stats.lastAction = `Granted Multi-Pin to ${name}`; + log(`✅ Granted Multi-Pin to "${name}"`); + updateDebugPanel('grants', stats.grants); + updateDebugPanel('lastAction', stats.lastAction); + return; + } + + // Menu opened but option not found — dismiss and retry + log(`grantMultipin: option not found for "${name}", attempt ${attempt}`); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await sleep(300); + } + + log(`grantMultipin: failed to grant Multi-Pin to "${name}" after retries`); + } + + // ───────────────────────────────────────────────────────────────────────── + // MODULE 3 — CAMERA CHECK SCAFFOLD + // ───────────────────────────────────────────────────────────────────────── + + /** + * Inspects the camera status icon for a participant row. + * If the camera is off, a reminder message should be sent via chat. + * + * @param {Element} row - Participant row element. + * @param {string} name - Participant display name. + */ + function checkCameraStatus(row, name) { + const cameraOff = resolve('cameraStatusIcon', row); + if (cameraOff) { + log(`📷 Camera is OFF for "${name}"`); + // TODO: Send chat message: + // "Please turn your camera on to use Multi-Pin." + // Use sendChatMessage() once implemented. + } + } + + // ───────────────────────────────────────────────────────────────────────── + // MODULE 4 — PARTICIPANT SCANNER + // ───────────────────────────────────────────────────────────────────────── + + /** + * Main scanning loop. Called on every interval tick. + * + * For each participant row: + * 1. Reads the participant's display name. + * 2. Skips participants that have already been processed. + * 3. Detects a raised-hand icon. + * 4. Checks whether Multi-Pin is already granted. + * 5. Grants Multi-Pin if needed. + */ + async function scanParticipants() { + const rows = resolveAll('participantRow'); + if (rows.length === 0) return; + + stats.scanned = rows.length; + updateDebugPanel('scanned', stats.scanned); + + for (const row of rows) { + try { + const nameEl = resolve('participantName', row); + const name = nameEl ? nameEl.textContent.trim() : null; + + if (!name) continue; + if (processedParticipants.has(name)) continue; + + const raisedHand = resolve('raisedHandIcon', row); + if (!raisedHand) continue; + + stats.hands++; + log(`✋ Raised hand detected: "${name}"`); + updateDebugPanel('hands', stats.hands); + + // Camera check (scaffold — does not block the flow) + checkCameraStatus(row, name); + + // Only grant if the option still exists in the menu + const shouldGrant = await needsMultipin(row); + if (shouldGrant) { + await grantMultipin(row, name); + } else { + // Multi-Pin already granted; mark as processed to avoid re-scanning + processedParticipants.add(name); + log(`ℹ️ Multi-Pin already granted for "${name}"; skipping`); + } + } catch (err) { + log(`scanParticipants error: ${err.message}`); + } + } + } + + // ───────────────────────────────────────────────────────────────────────── + // MODULE 5 — CHAT MONITOR SCAFFOLD + // ───────────────────────────────────────────────────────────────────────── + + /** + * Observes chat messages for potential spam content. + * Currently only logs detections; hook for moderation actions is prepared. + */ + function monitorChat() { + const container = resolve('chatContainer'); + if (!container) { + log('monitorChat: chat container not found; will retry on next interval'); + return; + } + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + + // Use the selector resolver for consistency + const msgEl = resolve('chatMessage', node) || ( + node.matches && ( + node.matches(SELECTORS.chatMessage.primary) || + node.matches(SELECTORS.chatMessage.fallback) + ) ? node : null + ); + + if (!msgEl) continue; + + const text = msgEl.textContent || ''; + const lowerText = text.toLowerCase(); + const spamDetected = SPAM_PATTERNS.some(p => lowerText.includes(p)); + + if (spamDetected) { + // Extract sender name using the configured selector + const senderEl = resolve('chatSender', node); + const sender = senderEl ? senderEl.textContent.trim() : 'unknown'; + + log(`⚠️ Possible spam detected | user: "${sender}" | message: "${text.trim()}"`); + + // TODO: hook for moderation actions, e.g.: + // - mute the participant + // - remove the message + // - flag to host dashboard + } + } + } + }); + + observer.observe(container, { childList: true, subtree: true }); + log('monitorChat: observer attached to chat container'); + } + + // ───────────────────────────────────────────────────────────────────────── + // MODULE 6 — DEBUG PANEL + // ───────────────────────────────────────────────────────────────────────── + + function createDebugPanel() { + const panel = document.createElement('div'); + panel.id = 'zha-debug-panel'; + panel.style.cssText = [ + 'position:fixed', + 'bottom:16px', + 'right:16px', + 'z-index:99999', + 'background:rgba(0,0,0,0.75)', + 'color:#fff', + 'font:12px/1.5 monospace', + 'padding:10px 14px', + 'border-radius:6px', + 'min-width:220px', + 'pointer-events:none', + ].join(';'); + + panel.innerHTML = ` +
🤖 Zoom Host Automation
+
Scanned: 0
+
Raised hands: 0
+
Multi-Pin grants: 0
+
Last action: None
+ `; + + document.body.appendChild(panel); + log('Debug panel created'); + } + + // ───────────────────────────────────────────────────────────────────────── + // UTILITY + // ───────────────────────────────────────────────────────────────────────── + + function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + // ───────────────────────────────────────────────────────────────────────── + // ENTRY POINT + // ───────────────────────────────────────────────────────────────────────── + + function init() { + log('Zoom Host Automation initializing…'); + + if (DEBUG_MODE) { + createDebugPanel(); + } + + // Start the polling loop + setInterval(async () => { + try { + await scanParticipants(); + } catch (err) { + log(`Polling error: ${err.message}`); + } + }, SCAN_INTERVAL); + + // Start chat monitoring; Zoom may render the chat panel lazily, + // so keep retrying until the container is found (up to ~60 seconds). + let chatRetryCount = 0; + const CHAT_RETRY_MAX = 20; + const chatRetry = setInterval(() => { + chatRetryCount++; + const container = resolve('chatContainer'); + if (container) { + monitorChat(); + clearInterval(chatRetry); + } else if (chatRetryCount >= CHAT_RETRY_MAX) { + log('monitorChat: chat panel not found after maximum retries; giving up'); + clearInterval(chatRetry); + } + }, 3000); + + log(`Zoom Host Automation active (interval: ${SCAN_INTERVAL}ms)`); + } + + // Wait for the Zoom meeting UI to finish loading before starting + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + +})(); diff --git a/selectors/zoom-dom-selectors.json b/selectors/zoom-dom-selectors.json new file mode 100644 index 000000000..8909d0053 --- /dev/null +++ b/selectors/zoom-dom-selectors.json @@ -0,0 +1,70 @@ +{ + "_comment": "Zoom Web DOM selectors used by the host automation userscript. Zoom may update its DOM structure at any time. When selectors stop working, update the values here — no changes to the main script are needed.", + "_instructions": "Each key maps to a CSS selector string. The script will attempt the primary selector first; if it finds no matching element it falls back to the 'fallback' value. Set 'fallback' to null when no alternative is known.", + + "participantList": { + "primary": ".participants-list__list", + "fallback": "[aria-label='Participants panel'] ul", + "_note": "Container element that holds all participant rows." + }, + + "participantRow": { + "primary": ".participants-item", + "fallback": ".participants-list__item", + "_note": "Repeating row element for a single participant inside the participant list." + }, + + "participantName": { + "primary": ".participants-item__name", + "fallback": ".participants-list__item-name", + "_note": "Text element inside a participant row that displays the participant's display name." + }, + + "raisedHandIcon": { + "primary": ".participants-item__raised-hand-icon", + "fallback": "[aria-label='Raise Hand'][class*='active']", + "_note": "Icon or element that appears inside a participant row when that participant has raised their hand." + }, + + "participantMenuButton": { + "primary": ".participants-item__more-btn", + "fallback": "[aria-label='More options for participant']", + "_note": "Button inside a participant row that opens the context menu for that participant." + }, + + "multipinMenuOption": { + "primary": "[aria-label='Allow to Multi-Pin']", + "fallback": "[role='menuitem']", + "_note": "Menu item inside the participant context menu that grants Multi-Pin permission. When the fallback is used, the script MUST additionally validate the element's text content contains 'Multi-Pin' because the fallback selector matches all menu items." + }, + + "chatSender": { + "primary": ".chat-message__sender", + "fallback": ".chat-list__item-sender", + "_note": "Element inside a chat message row that displays the sender's display name." + }, + + "cameraStatusIcon": { + "primary": ".participants-item__camera-icon--off", + "fallback": "[aria-label='Video off']", + "_note": "Icon indicating a participant's camera is currently off." + }, + + "chatContainer": { + "primary": ".chat-list__chat-virtualized", + "fallback": ".chat-message-list", + "_note": "Scrollable container that holds the meeting chat message history." + }, + + "chatMessage": { + "primary": ".chat-message__text", + "fallback": ".chat-list__item-content", + "_note": "Element inside the chat container that contains the text of a single message." + }, + + "chatInput": { + "primary": ".chat-box__chat-input", + "fallback": "[aria-label='Type message here']", + "_note": "Text input field in the chat panel where the host types a message." + } +}