diff --git a/rtpconn/webclient.go b/rtpconn/webclient.go index 0ee284e3..b75e698f 100644 --- a/rtpconn/webclient.go +++ b/rtpconn/webclient.go @@ -1906,7 +1906,7 @@ func handleClientMessage(c *webClient, m clientMessage) error { } t := g.GetClient(m.Dest) if t == nil { - return c.error(group.UserError("no suck user")) + return c.error(group.UserError("no such user")) } target, ok := t.(*webClient) if !ok { diff --git a/static/galene.css b/static/galene.css index 448b992f..64782e57 100644 --- a/static/galene.css +++ b/static/galene.css @@ -1,3 +1,27 @@ +/* Accessibility: Visible focus indicators for keyboard navigation */ +button:focus, +[role="button"]:focus, +select:focus, +input:focus, +textarea:focus, +a:focus { + outline: 2px solid #0066cc; + outline-offset: 2px; +} + +/* Accessibility: Screen reader only content */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + .nav-fixed .topnav { z-index: 1039; } @@ -109,15 +133,18 @@ display: none; } -.sidenav .user-logout a { +.sidenav .user-logout button { + background: transparent; + border: 0; font-size: 1em; padding: 7px 0 0; color: #e4157e; cursor: pointer; + font: inherit; line-height: .7; } -.sidenav .user-logout a:hover { +.sidenav .user-logout button:hover { color: #ab0659; } @@ -312,7 +339,6 @@ resize: none; overflow: hidden; padding: 5px; - outline: none; border: none; text-indent: 5px; box-shadow: none; @@ -865,11 +891,13 @@ h1 { } #input:focus { - outline: none; + outline: 2px solid #0066cc; + outline-offset: 2px; } #inputbutton:focus { - outline: none; + outline: 2px solid #0066cc; + outline-offset: 2px; } #resizer { diff --git a/static/galene.html b/static/galene.html index d7d44f1c..c632f561 100644 --- a/static/galene.html +++ b/static/galene.html @@ -16,7 +16,7 @@ -
+
-
+
-
+ diff --git a/static/galene.js b/static/galene.js index c5746b05..93f57b72 100644 --- a/static/galene.js +++ b/static/galene.js @@ -638,10 +638,14 @@ function setLocalMute(mute, reflect) { icon.classList.add('fa-microphone-slash'); icon.classList.remove('fa-microphone'); button.classList.add('muted'); + button.setAttribute('aria-pressed', 'true'); + button.setAttribute('aria-label', 'Unmute microphone'); } else { icon.classList.remove('fa-microphone-slash'); icon.classList.add('fa-microphone'); button.classList.remove('muted'); + button.setAttribute('aria-pressed', 'false'); + button.setAttribute('aria-label', 'Mute microphone'); } if(reflect) updateSettings({localMute: mute}); @@ -2073,6 +2077,10 @@ async function setMedia(c, mirror, video) { peersdiv.appendChild(div); } + // Add aria-label to identify video stream owner + let username = c.username || 'Participant'; + div.setAttribute('aria-label', `Video stream from ${username}`); + showHideMedia(c, div) let media = /** @type {HTMLVideoElement} */ @@ -2090,6 +2098,9 @@ async function setMedia(c, mirror, video) { media.autoplay = true; media.playsInline = true; media.id = 'media-' + c.localId; + // Add aria-label for screen readers + let username = c.username || 'Participant'; + media.setAttribute('aria-label', `Video from ${username}`); div.appendChild(media); addCustomControls(media, div, c, !!video); } @@ -2537,6 +2548,57 @@ document.getElementById('invite-dialog').onclose = function(e) { makeToken(template); }; +/** + * Adds keyboard navigation to Contextual menu + * @param {HTMLElement} menuElement + */ +function makeContextualMenuAccessible(menuElement) { + // Find all menu items + let menuItems = menuElement.querySelectorAll('.contextualMenuItem:not(.disabled)'); + if(menuItems.length === 0) return; + + let currentIndex = 0; + + // Make menu items focusable and add ARIA + menuItems.forEach((item, index) => { + item.setAttribute('role', 'menuitem'); + item.setAttribute('tabindex', index === 0 ? '0' : '-1'); + }); + + // Focus first item + menuItems[0].focus(); + + // Add keyboard navigation + menuElement.addEventListener('keydown', function(e) { + if(e.key === 'Escape') { + e.preventDefault(); + contextualCore.CloseMenu(); + return; + } + + if(e.key === 'ArrowDown') { + e.preventDefault(); + menuItems[currentIndex].setAttribute('tabindex', '-1'); + currentIndex = (currentIndex + 1) % menuItems.length; + menuItems[currentIndex].setAttribute('tabindex', '0'); + menuItems[currentIndex].focus(); + } else if(e.key === 'ArrowUp') { + e.preventDefault(); + menuItems[currentIndex].setAttribute('tabindex', '-1'); + currentIndex = currentIndex === 0 ? menuItems.length - 1 : currentIndex - 1; + menuItems[currentIndex].setAttribute('tabindex', '0'); + menuItems[currentIndex].focus(); + } else if(e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + // Click the parent li element which has the actual click handler + let parentLi = menuItems[currentIndex].parentElement; + if(parentLi) { + parentLi.click(); + } + } + }); +} + /** * @param {HTMLElement} elt */ @@ -2600,6 +2662,15 @@ function userMenu(elt) { new Contextual({ items: items, }); + + // Add keyboard navigation after menu is created + setTimeout(() => { + let menu = document.querySelector('.contextualMenu'); + if(menu) { + menu.setAttribute('role', 'menu'); + makeContextualMenuAccessible(menu); + } + }, 0); } /** @@ -2611,6 +2682,8 @@ function addUser(id, userinfo) { let user = document.createElement('div'); user.id = 'user-' + id; user.classList.add("user-p"); + user.setAttribute('role', 'button'); + user.setAttribute('tabindex', '0'); setUserStatus(id, user, userinfo); user.addEventListener('click', function(e) { let elt = e.currentTarget; @@ -2618,6 +2691,15 @@ function addUser(id, userinfo) { throw new Error("Couldn't find user div"); userMenu(elt); }); + user.addEventListener('keydown', function(e) { + if(e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + let elt = e.target; + if(!elt || !(elt instanceof HTMLElement)) + throw new Error("Couldn't find user div"); + userMenu(elt); + } + }); let us = div.children; @@ -2666,11 +2748,24 @@ function changeUser(id, userinfo) { * @param {user} userinfo */ function setUserStatus(id, elt, userinfo) { - elt.textContent = userinfo.username ? userinfo.username : '(anon)'; - if(userinfo.data.raisehand) + let username = userinfo.username ? userinfo.username : '(anon)'; + elt.textContent = username; + + let wasRaised = elt.classList.contains('user-status-raisehand'); + if(userinfo.data.raisehand) { elt.classList.add('user-status-raisehand'); - else + elt.setAttribute('aria-label', `${username} (hand raised)`); + // Announce when hand is newly raised (not on initial load) + if(!wasRaised && id !== (serverConnection && serverConnection.id)) { + let announcement = document.getElementById('chat-announcements'); + if(announcement) { + announcement.textContent = `${username} raised their hand`; + } + } + } else { elt.classList.remove('user-status-raisehand'); + elt.setAttribute('aria-label', username); + } let microphone=false, camera = false; for(let label in userinfo.streams) { @@ -2681,6 +2776,17 @@ function setUserStatus(id, elt, userinfo) { camera = true; } } + + // Build descriptive aria-label with media status + let statusParts = [username]; + if(userinfo.data.raisehand) + statusParts.push('hand raised'); + if(camera) + statusParts.push('camera on'); + if(microphone) + statusParts.push('microphone on'); + elt.setAttribute('aria-label', statusParts.join(', ')); + if(camera) { elt.classList.remove('user-status-microphone'); elt.classList.add('user-status-camera'); @@ -3350,7 +3456,7 @@ function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, m } if(doHeader) { - let header = document.createElement('p'); + let header = document.createElement('h3'); let user = document.createElement('span'); let u = dest && serverConnection.users[dest]; let name = (u && u.username); @@ -3399,6 +3505,15 @@ function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, m box.scrollTop = box.scrollHeight - box.clientHeight; } + // Announce new messages to screen readers (but not history) + if(!history) { + let announcement = document.getElementById('chat-announcements'); + if(announcement) { + let messageText = typeof message === 'string' ? message : body.textContent; + announcement.textContent = nick ? `${nick}: ${messageText}` : messageText; + } + } + return; } @@ -3442,6 +3557,15 @@ function chatMessageMenu(elt) { new Contextual({ items: items, }); + + // Add keyboard navigation after menu is created + setTimeout(() => { + let menu = document.querySelector('.contextualMenu'); + if(menu) { + menu.setAttribute('role', 'menu'); + makeContextualMenuAccessible(menu); + } + }, 0); } /** @@ -3560,7 +3684,11 @@ commands.help = { continue; cs.push(`/${cmd}${c.parameters?' ' + c.parameters:''}: ${c.description}`); } - localMessage(cs.sort().join('\n')); + let shortcuts = '\n\nKeyboard shortcuts:\n' + + 'Control+Alt+H: Raise or lower hand\n' + + 'Control+Alt+C: Expand or collapse chat\n' + + 'Control+Alt+T: Mute or unmute microphone'; + localMessage(cs.sort().join('\n') + shortcuts); } }; @@ -3634,6 +3762,24 @@ commands.unlock = { } }; +commands.raisehand = { + description: 'raise your hand', + f: (c, r) => { + if(!serverConnection || !serverConnection.id) + throw new Error('Not connected'); + serverConnection.userAction('setdata', serverConnection.id, {'raisehand': true}); + } +}; + +commands.unraisehand = { + description: 'lower your hand', + f: (c, r) => { + if(!serverConnection || !serverConnection.id) + throw new Error('Not connected'); + serverConnection.userAction('setdata', serverConnection.id, {'raisehand': null}); + } +}; + commands.record = { predicate: recordingPredicate, description: 'start recording', @@ -4292,6 +4438,30 @@ function chatResizer(e) { document.getElementById('resizer').addEventListener('mousedown', chatResizer, false); +// Add keyboard support for chat resizer +document.getElementById('resizer').addEventListener('keydown', function(e) { + if(e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.preventDefault(); + let full_width = document.getElementById("mainrow").offsetWidth; + let left = document.getElementById("left"); + let right = document.getElementById("right"); + let current_width = left.offsetWidth; + let step = 20; // pixels per keypress + + let new_width = e.key === 'ArrowLeft' ? current_width - step : current_width + step; + let left_percent = new_width * 100 / full_width; + + // set min chat width to 300px + let min_left_width = 300 * 100 / full_width; + if (left_percent < min_left_width) { + return; + } + + left.style.flex = left_percent.toString(); + right.style.flex = (100 - left_percent).toString(); + } +}, false); + /** * @param {unknown} message * @param {string} [level] @@ -4317,7 +4487,7 @@ function displayError(message, level) { /** @ts-ignore */ Toastify({ text: message, - duration: 4000, + duration: 8000, // Increased from 4000 for screen reader users close: true, position: position, gravity: gravity, @@ -4374,8 +4544,24 @@ function closeNav() { } document.getElementById('sidebarCollapse').onclick = function(e) { - document.getElementById("left-sidebar").classList.toggle("active"); + let sidebar = document.getElementById("left-sidebar"); + sidebar.classList.toggle("active"); document.getElementById("mainrow").classList.toggle("full-width-active"); + // Update aria-expanded to reflect the state + let isCollapsed = sidebar.classList.contains("active"); + e.currentTarget.setAttribute("aria-expanded", isCollapsed ? "false" : "true"); +}; + +document.getElementById('sidebarCollapse').onkeydown = function(e) { + if(e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + let sidebar = document.getElementById("left-sidebar"); + sidebar.classList.toggle("active"); + document.getElementById("mainrow").classList.toggle("full-width-active"); + // Update aria-expanded to reflect the state + let isCollapsed = sidebar.classList.contains("active"); + e.currentTarget.setAttribute("aria-expanded", isCollapsed ? "false" : "true"); + } }; document.getElementById('openside').onclick = function(e) { @@ -4389,12 +4575,32 @@ document.getElementById('openside').onclick = function(e) { } }; +document.getElementById('openside').onkeydown = function(e) { + if(e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + let sidewidth = document.getElementById("sidebarnav").style.width; + if (sidewidth !== "0px" && sidewidth !== "") { + closeNav(); + return; + } else { + openNav(); + } + } +}; -document.getElementById('clodeside').onclick = function(e) { + +document.getElementById('closeside').onclick = function(e) { e.preventDefault(); closeNav(); }; +document.getElementById('closeside').onkeydown = function(e) { + if(e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + closeNav(); + } +}; + document.getElementById('collapse-video').onclick = function(e) { e.preventDefault(); setVisibility('collapse-video', false); @@ -4402,6 +4608,15 @@ document.getElementById('collapse-video').onclick = function(e) { hideVideo(true); }; +document.getElementById('collapse-video').onkeydown = function(e) { + if(e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setVisibility('collapse-video', false); + setVisibility('show-video', true); + hideVideo(true); + } +}; + document.getElementById('show-video').onclick = function(e) { e.preventDefault(); setVisibility('video-container', true); @@ -4409,6 +4624,15 @@ document.getElementById('show-video').onclick = function(e) { setVisibility('show-video', false); }; +document.getElementById('show-video').onkeydown = function(e) { + if(e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setVisibility('video-container', true); + setVisibility('collapse-video', true); + setVisibility('show-video', false); + } +}; + document.getElementById('close-chat').onclick = function(e) { e.preventDefault(); setVisibility('left', false); @@ -4416,6 +4640,15 @@ document.getElementById('close-chat').onclick = function(e) { resizePeers(); }; +document.getElementById('close-chat').onkeydown = function(e) { + if(e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setVisibility('left', false); + setVisibility('show-chat', true); + resizePeers(); + } +}; + document.getElementById('show-chat').onclick = function(e) { e.preventDefault(); setVisibility('left', true); @@ -4423,6 +4656,15 @@ document.getElementById('show-chat').onclick = function(e) { resizePeers(); }; +document.getElementById('show-chat').onkeydown = function(e) { + if(e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setVisibility('left', true); + setVisibility('show-chat', false); + resizePeers(); + } +}; + async function serverConnect() { if(serverConnection && serverConnection.socket) serverConnection.close(); @@ -4503,4 +4745,119 @@ async function start() { setViewportHeight(); } +// Global keyboard shortcuts +document.addEventListener('keydown', function(e) { + // Control+Alt+H - Raise or lower hand + if(e.ctrlKey && e.altKey && e.key.toLowerCase() === 'h') { + e.preventDefault(); + if(!serverConnection || !serverConnection.id) { + return; + } + + let mydata = serverConnection.users[serverConnection.id].data; + let isRaised = mydata['raisehand']; + + // Toggle hand state + serverConnection.userAction( + 'setdata', + serverConnection.id, + {'raisehand': isRaised ? null : true} + ); + + // Announce the action + let announcement = document.getElementById('chat-announcements'); + if(announcement) { + announcement.textContent = isRaised ? 'Hand lowered' : 'Hand raised'; + } + + // Focus on user's name button in the user list (delayed to ensure announcement is read) + setTimeout(function() { + let userButton = document.getElementById('user-' + serverConnection.id); + if(userButton) { + userButton.focus(); + } + }, 100); + } + + // Control+Alt+C - Expand or collapse chat + if(e.ctrlKey && e.altKey && e.key.toLowerCase() === 'c') { + e.preventDefault(); + + let leftPanel = document.getElementById('left'); + let showChatButton = document.getElementById('show-chat'); + let closeChatButton = document.getElementById('close-chat'); + + // Check if chat is currently visible + let isChatVisible = leftPanel && leftPanel.style.display !== 'none'; + + if(isChatVisible) { + // Collapse chat + setVisibility('left', false); + setVisibility('show-chat', true); + resizePeers(); + + // Announce the action + let announcement = document.getElementById('chat-announcements'); + if(announcement) { + announcement.textContent = 'Chat collapsed'; + } + + // Focus the show-chat button (delayed to ensure announcement is read) + setTimeout(function() { + if(showChatButton) { + showChatButton.focus(); + } + }, 100); + } else { + // Expand chat + setVisibility('left', true); + setVisibility('show-chat', false); + resizePeers(); + + // Announce the action + let announcement = document.getElementById('chat-announcements'); + if(announcement) { + announcement.textContent = 'Chat expanded'; + } + + // Focus the close-chat button (delayed to ensure announcement is read) + setTimeout(function() { + if(closeChatButton) { + closeChatButton.focus(); + } + }, 100); + } + } + + // Control+Alt+T - Mute or unmute microphone + if(e.ctrlKey && e.altKey && e.key.toLowerCase() === 't') { + e.preventDefault(); + let muteButton = document.getElementById('mutebutton'); + if(!muteButton) { + return; + } + + let localMute = getSettings().localMute; + if (localMute && !findUpMedia('camera')) { + displayMessage('Please use Enable to enable your camera or microphone.'); + return; + } + + // Toggle mute state + localMute = !localMute; + setLocalMute(localMute, true); + + // Announce the action + let announcement = document.getElementById('chat-announcements'); + if(announcement) { + announcement.textContent = localMute ? 'Microphone muted' : 'Microphone unmuted'; + } + + // Focus the mute button (delayed to ensure announcement is read) + setTimeout(function() { + muteButton.focus(); + }, 100); + } +}); + start();