richard pushed to branch tor-browser-115.7.0esr-13.5-1 at The Tor Project / Applications / Tor Browser
Commits:
-
d66eecaa
by Henry Wilkes at 2024-01-31T09:28:02+00:00
-
48110609
by Henry Wilkes at 2024-01-31T09:28:03+00:00
6 changed files:
- browser/components/torpreferences/content/connectionPane.js
- + browser/components/torpreferences/content/loxInviteDialog.js
- + browser/components/torpreferences/content/loxInviteDialog.xhtml
- browser/components/torpreferences/content/torPreferences.css
- browser/components/torpreferences/jar.mn
- browser/locales/en-US/browser/tor-browser.ftl
Changes:
... | ... | @@ -1321,7 +1321,17 @@ const gLoxStatus = { |
1321 | 1321 | );
|
1322 | 1322 | |
1323 | 1323 | this._invitesButton.addEventListener("click", () => {
|
1324 | - // TODO: Show invites.
|
|
1324 | + gSubDialog.open(
|
|
1325 | + "chrome://browser/content/torpreferences/loxInviteDialog.xhtml",
|
|
1326 | + {
|
|
1327 | + features: "resizable=yes",
|
|
1328 | + closedCallback: () => {
|
|
1329 | + // TODO: Listen for events from Lox, rather than call _updateInvites
|
|
1330 | + // directly.
|
|
1331 | + this._updateInvites();
|
|
1332 | + },
|
|
1333 | + }
|
|
1334 | + );
|
|
1325 | 1335 | });
|
1326 | 1336 | this._unlockAlertButton.addEventListener("click", () => {
|
1327 | 1337 | // TODO: Have a way to ensure that the cleared event data matches the
|
1 | +"use strict";
|
|
2 | + |
|
3 | +const { TorSettings, TorSettingsTopics, TorBridgeSource } =
|
|
4 | + ChromeUtils.importESModule("resource://gre/modules/TorSettings.sys.mjs");
|
|
5 | + |
|
6 | +const { Lox, LoxErrors } = ChromeUtils.importESModule(
|
|
7 | + "resource://gre/modules/Lox.sys.mjs"
|
|
8 | +);
|
|
9 | + |
|
10 | +/**
|
|
11 | + * Fake Lox module
|
|
12 | + |
|
13 | +const LoxErrors = {
|
|
14 | + LoxServerUnreachable: "LoxServerUnreachable",
|
|
15 | + Other: "Other",
|
|
16 | +};
|
|
17 | + |
|
18 | +const Lox = {
|
|
19 | + remainingInvites: 5,
|
|
20 | + getRemainingInviteCount() {
|
|
21 | + return this.remainingInvites;
|
|
22 | + },
|
|
23 | + invites: [
|
|
24 | + '{"invite": [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22]}',
|
|
25 | + '{"invite": [9,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22]}',
|
|
26 | + ],
|
|
27 | + getInvites() {
|
|
28 | + return this.invites;
|
|
29 | + },
|
|
30 | + failError: null,
|
|
31 | + generateInvite() {
|
|
32 | + return new Promise((res, rej) => {
|
|
33 | + setTimeout(() => {
|
|
34 | + if (this.failError) {
|
|
35 | + rej({ type: this.failError });
|
|
36 | + return;
|
|
37 | + }
|
|
38 | + if (!this.remainingInvites) {
|
|
39 | + rej({ type: LoxErrors.Other });
|
|
40 | + return;
|
|
41 | + }
|
|
42 | + const invite = JSON.stringify({
|
|
43 | + invite: Array.from({ length: 100 }, () =>
|
|
44 | + Math.floor(Math.random() * 265)
|
|
45 | + ),
|
|
46 | + });
|
|
47 | + this.invites.push(invite);
|
|
48 | + this.remainingInvites--;
|
|
49 | + res(invite);
|
|
50 | + }, 4000);
|
|
51 | + });
|
|
52 | + },
|
|
53 | +};
|
|
54 | +*/
|
|
55 | + |
|
56 | +const gLoxInvites = {
|
|
57 | + /**
|
|
58 | + * Initialize the dialog.
|
|
59 | + */
|
|
60 | + init() {
|
|
61 | + this._dialog = document.getElementById("lox-invite-dialog");
|
|
62 | + this._remainingInvitesEl = document.getElementById(
|
|
63 | + "lox-invite-dialog-remaining"
|
|
64 | + );
|
|
65 | + this._generateButton = document.getElementById(
|
|
66 | + "lox-invite-dialog-generate-button"
|
|
67 | + );
|
|
68 | + this._connectingEl = document.getElementById(
|
|
69 | + "lox-invite-dialog-connecting"
|
|
70 | + );
|
|
71 | + this._errorEl = document.getElementById("lox-invite-dialog-error-message");
|
|
72 | + this._inviteListEl = document.getElementById("lox-invite-dialog-list");
|
|
73 | + |
|
74 | + this._generateButton.addEventListener("click", () => {
|
|
75 | + this._generateNewInvite();
|
|
76 | + });
|
|
77 | + |
|
78 | + const menu = document.getElementById("lox-invite-dialog-item-menu");
|
|
79 | + this._inviteListEl.addEventListener("contextmenu", event => {
|
|
80 | + if (!this._inviteListEl.selectedItem) {
|
|
81 | + return;
|
|
82 | + }
|
|
83 | + menu.openPopupAtScreen(event.screenX, event.screenY, true);
|
|
84 | + });
|
|
85 | + menu.addEventListener("popuphidden", () => {
|
|
86 | + menu.setAttribute("aria-hidden", "true");
|
|
87 | + });
|
|
88 | + menu.addEventListener("popupshowing", () => {
|
|
89 | + menu.removeAttribute("aria-hidden");
|
|
90 | + });
|
|
91 | + document
|
|
92 | + .getElementById("lox-invite-dialog-copy-menu-item")
|
|
93 | + .addEventListener("command", () => {
|
|
94 | + const selected = this._inviteListEl.selectedItem;
|
|
95 | + if (!selected) {
|
|
96 | + return;
|
|
97 | + }
|
|
98 | + const clipboard = Cc[
|
|
99 | + "@mozilla.org/widget/clipboardhelper;1"
|
|
100 | + ].getService(Ci.nsIClipboardHelper);
|
|
101 | + clipboard.copyString(selected.textContent);
|
|
102 | + });
|
|
103 | + |
|
104 | + // NOTE: TorSettings should already be initialized when this dialog is
|
|
105 | + // opened.
|
|
106 | + Services.obs.addObserver(this, TorSettingsTopics.SettingsChanged);
|
|
107 | + // TODO: Listen for new invites from Lox, when supported.
|
|
108 | + |
|
109 | + // Set initial _loxId value. Can close this dialog.
|
|
110 | + this._updateLoxId();
|
|
111 | + |
|
112 | + this._updateRemainingInvites();
|
|
113 | + this._updateExistingInvites();
|
|
114 | + },
|
|
115 | + |
|
116 | + /**
|
|
117 | + * Un-initialize the dialog.
|
|
118 | + */
|
|
119 | + uninit() {
|
|
120 | + Services.obs.removeObserver(this, TorSettingsTopics.SettingsChanged);
|
|
121 | + },
|
|
122 | + |
|
123 | + observe(subject, topic, data) {
|
|
124 | + switch (topic) {
|
|
125 | + case TorSettingsTopics.SettingsChanged:
|
|
126 | + const { changes } = subject.wrappedJSObject;
|
|
127 | + if (
|
|
128 | + changes.includes("bridges.source") ||
|
|
129 | + changes.includes("bridges.lox_id")
|
|
130 | + ) {
|
|
131 | + this._updateLoxId();
|
|
132 | + }
|
|
133 | + break;
|
|
134 | + }
|
|
135 | + },
|
|
136 | + |
|
137 | + /**
|
|
138 | + * The loxId this dialog is shown for. null if uninitailized.
|
|
139 | + *
|
|
140 | + * @type {string?}
|
|
141 | + */
|
|
142 | + _loxId: null,
|
|
143 | + /**
|
|
144 | + * Update the _loxId value. Will close the dialog if it changes after
|
|
145 | + * initialization.
|
|
146 | + */
|
|
147 | + _updateLoxId() {
|
|
148 | + const loxId =
|
|
149 | + TorSettings.bridges.source === TorBridgeSource.Lox
|
|
150 | + ? TorSettings.bridges.lox_id
|
|
151 | + : "";
|
|
152 | + if (!loxId || (this._loxId !== null && loxId !== this._loxId)) {
|
|
153 | + // No lox id, or it changed. Close this dialog.
|
|
154 | + this._dialog.cancelDialog();
|
|
155 | + }
|
|
156 | + this._loxId = loxId;
|
|
157 | + },
|
|
158 | + |
|
159 | + /**
|
|
160 | + * The invites that are already shown.
|
|
161 | + *
|
|
162 | + * @type {Set<string>}
|
|
163 | + */
|
|
164 | + _shownInvites: new Set(),
|
|
165 | + |
|
166 | + /**
|
|
167 | + * Add a new invite at the start of the list.
|
|
168 | + *
|
|
169 | + * @param {string} invite - The invite to add.
|
|
170 | + */
|
|
171 | + _addInvite(invite) {
|
|
172 | + if (this._shownInvites.has(invite)) {
|
|
173 | + return;
|
|
174 | + }
|
|
175 | + const newInvite = document.createXULElement("richlistitem");
|
|
176 | + newInvite.classList.add("lox-invite-dialog-list-item");
|
|
177 | + newInvite.textContent = invite;
|
|
178 | + |
|
179 | + this._inviteListEl.prepend(newInvite);
|
|
180 | + this._shownInvites.add(invite);
|
|
181 | + },
|
|
182 | + |
|
183 | + /**
|
|
184 | + * Update the display of the existing invites.
|
|
185 | + */
|
|
186 | + _updateExistingInvites() {
|
|
187 | + // Add new invites.
|
|
188 | + |
|
189 | + // NOTE: we only expect invites to be appended, so we won't re-order any.
|
|
190 | + // NOTE: invites are ordered with the oldest first.
|
|
191 | + for (const invite of Lox.getInvites()) {
|
|
192 | + this._addInvite(invite);
|
|
193 | + }
|
|
194 | + },
|
|
195 | + |
|
196 | + /**
|
|
197 | + * The shown number or remaining invites we have.
|
|
198 | + *
|
|
199 | + * @type {integer}
|
|
200 | + */
|
|
201 | + _remainingInvites: 0,
|
|
202 | + |
|
203 | + /**
|
|
204 | + * Update the display of the remaining invites.
|
|
205 | + */
|
|
206 | + _updateRemainingInvites() {
|
|
207 | + this._remainingInvites = Lox.getRemainingInviteCount();
|
|
208 | + |
|
209 | + document.l10n.setAttributes(
|
|
210 | + this._remainingInvitesEl,
|
|
211 | + "tor-bridges-lox-remaining-invites",
|
|
212 | + { numInvites: this._remainingInvites }
|
|
213 | + );
|
|
214 | + this._updateGenerateButtonState();
|
|
215 | + },
|
|
216 | + |
|
217 | + /**
|
|
218 | + * Whether we are currently generating an invite.
|
|
219 | + *
|
|
220 | + * @type {boolean}
|
|
221 | + */
|
|
222 | + _generating: false,
|
|
223 | + /**
|
|
224 | + * Set whether we are generating an invite.
|
|
225 | + *
|
|
226 | + * @param {boolean} isGenerating - Whether we are generating.
|
|
227 | + */
|
|
228 | + _setGenerating(isGenerating) {
|
|
229 | + this._generating = isGenerating;
|
|
230 | + this._updateGenerateButtonState();
|
|
231 | + this._connectingEl.classList.toggle("show-connecting", isGenerating);
|
|
232 | + },
|
|
233 | + |
|
234 | + /**
|
|
235 | + * Update the state of the generate button.
|
|
236 | + */
|
|
237 | + _updateGenerateButtonState() {
|
|
238 | + this._generateButton.disabled = this._generating || !this._remainingInvites;
|
|
239 | + },
|
|
240 | + |
|
241 | + /**
|
|
242 | + * Start generating a new invite.
|
|
243 | + */
|
|
244 | + _generateNewInvite() {
|
|
245 | + if (this._generating) {
|
|
246 | + console.error("Already generating an invite");
|
|
247 | + return;
|
|
248 | + }
|
|
249 | + this._setGenerating(true);
|
|
250 | + // Clear the previous error.
|
|
251 | + this._updateGenerateError(null);
|
|
252 | + // Move focus from the button to the connecting element, since button is
|
|
253 | + // now disabled.
|
|
254 | + this._connectingEl.focus();
|
|
255 | + |
|
256 | + let lostFocus = false;
|
|
257 | + Lox.generateInvite()
|
|
258 | + .finally(() => {
|
|
259 | + // Fetch whether the connecting label still has focus before we hide it.
|
|
260 | + lostFocus = this._connectingEl.contains(document.activeElement);
|
|
261 | + this._setGenerating(false);
|
|
262 | + })
|
|
263 | + .then(
|
|
264 | + invite => {
|
|
265 | + this._addInvite(invite);
|
|
266 | + |
|
267 | + if (!this._inviteListEl.contains(document.activeElement)) {
|
|
268 | + // Does not have focus, change the selected item to be the new
|
|
269 | + // invite (at index 0).
|
|
270 | + this._inviteListEl.selectedIndex = 0;
|
|
271 | + }
|
|
272 | + |
|
273 | + if (lostFocus) {
|
|
274 | + // Move focus to the new invite before we hide the "Connecting"
|
|
275 | + // message.
|
|
276 | + this._inviteListEl.focus();
|
|
277 | + }
|
|
278 | + |
|
279 | + // TODO: When Lox sends out notifications, let the observer handle the
|
|
280 | + // change rather than calling _updateRemainingInvites directly.
|
|
281 | + this._updateRemainingInvites();
|
|
282 | + },
|
|
283 | + loxError => {
|
|
284 | + console.error("Failed to generate an invite", loxError);
|
|
285 | + switch (loxError.type) {
|
|
286 | + case LoxErrors.LoxServerUnreachable:
|
|
287 | + this._updateGenerateError("no-server");
|
|
288 | + break;
|
|
289 | + default:
|
|
290 | + this._updateGenerateError("generic");
|
|
291 | + break;
|
|
292 | + }
|
|
293 | + |
|
294 | + if (lostFocus) {
|
|
295 | + // Move focus back to the button before we hide the "Connecting"
|
|
296 | + // message.
|
|
297 | + this._generateButton.focus();
|
|
298 | + }
|
|
299 | + }
|
|
300 | + );
|
|
301 | + },
|
|
302 | + |
|
303 | + /**
|
|
304 | + * Update the shown generation error.
|
|
305 | + *
|
|
306 | + * @param {string?} type - The error type, or null if no error should be
|
|
307 | + * shown.
|
|
308 | + */
|
|
309 | + _updateGenerateError(type) {
|
|
310 | + // First clear the existing error.
|
|
311 | + this._errorEl.removeAttribute("data-l10n-id");
|
|
312 | + this._errorEl.textContent = "";
|
|
313 | + this._errorEl.classList.toggle("show-error", !!type);
|
|
314 | + |
|
315 | + if (!type) {
|
|
316 | + return;
|
|
317 | + }
|
|
318 | + |
|
319 | + let errorId;
|
|
320 | + switch (type) {
|
|
321 | + case "no-server":
|
|
322 | + errorId = "lox-invite-dialog-no-server-error";
|
|
323 | + break;
|
|
324 | + case "generic":
|
|
325 | + // Generic error.
|
|
326 | + errorId = "lox-invite-dialog-generic-invite-error";
|
|
327 | + break;
|
|
328 | + }
|
|
329 | + |
|
330 | + document.l10n.setAttributes(this._errorEl, errorId);
|
|
331 | + },
|
|
332 | +};
|
|
333 | + |
|
334 | +window.addEventListener(
|
|
335 | + "DOMContentLoaded",
|
|
336 | + () => {
|
|
337 | + gLoxInvites.init();
|
|
338 | + window.addEventListener(
|
|
339 | + "unload",
|
|
340 | + () => {
|
|
341 | + gLoxInvites.uninit();
|
|
342 | + },
|
|
343 | + { once: true }
|
|
344 | + );
|
|
345 | + },
|
|
346 | + { once: true }
|
|
347 | +); |
1 | +<?xml version="1.0" encoding="UTF-8"?>
|
|
2 | +<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
|
|
3 | +<?xml-stylesheet href="chrome://browser/skin/preferences/preferences.css"?>
|
|
4 | +<?xml-stylesheet href="chrome://browser/content/torpreferences/torPreferences.css"?>
|
|
5 | + |
|
6 | +<window
|
|
7 | + type="child"
|
|
8 | + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
|
|
9 | + xmlns:html="http://www.w3.org/1999/xhtml"
|
|
10 | + data-l10n-id="lox-invite-dialog-title"
|
|
11 | +>
|
|
12 | + <!-- Context menu, aria-hidden whilst not shown so it does not appear in the
|
|
13 | + - document content. -->
|
|
14 | + <menupopup id="lox-invite-dialog-item-menu" aria-hidden="true">
|
|
15 | + <menuitem
|
|
16 | + id="lox-invite-dialog-copy-menu-item"
|
|
17 | + data-l10n-id="lox-invite-dialog-menu-item-copy-invite"
|
|
18 | + />
|
|
19 | + </menupopup>
|
|
20 | + <dialog id="lox-invite-dialog" buttons="accept">
|
|
21 | + <linkset>
|
|
22 | + <html:link rel="localization" href="browser/tor-browser.ftl" />
|
|
23 | + </linkset>
|
|
24 | + |
|
25 | + <script src="chrome://browser/content/torpreferences/loxInviteDialog.js" />
|
|
26 | + |
|
27 | + <description data-l10n-id="lox-invite-dialog-description"></description>
|
|
28 | + <html:div id="lox-invite-dialog-generate-area">
|
|
29 | + <html:span id="lox-invite-dialog-remaining"></html:span>
|
|
30 | + <html:button
|
|
31 | + id="lox-invite-dialog-generate-button"
|
|
32 | + data-l10n-id="lox-invite-dialog-request-button"
|
|
33 | + ></html:button>
|
|
34 | + <html:div id="lox-invite-dialog-message-area">
|
|
35 | + <html:span
|
|
36 | + id="lox-invite-dialog-error-message"
|
|
37 | + role="alert"
|
|
38 | + ></html:span>
|
|
39 | + <html:span
|
|
40 | + id="lox-invite-dialog-connecting"
|
|
41 | + role="alert"
|
|
42 | + tabindex="0"
|
|
43 | + data-l10n-id="lox-invite-dialog-connecting"
|
|
44 | + ></html:span>
|
|
45 | + </html:div>
|
|
46 | + </html:div>
|
|
47 | + <html:div
|
|
48 | + id="lox-invite-dialog-list-label"
|
|
49 | + data-l10n-id="lox-invite-dialog-invites-label"
|
|
50 | + ></html:div>
|
|
51 | + <richlistbox
|
|
52 | + id="lox-invite-dialog-list"
|
|
53 | + aria-labelledby="lox-invite-dialog-list-label"
|
|
54 | + ></richlistbox>
|
|
55 | + </dialog>
|
|
56 | +</window> |
... | ... | @@ -820,6 +820,75 @@ dialog#torPreferences-requestBridge-dialog > hbox { |
820 | 820 | background: var(--qr-one);
|
821 | 821 | }
|
822 | 822 | |
823 | +/* Lox invite dialog */
|
|
824 | + |
|
825 | +#lox-invite-dialog-generate-area {
|
|
826 | + flex: 0 0 auto;
|
|
827 | + display: grid;
|
|
828 | + grid-template:
|
|
829 | + ". remaining button" min-content
|
|
830 | + "message message message" auto
|
|
831 | + / 1fr max-content max-content;
|
|
832 | + gap: 8px;
|
|
833 | + margin-block: 16px 8px;
|
|
834 | + align-items: center;
|
|
835 | +}
|
|
836 | + |
|
837 | +#lox-invite-dialog-remaining {
|
|
838 | + grid-area: remaining;
|
|
839 | +}
|
|
840 | + |
|
841 | +#lox-invite-dialog-generate-button {
|
|
842 | + grid-area: button;
|
|
843 | +}
|
|
844 | + |
|
845 | +#lox-invite-dialog-message-area {
|
|
846 | + grid-area: message;
|
|
847 | + justify-self: end;
|
|
848 | +}
|
|
849 | + |
|
850 | +#lox-invite-dialog-message-area::after {
|
|
851 | + /* Zero width space, to ensure we are always one line high. */
|
|
852 | + content: "\200B";
|
|
853 | +}
|
|
854 | + |
|
855 | +#lox-invite-dialog-error-message {
|
|
856 | + color: var(--in-content-error-text-color);
|
|
857 | +}
|
|
858 | + |
|
859 | +#lox-invite-dialog-error-message:not(.show-error) {
|
|
860 | + display: none;
|
|
861 | +}
|
|
862 | + |
|
863 | +#lox-invite-dialog-connecting {
|
|
864 | + color: var(--text-color-deemphasized);
|
|
865 | + /* TODO: Add spinner ::before */
|
|
866 | +}
|
|
867 | + |
|
868 | +#lox-invite-dialog-connecting:not(.show-connecting) {
|
|
869 | + display: none;
|
|
870 | +}
|
|
871 | + |
|
872 | +#lox-invite-dialog-list-label {
|
|
873 | + font-weight: 700;
|
|
874 | +}
|
|
875 | + |
|
876 | +#lox-invite-dialog-list {
|
|
877 | + flex: 1 1 auto;
|
|
878 | + /* basis height */
|
|
879 | + height: 10em;
|
|
880 | + margin-block: 8px;
|
|
881 | +}
|
|
882 | + |
|
883 | +.lox-invite-dialog-list-item {
|
|
884 | + white-space: nowrap;
|
|
885 | + overflow-x: hidden;
|
|
886 | + /* FIXME: ellipsis does not show. */
|
|
887 | + text-overflow: ellipsis;
|
|
888 | + padding-block: 6px;
|
|
889 | + padding-inline: 8px;
|
|
890 | +}
|
|
891 | + |
|
823 | 892 | /* Builtin bridge dialog */
|
824 | 893 | #torPreferences-builtinBridge-header {
|
825 | 894 | margin: 8px 0 10px 0;
|
... | ... | @@ -9,6 +9,8 @@ browser.jar: |
9 | 9 | content/browser/torpreferences/lox-success.svg (content/lox-success.svg)
|
10 | 10 | content/browser/torpreferences/lox-complete-ring.svg (content/lox-complete-ring.svg)
|
11 | 11 | content/browser/torpreferences/lox-progress-ring.svg (content/lox-progress-ring.svg)
|
12 | + content/browser/torpreferences/loxInviteDialog.xhtml (content/loxInviteDialog.xhtml)
|
|
13 | + content/browser/torpreferences/loxInviteDialog.js (content/loxInviteDialog.js)
|
|
12 | 14 | content/browser/torpreferences/bridgeQrDialog.xhtml (content/bridgeQrDialog.xhtml)
|
13 | 15 | content/browser/torpreferences/bridgeQrDialog.js (content/bridgeQrDialog.js)
|
14 | 16 | content/browser/torpreferences/builtinBridgeDialog.xhtml (content/builtinBridgeDialog.xhtml)
|
... | ... | @@ -296,3 +296,17 @@ user-provide-bridge-dialog-result-invite = The following bridges were shared wit |
296 | 296 | user-provide-bridge-dialog-result-addresses = The following bridges were entered by you.
|
297 | 297 | user-provide-bridge-dialog-next-button =
|
298 | 298 | .label = Next
|
299 | + |
|
300 | +## Bridge pass invite dialog. Temporary.
|
|
301 | + |
|
302 | +lox-invite-dialog-title =
|
|
303 | + .title = Bridge pass invites
|
|
304 | +lox-invite-dialog-description = You can ask the bridge bot to create a new invite, which you can share with a trusted contact to give them their own bridge pass. Each invite can only be redeemed once, but you will unlock access to more invites over time.
|
|
305 | +lox-invite-dialog-request-button = Request new invite
|
|
306 | +lox-invite-dialog-connecting = Connecting to bridge pass server…
|
|
307 | +lox-invite-dialog-no-server-error = Unable to connect to bridge pass server.
|
|
308 | +lox-invite-dialog-generic-invite-error = Failed to create a new invite.
|
|
309 | +lox-invite-dialog-invites-label = Created invites:
|
|
310 | +lox-invite-dialog-menu-item-copy-invite =
|
|
311 | + .label = Copy invite
|
|
312 | + .accesskey = C |