Skip to content

Commit 9d21ed0

Browse files
committed
Add WhatsApp.
1. Pressing alt+1 focuses the first chat in the Chats list. 2. Pressing alt+2 focuses the last message in the active chat. 3. Pressing the context menu key opens the context menu for the current chat or message. 4. With NVDA, the reaction picker no longer switches to browse mode and starts reading all the reactions.
1 parent fd19216 commit 9d21ed0

File tree

1 file changed

+261
-0
lines changed

1 file changed

+261
-0
lines changed

WhatsAppAccessibilityFixes.user.js

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// ==UserScript==
2+
// @name WhatsApp Web Accessibility Fixes
3+
// @namespace http://axSgrease.nvaccess.org/
4+
// @description Improves the accessibility of WhatsApp Web.
5+
// @author James Teh <jamie@jantrid.net>
6+
// @copyright 2019-2025 Mozilla Corporation, Derek Riemer, James Teh
7+
// @license Mozilla Public License version 2.0
8+
// @version 2025.1
9+
// @include https://web.whatsapp.com/
10+
// ==/UserScript==
11+
12+
/*** Functions for common tweaks. ***/
13+
14+
/**
15+
* Adds text to the given live region, and clears it a second later so it's no
16+
* longer perceivable.
17+
* @param {string} regionid an id of a region.
18+
*/
19+
function announce(text, regionId) {
20+
getLiveRegion(regionId)
21+
.then((region) => {
22+
region.innerText = text;
23+
setTimeout(() => {
24+
region.innerText = '';
25+
}, 1000);
26+
});
27+
}
28+
29+
/**
30+
* create or fetch a live region that can be used with announce(). Returns a promise with the region.
31+
* @param {string} id the name of the new live region. This is an html id.
32+
* @return {!Promise<HTMLElement>} a div that contains the live region. This can typically be ignored, this exists to aid in chaining creation of non-existant regions.
33+
*/
34+
function getLiveRegion(id) {
35+
const updatePromise = new Promise((resolve, reject) => {
36+
if (!id) {
37+
reject('Need a valid id!');
38+
return;
39+
}
40+
const existingRegion = document.getElementById(id);
41+
if (existingRegion) {
42+
resolve(existingRegion);
43+
return;
44+
}
45+
const region = document.createElement('div');
46+
region.id = id;
47+
region.setAttribute('aria-live', 'polite');
48+
region.setAttribute('aria-atomic', 'true');
49+
region.style.position = 'absolute';
50+
region.style.width = '50px';
51+
region.style.height = '50px';
52+
region.style.opasity = 0;
53+
document.body.appendChild(region);
54+
// we need to delay a little to get the new region to actually read contents.
55+
// A11y APIs probably don't treat the relevant changes as "additions" until
56+
//an annimation frame has passed. It may, in reality be more like 2-4
57+
// annimation frames, so delay 134 ms to be safe.
58+
setTimeout(() => {
59+
resolve(region);
60+
}, 134);
61+
});
62+
return updatePromise;
63+
}
64+
65+
function makeHeading(el, level) {
66+
el.setAttribute("role", "heading");
67+
el.setAttribute("aria-level", level);
68+
}
69+
70+
function makeRegion(el, label) {
71+
el.setAttribute("role", "region");
72+
el.setAttribute("aria-label", label);
73+
}
74+
75+
function makeButton(el, label) {
76+
el.setAttribute("role", "button");
77+
if (label) {
78+
el.setAttribute("aria-label", label);
79+
}
80+
}
81+
82+
function makePresentational(el) {
83+
el.setAttribute("role", "presentation");
84+
}
85+
86+
function setLabel(el, label) {
87+
el.setAttribute("aria-label", label);
88+
}
89+
90+
function makeHidden(el) {
91+
el.setAttribute("aria-hidden", "true");
92+
}
93+
94+
function setExpanded(el, expanded) {
95+
el.setAttribute("aria-expanded", expanded ? "true" : "false");
96+
}
97+
98+
var idCounter = 0;
99+
// Get a node's id. If it doesn't have one, make and set one first.
100+
function setAriaIdIfNecessary(elem) {
101+
if (!elem.id) {
102+
elem.setAttribute("id", "axsg-" + idCounter++);
103+
}
104+
return elem.id;
105+
}
106+
107+
function makeElementOwn(parentElement, listOfNodes) {
108+
ids = [];
109+
for (let node of listOfNodes) {
110+
ids.push(setAriaIdIfNecessary(node));
111+
}
112+
parentElement.setAttribute("aria-owns", ids.join(" "));
113+
}
114+
115+
// Focus something even if it wasn't made focusable by the author.
116+
function forceFocus(el) {
117+
let focusable = el.hasAttribute("tabindex");
118+
if (focusable) {
119+
el.focus();
120+
return;
121+
}
122+
el.setAttribute("tabindex", "-1");
123+
el.focus();
124+
}
125+
126+
/*** Code to apply the tweaks when appropriate. ***/
127+
128+
function applyTweak(el, tweak) {
129+
if (Array.isArray(tweak.tweak)) {
130+
let [func, ...args] = tweak.tweak;
131+
func(el, ...args);
132+
} else {
133+
tweak.tweak(el);
134+
}
135+
}
136+
137+
function applyTweaks(root, tweaks, checkRoot, forAttrChange = false) {
138+
for (let tweak of tweaks) {
139+
if (!forAttrChange || tweak.whenAttrChangedOnAncestor !== false) {
140+
for (let el of root.querySelectorAll(tweak.selector)) {
141+
try {
142+
applyTweak(el, tweak);
143+
} catch (e) {
144+
console.log("Exception while applying tweak for '" + tweak.selector + "': " + e);
145+
}
146+
}
147+
}
148+
if (checkRoot && root.matches(tweak.selector)) {
149+
try {
150+
applyTweak(root, tweak);
151+
} catch (e) {
152+
console.log("Exception while applying tweak for '" + tweak.selector + "': " + e);
153+
}
154+
}
155+
}
156+
}
157+
158+
let observer = new MutationObserver(function (mutations) {
159+
for (let mutation of mutations) {
160+
try {
161+
if (mutation.type === "childList") {
162+
for (let node of mutation.addedNodes) {
163+
if (node.nodeType != Node.ELEMENT_NODE) {
164+
continue;
165+
}
166+
applyTweaks(node, DYNAMIC_TWEAKS, true);
167+
}
168+
} else if (mutation.type === "attributes") {
169+
applyTweaks(mutation.target, DYNAMIC_TWEAKS, true, true);
170+
}
171+
} catch (e) {
172+
// Catch exceptions for individual mutations so other mutations are still handled.
173+
console.log("Exception while handling mutation: " + e);
174+
}
175+
}
176+
});
177+
178+
function init() {
179+
applyTweaks(document, LOAD_TWEAKS, false);
180+
applyTweaks(document, DYNAMIC_TWEAKS, false);
181+
options = { childList: true, subtree: true };
182+
if (DYNAMIC_TWEAK_ATTRIBS.length > 0) {
183+
options.attributes = true;
184+
options.attributeFilter = DYNAMIC_TWEAK_ATTRIBS;
185+
}
186+
observer.observe(document, options);
187+
}
188+
189+
/*** Define the actual tweaks. ***/
190+
191+
// Tweaks that only need to be applied on load.
192+
const LOAD_TWEAKS = [
193+
];
194+
195+
// Attributes that should be watched for changes and cause dynamic tweaks to be
196+
// applied.
197+
const DYNAMIC_TWEAK_ATTRIBS = [];
198+
199+
// Tweaks that must be applied whenever an element is added/changed.
200+
const DYNAMIC_TWEAKS = [
201+
{selector: '[role=dialog]',
202+
tweak: el => {
203+
if (el.querySelector('[role=button][aria-pressed] img')) {
204+
// This is the reaction picker. It contains buttons which wilh switch to
205+
// browse mode, but we want to use WhatsApp's own arrow key navigation
206+
// here. Therefore, use role="application". Menu would be more appropriate,
207+
// but that's tricky because WhatsApp uses aria-pressed on the buttons.
208+
el.role = "application";
209+
}
210+
}
211+
},
212+
];
213+
214+
/** add your specific initialization here, so that if you ever update the framework from new skeleton your inits are not overridden. */
215+
function userInit(){
216+
document.addEventListener("keydown", event => {
217+
// Make alt+1 focus the first chat in the Chats list.
218+
if (event.altKey && event.key == "1") {
219+
const firstChat = document.querySelector('[role=grid] [role=gridcell] [aria-selected]');
220+
if (firstChat) {
221+
firstChat.focus();
222+
}
223+
return;
224+
}
225+
// Make alt+2 focus the last message in the active chat.
226+
if (event.altKey && event.key == "2") {
227+
const messages = document.querySelectorAll(".focusable-list-item");
228+
if (messages.length > 0) {
229+
const lastMessage = messages[messages.length - 1];
230+
if (!lastMessage.hasAttribute("tabindex")) {
231+
// Messages don't initially have the tabindex attribute, but they gain it
232+
// once you navigate to them with the keyboard.
233+
lastMessage.setAttribute("tabindex", 0);
234+
}
235+
lastMessage.focus();
236+
}
237+
return;
238+
}
239+
});
240+
document.addEventListener("contextmenu", event => {
241+
// The context menu key doesn't work properly for chat and message items.
242+
// Fix that.
243+
const focus = document.activeElement;
244+
const button = focus.querySelector(
245+
focus.classList.contains("focusable-list-item") ?
246+
// Message
247+
'[role=button][aria-expanded]' :
248+
// Chat
249+
'button'
250+
);
251+
if (button) {
252+
event.preventDefault();
253+
event.stopPropagation();
254+
button.click();
255+
}
256+
}, { capture: true });
257+
}
258+
259+
/*** Lights, camera, action! ***/
260+
init();
261+
userInit();

0 commit comments

Comments
 (0)