LinkedIn Tool

Minor enhancements to LinkedIn. Mostly just hotkeys.

目前为 2023-08-14 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name LinkedIn Tool
  3. // @namespace dalgoda@gmail.com
  4. // @match https://www.linkedin.com/*
  5. // @noframes
  6. // @version 2.5.3
  7. // @author Mike Castle
  8. // @description Minor enhancements to LinkedIn. Mostly just hotkeys.
  9. // @license GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0.txt
  10. // @supportURL https://github.com/nexushoratio/userscripts/blob/main/linkedin-tool.md
  11. // @require https://cdn.jsdelivr.net/npm/@violentmonkey/shortcut@1
  12. // @require https://cdn.jsdelivr.net/npm/@violentmonkey/dom@2
  13. // @grant window.onurlchange
  14. // ==/UserScript==
  15.  
  16. /* global VM */
  17.  
  18. (function () {
  19. 'use strict';
  20.  
  21. let navBarHeightPixels = 0;
  22. let navBarHeightCss = '0';
  23.  
  24. /**
  25. * Java's hashCode: s[0]*31(n-1) + s[1]*31(n-2) + ... + s[n-1]
  26. * @param {string} s - String to hash.
  27. * @returns {string} - Hash value.
  28. */
  29. function strHash(s) {
  30. let hash = 0;
  31. for (let i = 0; i < s.length; i++) {
  32. hash = ((hash << 5) - hash) + s.charCodeAt(i) | 0;
  33. }
  34. return `${hash}`;
  35. }
  36.  
  37. /**
  38. * Run querySelector to get an element, then click it.
  39. * @param {Element} base - Where to start looking.
  40. * @param {string[]} selectorArray - CSS selectors to use to find an
  41. * element.
  42. * @returns {boolean} - Whether an element could be found.
  43. */
  44. function clickElement(base, selectorArray) {
  45. if (base) {
  46. for (const selector of selectorArray) {
  47. const el = base.querySelector(selector);
  48. if (el) {
  49. el.click();
  50. return true;
  51. }
  52. }
  53. }
  54. return false;
  55. }
  56.  
  57. /**
  58. * Determines if the element accepts keyboard input.
  59. * @param {Element} element - HTML Element to examine.
  60. * @returns {boolean} - Indicating whether the element accepts keyboard input.
  61. */
  62. function isInput(element) {
  63. let tagName = '';
  64. if ('tagName' in element) {
  65. tagName = element.tagName.toLowerCase();
  66. }
  67. return (element.isContentEditable || ['input', 'textarea'].includes(tagName));
  68. }
  69.  
  70. /**
  71. * Bring the Brower's focus onto element.
  72. * @param {Element} element - HTML Element to focus on.
  73. * @returns {void}
  74. */
  75. function focusOnElement(element) {
  76. if (element) {
  77. const tabIndex = element.getAttribute('tabindex');
  78. element.setAttribute('tabindex', 0);
  79. element.focus();
  80. if (tabIndex) {
  81. element.setAttribute('tabindex', tabIndex);
  82. } else {
  83. element.removeAttribute('tabindex');
  84. }
  85. }
  86. }
  87.  
  88. /**
  89. * Scroll's LinkedIn common sidebar into view and moves focus to it.
  90. * @returns {void}
  91. */
  92. function focusOnSidebar() {
  93. const sidebar = document.querySelector('div.scaffold-layout__sidebar');
  94. sidebar.style.scrollMarginTop = navBarHeightCss;
  95. sidebar.scrollIntoView();
  96. sidebar.focus();
  97. }
  98.  
  99. /**
  100. * One time resize observer with timeout.
  101. * Will resolve automatically upon resize change.
  102. * @param {Element} base - Element to observe.
  103. * @param {?function()} trigger - Function to call that triggers observable events.
  104. * @param {number} timeout - Time to wait for completion in milliseconds, 0 disables.
  105. * @returns {promise} - Will resolve with the results from monitor.
  106. */
  107. function otrot(base, trigger, timeout) {
  108. const prom = new Promise((resolve, reject) => {
  109. let timeoutID = null;
  110. const initialHeight = base.clientHeight;
  111. const initialWidth = base.clientWidth;
  112. trigger = trigger || function () {};
  113. const observer = new ResizeObserver(() => {
  114. if (base.clientHeight !== initialHeight || base.clientWidth !== initialWidth) {
  115. observer.disconnect();
  116. clearTimeout(timeoutID);
  117. resolve(base);
  118. }
  119. });
  120. if (timeout) {
  121. timeoutID = setTimeout(() => {
  122. observer.disconnect();
  123. reject('timed out');
  124. }, timeout);
  125. }
  126. observer.observe(base);
  127. trigger();
  128. });
  129. return prom;
  130. }
  131.  
  132. /**
  133. * One time mutation observer with timeout.
  134. * @param {Element} base - Element to observe.
  135. * @param {object} options - MutationObserver().observe() options.
  136. * @param {function(MutationRecord[]): {done: boolean, results:
  137. * object}} monitor - Callback used to process all MutationObserver
  138. * records.
  139. * @param {?function()} trigger - Function to call that triggers
  140. * observable resultsl
  141. * @param {number} timeout - Time to wait for completion in
  142. * milliseconds, 0 disables.
  143. * @returns {promise} - Will resolve with the results from monitor
  144. * when done is true.
  145. */
  146. function otmot(base, options, monitor, trigger, timeout) {
  147. const prom = new Promise((resolve, reject) => {
  148. let timeoutID = null;
  149. trigger = trigger || function () {};
  150. const observer = new MutationObserver((records) => {
  151. const {done, results} = monitor(records);
  152. if (done) {
  153. observer.disconnect();
  154. clearTimeout(timeoutID);
  155. resolve(results);
  156. }
  157. });
  158. if (timeout) {
  159. timeoutID = setTimeout(() => {
  160. observer.disconnect();
  161. reject('timed out');
  162. }, timeout);
  163. }
  164. observer.observe(base, options);
  165. trigger();
  166. });
  167. return prom;
  168. }
  169.  
  170. // I'm lazy. The version of emacs I'm using does not support
  171. // #private variables out of the box, so using underscores until I
  172. // get a working configuration.
  173.  
  174. /** Simple dispatcher. It takes a fixed list of event types upon
  175. * construction and attempts to use an unknown event will throw an
  176. * error.
  177. */
  178. class Dispatcher {
  179. _handlers = new Map();
  180.  
  181. /**
  182. * @param{...string} eventTypes - Event types this instance can handle.
  183. */
  184. constructor(...eventTypes) {
  185. for (const eventType of eventTypes) {
  186. this._handlers.set(eventType, []);
  187. }
  188. }
  189.  
  190. /**
  191. * Look up array of handlers by event type.
  192. * @param {string} eventType - Event type to look up.
  193. * @throws {Error} - When eventType was not registered during instantiation.
  194. * @returns {function[]} - Handlers currently registered for this eventType.
  195. */
  196. _getHandlers(eventType) {
  197. const handlers = this._handlers.get(eventType);
  198. if (!handlers) {
  199. throw new Error(`Unknown event type: ${eventType}`);
  200. }
  201. return handlers;
  202. }
  203.  
  204. /**
  205. * Attach a function to an eventType.
  206. * @param {string} eventType - Event type to connect with.
  207. * @param {function} func - Single argument function to call.
  208. * @returns {void}
  209. */
  210. on(eventType, func) {
  211. const handlers = this._getHandlers(eventType);
  212. handlers.push(func);
  213. }
  214.  
  215. /**
  216. * Remove all instances of a function registered to an eventType.
  217. * @param {string} eventType - Event type to disconnect from.
  218. * @param {function} func - Function to remove.
  219. * @returns {void}
  220. */
  221. off(eventType, func) {
  222. const handlers = this._getHandlers(eventType)
  223. let index = 0;
  224. while ((index = handlers.indexOf(func)) !== -1) {
  225. handlers.splice(index, 1);
  226. }
  227. }
  228.  
  229. /**
  230. * Calls all registered functions for the given eventType.
  231. * @param {string} eventType - Event type to use.
  232. * @param {object} data - Data to pass to each function.
  233. * @returns {void}
  234. */
  235. fire(eventType, data) {
  236. const handlers = this._getHandlers(eventType);
  237. for (const handler of handlers) {
  238. handler(data);
  239. }
  240. }
  241. }
  242.  
  243. /**
  244. * An ordered collection of HTMLElements for a user to scroll through.
  245. *
  246. * The dispatcher can be used the handle the following events:
  247. * - 'out-of-range' - Scrolling went past one end of the collection.
  248. * - 'change' - The value of item has changed.
  249. * This is NOT an error condition, but rather a design feature.
  250. */
  251. class Scroller {
  252. _dispatcher = new Dispatcher('change', 'out-of-range');
  253. _currentItemId = null;
  254. _historicalIdToIndex = new Map();
  255.  
  256. /**
  257. * @param {Element} base - The container element.
  258. * @param {string[]} selectors - Array of CSS selectors to find
  259. * elements to collect, calling base.querySelectorAll().
  260. * @param {function(Element): string} uidCallback - Function that,
  261. * given an element, returns a unique identifier for it.
  262. * @param {string[]} classes - Array of CSS classes to add/remove
  263. * from an element as it becomes current.
  264. * @param {boolean} snapToTop - Whether items should snap to the
  265. * top of the window when coming into view.
  266. * @param {object} [debug={}] - Debug options
  267. * @param {boolean} [debug.enabled=false] - Enable messages.
  268. * @param {boolean} [debug.stackTrace=false] - Include stack traces.
  269. */
  270. constructor(base, selectors, uidCallback, classes, snapToTop, debug) {
  271. if (!(base instanceof Element)) {
  272. throw new TypeError(`Invalid base: ${base}`);
  273. }
  274. this._destroyed = false;
  275. this._base = base;
  276. this._selectors = selectors;
  277. this._uidCallback = uidCallback;
  278. this._classes = classes;
  279. this._snapToTop = snapToTop;
  280. this._debug = debug ?? {};
  281. this._debug.enabled ??= false;
  282. this._debug.stackTrace ??= false;
  283. this._msg('Scroller constructed', this);
  284. }
  285.  
  286. /**
  287. * Fancy-ish debug messages.
  288. * console message groups can be started and ended using magic
  289. * keywords in messages.
  290. * - 'Entered' - Starts new group named with the rest of the string.
  291. * - 'Starting' - Starts a new collapsed group (useful for loops).
  292. * - 'Leaving' and 'Finished' - Both end the most recent group,
  293. * with Leaving for the function, and Finished for the loop
  294. * (though not enforced).
  295. * @example
  296. * foo(x) {
  297. * this._msg('Entered foo', x);
  298. * ... do stuff ...
  299. * this._msg('Leaving foo with', y);
  300. * return y;
  301. * }
  302. * @param {string} msg - Debug message to send to the console.
  303. * @returns {void}
  304. */
  305. _msg(msg, ...rest) {
  306. /* eslint-disable no-console */
  307. if (this._debug.enabled) {
  308. if (this._debug.stackTrace) {
  309. console.groupCollapsed('call stack');
  310. console.trace();
  311. console.groupEnd();
  312. }
  313. if (typeof msg === 'string' && msg.startsWith('Entered')) {
  314. console.group(msg.substr(msg.indexOf(' ') + 1));
  315. } else if (typeof msg === 'string' && msg.startsWith('Starting')) {
  316. console.groupCollapsed(`${msg.substr(msg.indexOf(' ') + 1)} (collapsed)`);
  317. }
  318. console.debug(msg, ...rest);
  319. if (typeof msg === 'string' && (/^(Leaving|Finished)/).test(msg)) {
  320. console.groupEnd();
  321. }
  322. }
  323. /* eslint-enable */
  324. }
  325.  
  326. /**
  327. * @type {Dispatcher} - Accessor for dipatcher.
  328. */
  329. get dispatcher() {
  330. return this._dispatcher;
  331. }
  332.  
  333. /**
  334. * @type {Element} - Represents the current item.
  335. */
  336. get item() {
  337. this._msg('Entered get item');
  338. if (this._destroyed) {
  339. const msg = `Tried to work with destroyed ${this.constructor.name} on ${this._base}`;
  340. this._msg(msg);
  341. throw new Error(msg);
  342. }
  343. const items = this._getItems();
  344. let item = items.find(this._matchItem.bind(this));
  345. if (!item) {
  346. // We couldn't find the old id, so maybe it was rebuilt. Make
  347. // a guess by trying the old index.
  348. const idx = this._historicalIdToIndex.get(this._currentItemId);
  349. if (typeof idx === 'number' && 0 <= idx && idx < items.length) {
  350. item = items[idx];
  351. this._bottomHalf(item);
  352. }
  353. }
  354. this._msg('Leaving get item with', item);
  355. return item;
  356. }
  357.  
  358. /**
  359. * @param {Element} val - Update the current item with val.
  360. */
  361. set item(val) {
  362. this._msg('Entered set item with', val);
  363. if (this.item) {
  364. this.dull();
  365. }
  366. this._bottomHalf(val);
  367. this._msg('Leaving set item');
  368. }
  369.  
  370. /**
  371. * Since the getter will try to validate the current item (since
  372. * it could have changed out from under us), it too can update
  373. * information.
  374. * @param {Element} val - Element to make current.
  375. * @returns {void}
  376. */
  377. _bottomHalf(val) {
  378. this._msg('Entered bottomHalf with', val);
  379. this._currentItemId = this._uid(val);
  380. const idx = this._getItems().indexOf(val);
  381. this._historicalIdToIndex.set(this._currentItemId, idx);
  382. if (val) {
  383. this.shine();
  384. this._scrollToCurrentItem();
  385. }
  386. this.dispatcher.fire('change', {});
  387. this._msg('Leaving bottomHalf');
  388. }
  389.  
  390. /**
  391. * Builds the list of using the registered CSS selectors.
  392. * @returns {Elements[]} - Items to scroll through.
  393. */
  394. _getItems() {
  395. this._msg('Entered getItems');
  396. const items = [];
  397. for (const selector of this._selectors) {
  398. this._msg(`considering ${selector}`);
  399. items.push(...this._base.querySelectorAll(selector));
  400. }
  401. if (this._debug) {
  402. this._msg('Starting items');
  403. for (const item of items) {
  404. this._msg(item);
  405. }
  406. this._msg('Finished items');
  407. }
  408. this._msg(`Leaving getItems with ${items.length} items`);
  409. return items;
  410. }
  411.  
  412. /**
  413. * Returns the uid for the current element. Will use the
  414. * registered uidCallback function for this.
  415. * @param {Element} element - Element to identify.
  416. * @returns {string} - Computed uid for element.
  417. */
  418. _uid(element) {
  419. this._msg('Entered uid with', element);
  420. let uid = null;
  421. if (element) {
  422. if (!element.dataset.litId) {
  423. element.dataset.litId = this._uidCallback(element);
  424. }
  425. uid = element.dataset.litId;
  426. }
  427. this._msg('Leaving uid with', uid);
  428. return uid;
  429. }
  430.  
  431. /**
  432. * Checks if the element is the current one. Useful as a callback
  433. * to Array.find.
  434. * @param {Element} element - Element to check.
  435. * @returns {boolean} - Whether or not element is the current one.
  436. */
  437. _matchItem(element) {
  438. this._msg('Entered matchItem');
  439. const res = this._currentItemId === this._uid(element);
  440. this._msg('Leaving matchItem with', res);
  441. return res;
  442. }
  443.  
  444. /**
  445. * Scroll the current item into the view port. Depending on the
  446. * instance configuration, this could snap to the top, snap to the
  447. * bottom, or be a no-op.
  448. * @returns {void}
  449. */
  450. _scrollToCurrentItem() {
  451. this._msg('Entered scrollToCurrentItem with', this._snapToTop);
  452. const item = this.item;
  453. item.style.scrollMarginTop = navBarHeightCss;
  454. if (this._snapToTop) {
  455. this._msg('snapping to top');
  456. item.scrollIntoView(true);
  457. } else {
  458. item.style.scrollMarginBottom = '3em';
  459. const rect = item.getBoundingClientRect();
  460. // If both scrolling happens, it means the item is too tall to
  461. // fit on the page, so the top is preferred.
  462. if (rect.bottom > document.documentElement.clientHeight) {
  463. this._msg('scrolling up onto page');
  464. item.scrollIntoView(false);
  465. }
  466. if (rect.top < navBarHeightPixels) {
  467. this._msg('scrolling down onto page');
  468. item.scrollIntoView(true);
  469. }
  470. }
  471. this._msg('Leaving scrollToCurrentItem');
  472. }
  473.  
  474. /**
  475. * Jump an item on the end of the collection.
  476. * @param {boolean} first - If true, the first item in the
  477. * collection, else, the last.
  478. * @returns {void}
  479. */
  480. _jumpToEndItem(first) {
  481. // Reset in case item was heavily modified
  482. this.item;
  483.  
  484. const items = this._getItems();
  485. if (items.length) {
  486. let idx = first ? 0 : (items.length - 1);
  487. let item = items[idx];
  488.  
  489. // Content of items is sometimes loaded lazily and can be
  490. // detected by having no innerText yet. So start at the end
  491. // and work our way up to the last one loaded.
  492. if (!first) {
  493. while (!item.innerText.length) {
  494. idx--;
  495. item = items[idx];
  496. }
  497. }
  498. this.item = item;
  499. }
  500. }
  501.  
  502. /**
  503. * Move forward or backwards in the collection by at least n.
  504. * @param {number} n - How many items to move and the intended direction.
  505. * @fires 'out-of-range'
  506. * @returns {void}
  507. */
  508. _scrollBy(n) {
  509. this._msg('Entered scrollBy', n);
  510. // Reset in case item was heavily modified
  511. this.item;
  512.  
  513. const items = this._getItems();
  514. if (items.length) {
  515. let idx = items.findIndex(this._matchItem.bind(this));
  516. this._msg('starting idx', idx);
  517. idx += n;
  518. if (idx < -1) {
  519. idx = items.length - 1;
  520. }
  521. if (idx === -1 || idx >= items.length) {
  522. this._msg('left the container');
  523. this.item = null;
  524. this.dispatcher.fire('out-of-range', null);
  525. } else {
  526. // Skip over empty items
  527. let item = items[idx];
  528. while (!item.clientHeight) {
  529. this._msg('skipping empty item', item);
  530. idx += n;
  531. item = items[idx];
  532. }
  533. this._msg('final idx', idx);
  534. this.item = item;
  535. }
  536. }
  537. this._msg('Leaving scrollBy');
  538. }
  539.  
  540. /**
  541. * Move to the next item in the collection.
  542. * @returns {void}
  543. */
  544. next() {
  545. this._scrollBy(1);
  546. }
  547.  
  548. /**
  549. * Move to the previous item in the collection.
  550. * @returns {void}
  551. */
  552. prev() {
  553. this._scrollBy(-1);
  554. }
  555.  
  556. /**
  557. * Jump to the first item in the collection.
  558. * @returns {void}
  559. */
  560. first() {
  561. this._jumpToEndItem(true);
  562. }
  563.  
  564. /**
  565. * Jump to last item in the collection.
  566. * @returns {void}
  567. */
  568. last() {
  569. this._jumpToEndItem(false);
  570. }
  571.  
  572. /**
  573. * Adds the registered CSS classes to the current element.
  574. * @returns {void}
  575. */
  576. shine() {
  577. this.item.classList.add(...this._classes);
  578. }
  579.  
  580. /**
  581. * Removes the registered CSS classes from the current element.
  582. * @returns {void}
  583. */
  584. dull() {
  585. this.item.classList.remove(...this._classes);
  586. }
  587.  
  588. /**
  589. * Bring current item back into view.
  590. * @returns {void}
  591. */
  592. show() {
  593. this._scrollToCurrentItem();
  594. }
  595.  
  596. /**
  597. * Mark instance as inactive and do any internal cleanup.
  598. * @returns {void}
  599. */
  600. destroy() {
  601. this._msg('Entered destroy');
  602. this.item = null;
  603. this._destroyed = true;
  604. this._msg('Leaving destroy');
  605. }
  606. }
  607.  
  608. class Page {
  609. // The immediate following can be set if derived classes
  610.  
  611. // What pathname part of the URL this page should handle. The
  612. // special case of null is used by the Pages class to represent
  613. // global keys.
  614. _pathname;
  615.  
  616. // CSS selector for capturing clicks on this page. If overridden,
  617. // then the class should also provide a _onClick() method.
  618. _on_click_selector = null;
  619.  
  620. // List of keystrokes to register automatically. They are objects
  621. // with keys of `seq`, `desc`, and `func`. The `seq` is used to
  622. // define they keystroke sequence to trigger the function. The
  623. // `desc` is used to create the help screen. The `func` is a
  624. // function, usually in the form of `this.methodName`. The
  625. // function is bound to `this` before registering it with
  626. // VM.shortcut.
  627. _auto_keys = [];
  628.  
  629. // Private members.
  630.  
  631. _keyboard = new VM.shortcut.KeyboardService();
  632.  
  633. // Tracks which HTMLElement holds the `onclick` function.
  634. _on_click_element = null;
  635.  
  636. // Magic for VM.shortcut. This disables keys when focus is on an
  637. // input type field.
  638. static _navOption = {
  639. caseSensitive: true,
  640. condition: '!inputFocus',
  641. };
  642.  
  643. constructor() {
  644. this._boundOnClick = this._onClick.bind(this);
  645. }
  646.  
  647. start() {
  648. for (const {seq, func} of this._auto_keys) {
  649. this._addKey(seq, func.bind(this));
  650. }
  651. }
  652.  
  653. get pathname() {
  654. return this._pathname;
  655. }
  656.  
  657. get keyboard() {
  658. return this._keyboard;
  659. }
  660.  
  661. activate() {
  662. this._keyboard.enable();
  663. this._enableOnClick();
  664. }
  665.  
  666. deactivate() {
  667. this._keyboard.disable();
  668. this._disableOnClick();
  669. }
  670.  
  671. get helpHeader() {
  672. return this.constructor.name;
  673. }
  674.  
  675. get helpContent() {
  676. return this._auto_keys;
  677. }
  678.  
  679. _addKey(seq, func) {
  680. this._keyboard.register(seq, func, Page._navOption);
  681. }
  682.  
  683. _enableOnClick() {
  684. if (this._on_click_selector) {
  685. // Page is dynamically building, so keep watching it until the
  686. // element shows up.
  687. VM.observe(document.body, () => {
  688. const element = document.querySelector(this._on_click_selector);
  689. if (element) {
  690. this._on_click_element = element;
  691. this._on_click_element.addEventListener('click', this._boundOnClick);
  692.  
  693. return true;
  694. }
  695. });
  696. }
  697. }
  698.  
  699. _disableOnClick() {
  700. if (this._on_click_element) {
  701. this._on_click_element.removeEventListener('click', this._boundOnClick);
  702. this._on_click_element = null;
  703. }
  704. }
  705.  
  706. // Override this function in derived classes that want to react to
  707. // random clicks on a page, say to update current element in
  708. // focus.
  709. _onClick(evt) { // eslint-disable-line no-unused-vars
  710. alert(`Found a bug! ${this.constructor.name} wants to handle clicks, but forgot to create a handler.`);
  711. }
  712.  
  713. }
  714.  
  715. class Global extends Page {
  716. _pathname = null;
  717. _auto_keys = [
  718. {seq: '?', desc: 'Show keyboard help', func: this._help},
  719. {seq: '/', desc: 'Go to Search box', func: this._gotoSearch},
  720. {seq: 'g h', desc: 'Go Home (aka, Feed)', func: this._goHome},
  721. {seq: 'g m', desc: 'Go to My Network', func: this._gotoMyNetwork},
  722. {seq: 'g j', desc: 'Go to Jobs', func: this._gotoJobs},
  723. {seq: 'g g', desc: 'Go to Messaging', func: this._gotoMessaging},
  724. {seq: 'g n', desc: 'Go to Notifications', func: this._gotoNotifications},
  725. {seq: 'g p', desc: 'Go to Profile (aka, Me)', func: this._gotoProfile},
  726. {seq: 'g b', desc: 'Go to Business', func: this._gotoBusiness},
  727. {seq: 'g l', desc: 'Go to Learning', func: this._gotoLearning},
  728. ];
  729.  
  730. get helpId() {
  731. return this._helpId;
  732. }
  733.  
  734. set helpId(val) {
  735. this._helpId = val;
  736. }
  737.  
  738. _gotoNavLink(item) {
  739. clickElement(document, [`#global-nav a[href*="/${item}"`]);
  740. }
  741.  
  742. _gotoNavButton(item) {
  743. const buttons = Array.from(document.querySelectorAll('#global-nav button'));
  744. const button = buttons.find(el => el.textContent.includes(item));
  745. if (button) {
  746. button.click();
  747. }
  748. }
  749.  
  750. _help() {
  751. const help = document.querySelector(`#${this.helpId}`);
  752. help.showModal();
  753. help.focus();
  754. }
  755.  
  756. _gotoSearch() {
  757. clickElement(document, ['#global-nav-search button']);
  758. }
  759.  
  760. _goHome() {
  761. this._gotoNavLink('feed');
  762. }
  763.  
  764. _gotoMyNetwork() {
  765. this._gotoNavLink('mynetwork');
  766. }
  767.  
  768. _gotoJobs() {
  769. this._gotoNavLink('jobs');
  770. }
  771.  
  772. _gotoMessaging() {
  773. this._gotoNavLink('messaging');
  774. }
  775.  
  776. _gotoNotifications() {
  777. this._gotoNavLink('notifications');
  778. }
  779.  
  780. _gotoProfile() {
  781. this._gotoNavButton('Me');
  782. }
  783.  
  784. _gotoBusiness() {
  785. this._gotoNavButton('Business');
  786. }
  787.  
  788. _gotoLearning() {
  789. this._gotoNavLink('learning');
  790. }
  791.  
  792. }
  793.  
  794. class Feed extends Page {
  795. _pathname = '/feed/';
  796. _on_click_selector = 'main';
  797. _auto_keys = [
  798. {seq: 'X', desc: 'Toggle hiding current post', func: this._togglePost},
  799. {seq: 'j', desc: 'Next post', func: this._nextPost},
  800. {seq: 'J', desc: 'Toggle hiding then next post', func: this._nextPostPlus},
  801. {seq: 'k', desc: 'Previous post', func: this._prevPost},
  802. {seq: 'K', desc: 'Toggle hiding then previous post', func: this._prevPostPlus},
  803. {seq: 'm', desc: 'Show more of the post or comment', func: this._seeMore},
  804. {seq: 'c', desc: 'Show comments', func: this._showComments},
  805. {seq: 'n', desc: 'Next comment', func: this._nextComment},
  806. {seq: 'p', desc: 'Previous comment', func: this._prevComment},
  807. {seq: 'l', desc: 'Load more posts (if the <button>New Posts</button> button is available, load those)', func: this._loadMorePosts},
  808. {seq: 'L', desc: 'Like post or comment', func: this._likePostOrComment},
  809. {seq: '<', desc: 'Go to first post or comment', func: this._firstPostOrComment},
  810. {seq: '>', desc: 'Go to last post or comment currently loaded', func: this._lastPostOrComment},
  811. {seq: 'f', desc: 'Change browser focus to current post or comment', func: this._focusBrowser},
  812. {seq: 'v p', desc: 'View the post directly', func: this._viewPost},
  813. {seq: 'v r', desc: 'View reactions on current post or comment', func: this._viewReactions},
  814. {seq: 'P', desc: 'Go to the share box to start a post or <kbd>TAB</kbd> to the other creator options', func: this._gotoShare},
  815. {seq: '=', desc: 'Open the (⋯) menu', func: this._openMeatballMenu},
  816. ];
  817.  
  818. _postScroller = null;
  819. _commentScroller = null;
  820.  
  821. constructor() {
  822. super();
  823. this._postScroller = new Scroller(document.body, ['main div[data-id]'], this._uniqueIdentifier, ['tom'], true, {enabled: true, stackTrace: true});
  824. this._postScroller.dispatcher.on('out-of-range', focusOnSidebar);
  825. this._postScroller.dispatcher.on('change', this._changedPost.bind(this));
  826. }
  827.  
  828. _onClick(evt) {
  829. const post = evt.target.closest('div[data-id]');
  830. if (post && post !== this._posts.item) {
  831. this._posts.item = post;
  832. }
  833. }
  834.  
  835. get _posts() {
  836. return this._postScroller;
  837. }
  838.  
  839. get _comments() {
  840. if (!this._commentScroller && this._posts.item) {
  841. this._commentScroller = new Scroller(this._posts.item, ['article.comments-comment-item'], this._uniqueIdentifier, ['dick'], false);
  842. this._commentScroller.dispatcher.on('out-of-range', this._returnToPost.bind(this));
  843. }
  844. return this._commentScroller;
  845. }
  846.  
  847. set _comments(val) {
  848. if (this._commentScroller) {
  849. this._commentScroller.destroy();
  850. this._commentScroller = null;
  851. }
  852. }
  853.  
  854. get _hasActiveComment() {
  855. return this._comments && this._comments.item;
  856. }
  857.  
  858. _uniqueIdentifier(element) {
  859. if (element) {
  860. return element.dataset.id;
  861. } else {
  862. return null;
  863. }
  864. }
  865.  
  866. _returnToPost() {
  867. this._posts.item = this._posts.item;
  868. }
  869.  
  870. _changedPost() {
  871. this._comments = null;
  872. }
  873.  
  874. _nextPost() {
  875. this._posts.next();
  876. }
  877.  
  878. _nextPostPlus() {
  879. function f() {
  880. this._togglePost();
  881. this._nextPost();
  882. }
  883. // XXX Need to remove the highlight before otrot sees it because
  884. // it affects the .clientHeight.
  885. this._posts.dull();
  886. otrot(this._posts.item, f.bind(this), 3000).then(() => {
  887. this._posts.show();
  888. }).catch(e => console.error(e)); // eslint-disable-line no-console
  889. }
  890.  
  891. _prevPost() {
  892. this._posts.prev();
  893. }
  894.  
  895. _prevPostPlus() {
  896. this._togglePost();
  897. this._prevPost();
  898. }
  899.  
  900. _nextComment() {
  901. this._comments.next();
  902. }
  903.  
  904. _prevComment() {
  905. this._comments.prev();
  906. }
  907.  
  908. _togglePost() {
  909. clickElement(this._posts.item, ['button[aria-label^="Dismiss post"]', 'button[aria-label^="Undo and show"]']);
  910. }
  911.  
  912. _showComments() {
  913. function tryComment(comment) {
  914. if (comment) {
  915. const buttons = Array.from(comment.querySelectorAll('button'));
  916. const button = buttons.find(el => el.textContent.includes('Load previous replies'));
  917. if (button) {
  918. button.click();
  919. return true;
  920. }
  921. }
  922. return false;
  923. }
  924.  
  925. if (!tryComment(this._comments.item)) {
  926. clickElement(this._posts.item, ['button[aria-label*="comment"]']);
  927. }
  928. }
  929.  
  930. _seeMore() {
  931. const el = this._comments.item ?? this._posts.item;
  932. clickElement(el, ['button[aria-label^="see more"]']);
  933. }
  934.  
  935. _likePostOrComment() {
  936. const el = this._comments.item ?? this._posts.item;
  937. clickElement(el, ['button[aria-label^="Open reactions menu"]']);
  938. }
  939.  
  940. _firstPostOrComment() {
  941. if (this._hasActiveComment) {
  942. this._comments.first();
  943. } else {
  944. this._posts.first();
  945. }
  946. }
  947.  
  948. _lastPostOrComment() {
  949. if (this._hasActiveComment) {
  950. this._comments.last();
  951. } else {
  952. this._posts.last();
  953. }
  954. }
  955.  
  956. _loadMorePosts() {
  957. const container = document.querySelector('div.scaffold-finite-scroll__content');
  958. function f() {
  959. const initialPost = this._posts.item;
  960. this._posts.first();
  961. if (!clickElement(this._posts.item, ['div.feed-new-update-pill button'])) {
  962. this._posts.item = initialPost;
  963. clickElement(document, ['main button.scaffold-finite-scroll__load-button']);
  964. }
  965. }
  966. otrot(container, f.bind(this), 3000).then(() => {
  967. this._posts.item = this._posts.item;
  968. });
  969. }
  970.  
  971. _gotoShare() {
  972. const share = document.querySelector('div.share-box-feed-entry__top-bar').parentElement;
  973. share.style.scrollMarginTop = navBarHeightCss;
  974. share.scrollIntoView();
  975. share.querySelector('button').focus();
  976. }
  977.  
  978. _openMeatballMenu() {
  979. // XXX In this case, the identifier is on an svg element, not
  980. // the button, so use the parentElement. When Firefox [fully
  981. // supports](https://bugzilla.mozilla.org/show_bug.cgi?id=418039)
  982. // the `:has()` pseudo-selector, we can probably use that and
  983. // use `clickElement()`.
  984. if (this._comments.item) {
  985. const button = this._comments.item.querySelector('[aria-label^="Open options"]').parentElement;
  986. button.click();
  987. } else if (this._posts.item) {
  988. const button = this._posts.item.querySelector('[a11y-text^="Open control menu"]').parentElement;
  989. button.click();
  990. }
  991. }
  992.  
  993. _focusBrowser() {
  994. const el = this._comments.item ?? this._posts.item;
  995. focusOnElement(el);
  996. }
  997.  
  998. _viewPost() {
  999. const post = this._posts.item;
  1000. if (post) {
  1001. const urn = post.dataset.id;
  1002. const id = `lt-${urn.replaceAll(':', '-')}`;
  1003. let a = post.querySelector(`#${id}`);
  1004. if (!a) {
  1005. a = document.createElement('a');
  1006. a.href = `/feed/update/${urn}/`;
  1007. a.id = id;
  1008. post.append(a);
  1009. }
  1010. a.click();
  1011. }
  1012. }
  1013.  
  1014. _viewReactions() {
  1015. // Bah! The queries are annoyingly different.
  1016. if (this._comments.item) {
  1017. clickElement(this._comments.item, ['button.comments-comment-social-bar__reactions-count']);
  1018. } else if (this._posts.item) {
  1019. clickElement(this._posts.item, ['button.feed-shared-social-action-bar-counts']);
  1020. }
  1021. }
  1022.  
  1023. }
  1024.  
  1025. class Jobs extends Page {
  1026. _pathname = '/jobs/';
  1027. _auto_keys = [
  1028. {seq: 'j', desc: 'Next section', func: this._nextSection},
  1029. {seq: 'k', desc: 'Previous section', func: this._prevSection},
  1030. {seq: '<', desc: 'Go to to first section', func: this._firstSection},
  1031. {seq: '>', desc: 'Go to last section currently loaded', func: this._lastSection},
  1032. {seq: 'f', desc: 'Change browser focus to current section', func: this._focusBrowser},
  1033. {seq: 'l', desc: 'Load more sections', func: this._loadMoreSections},
  1034. ];
  1035.  
  1036. _currentSectionId = null;
  1037.  
  1038. get _section() {
  1039. return this._getSections().find(this._match.bind(this));
  1040. }
  1041.  
  1042. set _section(val) {
  1043. if (this._section) {
  1044. this._section.classList.remove('tom');
  1045. }
  1046. this._currentSectionId = this._uniqueIdentifier(val);
  1047. if (val) {
  1048. val.classList.add('tom');
  1049. this._scrollToCurrentSection();
  1050. }
  1051. }
  1052.  
  1053. _getSections() {
  1054. return Array.from(document.querySelectorAll('main section'));
  1055. }
  1056.  
  1057. _uniqueIdentifier(element) {
  1058. if (element) {
  1059. const h2 = element.querySelector('h2');
  1060. if (h2) {
  1061. return h2.innerText;
  1062. } else {
  1063. return element.innerText;
  1064. }
  1065. } else {
  1066. return null;
  1067. }
  1068. }
  1069.  
  1070. _match(el) {
  1071. return this._currentSectionId === this._uniqueIdentifier(el);
  1072. }
  1073.  
  1074. _scrollToCurrentSection() {
  1075. if (this._section) {
  1076. this._section.style.scrollMarginTop = navBarHeightCss;
  1077. this._section.scrollIntoView();
  1078. }
  1079. }
  1080.  
  1081. _scrollBy(n) {
  1082. const sections = this._getSections();
  1083. if (sections.length) {
  1084. let idx = sections.findIndex(this._match.bind(this)) + n;
  1085. if (idx < -1) {
  1086. idx = sections.length - 1;
  1087. }
  1088. if (idx === -1 || idx >= sections.length) {
  1089. focusOnSidebar();
  1090. this._section = null;
  1091. } else {
  1092. this._section = sections[idx];
  1093. }
  1094. }
  1095. }
  1096.  
  1097. _nextSection() {
  1098. this._scrollBy(1);
  1099. }
  1100.  
  1101. _prevSection() {
  1102. this._scrollBy(-1);
  1103. }
  1104.  
  1105. _jumpToSection(first) {
  1106. const sections = this._getSections();
  1107. if (sections.length) {
  1108. const idx = first ? 0 : (sections.length - 1);
  1109. this._section = sections[idx];
  1110. }
  1111. }
  1112.  
  1113. _firstSection() {
  1114. this._jumpToSection(true);
  1115. }
  1116.  
  1117. _lastSection() {
  1118. this._jumpToSection(false);
  1119. }
  1120.  
  1121. _focusBrowser() {
  1122. focusOnElement(this._section);
  1123. }
  1124.  
  1125. _loadMoreSections() {
  1126. const container = document.querySelector('div.scaffold-finite-scroll__content');
  1127. function f() {
  1128. clickElement(document, ['main button.scaffold-finite-scroll__load-button']);
  1129. }
  1130. otrot(container, f.bind(this), 3000).then(() => {
  1131. // This forces a scrolling and highlighting because the
  1132. // elements were remade.
  1133. this._section = this._section;
  1134. });
  1135. }
  1136. }
  1137.  
  1138. class JobsCollections extends Page {
  1139. _pathname = '/jobs/collections/';
  1140. }
  1141.  
  1142. class Notifications extends Page {
  1143. _pathname = '/notifications/';
  1144. _on_click_selector = 'main';
  1145. _auto_keys = [
  1146. {seq: 'j', desc: 'Next notification', func: this._nextNotification},
  1147. {seq: 'k', desc: 'Previous notification', func: this._prevNotification},
  1148. {seq: 'Enter', desc: 'Activate the notification (click on it)', func: this._activateNotification},
  1149. {seq: 'X', desc: 'Toggle current notification deletion', func: this._deleteNotification},
  1150. {seq: 'l', desc: 'Load more notifications', func: this._loadMoreNotifications},
  1151. {seq: 'f', desc: 'Change browser focus to current notification', func: this._focusBrowser},
  1152. {seq: '<', desc: 'Go to first notification', func: this._firstNotification},
  1153. {seq: '>', desc: 'Go to last notification', func: this._lastNotification},
  1154. {seq: '=', desc: 'Open the (⋯) menu', func: this._openMeatballMenu},
  1155. ];
  1156.  
  1157. // Ugh. When notification cards are deleted or restored, the
  1158. // entire element, and parent elements, are deleted and replaced
  1159. // by new elements. And the deleted versions have nothing in
  1160. // common with the original ones. Since we can't detect when a
  1161. // card is deleted, we need to track where it was so maybe we can
  1162. // refind it again later.
  1163. _currentNotificationId = null;
  1164. _historicalNotificationIdToIndex = new Map();
  1165.  
  1166. _onClick(evt) {
  1167. const notification = evt.target.closest('div.nt-card-list article');
  1168. if (notification) {
  1169. this._notification = notification;
  1170. }
  1171. }
  1172.  
  1173. get _notification() {
  1174. const notifications = this._getNotifications();
  1175. let notification = notifications.find(this._match.bind(this));
  1176. if (!notification) {
  1177. // Couldn't find the old id. Maybe the card was modified, so
  1178. // try the old index.
  1179. const idx = this._historicalNotificationIdToIndex.get(this._currentNotificationId);
  1180. if (typeof idx === 'number' && 0 <= idx && idx < notifications.length) {
  1181. notification = notifications[idx];
  1182. this._setBottomHalf(notification);
  1183. }
  1184. }
  1185. return notification;
  1186. }
  1187.  
  1188. set _notification(val) {
  1189. if (this._notification) {
  1190. this._notification.classList.remove('tom');
  1191. }
  1192. this._setBottomHalf(val);
  1193. }
  1194.  
  1195. _setBottomHalf(val) {
  1196. this._currentNotificationId = this._uniqueIdentifier(val);
  1197. const idx = this._getNotifications().indexOf(val);
  1198. this._historicalNotificationIdToIndex.set(this._currentNotificationId, idx);
  1199. if (val) {
  1200. val.classList.add('tom');
  1201. this._scrollToCurrentNotification();
  1202. }
  1203. }
  1204.  
  1205. _getNotifications() {
  1206. return Array.from(document.querySelectorAll('main section div.nt-card-list article'));
  1207. }
  1208.  
  1209. // Complicated because there are so many variations in
  1210. // notification cards. We do not want to use reaction counts
  1211. // because they can change too quickly.
  1212. _uniqueIdentifier(element) {
  1213. if (element) {
  1214. if (!element.dataset.litId) {
  1215. let content = element.innerText;
  1216. if (element.childElementCount === 3) {
  1217. let content = element.children[1].innerText;
  1218. if (content.includes('Reactions')) {
  1219. for (const el of element.children[1].querySelectorAll('*')) {
  1220. if (el.innerText) {
  1221. content = el.innerText;
  1222. break;
  1223. }
  1224. }
  1225. }
  1226. }
  1227. if (content.startsWith('Notification deleted.')) {
  1228. // Mix in something unique from the parent.
  1229. content += element.parentElement.dataset.finiteScrollHotkeyItem;
  1230. }
  1231. element.dataset.litId = strHash(content);
  1232. }
  1233. return element.dataset.litId;
  1234. } else {
  1235. return null;
  1236. }
  1237. }
  1238.  
  1239. _match(el) {
  1240. return this._currentNotificationId === this._uniqueIdentifier(el);
  1241. }
  1242.  
  1243. _scrollToCurrentNotification() {
  1244. const rect = this._notification.getBoundingClientRect();
  1245. this._notification.style.scrollMarginTop = navBarHeightCss;
  1246. this._notification.style.scrollMarginBottom = '3em';
  1247. if (rect.bottom > document.documentElement.clientHeight) {
  1248. this._notification.scrollIntoView(false);
  1249. }
  1250. if (rect.top < navBarHeightPixels) {
  1251. this._notification.scrollIntoView();
  1252. }
  1253. }
  1254.  
  1255. _scrollBy(n) {
  1256. // XXX This standalone line invokes the magic code in the
  1257. // getter. Necessary when scrolling is the first thing after
  1258. // deleting a card.
  1259. this._notification;
  1260. const notifications = this._getNotifications();
  1261. if (notifications.length) {
  1262. let idx = notifications.findIndex(this._match.bind(this)) + n;
  1263. if (idx < -1) {
  1264. idx = notifications.length - 1;
  1265. }
  1266. if (idx === -1 || idx >= notifications.length) {
  1267. focusOnSidebar();
  1268. this._notification = null;
  1269. } else {
  1270. this._notification = notifications[idx];
  1271. }
  1272. }
  1273. }
  1274.  
  1275. _nextNotification() {
  1276. this._scrollBy(1);
  1277. }
  1278.  
  1279. _prevNotification() {
  1280. this._scrollBy(-1);
  1281. }
  1282.  
  1283. _jumpToNotification(first) {
  1284. const notifications = this._getNotifications();
  1285. if (notifications.length) {
  1286. const idx = first ? 0 : (notifications.length - 1);
  1287. this._notification = notifications[idx];
  1288. }
  1289. }
  1290.  
  1291. _focusBrowser() {
  1292. focusOnElement(this._notification);
  1293. }
  1294.  
  1295. _firstNotification() {
  1296. this._jumpToNotification(true);
  1297. }
  1298.  
  1299. _lastNotification() {
  1300. this._jumpToNotification(false);
  1301. }
  1302.  
  1303. _openMeatballMenu() {
  1304. clickElement(this._notification, ['button[aria-label^="Settings menu"]']);
  1305. }
  1306.  
  1307. _activateNotification() {
  1308. if (this._notification) {
  1309. // Because we are using Enter as the hotkey here, if the
  1310. // active element is inside the current card, we want that to
  1311. // take precedence.
  1312. if (document.activeElement.closest('article') === this._notification) {
  1313. return;
  1314. }
  1315. // Every notification is different.
  1316. // It may be that notifications are settling on 'a.nt-card__headline'.
  1317. function matchesKnownText(el) { // eslint-disable-line no-inner-declarations
  1318. if (el.innerText === 'Apply early') return true;
  1319. return false;
  1320. }
  1321.  
  1322. // Debugging code.
  1323. if (this._notification.querySelectorAll('a.nt-card__headline').length === 1 && this._notification.querySelector('button.message-anywhere-button')) {
  1324. console.debug(this._notification); // eslint-disable-line no-console
  1325. alert('Yes, can be simplified');
  1326. }
  1327.  
  1328. if (!clickElement(this._notification, ['button.message-anywhere-button'])) {
  1329. const buttons = Array.from(this._notification.querySelectorAll('button'));
  1330. const button = buttons.find(matchesKnownText);
  1331. if (button) {
  1332. button.click();
  1333. } else {
  1334. const links = this._notification.querySelectorAll('a.nt-card__headline');
  1335. if (links.length === 1) {
  1336. links[0].click();
  1337. } else {
  1338. console.debug(this._notification); // eslint-disable-line no-console
  1339. for (const el of this._notification.querySelectorAll('*')) {
  1340. console.debug(el); // eslint-disable-line no-console
  1341. }
  1342. const msg = [
  1343. 'You tried to activate an unsupported notification',
  1344. 'element. Please file a bug. If you are comfortable',
  1345. 'with using the browser\'s Developer Tools (often the',
  1346. 'F12 key), consider sharing the information just logged',
  1347. 'in the console / debug view.',
  1348. ];
  1349. alert(msg.join(' '));
  1350. }
  1351. }
  1352. }
  1353. } else {
  1354. // Again, because we use Enter as the hotkey for this action.
  1355. document.activeElement.click();
  1356. }
  1357. }
  1358.  
  1359. _deleteNotification() {
  1360. if (this._notification) {
  1361. // Hah. Unlike in other places, these buttons already exist,
  1362. // just hidden under the menu.
  1363. const buttons = Array.from(this._notification.querySelectorAll('button'));
  1364. const button = buttons.find(el => el.textContent.includes('Delete this notification'));
  1365. if (button) {
  1366. button.click();
  1367. } else {
  1368. clickElement(this._notification, ['button[aria-label^="Undo notification deletion"]']);
  1369. }
  1370. }
  1371. }
  1372.  
  1373. _loadMoreNotifications() {
  1374. const buttons = Array.from(document.querySelectorAll('main section button'));
  1375. const button = buttons.find(el => el.textContent.includes('Show more results'));
  1376. if (button) {
  1377. button.click();
  1378. }
  1379. }
  1380.  
  1381. }
  1382.  
  1383. class Pages {
  1384. _global = null;
  1385. _page = null;
  1386. _pages = new Map();
  1387.  
  1388. _lastInputElement = null;
  1389.  
  1390. constructor() {
  1391. this._id = crypto.randomUUID();
  1392. this._installNavStyle();
  1393. this._initializeHelpMenu();
  1394. document.addEventListener('focus', this._onFocus.bind(this), true);
  1395. document.addEventListener('urlchange', this._onUrlChange.bind(this), true);
  1396. }
  1397.  
  1398. _setInputFocus(state) {
  1399. const pages = Array.from(this._pages.values());
  1400. pages.push(this._global);
  1401. for (const page of pages) {
  1402. if (page) {
  1403. page.keyboard.setContext('inputFocus', state);
  1404. }
  1405. }
  1406. }
  1407.  
  1408. _onFocus(evt) {
  1409. if (this._lastInputElement && evt.target !== this._lastInputElement) {
  1410. this._lastInputElement = null
  1411. this._setInputFocus(false);
  1412. }
  1413. if (isInput(evt.target)) {
  1414. this._setInputFocus(true);
  1415. this._lastInputElement = evt.target;
  1416. }
  1417. }
  1418.  
  1419. _onUrlChange(evt) {
  1420. this.activate(evt.detail.url.pathname);
  1421. }
  1422.  
  1423. _installNavStyle() {
  1424. const style = document.createElement('style');
  1425. style.textContent += '.tom { border-color: orange !important; border-style: solid !important; border-width: medium !important; }';
  1426. style.textContent += '.dick { border-color: red !important; border-style: solid !important; border-width: thin !important; }';
  1427. document.head.append(style);
  1428. }
  1429.  
  1430. _initializeHelpMenu() {
  1431. this._helpId = `help-${this._id}`;
  1432. const style = document.createElement('style');
  1433. style.textContent += `#${this._helpId} kbd {font-size: 0.85em; padding: 0.07em; border-width: 1px; border-style: solid; }`;
  1434. style.textContent += `#${this._helpId} th { padding-top: 1em; text-align: left; }`;
  1435. style.textContent += `#${this._helpId} td:first-child { white-space: nowrap; text-align: right; padding-right: 0.5em; }`;
  1436. style.textContent += `#${this._helpId} button { border-width: 1px; border-style: solid; border-radius: 0.25em; }`;
  1437. document.head.prepend(style);
  1438. const dialog = document.createElement('dialog');
  1439. dialog.id = this._helpId
  1440. dialog.innerHTML = '<table><caption>' +
  1441. '<span style="float: left">Keyboard shortcuts</span>' +
  1442. '<span style="float: right">Hit <kbd>ESC</kbd> to close</span>' +
  1443. '</caption><tbody></tbody></table>';
  1444. document.body.prepend(dialog);
  1445. }
  1446.  
  1447. // ThisPage -> This Page
  1448. _parseHeader(text) {
  1449. return text.replace(/([A-Z])/g, ' $1').trim();
  1450. }
  1451.  
  1452. // 'a b' -> '<kbd>a</kbd> then <kbd>b</kbd>'
  1453. _parseSeq(seq) {
  1454. const letters = seq.split(' ').map(w => `<kbd>${w}</kbd>`);
  1455. const s = letters.join(' then ');
  1456. return s;
  1457. }
  1458.  
  1459. _addHelp(page) {
  1460. const help = document.querySelector(`#${this._helpId} tbody`);
  1461. const section = this._parseHeader(page.helpHeader);
  1462. let s = `<tr><th></th><th>${section}</th></tr>`;
  1463. for (const {seq, desc} of page.helpContent) {
  1464. const keys = this._parseSeq(seq);
  1465. s += `<tr><td>${keys}:</td><td>${desc}</td></tr>`;
  1466. }
  1467. // Don't include works in progress that have no keys yet.
  1468. if (page.helpContent.length) {
  1469. help.innerHTML += s;
  1470. }
  1471. }
  1472.  
  1473. register(page) {
  1474. page.start();
  1475. this._addHelp(page);
  1476. if (page.pathname === null) {
  1477. page.helpId = this._helpId
  1478. this._global = page;
  1479. this._global.activate();
  1480. } else {
  1481. this._pages.set(page.pathname, page);
  1482. }
  1483. }
  1484.  
  1485. _findPage(pathname) {
  1486. const pathnames = Array.from(this._pages.keys());
  1487. const candidates = pathnames.filter(p => pathname.startsWith(p));
  1488. const candidate = candidates.reduce((a, b) => (a.length > b.length ? a : b), '');
  1489. return this._pages.get(candidate) || null;
  1490. }
  1491.  
  1492. activate(pathname) {
  1493. if (this._page) {
  1494. this._page.deactivate();
  1495. }
  1496. const page = this._findPage(pathname);
  1497. this._page = page;
  1498. if (page) {
  1499. page.activate();
  1500. }
  1501. }
  1502. }
  1503.  
  1504. const pages = new Pages();
  1505. pages.register(new Global());
  1506. pages.register(new Feed());
  1507. pages.register(new Jobs());
  1508. pages.register(new JobsCollections());
  1509. pages.register(new Notifications());
  1510. pages.activate(window.location.pathname);
  1511.  
  1512.  
  1513. function navBarMonitor() {
  1514. const navbar = document.querySelector('#global-nav');
  1515. if (navbar) {
  1516. return {done: true, results: navbar};
  1517. }
  1518. return {done: false, results: null};
  1519. }
  1520.  
  1521. // In this case, the trigger was the page load. It already happened
  1522. // by the time we got here.
  1523. otmot(document.body, {childList: true, subtree: true}, navBarMonitor,
  1524. null, 0)
  1525. .then((el) => {
  1526. navBarHeightPixels = el.clientHeight + 4;
  1527. navBarHeightCss = `${navBarHeightPixels}px`;
  1528. });
  1529.  
  1530. if (window.onurlchange === null) {
  1531. // We are likely running on Tampermonkey, so use native support.
  1532. console.debug('Using window.onurlchange for monitoring URL updates.'); // eslint-disable-line no-console
  1533. window.addEventListener('urlchange', (info) => {
  1534. // The info that TM gives is not really an event. So we turn it
  1535. // into one and throw it again, this time onto `document` there
  1536. // `pages` is listening for it.
  1537. const newUrl = new URL(info.url);
  1538. const evt = new CustomEvent('urlchange', {detail: {url: newUrl}});
  1539. document.dispatchEvent(evt);
  1540. });
  1541. } else {
  1542. console.debug('Using MutationObserver for monitoring URL updates.'); // eslint-disable-line no-console
  1543.  
  1544. let oldUrl = new URL(window.location);
  1545. function registerUrlMonitor(element) { // eslint-disable-line no-inner-declarations
  1546. const observer = new MutationObserver(() => {
  1547. const newUrl = new URL(window.location);
  1548. if (oldUrl.href !== newUrl.href) {
  1549. const evt = new CustomEvent('urlchange', {detail: {url: newUrl}});
  1550. oldUrl = newUrl;
  1551. document.dispatchEvent(evt);
  1552. }
  1553. });
  1554. observer.observe(element, {childList: true, subtree: true});
  1555. }
  1556.  
  1557. function authenticationOutletMonitor() { // eslint-disable-line no-inner-declarations
  1558. const div = document.body.querySelector('div.authentication-outlet');
  1559. if (div) {
  1560. return {done: true, results: div};
  1561. }
  1562. return {done: false, results: null};
  1563. }
  1564.  
  1565. otmot(document.body, {childList: true, subtree: true}, authenticationOutletMonitor, null, 0)
  1566. .then((el) => registerUrlMonitor(el));
  1567. }
  1568.  
  1569. console.debug('Initialization successful.'); // eslint-disable-line no-console
  1570.  
  1571. })();