LinkedIn Tool

Minor enhancements to LinkedIn. Mostly just hotkeys.

目前为 2023-11-04 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name LinkedIn Tool
  3. // @namespace dalgoda@gmail.com
  4. // @match https://www.linkedin.com/*
  5. // @noframes
  6. // @version 5.25
  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-standalone.html
  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://greasyfork.org/scripts/478188-nh-xunit/code/NH_xunit.js?version=1271279
  13. // @require https://greasyfork.org/scripts/477290-nh-base/code/NH_base.js?version=1274745
  14. // @require https://greasyfork.org/scripts/478349-nh-userscript/code/NH_userscript.js?version=1271747
  15. // @require https://greasyfork.org/scripts/478440-nh-web/code/NH_web.js?version=1271884
  16. // @require https://greasyfork.org/scripts/478676-nh-widget/code/NH_widget.js?version=1273298
  17. // @grant window.onurlchange
  18. // ==/UserScript==
  19.  
  20. /* global VM */
  21.  
  22. // eslint-disable-next-line max-lines-per-function
  23. (async () => {
  24. 'use strict';
  25.  
  26. const NH = window.NexusHoratio.base.ensure([
  27. {name: 'xunit', minVersion: 3},
  28. {name: 'base', minVersion: 22},
  29. {name: 'userscript', minVersion: 2},
  30. {name: 'web', minVersion: 1},
  31. {name: 'widget', minVersion: 1},
  32. ]);
  33.  
  34. // TODO(#170): Placeholder comment to allow easy patching of test code.
  35.  
  36. // TODO(#145): The if test is just here while developing.
  37. if (NH.xunit.testing.enabled) {
  38. // eslint-disable-next-line require-atomic-updates
  39. NH.base.Logger.configs = await GM.getValue('Logger');
  40. document.addEventListener('visibilitychange', async () => {
  41. if (document.visibilityState === 'hidden') {
  42. await GM.setValue('Logger', NH.base.Logger.configs);
  43. }
  44. });
  45. await NH.widget.w2uiCssInstall();
  46. } else {
  47. NH.base.Logger.config('Default').enabled = true;
  48. }
  49.  
  50. const log = new NH.base.Logger('Default');
  51.  
  52. /**
  53. * Implement HTML for a tabbed user interface.
  54. *
  55. * This version uses radio button/label pairs to select the active panel.
  56. *
  57. * @example
  58. * const tabby = new TabbedUI('Tabby Cat');
  59. * document.body.append(tabby.container);
  60. * tabby.addTab(helpTabDefinition);
  61. * tabby.addTab(docTabDefinition);
  62. * tabby.addTab(contactTabDefinition);
  63. * tabby.goto(helpTabDefinition.name); // Set initial tab
  64. * tabby.next();
  65. * const entry = tabby.tabs.get(contactTabDefinition.name);
  66. * entry.classList.add('random-css');
  67. * entry.innerHTML += '<p>More contact info.</p>';
  68. */
  69. class TabbedUI {
  70.  
  71. /**
  72. * @param {string} name - Used to distinguish HTML elements and CSS
  73. * classes.
  74. */
  75. constructor(name) {
  76. this.#log = new NH.base.Logger(`TabbedUI ${name}`);
  77. this.#name = name;
  78. this.#idName = NH.base.safeId(name);
  79. this.#id = NH.base.uuId(this.#idName);
  80. this.#container = document.createElement('section');
  81. this.#container.id = `${this.#id}-container`;
  82. this.#installControls();
  83. this.#container.append(this.#nav);
  84. this.#installStyle();
  85. this.#log.log(`${this.#name} constructed`);
  86. }
  87.  
  88. /** @type {Element} */
  89. get container() {
  90. return this.#container;
  91. }
  92.  
  93. /**
  94. * @typedef {object} TabEntry
  95. * @property {string} name - Tab name.
  96. * @property {Element} label - Tab label, so CSS can be applied.
  97. * @property {Element} panel - Tab panel, so content can be updated.
  98. */
  99.  
  100. /** @type {Map<string,TabEntry>} */
  101. get tabs() {
  102. const entries = new Map();
  103. for (const label of this.#nav.querySelectorAll(
  104. ':scope > label[data-tabbed-name]'
  105. )) {
  106. entries.set(label.dataset.tabbedName, {label: label});
  107. }
  108. for (const panel of this.container.querySelectorAll(
  109. `:scope > .${this.#idName}-panel`
  110. )) {
  111. entries.get(panel.dataset.tabbedName).panel = panel;
  112. }
  113. return entries;
  114. }
  115.  
  116. /**
  117. * A string of HTML or a prebuilt Element.
  118. * @typedef {(string|Element)} TabContent
  119. */
  120.  
  121. /**
  122. * @typedef {object} TabDefinition
  123. * @property {string} name - Tab name.
  124. * @property {TabContent} content - Initial content.
  125. */
  126.  
  127. /** @param {TabDefinition} tab - The new tab. */
  128. addTab(tab) {
  129. const me = 'addTab';
  130. this.#log.entered(me, tab);
  131. const {
  132. name,
  133. content,
  134. } = tab;
  135. const idName = NH.base.safeId(name);
  136. const input = this.#createInput(name, idName);
  137. const label = this.#createLabel(name, input, idName);
  138. const panel = this.#createPanel(name, idName, content);
  139. input.addEventListener('change', this.#onChange.bind(this, panel));
  140. this.#nav.before(input);
  141. this.#navSpacer.before(label);
  142. this.container.append(panel);
  143.  
  144. const inputChecked =
  145. `#${this.container.id} > ` +
  146. `input[data-tabbed-name="${name}"]:checked`;
  147. this.#style.textContent +=
  148. `${inputChecked} ~ nav > [data-tabbed-name="${name}"] {` +
  149. ' border-bottom: 3px solid black;' +
  150. '}\n';
  151. this.#style.textContent +=
  152. `${inputChecked} ~ div[data-tabbed-name="${name}"] {` +
  153. ' display: flex;' +
  154. '}\n';
  155.  
  156. this.#log.leaving(me);
  157. }
  158.  
  159. /** Activate the next tab. */
  160. next() {
  161. const me = 'next';
  162. this.#log.entered(me);
  163. this.#switchTab(1);
  164. this.#log.leaving(me);
  165. }
  166.  
  167. /** Activate the previous tab. */
  168. prev() {
  169. const me = 'prev';
  170. this.#log.entered(me);
  171. this.#switchTab(-1);
  172. this.#log.leaving(me);
  173. }
  174.  
  175. /** @param {string} name - Name of the tab to activate. */
  176. goto(name) {
  177. const me = 'goto';
  178. this.#log.entered(me, name);
  179. const controls = this.#getTabControls();
  180. const control = controls.find(item => item.dataset.tabbedName === name);
  181. control.click();
  182. this.#log.leaving(me);
  183. }
  184.  
  185. #container
  186. #id
  187. #idName
  188. #log
  189. #name
  190. #nav
  191. #navSpacer
  192. #nextButton
  193. #prevButton
  194. #style
  195.  
  196. /** Installs basic CSS styles for the UI. */
  197. #installStyle = () => {
  198. this.#style = document.createElement('style');
  199. this.#style.id = `${this.#id}-style`;
  200. const styles = [
  201. `#${this.container.id} {` +
  202. ' flex-grow: 1; overflow-y: hidden; display: flex;' +
  203. ' flex-direction: column;' +
  204. '}',
  205. `#${this.container.id} > input { display: none; }`,
  206. `#${this.container.id} > nav { display: flex; flex-direction: row; }`,
  207. `#${this.container.id} > nav button { border-radius: 50%; }`,
  208. `#${this.container.id} > nav > label {` +
  209. ' cursor: pointer;' +
  210. ' margin-top: 1ex; margin-left: 1px; margin-right: 1px;' +
  211. ' padding: unset; color: unset !important;' +
  212. '}',
  213. `#${this.container.id} > nav > .spacer {` +
  214. ' margin-left: auto; margin-right: auto;' +
  215. ' border-right: 1px solid black;' +
  216. '}',
  217. `#${this.container.id} label::before { all: unset; }`,
  218. `#${this.container.id} label::after { all: unset; }`,
  219. // Panels are both flex items AND flex containers.
  220. `#${this.container.id} .${this.#idName}-panel {` +
  221. ' display: none; overflow-y: auto; flex-grow: 1;' +
  222. ' flex-direction: column;' +
  223. '}',
  224. '',
  225. ];
  226. this.#style.textContent = styles.join('\n');
  227. document.head.prepend(this.#style);
  228. }
  229.  
  230. /**
  231. * Get the tab controls currently in the container.
  232. * @returns {Element[]} - Control elements for the tabs.
  233. */
  234. #getTabControls = () => {
  235. const controls = Array.from(this.container.querySelectorAll(
  236. ':scope > input'
  237. ));
  238. return controls;
  239. }
  240.  
  241. /**
  242. * Switch to an adjacent tab.
  243. * @param {number} direction - Either 1 or -1.
  244. * @fires Event#change
  245. */
  246. #switchTab = (direction) => {
  247. const me = 'switchTab';
  248. this.#log.entered(me, direction);
  249. const controls = this.#getTabControls();
  250. this.#log.log('controls:', controls);
  251. let idx = controls.findIndex(item => item.checked);
  252. if (idx === NH.base.NOT_FOUND) {
  253. idx = 0;
  254. } else {
  255. idx = (idx + direction + controls.length) % controls.length;
  256. }
  257. controls[idx].click();
  258. this.#log.leaving(me);
  259. }
  260.  
  261. /**
  262. * @param {string} name - Human readable name for tab.
  263. * @param {string} idName - Normalized to be CSS class friendly.
  264. * @returns {Element} - Input portion of the tab.
  265. */
  266. #createInput = (name, idName) => {
  267. const me = 'createInput';
  268. this.#log.entered(me);
  269. const input = document.createElement('input');
  270. input.id = `${this.#idName}-input-${idName}`;
  271. input.name = `${this.#idName}`;
  272. input.dataset.tabbedId = `${this.#idName}-input-${idName}`;
  273. input.dataset.tabbedName = name;
  274. input.type = 'radio';
  275. this.#log.leaving(me, input);
  276. return input;
  277. }
  278.  
  279. /**
  280. * @param {string} name - Human readable name for tab.
  281. * @param {Element} input - Input element associated with this label.
  282. * @param {string} idName - Normalized to be CSS class friendly.
  283. * @returns {Element} - Label portion of the tab.
  284. */
  285. #createLabel = (name, input, idName) => {
  286. const me = 'createLabel';
  287. this.#log.entered(me);
  288. const label = document.createElement('label');
  289. label.dataset.tabbedId = `${this.#idName}-label-${idName}`;
  290. label.dataset.tabbedName = name;
  291. label.htmlFor = input.id;
  292. label.innerText = `[${name}]`;
  293. this.#log.leaving(me, label);
  294. return label;
  295. }
  296.  
  297. /**
  298. * @param {string} name - Human readable name for tab.
  299. * @param {string} idName - Normalized to be CSS class friendly.
  300. * @param {TabContent} content - Initial content.
  301. * @returns {Element} - Panel portion of the tab.
  302. */
  303. #createPanel = (name, idName, content) => {
  304. const me = 'createPanel';
  305. this.#log.entered(me);
  306. const panel = document.createElement('div');
  307. panel.dataset.tabbedId = `${this.#idName}-panel-${idName}`;
  308. panel.dataset.tabbedName = name;
  309. panel.classList.add(`${this.#idName}-panel`);
  310. if (content instanceof Element) {
  311. panel.append(content);
  312. } else {
  313. panel.innerHTML = content;
  314. }
  315. this.#log.leaving(me, panel);
  316. return panel;
  317. }
  318.  
  319. /**
  320. * Event handler for change events. When the active tab changes, this
  321. * will resend an 'expose' event to the associated panel.
  322. * @param {Element} panel - The panel associated with this tab.
  323. * @param {Event} evt - The original change event.
  324. * @fires Event#expose
  325. */
  326. #onChange = (panel, evt) => {
  327. const me = 'onChange';
  328. this.#log.entered(me, evt, panel);
  329. panel.dispatchEvent(new Event('expose'));
  330. this.#log.leaving(me);
  331. }
  332.  
  333. /** Installs navigational control elements. */
  334. #installControls = () => {
  335. this.#nav = document.createElement('nav');
  336. this.#nav.id = `${this.#id}-controls`;
  337. this.#navSpacer = document.createElement('span');
  338. this.#navSpacer.classList.add('spacer');
  339. this.#prevButton = document.createElement('button');
  340. this.#nextButton = document.createElement('button');
  341. this.#prevButton.innerText = '←';
  342. this.#nextButton.innerText = '→';
  343. this.#prevButton.dataset.name = 'prev';
  344. this.#nextButton.dataset.name = 'next';
  345. this.#prevButton.addEventListener('click', () => this.prev());
  346. this.#nextButton.addEventListener('click', () => this.next());
  347. // XXX: Cannot get 'button' elements to style nicely, so cheating by
  348. // wrapping them in a label.
  349. const prevLabel = document.createElement('label');
  350. const nextLabel = document.createElement('label');
  351. prevLabel.append(this.#prevButton);
  352. nextLabel.append(this.#nextButton);
  353. this.#nav.append(this.#navSpacer, prevLabel, nextLabel);
  354. }
  355.  
  356. }
  357.  
  358. /**
  359. * An ordered collection of HTMLElements for a user to continuously scroll
  360. * through.
  361. *
  362. * The dispatcher can be used the handle the following events:
  363. * - 'out-of-range' - Scrolling went past one end of the collection. This
  364. * is NOT an error condition, but rather a design feature.
  365. * - 'change' - The value of item has changed.
  366. * - 'activate' - The Scroller was activated.
  367. * - 'deactivate' - The Scroller was deactivated.
  368. */
  369. class Scroller {
  370.  
  371. /**
  372. * Function that generates a, preferably, reproducible unique identifier
  373. * for an Element.
  374. * @callback uidCallback
  375. * @param {Element} element - Element to examine.
  376. * @returns {string} - A value unique to this element.
  377. */
  378.  
  379. /**
  380. * Contains CSS selectors to first find a base element, then items that it
  381. * contains.
  382. * @typedef {object} ContainerItemsSelector
  383. * @property {string} container - CSS selector to find the container
  384. * element.
  385. * @property {string} items - CSS selector to find the items inside the
  386. * container.
  387. */
  388.  
  389. /**
  390. * There are two ways to describe what elements go into a Scroller:
  391. * 1. An explicit container (base) element and selectors stemming from it.
  392. * 2. An array of ContainerItemsSelector that can allow for multiple
  393. * containers with items. This approach will also allow the Scroller to
  394. * automatically wait for all container elements to exist during
  395. * activation.
  396. * @typedef {object} What
  397. * @property {string} name - Name for this scroller, used for logging.
  398. * @property {Element} base - The container to use as a base for selecting
  399. * elements.
  400. * @property {string[]} selectors - Array of CSS selectors to find
  401. * elements to collect, calling base.querySelectorAll().
  402. * @property {ContainerItemsSelector[]} containerItems - Array of
  403. * ContainerItemsSelectors.
  404. */
  405.  
  406. /**
  407. * @typedef {object} How
  408. * @property {uidCallback} uidCallback - Callback to generate a uid.
  409. * @property {string[]} [classes=[]] - Array of CSS classes to add/remove
  410. * from an element as it becomes current.
  411. * @property {boolean} [handleClicks=true] - Whether the scroller should
  412. * watch for clicks and if one is inside an item, select it.
  413. * @property {boolean} [autoActivate=false] - Whether to call the activate
  414. * method at the end of construction.
  415. * @property {boolean} [snapToTop=false] - Whether items should snap to
  416. * the top of the window when coming into view.
  417. * @property {number} [topMarginPixels=0] - Used to determine if scrolling
  418. * should happen when {snapToTop} is false.
  419. * @property {number} [bottomMarginPixels=0] - Used to determin if
  420. * scrolling should happen when {snapToTop} is false.
  421. * @property {string} [topMarginCSS='0'] - CSS applied to
  422. * `scrollMarginTop`.
  423. * @property {string} [bottomMarginCSS='0'] - CSS applied to
  424. * `scrollMarginBottom`.
  425. */
  426.  
  427. /**
  428. * @param {What} what - What we want to scroll.
  429. * @param {How} how - How we want to scroll.
  430. * @throws {Scroller.Error} - On many construction problems.
  431. */
  432. constructor(what, how) {
  433. ({
  434. name: this.#name = 'Unnamed scroller',
  435. base: this.#base,
  436. selectors: this.#selectors,
  437. containerItems: this.#containerItems = [],
  438. } = what);
  439. ({
  440. uidCallback: this.#uidCallback,
  441. classes: this.#classes = [],
  442. handleClicks: this.#handleClicks = true,
  443. autoActivate: this.#autoActivate = false,
  444. snapToTop: this.#snapToTop = false,
  445. topMarginPixels: this.#topMarginPixels = 0,
  446. bottomMarginPixels: this.#bottomMarginPixels = 0,
  447. topMarginCSS: this.#topMarginCSS = '0',
  448. bottomMarginCSS: this.#bottomMarginCSS = '0',
  449. } = how);
  450.  
  451. this.#validateInstance();
  452.  
  453. this.#mutationObserver = new MutationObserver(this.#mutationHandler);
  454.  
  455. this.#logger = new NH.base.Logger(`{${this.#name}}`);
  456. this.logger.log('Scroller constructed', this);
  457.  
  458. if (this.#autoActivate) {
  459. this.activate();
  460. }
  461. }
  462.  
  463. static Error = class extends Error {
  464.  
  465. /** @inheritdoc */
  466. constructor(...rest) {
  467. super(...rest);
  468. this.name = this.constructor.name;
  469. }
  470.  
  471. };
  472.  
  473. /** @type {NH.base.Dispatcher} */
  474. get dispatcher() {
  475. return this.#dispatcher;
  476. }
  477.  
  478. /** @type {Element} - Represents the current item. */
  479. get item() {
  480. const me = 'get item';
  481. this.logger.entered(me);
  482. if (this.#destroyed) {
  483. const msg = `Tried to work with destroyed ${Scroller.name} ` +
  484. `on ${this.#base}`;
  485. this.logger.log(msg);
  486. throw new Error(msg);
  487. }
  488. const items = this.#getItems();
  489. let item = items.find(this.#matchItem);
  490. if (!item) {
  491. // We couldn't find the old id, so maybe it was rebuilt. Make a guess
  492. // by trying the old index.
  493. const idx = this.#historicalIdToIndex.get(this.#currentItemId);
  494. if (typeof idx === 'number' && (0 <= idx && idx < items.length)) {
  495. item = items[idx];
  496. this.#bottomHalf(item);
  497. }
  498. }
  499. this.logger.leaving(me, item);
  500. return item;
  501. }
  502.  
  503. /** @param {Element} val - Set the current item. */
  504. set item(val) {
  505. const me = 'set item';
  506. this.logger.entered(me, val);
  507. this.dull();
  508. this.#bottomHalf(val);
  509. this.logger.leaving(me);
  510. }
  511.  
  512. /** @type {string} - Current item's uid. */
  513. get itemUid() {
  514. return this.#currentItemId;
  515. }
  516.  
  517. /** @type {NH.base.Logger} */
  518. get logger() {
  519. return this.#logger;
  520. }
  521.  
  522. /** Move to the next item in the collection. */
  523. next() {
  524. this.#scrollBy(1);
  525. }
  526.  
  527. /** Move to the previous item in the collection. */
  528. prev() {
  529. this.#scrollBy(-1);
  530. }
  531.  
  532. /** Jump to the first item in the collection. */
  533. first() {
  534. this.#jumpToEndItem(true);
  535. }
  536.  
  537. /** Jump to last item in the collection. */
  538. last() {
  539. this.#jumpToEndItem(false);
  540. }
  541.  
  542. /**
  543. * Move to a specific item if possible.
  544. * @param {Element} item - Item to go to.
  545. */
  546. goto(item) {
  547. this.item = item;
  548. }
  549.  
  550. /**
  551. * Move to a specific item if possible, by uid.
  552. * @param {string} uid - The uid of a specific item.
  553. * @returns {boolean} - Was able to goto the item.
  554. */
  555. gotoUid(uid) {
  556. const me = 'goto';
  557. this.logger.entered(me, uid);
  558. const items = this.#getItems();
  559. const item = items.find(el => uid === this.#uid(el));
  560. let success = false;
  561. if (item) {
  562. this.item = item;
  563. success = true;
  564. }
  565. this.logger.leaving(me, success, item);
  566. return success;
  567. }
  568.  
  569. /** Adds the registered CSS classes to the current element. */
  570. shine() {
  571. this.item?.classList.add(...this.#classes);
  572. }
  573.  
  574. /** Removes the registered CSS classes from the current element. */
  575. dull() {
  576. this.item?.classList.remove(...this.#classes);
  577. }
  578.  
  579. /** Bring current item back into view. */
  580. show() {
  581. this.#scrollToCurrentItem();
  582. }
  583.  
  584. /**
  585. * Activate the scroller.
  586. * @fires 'out-of-range'
  587. */
  588. async activate() {
  589. const me = 'activate';
  590. this.logger.entered(me);
  591.  
  592. const bases = new Set(await this.#waitForBases());
  593. if (this.#base) {
  594. bases.add(this.#base);
  595. }
  596.  
  597. for (const base of bases) {
  598. if (this.#handleClicks) {
  599. this.#onClickElements.add(base);
  600. base.addEventListener('click', this.#onClick);
  601. }
  602. this.#mutationObserver.observe(base, {childList: true});
  603. }
  604. this.dispatcher.fire('activate', null);
  605.  
  606. this.logger.leaving(me);
  607. }
  608.  
  609. /**
  610. * Deactivate the scroller (but do not destroy it).
  611. * @fires 'out-of-range'
  612. */
  613. deactivate() {
  614. this.#mutationObserver.disconnect();
  615. for (const base of this.#onClickElements) {
  616. base.removeEventListener('click', this.#onClick);
  617. }
  618. this.#onClickElements.clear();
  619. this.dispatcher.fire('deactivate', null);
  620. }
  621.  
  622. /** Mark instance as inactive and do any internal cleanup. */
  623. destroy() {
  624. const me = 'destroy';
  625. this.logger.entered(me);
  626. this.deactivate();
  627. this.item = null;
  628. this.#destroyed = true;
  629. this.logger.leaving(me);
  630. }
  631.  
  632. /**
  633. * Determines if the item can be viewed. Usually this means the content
  634. * is being loaded lazily and is not ready yet.
  635. * @param {Element} item - The item to inspect.
  636. * @returns {boolean} - Whether the item has viewable content.
  637. */
  638. static #isItemViewable(item) {
  639. return item.clientHeight && item.innerText.length;
  640. }
  641.  
  642. #autoActivate
  643. #base
  644. #bottomMarginCSS
  645. #bottomMarginPixels
  646. #classes
  647. #containerItems
  648. #currentItemId = null;
  649. #destroyed = false;
  650.  
  651. #dispatcher = new NH.base.Dispatcher(
  652. 'change', 'out-of-range', 'activate', 'deactivate'
  653. );
  654.  
  655. #handleClicks
  656. #historicalIdToIndex = new Map();
  657. #logger
  658. #mutationObserver
  659. #name
  660. #onClickElements = new Set();
  661. #selectors
  662. #snapToTop
  663. #stackTrace
  664. #topMarginCSS
  665. #topMarginPixels
  666. #uidCallback
  667.  
  668. /**
  669. * If an item is clicked, switch to it.
  670. * @param {Event} evt - Standard 'click' event.
  671. */
  672. #onClick = (evt) => {
  673. const me = 'onClick';
  674. this.logger.entered(me, evt);
  675. for (const item of this.#getItems()) {
  676. if (item.contains(evt.target)) {
  677. this.logger.log('found:', item);
  678. if (item !== this.item) {
  679. this.item = item;
  680. }
  681. }
  682. }
  683. this.logger.leaving(me);
  684. }
  685.  
  686. /** @param {MutationRecord[]} records - Standard mutation records. */
  687. #mutationHandler = (records) => {
  688. const me = 'mutationHandler';
  689. this.logger.entered(
  690. me, `records: ${records.length} type: ${records[0].type}`
  691. );
  692. for (const record of records) {
  693. if (record.type === 'childList') {
  694. this.logger.log('childList record');
  695. } else if (record.type === 'attributes') {
  696. this.logger.log('attribute records');
  697. }
  698. }
  699. this.logger.leaving(me);
  700. }
  701.  
  702. /**
  703. * Since the getter will try to validate the current item (since it could
  704. * have changed out from under us), it too can update information.
  705. * @param {Element} val - Element to make current.
  706. */
  707. #bottomHalf = (val) => {
  708. const me = 'bottomHalf';
  709. this.logger.entered(me, val);
  710. this.#currentItemId = this.#uid(val);
  711. const idx = this.#getItems().indexOf(val);
  712. this.#historicalIdToIndex.set(this.#currentItemId, idx);
  713. this.shine();
  714. this.#scrollToCurrentItem();
  715. this.dispatcher.fire('change', {});
  716. this.logger.leaving(me);
  717. }
  718.  
  719. /**
  720. * Builds the list of elements using the registered CSS selectors.
  721. * @returns {Elements[]} - Items to scroll through.
  722. */
  723. #getItems = () => {
  724. const me = 'getItems';
  725. this.logger.entered(me);
  726. const items = [];
  727. if (this.#base) {
  728. for (const selector of this.#selectors) {
  729. this.logger.log(`considering ${selector}`);
  730. items.push(...this.#base.querySelectorAll(selector));
  731. }
  732. } else {
  733. for (const {container, items: selector} of this.#containerItems) {
  734. const base = document.querySelector(container);
  735. items.push(...base.querySelectorAll(selector));
  736. }
  737. }
  738. this.logger.starting('items');
  739. for (const item of items) {
  740. this.logger.log('item:', item);
  741. }
  742. this.logger.finished('items');
  743.  
  744. this.logger.leaving(me, `${items.length} items`);
  745. return items;
  746. }
  747.  
  748. /**
  749. * Returns the uid for the current element. Will use the registered
  750. * uidCallback function for this.
  751. * @param {Element} element - Element to identify.
  752. * @returns {string} - Computed uid for element.
  753. */
  754. #uid = (element) => {
  755. const me = 'uid';
  756. this.logger.entered(me, element);
  757. let uid = null;
  758. if (element) {
  759. if (!element.dataset.scrollerId) {
  760. element.dataset.scrollerId = this.#uidCallback(element);
  761. }
  762. uid = element.dataset.scrollerId;
  763. }
  764. this.logger.leaving(me, uid);
  765. return uid;
  766. }
  767.  
  768. /**
  769. * Checks if the element is the current one. Useful as a callback to
  770. * Array.find.
  771. * @param {Element} element - Element to check.
  772. * @returns {boolean} - Whether or not element is the current one.
  773. */
  774. #matchItem = (element) => {
  775. const me = 'matchItem';
  776. this.logger.entered(me);
  777. const res = this.#currentItemId === this.#uid(element);
  778. this.logger.leaving(me, res);
  779. return res;
  780. }
  781.  
  782. /**
  783. * Scroll the current item into the view port. Depending on the instance
  784. * configuration, this could snap to the top, snap to the bottom, or be a
  785. * no-op.
  786. */
  787. #scrollToCurrentItem = () => {
  788. const me = 'scrollToCurrentItem';
  789. this.logger.entered(me, `snaptoTop: ${this.#snapToTop}`);
  790. const {item} = this;
  791. if (item) {
  792. item.style.scrollMarginTop = this.#topMarginCSS;
  793. if (this.#snapToTop) {
  794. this.logger.log('snapping to top');
  795. item.scrollIntoView(true);
  796. } else {
  797. this.logger.log('not snapping to top');
  798. item.style.scrollMarginBottom = this.#bottomMarginCSS;
  799. const rect = item.getBoundingClientRect();
  800. // If both scrolling happens, it means the item is too tall to fit
  801. // on the page, so the top is preferred.
  802. const allowedBottom = document.documentElement.clientHeight -
  803. this.#bottomMarginPixels;
  804. if (rect.bottom > allowedBottom) {
  805. this.logger.log('scrolling up onto page');
  806. item.scrollIntoView(false);
  807. }
  808. if (rect.top < this.#topMarginPixels) {
  809. this.logger.log('scrolling down onto page');
  810. item.scrollIntoView(true);
  811. }
  812. // XXX: The following was added to support horizontal scrolling in
  813. // carousels. Nothing seemed to break. TODO(#132): Did find a side
  814. // effect though: it can cause an item being *left* to shift up if
  815. // the scrollMarginBottom has been set.
  816. item.scrollIntoView({block: 'nearest', inline: 'nearest'});
  817. }
  818. }
  819. this.logger.leaving(me);
  820. }
  821.  
  822. /**
  823. * Jump an item on an end of the collection.
  824. * @param {boolean} first - If true, the first item in the collection,
  825. * else, the last.
  826. */
  827. #jumpToEndItem = (first) => {
  828. const me = 'jumpToEndItem';
  829. this.logger.entered(me, `first=${first}`);
  830. // Reset in case item was heavily modified
  831. this.item = this.item;
  832.  
  833. const items = this.#getItems();
  834. if (items.length) {
  835. // eslint-disable-next-line no-extra-parens
  836. let idx = first ? 0 : (items.length - 1);
  837. let item = items[idx];
  838.  
  839. // Content of items is sometimes loaded lazily and can be detected by
  840. // having no innerText yet. So start at the end and work our way up
  841. // to the last one loaded.
  842. if (!first) {
  843. while (!Scroller.#isItemViewable(item)) {
  844. this.logger.log('skipping item', item);
  845. idx -= 1;
  846. item = items[idx];
  847. }
  848. }
  849. this.item = item;
  850. }
  851. this.logger.leaving(me);
  852. }
  853.  
  854. /**
  855. * Move forward or backwards in the collection by at least n.
  856. * @param {number} n - How many items to move and the intended direction.
  857. * @fires 'out-of-range'
  858. */
  859. #scrollBy = (n) => { // eslint-disable-line max-statements
  860. const me = 'scrollBy';
  861. this.logger.entered(me, n);
  862. // Reset in case item was heavily modified
  863. this.item = this.item;
  864.  
  865. const items = this.#getItems();
  866. if (items.length) {
  867. let idx = items.findIndex(this.#matchItem);
  868. this.logger.log('initial idx', idx);
  869. idx += n;
  870. if (idx < NH.base.NOT_FOUND) {
  871. idx = items.length - 1;
  872. }
  873. if (idx === NH.base.NOT_FOUND || idx >= items.length) {
  874. this.item = null;
  875. this.dispatcher.fire('out-of-range', null);
  876. } else {
  877. // Skip over empty items
  878. let item = items[idx];
  879. while (!Scroller.#isItemViewable(item)) {
  880. this.logger.log('skipping item', item);
  881. idx += n;
  882. item = items[idx];
  883. }
  884. this.logger.log('final idx', idx);
  885. this.item = item;
  886. }
  887. }
  888. this.logger.leaving(me);
  889. }
  890.  
  891. /** @throws {Scroller.Error} - On many validation issues. */
  892. #validateInstance = () => {
  893.  
  894. if (this.#base && this.#containerItems.length) {
  895. throw new Scroller.Error(
  896. `Cannot have both base AND containerItems: ${this.#name} has both`
  897. );
  898. }
  899.  
  900. if (!this.#base && !this.#containerItems.length) {
  901. throw new Scroller.Error(
  902. `Needs either base OR containerItems: ${this.#name} has neither`
  903. );
  904. }
  905.  
  906. if (this.#base && !(this.#base instanceof Element)) {
  907. throw new Scroller.Error(
  908. `Not an element: base ${this.#base} given for ${this.#name}`
  909. );
  910. }
  911.  
  912. if (this.#base && !this.#selectors) {
  913. throw new Scroller.Error(
  914. `No selectors: ${this.#name} is missing selectors`
  915. );
  916. }
  917.  
  918. if (this.#selectors && !this.#base) {
  919. throw new Scroller.Error(
  920. `No base: ${this.#name} is using selectors and so needs a base`
  921. );
  922. }
  923.  
  924. }
  925.  
  926. /**
  927. * The page may still be loading, so wait for many things to settle.
  928. * @returns {Promise<Element[]>} - All the new base elements.
  929. */
  930. #waitForBases = () => {
  931. const me = 'waitForBases';
  932. this.logger.entered(me);
  933.  
  934. const results = [];
  935.  
  936. for (const {container} of this.#containerItems) {
  937. results.push(NH.web.waitForSelector(container, 0));
  938. }
  939.  
  940. this.logger.leaving(me, results);
  941. return Promise.all(results);
  942. }
  943.  
  944. }
  945.  
  946. /* eslint-disable max-lines-per-function */
  947. /* eslint-disable no-new */
  948. /** Test case. */
  949. function testScroller() {
  950. const tests = new Map();
  951.  
  952. tests.set('needsBaseOrContainerItems', {test: () => {
  953. const what = {
  954. name: 'needsBaseOrContainerItems',
  955. };
  956. const how = {
  957. };
  958. try {
  959. new Scroller(what, how);
  960. } catch (e) {
  961. if (e instanceof Scroller.Error &&
  962. e.message.includes('Needs either base OR containerItems:')) {
  963. return 'passed';
  964. }
  965. return 'caught-but-wrong-error';
  966. }
  967. return 'failed';
  968. },
  969. expected: 'passed'});
  970.  
  971. tests.set('notBaseAndContainerItems', {test: () => {
  972. const what = {
  973. name: 'needsBaseAndContainerItems',
  974. base: document.body,
  975. containerItems: [{}],
  976. };
  977. const how = {
  978. };
  979. try {
  980. new Scroller(what, how);
  981. } catch (e) {
  982. if (e instanceof Scroller.Error &&
  983. e.message.includes('Cannot have both base AND containerItems:')) {
  984. return 'passed';
  985. }
  986. return 'caught-but-wrong-error';
  987. }
  988. return 'failed';
  989. },
  990. expected: 'passed'});
  991.  
  992. tests.set('baseIsElement', {test: () => {
  993. const what = {
  994. name: 'baseIsElement',
  995. base: document,
  996. };
  997. const how = {
  998. };
  999. try {
  1000. new Scroller(what, how);
  1001. } catch (e) {
  1002. if (e instanceof Scroller.Error &&
  1003. e.message.includes('Not an element:')) {
  1004. return 'passed';
  1005. }
  1006. return 'caught-but-wrong-error';
  1007. }
  1008. return 'failed';
  1009. },
  1010. expected: 'passed'});
  1011.  
  1012. tests.set('baseNeedsSelector', {test: () => {
  1013. const what = {
  1014. name: 'baseNeedsSelector',
  1015. base: document.body,
  1016. };
  1017. const how = {
  1018. };
  1019. try {
  1020. new Scroller(what, how);
  1021. } catch (e) {
  1022. if (e instanceof Scroller.Error &&
  1023. e.message.includes('No selectors:')) {
  1024. return 'passed';
  1025. }
  1026. return 'caught-but-wrong-error';
  1027. }
  1028. return 'failed';
  1029. },
  1030. expected: 'passed'});
  1031.  
  1032. tests.set('selectorNeedsBase', {test: () => {
  1033. const what = {
  1034. name: 'selectorNeedsBase',
  1035. selectors: [],
  1036. containerItems: [{}],
  1037. };
  1038. const how = {
  1039. };
  1040. try {
  1041. new Scroller(what, how);
  1042. } catch (e) {
  1043. if (e instanceof Scroller.Error && e.message.includes('No base:')) {
  1044. return 'passed';
  1045. }
  1046. return 'caught-but-wrong-error';
  1047. }
  1048. return 'failed';
  1049. },
  1050. expected: 'passed'});
  1051.  
  1052. tests.set('baseWithSelectorsIsFine', {test: () => {
  1053. const what = {
  1054. name: 'baseWithSelectorsIsFine',
  1055. base: document.body,
  1056. selectors: [],
  1057. };
  1058. const how = {
  1059. };
  1060. try {
  1061. new Scroller(what, how);
  1062. } catch (e) {
  1063. return 'failed';
  1064. }
  1065. return 'passed';
  1066. },
  1067. expected: 'passed'});
  1068.  
  1069. for (const [name, {test, expected}] of tests) {
  1070. const actual = test();
  1071. const passed = actual === expected;
  1072. const msg = `t:${name} e:${expected} a:${actual} p:${passed}`;
  1073. NH.xunit.testing.log.log(msg);
  1074. if (!passed) {
  1075. throw new Error(msg);
  1076. }
  1077. }
  1078.  
  1079. }
  1080. /* eslint-enable */
  1081.  
  1082. NH.xunit.testing.funcs.push(testScroller);
  1083.  
  1084. /**
  1085. * This class exists solely to avoid some `no-use-before-define` linter
  1086. * issues.
  1087. */
  1088. class LinkedInGlobals {
  1089.  
  1090. /** @type {string} - LinkedIn's common aside used in many layouts. */
  1091. static get asideSelector() {
  1092. return this.#asideSelector;
  1093. }
  1094.  
  1095. /** @type {string} - LinkedIn's common sidebar used in many layouts. */
  1096. static get sidebarSelector() {
  1097. return this.#sidebarSelector;
  1098. }
  1099.  
  1100. /** @type {string} - The height of the navbar as CSS string. */
  1101. get navBarHeightCSS() {
  1102. return `${this.#navBarHeightPixels}px`;
  1103. }
  1104.  
  1105. /** @type {number} - The height of the navbar in pixels. */
  1106. get navBarHeightPixels() {
  1107. return this.#navBarHeightPixels;
  1108. }
  1109.  
  1110. /** @param {number} val - Set height of the navbar in pixels. */
  1111. set navBarHeightPixels(val) {
  1112. this.#navBarHeightPixels = val;
  1113. }
  1114.  
  1115. /** Scroll common sidebar into view and move focus to it. */
  1116. focusOnSidebar = () => {
  1117. const sidebar = document.querySelector(LinkedInGlobals.sidebarSelector);
  1118. if (sidebar) {
  1119. sidebar.style.scrollMarginTop = this.navBarHeightCSS;
  1120. sidebar.scrollIntoView();
  1121. NH.web.focusOnElement(sidebar);
  1122. }
  1123. }
  1124.  
  1125. /**
  1126. * Scroll common aside (right-hand sidebar) into view and move focus to
  1127. * it.
  1128. */
  1129. focusOnAside = () => {
  1130. const aside = document.querySelector(LinkedInGlobals.asideSelector);
  1131. if (aside) {
  1132. aside.style.scrollMarginTop = this.navBarHeightCSS;
  1133. aside.scrollIntoView();
  1134. NH.web.focusOnElement(aside);
  1135. }
  1136. }
  1137.  
  1138. /**
  1139. * Create a Greasy Fork project URL.
  1140. * @param {string} path - Portion of the URL.
  1141. * @returns {string} - Full URL.
  1142. */
  1143. gfUrl = (path) => {
  1144. const base = 'https://greasyfork.org/en/scripts/472097-linkedin-tool';
  1145. const url = `${base}/${path}`;
  1146. return url;
  1147. }
  1148.  
  1149. /**
  1150. * Create a GitHub project URL.
  1151. * @param {string} path - Portion of the URL.
  1152. * @returns {string} - Full URL.
  1153. */
  1154. ghUrl = (path) => {
  1155. const base = 'https://github.com/nexushoratio/userscripts';
  1156. const url = `${base}/${path}`;
  1157. return url;
  1158. }
  1159.  
  1160. static #asideSelector = 'aside.scaffold-layout__aside';
  1161. static #sidebarSelector = 'div.scaffold-layout__sidebar';
  1162.  
  1163. #navBarHeightPixels = 0;
  1164.  
  1165. }
  1166.  
  1167. /** A table with collapsible sections. */
  1168. class AccordionTableWidget extends NH.widget.Widget {
  1169.  
  1170. /** @param {string} name - Name for this instance. */
  1171. constructor(name) {
  1172. super(name, 'table');
  1173. this.logger.log(`${this.name} constructed`);
  1174. }
  1175.  
  1176. /**
  1177. * This becomes the current section.
  1178. * @param {string} name - Name of the new section.
  1179. * @returns {Element} - The new section.
  1180. */
  1181. addSection(name) {
  1182. this.#currentSection = document.createElement('tbody');
  1183. this.#currentSection.id = NH.base.safeId(`${this.id}-${name}`);
  1184. this.container.append(this.#currentSection);
  1185. return this.#currentSection;
  1186. }
  1187.  
  1188. /**
  1189. * Add a row of header cells to the current section.
  1190. * @param {...string} items - To make up the row cells.
  1191. */
  1192. addHeader(...items) {
  1193. this.#addRow('th', ...items);
  1194. }
  1195.  
  1196. /**
  1197. * Add a row of data cells to the current section.
  1198. * @param {...string} items - To make up the row cells.
  1199. */
  1200. addData(...items) {
  1201. this.#addRow('td', ...items);
  1202. }
  1203.  
  1204. #currentSection
  1205.  
  1206. /**
  1207. * Add a row to the current section.
  1208. * @param {string} type - Cell type, typically 'td' or 'th'.
  1209. * @param {...string} items - To make up the row cells.
  1210. */
  1211. #addRow = (type, ...items) => {
  1212. const tr = document.createElement('tr');
  1213. for (const item of items) {
  1214. const cell = document.createElement(type);
  1215. cell.innerHTML = item;
  1216. tr.append(cell);
  1217. }
  1218. this.container.append(tr);
  1219. }
  1220.  
  1221. }
  1222.  
  1223. /**
  1224. * Self-decorating class useful for integrating with a hotkey service.
  1225. *
  1226. * @example
  1227. * // Wrap an arrow function:
  1228. * foo = new Shortcut('c-c', 'Clear the console.', () => {
  1229. * console.clear();
  1230. * console.log('I did it!', this);
  1231. * });
  1232. *
  1233. * // Search for instances:
  1234. * const keys = [];
  1235. * for (const prop of Object.values(this)) {
  1236. * if (prop instanceof Shortcut) {
  1237. * keys.push({seq: prop.seq, desc: prop.seq, func: prop});
  1238. * }
  1239. * }
  1240. * ... Send keys off to service ...
  1241. */
  1242. class Shortcut extends Function {
  1243.  
  1244. /**
  1245. * Wrap a function.
  1246. * @param {string} seq - Key sequence to activate this function.
  1247. * @param {string} desc - Human readable documenation about this function.
  1248. * @param {SimpleFunction} func - Function to wrap, usually in the form of
  1249. * an arrow function. Keep JS `this` magic in mind!
  1250. */
  1251. constructor(seq, desc, func) {
  1252. super('return this.func();');
  1253. const self = this.bind(this);
  1254. self.seq = seq;
  1255. self.desc = desc;
  1256. this.func = func;
  1257. return self;
  1258. }
  1259.  
  1260. }
  1261.  
  1262. /**
  1263. * Base class for building services to go with {@link SPA}.
  1264. *
  1265. * This should be subclassed to implement services that instances of {@link
  1266. * Page} will instantiate, initialize, active and deactivate at appropriate
  1267. * times.
  1268. *
  1269. * It is expected that each {Page} subclass will have individual instances
  1270. * of the services, though nothing will enforce that.
  1271. *
  1272. * @example
  1273. * class DummyService extends Service {
  1274. * ... implement methods ...
  1275. * }
  1276. *
  1277. * class CustomPage extends Page {
  1278. * constructor() {
  1279. * this.addService(DummyService);
  1280. * }
  1281. * }
  1282. */
  1283. class Service {
  1284.  
  1285. /** @param {string} name - Custom portion of this instance. */
  1286. constructor(name) {
  1287. if (new.target === Service) {
  1288. throw new TypeError('Abstract class; do not instantiate directly.');
  1289. }
  1290. this.#name = `${this.constructor.name}: ${name}`;
  1291. this.#shortName = name;
  1292. this.#logger = new NH.base.Logger(this.#name);
  1293. }
  1294.  
  1295. /** @type {NH.base.Logger} - NH.base.Logger instance. */
  1296. get logger() {
  1297. return this.#logger;
  1298. }
  1299.  
  1300. /** @type {string} - Instance name. */
  1301. get name() {
  1302. return this.#name;
  1303. }
  1304.  
  1305. /** @type {string} - Shorter instance name. */
  1306. get shortName() {
  1307. return this.#shortName;
  1308. }
  1309.  
  1310. /** Called each time service is activated. */
  1311. activate() {
  1312. this.#notImplemented('activate');
  1313. }
  1314.  
  1315. /** Called each time service is deactivated. */
  1316. deactivate() {
  1317. this.#notImplemented('deactivate');
  1318. }
  1319.  
  1320. #logger
  1321. #name
  1322. #shortName
  1323.  
  1324. /** @param {string} name - Name of method that was not implemented. */
  1325. #notImplemented(name) {
  1326. const msg = `Class ${this.constructor.name} did not implement ` +
  1327. `method "${name}".`;
  1328. this.logger.log(msg);
  1329. throw new Error(msg);
  1330. }
  1331.  
  1332. }
  1333.  
  1334. /** Toy service for experimenting. */
  1335. class DummyService extends Service {
  1336.  
  1337. /** @inheritdoc */
  1338. activate() {
  1339. this.logger.log('Dummy activate');
  1340. }
  1341.  
  1342. /** @inheritdoc */
  1343. deactivate() {
  1344. this.logger.log('Dummy deactivate');
  1345. }
  1346.  
  1347. }
  1348.  
  1349. /** Manage a {Scroller} via {Service}. */
  1350. class ScrollerService extends Service {
  1351.  
  1352. /**
  1353. * @param {string} name - Custom portion of this instance.
  1354. * @param {Scroller} scroller - Scroller instance to manage.
  1355. */
  1356. constructor(name, scroller) {
  1357. super(name);
  1358. this.#scroller = scroller;
  1359. }
  1360.  
  1361. /** @inheritdoc */
  1362. activate() {
  1363. this.#scroller.activate();
  1364. }
  1365.  
  1366. /** @inheritdoc */
  1367. deactivate() {
  1368. this.#scroller.deactivate();
  1369. }
  1370.  
  1371. #scroller
  1372.  
  1373. }
  1374.  
  1375. /**
  1376. * @external VMShortcuts
  1377. * @see {@link https://violentmonkey.github.io/guide/keyboard-shortcuts/}
  1378. */
  1379.  
  1380. /**
  1381. * Integrates {@link external:VMShortcuts} with {@link Shortcut}s.
  1382. *
  1383. * NB {Shortcut} was designed to work natively with {external:VMShortcuts},
  1384. * but there should be no known technical reason preventing other
  1385. * implementations from being used, would have have to write a different
  1386. * service.
  1387. *
  1388. * Instances of classes that have {@link Shortcut} properties on them can be
  1389. * added and removed to each instance of this service. The shortcuts will
  1390. * be enabled and disabled as the service is activated/deactived. This can
  1391. * allow each service to have different groups of shortcuts present.
  1392. *
  1393. * All Shortcuts can react to VM.shortcut style conditions. These
  1394. * conditions are added once during each call to addService(), and default
  1395. * to '!inputFocus'.
  1396. *
  1397. * The built in handler for 'inputFocus' can be enabled by executing:
  1398. *
  1399. * @example
  1400. * VMKeyboardService.start();
  1401. */
  1402. class VMKeyboardService extends Service {
  1403.  
  1404. /** @inheritdoc */
  1405. constructor(name) {
  1406. super(name);
  1407. VMKeyboardService.#services.add(this);
  1408. }
  1409.  
  1410. static keyMap = new Map([
  1411. ['LEFT', '←'],
  1412. ['UP', '↑'],
  1413. ['RIGHT', '→'],
  1414. ['DOWN', '↓'],
  1415. ]);
  1416.  
  1417. /** @param {string} val - New condition. */
  1418. static set condition(val) {
  1419. this.#navOption.condition = val;
  1420. }
  1421.  
  1422. /** @type {Set<VMKeyboardService>} - Instantiated services. */
  1423. static get services() {
  1424. return new Set(this.#services.values());
  1425. }
  1426.  
  1427. /** Add listener. */
  1428. static start() {
  1429. document.addEventListener('focus', this.#onFocus, this.#focusOption);
  1430. }
  1431.  
  1432. /** Remove listener. */
  1433. static stop() {
  1434. document.removeEventListener('focus', this.#onFocus, this.#focusOption);
  1435. }
  1436.  
  1437. /**
  1438. * Set the keyboard context to a specific value.
  1439. * @param {string} context - The name of the context.
  1440. * @param {object} state - What the value should be.
  1441. */
  1442. static setKeyboardContext(context, state) {
  1443. for (const service of this.#services) {
  1444. for (const keyboard of service.#keyboards.values()) {
  1445. keyboard.setContext(context, state);
  1446. }
  1447. }
  1448. }
  1449.  
  1450. /**
  1451. * Parse a {@link Shortcut.seq} and wrap it in HTML.
  1452. * @example
  1453. * 'a c-b' ->
  1454. * '<kbd><kbd>a</kbd> then <kbd>Ctrl</kbd> + <kbd>b</kbd></kbd>'
  1455. * @param {Shortcut.seq} seq - Keystroke sequence.
  1456. * @returns {string} - Appropriately wrapped HTML.
  1457. */
  1458. static parseSeq(seq) {
  1459.  
  1460. /**
  1461. * Convert a VM.shortcut style into an HTML snippet.
  1462. * @param {IShortcutKey} key - A particular key press.
  1463. * @returns {string} - HTML snippet.
  1464. */
  1465. function reprKey(key) {
  1466. if (key.base.length === 1) {
  1467. if ((/\p{Uppercase_Letter}/u).test(key.base)) {
  1468. key.base = key.base.toLowerCase();
  1469. key.modifierState.s = true;
  1470. }
  1471. } else {
  1472. key.base = key.base.toUpperCase();
  1473. const mapped = VMKeyboardService.keyMap.get(key.base);
  1474. if (mapped) {
  1475. key.base = mapped;
  1476. }
  1477. }
  1478. const sequence = [];
  1479. if (key.modifierState.c) {
  1480. sequence.push('Ctrl');
  1481. }
  1482. if (key.modifierState.a) {
  1483. sequence.push('Alt');
  1484. }
  1485. if (key.modifierState.s) {
  1486. sequence.push('Shift');
  1487. }
  1488. sequence.push(key.base);
  1489. return sequence.map(c => `<kbd>${c}</kbd>`).join('+');
  1490. }
  1491. const res = VM.shortcut.normalizeSequence(seq, true)
  1492. .map(key => reprKey(key))
  1493. .join(' then ');
  1494. return `<kbd>${res}</kbd>`;
  1495. }
  1496.  
  1497. /** @type {boolean} */
  1498. get active() {
  1499. return this.#active;
  1500. }
  1501.  
  1502. /** @type {Shortcut[]} - Well, seq and desc properties only. */
  1503. get shortcuts() {
  1504. return this.#shortcuts;
  1505. }
  1506.  
  1507. /** @inheritdoc */
  1508. activate() {
  1509. for (const keyboard of this.#keyboards.values()) {
  1510. this.logger.log('would enable keyboard', keyboard);
  1511. // TODO: keyboard.enable();
  1512. }
  1513. this.#active = true;
  1514. }
  1515.  
  1516. /** @inheritdoc */
  1517. deactivate() {
  1518. for (const keyboard of this.#keyboards.values()) {
  1519. this.logger.log('would disable keyboard', keyboard);
  1520. // TODO: keyboard.disable();
  1521. }
  1522. this.#active = false;
  1523. }
  1524.  
  1525. /** @param {*} instance - Object with {Shortcut} properties. */
  1526. addInstance(instance) {
  1527. const me = 'addInstance';
  1528. this.logger.entered(me, instance);
  1529. if (this.#keyboards.has(instance)) {
  1530. this.logger.log('Already registered');
  1531. } else {
  1532. const keyboard = new VM.shortcut.KeyboardService();
  1533. for (const prop of Object.values(instance)) {
  1534. if (prop instanceof Shortcut) {
  1535. // While we are here, give the function a name.
  1536. Object.defineProperty(prop, 'name', {value: name});
  1537. keyboard.register(prop.seq, prop, VMKeyboardService.#navOption);
  1538. }
  1539. }
  1540. this.#keyboards.set(instance, keyboard);
  1541. this.#rebuildShortcuts();
  1542. }
  1543. this.logger.leaving(me);
  1544. }
  1545.  
  1546. /** @param {*} instance - Object with {Shortcut} properties. */
  1547. removeInstance(instance) {
  1548. const me = 'removeInstance';
  1549. this.logger.entered(me, instance);
  1550. if (this.#keyboards.has(instance)) {
  1551. const keyboard = this.#keyboards.get(instance);
  1552. keyboard.disable();
  1553. this.#keyboards.delete(instance);
  1554. this.#rebuildShortcuts();
  1555. } else {
  1556. this.logger.log('Was not registered');
  1557. }
  1558. this.logger.leaving(me);
  1559. }
  1560.  
  1561. static #focusOption = {
  1562. capture: true,
  1563. };
  1564.  
  1565. static #lastFocusedElement = null
  1566.  
  1567. /**
  1568. * @type {VM.shortcut.IShortcutOptions} - Disables keys when focus is on
  1569. * an element or info view.
  1570. */
  1571. static #navOption = {
  1572. condition: '!inputFocus',
  1573. caseSensitive: true,
  1574. };
  1575.  
  1576. static #services = new Set();
  1577.  
  1578. /**
  1579. * Handle focus event to determine if shortcuts should be disabled.
  1580. * @param {Event} evt - Standard 'focus' event.
  1581. */
  1582. static #onFocus = (evt) => {
  1583. if (this.#lastFocusedElement &&
  1584. evt.target !== this.#lastFocusedElement) {
  1585. this.#lastFocusedElement = null;
  1586. this.setKeyboardContext('inputFocus', false);
  1587. }
  1588. if (NH.web.isInput(evt.target)) {
  1589. this.setKeyboardContext('inputFocus', true);
  1590. this.#lastFocusedElement = evt.target;
  1591. }
  1592. }
  1593.  
  1594. #active = false;
  1595. #keyboards = new Map();
  1596. #shortcuts = [];
  1597.  
  1598. #rebuildShortcuts = () => {
  1599. this.#shortcuts = [];
  1600. for (const instance of this.#keyboards.keys()) {
  1601. for (const prop of Object.values(instance)) {
  1602. if (prop instanceof Shortcut) {
  1603. this.#shortcuts.push({seq: prop.seq, desc: prop.desc});
  1604. }
  1605. }
  1606. }
  1607. }
  1608.  
  1609. }
  1610.  
  1611. /** Test case. */
  1612. function testParseSeq() {
  1613. const tests = [
  1614. {test: 'q', expected: '<kbd><kbd>q</kbd></kbd>'},
  1615. {test: 's-q', expected: '<kbd><kbd>Shift</kbd>+<kbd>q</kbd></kbd>'},
  1616. {test: 'Q', expected: '<kbd><kbd>Shift</kbd>+<kbd>q</kbd></kbd>'},
  1617. {test: 'a b', expected: '<kbd><kbd>a</kbd> then <kbd>b</kbd></kbd>'},
  1618. {test: '<', expected: '<kbd><kbd><</kbd></kbd>'},
  1619. {test: 'C-q', expected: '<kbd><kbd>Ctrl</kbd>+<kbd>q</kbd></kbd>'},
  1620. {test: 'c-q', expected: '<kbd><kbd>Ctrl</kbd>+<kbd>q</kbd></kbd>'},
  1621. {test: 'c-a-t',
  1622. expected: '<kbd><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+' +
  1623. '<kbd>t</kbd></kbd>'},
  1624. {test: 'a-c-T',
  1625. expected: '<kbd><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+' +
  1626. '<kbd>Shift</kbd>+<kbd>t</kbd></kbd>'},
  1627. {test: 'c-down esc',
  1628. expected: '<kbd><kbd>Ctrl</kbd>+<kbd>↓</kbd> ' +
  1629. 'then <kbd>ESC</kbd></kbd>'},
  1630. {test: 'alt-up tab',
  1631. expected: '<kbd><kbd>Alt</kbd>+<kbd>↑</kbd> ' +
  1632. 'then <kbd>TAB</kbd></kbd>'},
  1633. {test: 'shift-X control-alt-del',
  1634. expected: '<kbd><kbd>Shift</kbd>+<kbd>x</kbd> ' +
  1635. 'then <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>DEL</kbd></kbd>'},
  1636. {test: 'c-x c-v',
  1637. expected: '<kbd><kbd>Ctrl</kbd>+<kbd>x</kbd> ' +
  1638. 'then <kbd>Ctrl</kbd>+<kbd>v</kbd></kbd>'},
  1639. {test: 'a-x enter',
  1640. expected: '<kbd><kbd>Alt</kbd>+<kbd>x</kbd> ' +
  1641. 'then <kbd>ENTER</kbd></kbd>'},
  1642. {test: 'up up down down left right left right b shift-a enter',
  1643. expected: '<kbd><kbd>↑</kbd> then <kbd>↑</kbd> then <kbd>↓</kbd> ' +
  1644. 'then <kbd>↓</kbd> then <kbd>←</kbd> then <kbd>→</kbd> ' +
  1645. 'then <kbd>←</kbd> then <kbd>→</kbd> then <kbd>b</kbd> ' +
  1646. 'then <kbd>Shift</kbd>+<kbd>a</kbd> then <kbd>ENTER</kbd></kbd>'},
  1647. ];
  1648.  
  1649. for (const {test, expected} of tests) {
  1650. const actual = VMKeyboardService.parseSeq(test);
  1651. const passed = actual === expected;
  1652. const msg = `t:${test} e:${expected} a:${actual}, p:${passed}`;
  1653. NH.xunit.testing.log.log(msg);
  1654. if (!passed) {
  1655. throw new Error(msg);
  1656. }
  1657. }
  1658. }
  1659.  
  1660. NH.xunit.testing.funcs.push(testParseSeq);
  1661.  
  1662. // TODO(#173): Migrate to style guide
  1663.  
  1664. /**
  1665. * Base class for handling various views of a single-page application.
  1666. *
  1667. * Generally, new classes should subclass this, override a few properties
  1668. * and methods, and then register themselves with an instance of the {@link
  1669. * SPA} class.
  1670. */
  1671. class Page {
  1672.  
  1673. /**
  1674. * @typedef {object} PageDetails
  1675. * @property {SPA} spa - SPA instance that manages this Page.
  1676. * @property {string|RegExp} [pathname=RegExp(.*)] - Pathname portion of
  1677. * the URL this page should handle.
  1678. * @property {string} [pageReadySelector='body'] - CSS selector that is
  1679. * used to detect that the page is loaded enough to activate.
  1680. */
  1681.  
  1682. /** @param {PageDetails} details - Details about the instance. */
  1683. constructor(details = {}) {
  1684. if (new.target === Page) {
  1685. throw new TypeError('Abstract class; do not instantiate directly.');
  1686. }
  1687. this.#spa = details.spa;
  1688. this.#logger = new NH.base.Logger(this.constructor.name);
  1689. this.#pathnameRE = this.#computePathname(details.pathname);
  1690. ({
  1691. pageReadySelector: this.#pageReadySelector = 'body',
  1692. } = details);
  1693. this.#logger.log('Base page constructed', this);
  1694. }
  1695.  
  1696. /** @type {Shortcut[]} - List of {@link Shortcut}s to register. */
  1697. get allShortcuts() {
  1698. const shortcuts = [];
  1699. for (const prop of Object.values(this)) {
  1700. if (prop instanceof Shortcut) {
  1701. shortcuts.push(prop);
  1702. // While we are here, give the function a name.
  1703. Object.defineProperty(prop, 'name', {value: name});
  1704. }
  1705. }
  1706. return shortcuts;
  1707. }
  1708.  
  1709. /** @type {string} - Describes what the header should be. */
  1710. get infoHeader() {
  1711. return this.constructor.name;
  1712. }
  1713.  
  1714. /** @type {KeyboardService} */
  1715. get keyboard() {
  1716. return this.#keyboard;
  1717. }
  1718.  
  1719. /** @type {NH.base.Logger} */
  1720. get logger() {
  1721. return this.#logger;
  1722. }
  1723.  
  1724. /** @type {RegExp} */
  1725. get pathname() {
  1726. return this.#pathnameRE;
  1727. }
  1728.  
  1729. /** @type {SPA} */
  1730. get spa() {
  1731. return this.#spa;
  1732. }
  1733.  
  1734. /**
  1735. * Register a new {@link Service}.
  1736. * @param {function(): Service} Klass - A service class to instantiate.
  1737. * @param {...*} rest - Arbitrary objects to pass to constructor.
  1738. * @returns {Service} - Instance of Klass.
  1739. */
  1740. addService(Klass, ...rest) {
  1741. const me = 'addService';
  1742. let instance = null;
  1743. this.logger.entered(me, Klass, ...rest);
  1744. if (Klass.prototype instanceof Service) {
  1745. instance = new Klass(this.constructor.name, ...rest);
  1746. this.#services.add(instance);
  1747. } else {
  1748. this.logger.log('Bad class was passed.');
  1749. throw new Error(`${Klass.name} is not a Service`);
  1750. }
  1751. this.logger.leaving(me, instance);
  1752. return instance;
  1753. }
  1754.  
  1755. /**
  1756. * Called when registered via {@link SPA}.
  1757. */
  1758. start() {
  1759. for (const shortcut of this.allShortcuts) {
  1760. this.#addKey(shortcut);
  1761. }
  1762. }
  1763.  
  1764. /**
  1765. * Turns on this Page's features. Called by {@link SPA} when this becomes
  1766. * the current view.
  1767. */
  1768. async activate() {
  1769. this.#keyboard.enable();
  1770. await this.#waitUntilReady();
  1771. for (const service of this.#services) {
  1772. service.activate();
  1773. }
  1774. }
  1775.  
  1776. /**
  1777. * Turns off this Page's features. Called by {@link SPA} when this is no
  1778. * longer the current view.
  1779. */
  1780. deactivate() {
  1781. this.#keyboard.disable();
  1782. for (const service of this.#services) {
  1783. service.deactivate();
  1784. }
  1785. }
  1786.  
  1787. /**
  1788. * @type {IShortcutOptions} - Disables keys when focus is on an element or
  1789. * info view.
  1790. */
  1791. static #navOption = {
  1792. caseSensitive: true,
  1793. condition: '!inputFocus && !inDialog',
  1794. };
  1795.  
  1796. /** @type {KeyboardService} */
  1797. #keyboard = new VM.shortcut.KeyboardService();
  1798.  
  1799. /** @type {NH.base.Logger} - NH.base.Logger instance. */
  1800. #logger
  1801.  
  1802. #pageReadySelector
  1803.  
  1804. /** @type {RegExp} - Computed RegExp version of _pathname. */
  1805. #pathnameRE
  1806.  
  1807. #services = new Set();
  1808.  
  1809. /** @type {SPA} - SPA instance managing this instance. */
  1810. #spa
  1811.  
  1812. /**
  1813. * Turn a pathname into a RegExp.
  1814. * @param {string|RegExp} pathname - A pathname to convert.
  1815. * @returns {RegExp} - A converted pathname.
  1816. */
  1817. #computePathname = (pathname) => {
  1818. const me = 'computePath';
  1819. this.logger.entered(me, pathname);
  1820. let pathnameRE = /.*/u;
  1821. if (pathname instanceof RegExp) {
  1822. pathnameRE = pathname;
  1823. } else if (pathname) {
  1824. pathnameRE = RegExp(`^${pathname}$`, 'u');
  1825. }
  1826. this.logger.leaving(me, pathnameRE);
  1827. return pathnameRE;
  1828. }
  1829.  
  1830. /**
  1831. * Wait until the page has loaded enough to continue.
  1832. * @returns {Element} - The element matched by #pageReadySelector.
  1833. */
  1834. #waitUntilReady = async () => {
  1835. const me = 'waitUntilReady';
  1836. this.logger.entered(me);
  1837.  
  1838. this.logger.log('pageReadySelector:', this.#pageReadySelector);
  1839. const element = await NH.web.waitForSelector(
  1840. this.#pageReadySelector, 0
  1841. );
  1842. this.logger.leaving(me, element);
  1843.  
  1844. return element;
  1845. }
  1846.  
  1847. /**
  1848. * Registers a specific key sequence with a function with VM.shortcut.
  1849. * @param {Shortcut} shortcut - Shortcut to register.
  1850. */
  1851. #addKey = (shortcut) => {
  1852. this.#keyboard.register(shortcut.seq, shortcut, Page.#navOption);
  1853. }
  1854.  
  1855. }
  1856.  
  1857. /** Class for holding keystrokes that simplify debugging. */
  1858. class DebugKeys {
  1859.  
  1860. clearConsole = new Shortcut('c-c c-c', 'Clear the debug console', () => {
  1861. NH.base.Logger.clear();
  1862. });
  1863.  
  1864. }
  1865.  
  1866. const linkedInGlobals = new LinkedInGlobals();
  1867.  
  1868. /**
  1869. * Class for handling aspects common across LinkedIn.
  1870. *
  1871. * This includes things like the global nav bar, information view, etc.
  1872. */
  1873. class Global extends Page {
  1874.  
  1875. /**
  1876. * Create a Global instance.
  1877. * @param {SPA} spa - SPA instance that manages this Page.
  1878. */
  1879. constructor(spa) {
  1880. super({spa: spa});
  1881.  
  1882. this.#keyboardService = this.addService(VMKeyboardService);
  1883. this.#keyboardService.addInstance(this);
  1884. if (NH.xunit.testing.enabled) {
  1885. this.#keyboardService.addInstance(new DebugKeys());
  1886. }
  1887. }
  1888.  
  1889. info = new Shortcut('?', 'Show this information view', () => {
  1890. Global.#gotoNavButton('Tool');
  1891. });
  1892.  
  1893. gotoSearch = new Shortcut('/', 'Go to Search box', () => {
  1894. NH.web.clickElement(document, ['#global-nav-search button']);
  1895. });
  1896.  
  1897. goHome = new Shortcut('g h', 'Go Home (aka, Feed)', () => {
  1898. Global.#gotoNavLink('feed');
  1899. });
  1900.  
  1901. gotoMyNetwork = new Shortcut('g m', 'Go to My Network', () => {
  1902. Global.#gotoNavLink('mynetwork');
  1903. });
  1904.  
  1905. gotoJobs = new Shortcut('g j', 'Go to Jobs', () => {
  1906. Global.#gotoNavLink('jobs');
  1907. });
  1908.  
  1909. gotoMessaging = new Shortcut('g g', 'Go to Messaging', () => {
  1910. Global.#gotoNavLink('messaging');
  1911. });
  1912.  
  1913. gotoNotifications = new Shortcut('g n', 'Go to Notifications', () => {
  1914. Global.#gotoNavLink('notifications');
  1915. });
  1916.  
  1917. gotoProfile = new Shortcut('g p', 'Go to Profile (aka, Me)', () => {
  1918. Global.#gotoNavButton('Me');
  1919. });
  1920.  
  1921. gotoBusiness = new Shortcut('g b', 'Go to Business', () => {
  1922. Global.#gotoNavButton('Business');
  1923. });
  1924.  
  1925. gotoLearning = new Shortcut('g l', 'Go to Learning', () => {
  1926. Global.#gotoNavLink('learning');
  1927. });
  1928.  
  1929. focusOnSidebar = new Shortcut(
  1930. ',', 'Focus on the left/top sidebar (not always present)', () => {
  1931. linkedInGlobals.focusOnSidebar();
  1932. }
  1933. );
  1934.  
  1935. focusOnAside = new Shortcut(
  1936. '.', 'Focus on the right/bottom sidebar (not always present)', () => {
  1937. linkedInGlobals.focusOnAside();
  1938. }
  1939. );
  1940.  
  1941. /**
  1942. * Click on the requested link in the global nav bar.
  1943. * @param {string} item - Portion of the link to match.
  1944. */
  1945. static #gotoNavLink = (item) => {
  1946. NH.web.clickElement(document, [`#global-nav a[href*="/${item}"`]);
  1947. }
  1948.  
  1949. /**
  1950. * Click on the requested button in the global nav bar.
  1951. * @param {string} item - Text on the button to look for.
  1952. */
  1953. static #gotoNavButton = (item) => {
  1954. const buttons = Array.from(
  1955. document.querySelectorAll('#global-nav button')
  1956. );
  1957. const button = buttons.find(el => el.textContent.includes(item));
  1958. button?.click();
  1959. }
  1960.  
  1961. #keyboardService
  1962.  
  1963. }
  1964.  
  1965. /** Class for handling the Posts feed. */
  1966. class Feed extends Page {
  1967.  
  1968. /**
  1969. * Create a Feed instance.
  1970. * @param {SPA} spa - SPA instance that manages this Page.
  1971. */
  1972. constructor(spa) {
  1973. super({spa: spa, ...Feed.#details});
  1974.  
  1975. this.#keyboardService = this.addService(VMKeyboardService);
  1976. this.#keyboardService.addInstance(this);
  1977.  
  1978. spa.details.navBarScrollerFixup(Feed.#postsHow);
  1979. spa.details.navBarScrollerFixup(Feed.#commentsHow);
  1980.  
  1981. this.#dummy = this.addService(DummyService);
  1982.  
  1983. this.#postScroller = new Scroller(Feed.#postsWhat, Feed.#postsHow);
  1984. this.addService(ScrollerService, this.#postScroller);
  1985. this.#postScroller.dispatcher.on(
  1986. 'out-of-range', linkedInGlobals.focusOnSidebar
  1987. );
  1988. this.#postScroller.dispatcher.on('activate', this.#onPostActivate);
  1989. this.#postScroller.dispatcher.on('change', this.#onPostChange);
  1990.  
  1991. this.#lastScroller = this.#postScroller;
  1992. }
  1993.  
  1994. /**
  1995. * @implements {Scroller~uidCallback}
  1996. * @param {Element} element - Element to examine.
  1997. * @returns {string} - A value unique to this element.
  1998. */
  1999. static uniqueIdentifier(element) {
  2000. if (element) {
  2001. return element.dataset.id;
  2002. }
  2003. return null;
  2004. }
  2005.  
  2006. #tabSnippet = VMKeyboardService.parseSeq('tab');
  2007.  
  2008. #postScroller = null;
  2009. #commentScroller = null;
  2010. #lastScroller
  2011.  
  2012. #dummy
  2013.  
  2014. /** @type {Scroller~What} */
  2015. static #postsWhat = {
  2016. name: 'Feed posts',
  2017. containerItems: [
  2018. {
  2019. container: 'main div.scaffold-finite-scroll__content',
  2020. items: 'div[data-id]',
  2021. },
  2022. ],
  2023. };
  2024.  
  2025. /** @type {Scroller~How} */
  2026. static #postsHow = {
  2027. uidCallback: Feed.uniqueIdentifier,
  2028. classes: ['tom'],
  2029. snapToTop: true,
  2030. };
  2031.  
  2032. /** @type {Scroller~What} */
  2033. static #commentsWhat = {
  2034. name: 'Feed comments',
  2035. selectors: ['article.comments-comment-item'],
  2036. };
  2037.  
  2038. /** @type {Scroller~How} */
  2039. static #commentsHow = {
  2040. uidCallback: Feed.uniqueIdentifier,
  2041. classes: ['dick'],
  2042. autoActivate: true,
  2043. snapToTop: false,
  2044. };
  2045.  
  2046. /** @type {Page~PageDetails} */
  2047. static #details = {
  2048. pathname: '/feed/',
  2049. pageReadySelector: 'main',
  2050. };
  2051.  
  2052. #onPostActivate = () => {
  2053.  
  2054. /**
  2055. * Wait for the post to be reloaded.
  2056. * @implements {Monitor}
  2057. * @param {MutationRecord[]} records - Standard mutation records.
  2058. * @returns {Continuation} - Indicate whether done monitoring.
  2059. */
  2060. function monitor(records) {
  2061. for (const record of records) {
  2062. if (record.oldValue.includes('has-occluded-height')) {
  2063. return {done: true};
  2064. }
  2065. }
  2066. return {done: false};
  2067. }
  2068. if (this._posts.item) {
  2069. const what = {
  2070. name: 'Feed onPostActivate',
  2071. base: this._posts.item,
  2072. };
  2073. const how = {
  2074. observeOptions: {
  2075. attributeFilter: ['class'],
  2076. attributes: true,
  2077. attributeOldValue: true,
  2078. },
  2079. monitor: monitor,
  2080. timeout: 5000,
  2081. };
  2082. NH.web.otmot(what, how).finally(() => {
  2083. this._posts.shine();
  2084. this._posts.show();
  2085. });
  2086. }
  2087. }
  2088.  
  2089. /** @type {Scroller} */
  2090. get _comments() {
  2091. const me = 'get comments';
  2092. this.logger.entered(me, this.#commentScroller, this._posts.item);
  2093.  
  2094. if (!this.#commentScroller && this._posts.item) {
  2095. this.#commentScroller = new Scroller(
  2096. {base: this._posts.item, ...Feed.#commentsWhat}, Feed.#commentsHow
  2097. );
  2098. this.#commentScroller.dispatcher.on(
  2099. 'out-of-range', this.#returnToPost
  2100. );
  2101. this.#commentScroller.dispatcher.on('change', this.#onCommentChange);
  2102. }
  2103.  
  2104. this.logger.leaving(me, this.#commentScroller);
  2105. return this.#commentScroller;
  2106. }
  2107.  
  2108. /** @type {Scroller} */
  2109. get _posts() {
  2110. return this.#postScroller;
  2111. }
  2112.  
  2113. /** Reset the comment scroller. */
  2114. #resetComments() {
  2115. if (this.#commentScroller) {
  2116. this.#commentScroller.destroy();
  2117. this.#commentScroller = null;
  2118. }
  2119. this._comments;
  2120. }
  2121.  
  2122. #onCommentChange = () => {
  2123. this.#lastScroller = this._comments;
  2124. }
  2125.  
  2126. /**
  2127. * Reselects current post, triggering same actions as initial selection.
  2128. */
  2129. #returnToPost = () => {
  2130. this._posts.item = this._posts.item;
  2131. }
  2132.  
  2133. /** Resets the comments {@link Scroller}. */
  2134. #onPostChange = () => {
  2135. const me = 'onPostChange';
  2136. this.logger.entered(me, this._posts.item);
  2137. this.#resetComments();
  2138. this.#lastScroller = this._posts;
  2139. this.logger.leaving(me);
  2140. }
  2141.  
  2142. _nextPost = new Shortcut('j', 'Next post', () => {
  2143. this._posts.next();
  2144. });
  2145.  
  2146. _prevPost = new Shortcut('k', 'Previous post', () => {
  2147. this._posts.prev();
  2148. });
  2149.  
  2150. _nextComment = new Shortcut('n', 'Next comment', () => {
  2151. this._comments.next();
  2152. });
  2153.  
  2154. _prevComment = new Shortcut('p', 'Previous comment', () => {
  2155. this._comments.prev();
  2156. });
  2157.  
  2158. _firstItem = new Shortcut('<', 'Go to first post or comment', () => {
  2159. this.#lastScroller.first();
  2160. });
  2161.  
  2162. _lastItem = new Shortcut(
  2163. '>', 'Go to last post or comment currently loaded', () => {
  2164. this.#lastScroller.last();
  2165. }
  2166. );
  2167.  
  2168. _focusBrowser = new Shortcut(
  2169. 'f', 'Change browser focus to current item', () => {
  2170. const el = this.#lastScroller.item;
  2171. this._posts.show();
  2172. this._comments?.show();
  2173. NH.web.focusOnElement(el);
  2174. }
  2175. );
  2176.  
  2177. _showComments = new Shortcut('c', 'Show comments', () => {
  2178. if (!NH.web.clickElement(this._comments.item,
  2179. ['button.show-prev-replies'])) {
  2180. NH.web.clickElement(this._posts.item,
  2181. ['button[aria-label*="comment"]']);
  2182. }
  2183. });
  2184.  
  2185. _seeMore = new Shortcut(
  2186. 'm', 'Show more of current post or comment', () => {
  2187. const el = this.#lastScroller.item;
  2188. NH.web.clickElement(el, ['button[aria-label^="see more"]']);
  2189. }
  2190. );
  2191.  
  2192. _loadMorePosts = new Shortcut(
  2193. 'l',
  2194. 'Load more posts (if the <button>New Posts</button> button ' +
  2195. 'is available, load those)', () => {
  2196. const savedScrollTop = document.documentElement.scrollTop;
  2197. let first = false;
  2198. const posts = this._posts;
  2199.  
  2200. /** Trigger function for {@link NH.web.otrot2}. */
  2201. function trigger() {
  2202. // The topButton only shows up when the app detects new posts. In
  2203. // that case, going back to the first post is appropriate.
  2204. const topButton = 'main div.feed-new-update-pill button';
  2205. // If there is not top button, there should always be a button at
  2206. // the bottom the click.
  2207. const botButton = 'main button.scaffold-finite-scroll__load-button';
  2208. if (NH.web.clickElement(document, [topButton])) {
  2209. first = true;
  2210. } else {
  2211. NH.web.clickElement(document, [botButton]);
  2212. }
  2213. }
  2214.  
  2215. /** Action function for {@link NH.web.otrot2}. */
  2216. function action() {
  2217. if (first) {
  2218. if (posts.item) {
  2219. posts.first();
  2220. }
  2221. } else {
  2222. document.documentElement.scrollTop = savedScrollTop;
  2223. }
  2224. }
  2225.  
  2226. const what = {
  2227. name: 'loadMorePosts',
  2228. base: document.querySelector('div.scaffold-finite-scroll__content'),
  2229. };
  2230. const how = {
  2231. trigger: trigger,
  2232. action: action,
  2233. duration: 2000,
  2234. };
  2235. NH.web.otrot2(what, how);
  2236. }
  2237. );
  2238.  
  2239. _viewPost = new Shortcut('v p', 'View current post directly', () => {
  2240. const post = this._posts.item;
  2241. if (post) {
  2242. const urn = post.dataset.id;
  2243. const id = `lt-${urn.replaceAll(':', '-')}`;
  2244. let a = post.querySelector(`#${id}`);
  2245. if (!a) {
  2246. a = document.createElement('a');
  2247. a.href = `/feed/update/${urn}/`;
  2248. a.id = id;
  2249. post.append(a);
  2250. }
  2251. a.click();
  2252. }
  2253. });
  2254.  
  2255. _viewReactions = new Shortcut(
  2256. 'v r', 'View reactions on current post or comment', () => {
  2257. const el = this.#lastScroller.item;
  2258. const selector = [
  2259. // Button on a comment
  2260. 'button.comments-comment-social-bar__reactions-count',
  2261. // Original button on a post
  2262. 'button.feed-shared-social-action-bar-counts',
  2263. // Possibly new button on a post
  2264. 'button.social-details-social-counts__count-value',
  2265. ].join(',');
  2266. NH.web.clickElement(el, [selector]);
  2267. }
  2268. );
  2269.  
  2270. _viewReposts = new Shortcut(
  2271. 'v R', 'View reposts of current post', () => {
  2272. NH.web.clickElement(this._posts.item,
  2273. ['button[aria-label*="repost"]']);
  2274. }
  2275. );
  2276.  
  2277. _openMeatballMenu = new Shortcut(
  2278. '=',
  2279. 'Open closest <button class="spa-meatball">⋯</button> menu',
  2280. () => {
  2281. // XXX: In this case, the identifier is on an svg element, not the
  2282. // button, so use the parentElement. When Firefox [fully
  2283. // supports](https://bugzilla.mozilla.org/show_bug.cgi?id=418039) the
  2284. // `:has()` pseudo-selector, we can probably use that and use
  2285. // `NH.web.clickElement()`.
  2286. const el = this.#lastScroller.item;
  2287. const selector = [
  2288. // Comment variant
  2289. '[aria-label^="Open options"]',
  2290. // Original post variant
  2291. '[aria-label^="Open control menu"]',
  2292. // Maybe new post variant
  2293. '[a11y-text^="Open control menu"]',
  2294. ].join(',');
  2295. const button = el.querySelector(selector).parentElement;
  2296. button?.click();
  2297. }
  2298. );
  2299.  
  2300. _likeItem = new Shortcut('L', 'Like current post or comment', () => {
  2301. const el = this.#lastScroller.item;
  2302. NH.web.clickElement(el, ['button[aria-label^="Open reactions menu"]']);
  2303. });
  2304.  
  2305. _commentOnItem = new Shortcut(
  2306. 'C', 'Comment on current post or comment', () => {
  2307. // Order of the queries matters here. If a post has visible comments,
  2308. // the wrong button could be selected.
  2309. NH.web.clickElement(this.#lastScroller.item, [
  2310. 'button[aria-label^="Comment"]',
  2311. 'button[aria-label^="Reply"]',
  2312. ]);
  2313. }
  2314. );
  2315.  
  2316. _repost = new Shortcut('R', 'Repost current post', () => {
  2317. const el = this._posts.item;
  2318. NH.web.clickElement(el, ['button.social-reshare-button']);
  2319. });
  2320.  
  2321. _sendPost = new Shortcut('S', 'Send current post privately', () => {
  2322. const el = this._posts.item;
  2323. NH.web.clickElement(el, ['button.send-privately-button']);
  2324. });
  2325.  
  2326. _gotoShare = new Shortcut(
  2327. 'P',
  2328. `Go to the share box to start a post or ${this.#tabSnippet} ` +
  2329. 'to the other creator options',
  2330. () => {
  2331. const share = document.querySelector(
  2332. 'div.share-box-feed-entry__top-bar'
  2333. ).parentElement;
  2334. share.style.scrollMarginTop = linkedInGlobals.navBarHeightCSS;
  2335. share.scrollIntoView();
  2336. share.querySelector('button').focus();
  2337. }
  2338. );
  2339.  
  2340. _togglePost = new Shortcut('X', 'Toggle hiding current post', () => {
  2341. NH.web.clickElement(
  2342. this._posts.item,
  2343. [
  2344. 'button[aria-label^="Dismiss post"]',
  2345. 'button[aria-label^="Undo and show"]',
  2346. ]
  2347. );
  2348. });
  2349.  
  2350. _nextPostPlus = new Shortcut(
  2351. 'J', 'Toggle hiding then next post', async () => {
  2352.  
  2353. /** Trigger function for {@link NH.web.otrot}. */
  2354. const trigger = () => {
  2355. this._togglePost();
  2356. this._nextPost();
  2357. };
  2358. // XXX: Need to remove the highlights before NH.web.otrot sees it
  2359. // because it affects the .clientHeight.
  2360. this._posts.dull();
  2361. this._comments?.dull();
  2362. if (this._posts.item) {
  2363. const what = {
  2364. name: 'nextPostPlus',
  2365. base: this._posts.item,
  2366. };
  2367. const how = {
  2368. trigger: trigger,
  2369. timeout: 3000,
  2370. };
  2371. await NH.web.otrot(what, how);
  2372. this._posts.show();
  2373. } else {
  2374. trigger();
  2375. }
  2376. }
  2377. );
  2378.  
  2379. _prevPostPlus = new Shortcut(
  2380. 'K', 'Toggle hiding then previous post', () => {
  2381. this._togglePost();
  2382. this._prevPost();
  2383. }
  2384. );
  2385.  
  2386. #keyboardService
  2387.  
  2388. }
  2389.  
  2390. /**
  2391. * Class for handling the base MyNetwork page.
  2392. *
  2393. * This page takes 3-4 seconds to load every time. Revisits are
  2394. * likely to take a while.
  2395. */
  2396. class MyNetwork extends Page {
  2397.  
  2398. /**
  2399. * Create a MyNetwork instance.
  2400. * @param {SPA} spa - SPA instance that manages this Page.
  2401. */
  2402. constructor(spa) {
  2403. super({spa: spa, ...MyNetwork.#details});
  2404.  
  2405. this.#keyboardService = this.addService(VMKeyboardService);
  2406. this.#keyboardService.addInstance(this);
  2407.  
  2408. spa.details.navBarScrollerFixup(MyNetwork.#sectionsHow);
  2409. spa.details.navBarScrollerFixup(MyNetwork.#cardsHow);
  2410.  
  2411. this.#sectionScroller = new Scroller(MyNetwork.#sectionsWhat,
  2412. MyNetwork.#sectionsHow);
  2413. this.addService(ScrollerService, this.#sectionScroller);
  2414. this.#sectionScroller.dispatcher.on('out-of-range',
  2415. linkedInGlobals.focusOnSidebar);
  2416. this.#sectionScroller.dispatcher.on('activate',
  2417. this.#onSectionActivate);
  2418. this.#sectionScroller.dispatcher.on('change', this.#onSectionChange);
  2419.  
  2420. this.#lastScroller = this.#sectionScroller;
  2421. }
  2422.  
  2423. /**
  2424. * @implements {Scroller~uidCallback}
  2425. * @param {Element} element - Element to examine.
  2426. * @returns {string} - A value unique to this element.
  2427. */
  2428. static uniqueSectionIdentifier(element) {
  2429. const h2 = element.querySelector('h2');
  2430. let content = element.innerText;
  2431. if (h2?.innerText) {
  2432. content = h2.innerText;
  2433. }
  2434. return NH.base.strHash(content);
  2435. }
  2436.  
  2437. /**
  2438. * @implements {Scroller~uidCallback}
  2439. * @param {Element} element - Element to examine.
  2440. * @returns {string} - A value unique to this element.
  2441. */
  2442. static uniqueCardsIdentifier(element) {
  2443. const content = element.innerText;
  2444. return NH.base.strHash(content);
  2445. }
  2446.  
  2447. #sectionScroller
  2448. #cardScroller
  2449. #lastScroller
  2450.  
  2451. #currentSectionText
  2452.  
  2453. /** @type {Scroller~What} */
  2454. static #sectionsWhat = {
  2455. name: 'MyNetwork sections',
  2456. containerItems: [
  2457. {
  2458. container: 'main',
  2459. items: [
  2460. // Invitations
  2461. ':scope > section',
  2462. // Most sections
  2463. ':scope > ul > li',
  2464. // More suggestions for you section
  2465. ':scope > div > section',
  2466. ].join(','),
  2467. },
  2468. ],
  2469. };
  2470.  
  2471. /** @type {Scroller~How} */
  2472. static #sectionsHow = {
  2473. uidCallback: MyNetwork.uniqueSectionIdentifier,
  2474. classes: ['tom'],
  2475. snapToTop: true,
  2476. };
  2477.  
  2478. /** @type {Scroller~What} */
  2479. static #cardsWhat = {
  2480. name: 'MyNetwork cards',
  2481. selectors: [
  2482. [
  2483. // Invitations -> See all
  2484. ':scope > header > a',
  2485. // Other sections -> See all
  2486. ':scope > div > button',
  2487. // Most cards
  2488. ':scope > ul > li',
  2489. // More suggestions for you cards
  2490. ':scope > section ul > li section',
  2491. ].join(','),
  2492. ],
  2493. };
  2494.  
  2495. /** @type {Scroller~How} */
  2496. static #cardsHow = {
  2497. uidCallback: MyNetwork.uniqueCardsIdentifier,
  2498. classes: ['dick'],
  2499. autoActivate: true,
  2500. snapToTop: false,
  2501. };
  2502.  
  2503. /** @type {Page~PageDetails} */
  2504. static #details = {
  2505. pathname: '/mynetwork/',
  2506. pageReadySelector: 'main > ul',
  2507. };
  2508.  
  2509. /** @inheritdoc */
  2510. #onSectionActivate = async () => {
  2511.  
  2512. /**
  2513. * Wait for sections to eventually show up to see if our current one
  2514. * comes back. It may not.
  2515. * @implements {Monitor}
  2516. * @param {MutationRecord[]} records - Standard mutation records.
  2517. * @returns {Continuation} - Indicate whether done monitoring.
  2518. */
  2519. const monitor = (records) => {
  2520. for (const record of records) {
  2521. if (record.type === 'childList') {
  2522. for (const node of record.addedNodes) {
  2523. const newText = node.innerText?.trim().split('\n')[0];
  2524. if (newText && newText === this.#currentSectionText) {
  2525. return {done: true};
  2526. }
  2527. }
  2528. }
  2529. }
  2530. return {done: false};
  2531. };
  2532. const what = {
  2533. name: 'MyNetwork onSectionActivate',
  2534. base: document.body.querySelector('main'),
  2535. };
  2536. const how = {
  2537. observeOptions: {childList: true, subtree: true},
  2538. monitor: monitor,
  2539. timeout: 10000,
  2540. };
  2541.  
  2542. if (this.#currentSectionText) {
  2543. await NH.web.otmot(what, how);
  2544. this._sections.shine();
  2545. this._sections.show();
  2546. this.#resetCards();
  2547. }
  2548. }
  2549.  
  2550. /** @type {Scroller} */
  2551. get _cards() {
  2552. if (!this.#cardScroller && this._sections.item) {
  2553. this.#cardScroller = new Scroller(
  2554. {base: this._sections.item, ...MyNetwork.#cardsWhat},
  2555. MyNetwork.#cardsHow
  2556. );
  2557. this.#cardScroller.dispatcher.on('change', this.#onCardChange);
  2558. this.#cardScroller.dispatcher.on(
  2559. 'out-of-range', this.#returnToSection
  2560. );
  2561. }
  2562. return this.#cardScroller;
  2563. }
  2564.  
  2565. /** @type {Scroller} */
  2566. get _sections() {
  2567. return this.#sectionScroller;
  2568. }
  2569.  
  2570. #resetCards = () => {
  2571. if (this.#cardScroller) {
  2572. this.#cardScroller.destroy();
  2573. this.#cardScroller = null;
  2574. }
  2575. this._cards;
  2576. }
  2577.  
  2578. #onCardChange = () => {
  2579. this.#lastScroller = this._cards;
  2580. }
  2581.  
  2582. #onSectionChange = () => {
  2583. this.#currentSectionText = this._sections.item?.innerText
  2584. .trim().split('\n')[0];
  2585. this.#resetCards();
  2586. this.#lastScroller = this._sections;
  2587. }
  2588.  
  2589. #returnToSection = () => {
  2590. this._sections.item = this._sections.item;
  2591. }
  2592.  
  2593. nextSection = new Shortcut('j', 'Next section', () => {
  2594. this._sections.next();
  2595. });
  2596.  
  2597. prevSection = new Shortcut('k', 'Previous section', () => {
  2598. this._sections.prev();
  2599. });
  2600.  
  2601. nextCard = new Shortcut('n', 'Next card in section', () => {
  2602. this._cards.next();
  2603. });
  2604.  
  2605. prevCard = new Shortcut('p', 'Previous card in section', () => {
  2606. this._cards.prev();
  2607. });
  2608.  
  2609. firstItem = new Shortcut('<', 'Go to the first section or card', () => {
  2610. this.#lastScroller.first();
  2611. });
  2612.  
  2613. lastItem = new Shortcut('>', 'Go to the last section or card', () => {
  2614. this.#lastScroller.last();
  2615. });
  2616.  
  2617. focusBrowser = new Shortcut(
  2618. 'f', 'Change browser focus to current item', () => {
  2619. NH.web.focusOnElement(this.#lastScroller.item);
  2620. }
  2621. );
  2622.  
  2623. viewItem = new Shortcut('Enter', 'View the current item', () => {
  2624. const card = this._cards?.item;
  2625. if (card) {
  2626. if (!NH.web.clickElement(card, ['a', 'button'], true)) {
  2627. this.spa.dumpInfoAboutElement(card, 'network card');
  2628. }
  2629. } else {
  2630. document.activeElement.click();
  2631. }
  2632. });
  2633.  
  2634. enagageCard = new Shortcut(
  2635. 'E', 'Engage the card (Connect, Follow, Join, etc)', () => {
  2636. const me = 'enagageCard';
  2637. this.logger.entered(me);
  2638. const selector = [
  2639. // Connect w/ Person, Join Group, View event
  2640. 'footer > button',
  2641. // Follow person, Follow page
  2642. 'div.discover-entity-type-card__container-bottom > button',
  2643. // Subscribe to newsletter
  2644. 'div.p3 > button',
  2645. ].join(',');
  2646. this.logger.log('button?', this._cards.item.querySelector(selector));
  2647. NH.web.clickElement(this._cards?.item, [selector]);
  2648. this.logger.leaving(me);
  2649. }
  2650. );
  2651.  
  2652. dismissCard = new Shortcut('X', 'Dismiss current card', () => {
  2653. NH.web.clickElement(this._cards?.item,
  2654. ['button.artdeco-card__dismiss']);
  2655. });
  2656.  
  2657. #keyboardService
  2658.  
  2659. }
  2660.  
  2661. /** Class for handling the Invitation manager page. */
  2662. class InvitationManager extends Page {
  2663.  
  2664. /**
  2665. * Create a InvitationManager instance.
  2666. * @param {SPA} spa - SPA instance that manages this Page.
  2667. */
  2668. constructor(spa) {
  2669. super({spa: spa, ...InvitationManager.#details});
  2670.  
  2671. this.#keyboardService = this.addService(VMKeyboardService);
  2672. this.#keyboardService.addInstance(this);
  2673.  
  2674. spa.details.navBarScrollerFixup(InvitationManager.#invitesHow);
  2675.  
  2676. this.#inviteScroller = new Scroller(
  2677. InvitationManager.#invitesWhat, InvitationManager.#invitesHow
  2678. );
  2679. this.addService(ScrollerService, this.#inviteScroller);
  2680. this.#inviteScroller.dispatcher.on('activate', this.#onActivate);
  2681. this.#inviteScroller.dispatcher.on('change', this.#onChange);
  2682. }
  2683.  
  2684. /**
  2685. * @implements {Scroller~uidCallback}
  2686. * @param {Element} element - Element to examine.
  2687. * @returns {string} - A value unique to this element.
  2688. */
  2689. static uniqueIdentifier(element) {
  2690. let content = element.innerText;
  2691. const anchor = element.querySelector('a');
  2692. if (anchor?.href) {
  2693. content = anchor.href;
  2694. }
  2695. return NH.base.strHash(content);
  2696. }
  2697.  
  2698. #inviteScroller
  2699. #currentInviteText
  2700.  
  2701. /** @type {Scroller~What} */
  2702. static #invitesWhat = {
  2703. name: 'Invitation cards',
  2704. base: document.body,
  2705. selectors: [
  2706. [
  2707. // Actual invites
  2708. 'main > section section > ul > li',
  2709. ].join(','),
  2710. ],
  2711. };
  2712.  
  2713. static #invitesHow = {
  2714. uidCallback: InvitationManager.uniqueIdentifier,
  2715. classes: ['tom'],
  2716. };
  2717.  
  2718. /** @type {Scroller} */
  2719. get _invites() {
  2720. return this.#inviteScroller;
  2721. }
  2722.  
  2723. /** @type {Page~PageDetails} */
  2724. static #details = {
  2725. pathname: '/mynetwork/invitation-manager/',
  2726. pageReadySelector: 'main',
  2727. };
  2728.  
  2729. #onActivate = async () => {
  2730. const me = 'onActivate';
  2731. this.logger.entered(me);
  2732.  
  2733. /**
  2734. * Wait for current invitation to show back up.
  2735. * @implements {Monitor}
  2736. * @returns {Continuation} - Indicate whether done monitoring.
  2737. */
  2738. const monitor = () => {
  2739. for (const el of document.body.querySelectorAll(
  2740. 'main > section section > ul > li'
  2741. )) {
  2742. const text = el.innerText.trim().split('\n')[0];
  2743. if (text === this.#currentInviteText) {
  2744. return {done: true};
  2745. }
  2746. }
  2747. return {done: false};
  2748. };
  2749. const what = {
  2750. name: 'InviteManager onActivate',
  2751. base: document.body.querySelector('main'),
  2752. };
  2753. const how = {
  2754. observeOptions: {childList: true, subtree: true},
  2755. monitor: monitor,
  2756. timeout: 3000,
  2757. };
  2758.  
  2759. if (this.#currentInviteText) {
  2760. this.logger.log(`We will look for ${this.#currentInviteText}`);
  2761. await NH.web.otmot(what, how);
  2762. this._invites.shine();
  2763. this._invites.show();
  2764. }
  2765. this.logger.leaving(me);
  2766. }
  2767.  
  2768. #onChange = () => {
  2769. const me = 'onChange';
  2770. this.logger.entered(me);
  2771. this.#currentInviteText = this._invites.item?.innerText
  2772. .trim().split('\n')[0];
  2773. this.logger.log('current', this.#currentInviteText);
  2774. this.logger.leaving(me);
  2775. }
  2776.  
  2777. nextInvite = new Shortcut('j', 'Next invitation', () => {
  2778. this._invites.next();
  2779. });
  2780.  
  2781. prevInvite = new Shortcut('k', 'Previous invitation', () => {
  2782. this._invites.prev();
  2783. });
  2784.  
  2785. firstInvite = new Shortcut('<', 'Go to the first invitation', () => {
  2786. this._invites.first();
  2787. });
  2788.  
  2789. lastInvite = new Shortcut('>', 'Go to the last invitation', () => {
  2790. this._invites.last();
  2791. });
  2792.  
  2793. focusBrowser = new Shortcut(
  2794. 'f', 'Change browser focus to current item', () => {
  2795. const item = this._invites.item;
  2796. NH.web.focusOnElement(item);
  2797. }
  2798. );
  2799.  
  2800. seeMore = new Shortcut(
  2801. 'm', 'Toggle seeing more of current invite', () => {
  2802. NH.web.clickElement(
  2803. this._invites?.item,
  2804. ['a.lt-line-clamp__more, a.lt-line-clamp__less']
  2805. );
  2806. }
  2807. );
  2808.  
  2809. viewInviter = new Shortcut('i', 'View inviter', () => {
  2810. NH.web.clickElement(this._invites?.item,
  2811. ['a.app-aware-link:not(.invitation-card__picture)']);
  2812. });
  2813.  
  2814. viewTarget = new Shortcut(
  2815. 't',
  2816. 'View invitation target ' +
  2817. '(may not be the same as inviter, e.g., Newsletter)',
  2818. () => {
  2819. NH.web.clickElement(this._invites?.item,
  2820. ['a.invitation-card__picture']);
  2821. }
  2822. );
  2823.  
  2824. openMeatballMenu = new Shortcut(
  2825. '=', 'Open <button class="spa-meatball">⋯</button> menu', () => {
  2826. this._invites?.item
  2827. .querySelector('svg[aria-label^="Report message"]')
  2828. ?.closest('button')
  2829. ?.click();
  2830. }
  2831. );
  2832.  
  2833. acceptInvite = new Shortcut('A', 'Accept invite', () => {
  2834. NH.web.clickElement(this._invites?.item,
  2835. ['button[aria-label^="Accept"]']);
  2836. });
  2837.  
  2838. ignoreInvite = new Shortcut('I', 'Ignore invite', () => {
  2839. NH.web.clickElement(this._invites?.item,
  2840. ['button[aria-label^="Ignore"]']);
  2841. });
  2842.  
  2843. messageInviter = new Shortcut('M', 'Message inviter', () => {
  2844. NH.web.clickElement(this._invites?.item,
  2845. ['button[aria-label*=" message"]']);
  2846. });
  2847.  
  2848. #keyboardService
  2849.  
  2850. }
  2851.  
  2852. /**
  2853. * Class for handling the base Jobs page.
  2854. *
  2855. * This particular page requires a lot of careful monitoring. Unlike other
  2856. * pages, this one will destroy and recreate HTML elements, often with the
  2857. * exact same content, every time something interesting happens. Like
  2858. * loading more sections or jobs, or toggling state of a job.
  2859. */
  2860. class Jobs extends Page {
  2861.  
  2862. /**
  2863. * Create a Jobs instance.
  2864. * @param {SPA} spa - SPA instance that manages this Page.
  2865. */
  2866. constructor(spa) {
  2867. super({spa: spa, ...Jobs.#details});
  2868.  
  2869. this.#keyboardService = this.addService(VMKeyboardService);
  2870. this.#keyboardService.addInstance(this);
  2871.  
  2872. spa.details.navBarScrollerFixup(Jobs.#sectionsHow);
  2873. spa.details.navBarScrollerFixup(Jobs.#jobsHow);
  2874.  
  2875. this.#sectionScroller = new Scroller(Jobs.#sectionsWhat,
  2876. Jobs.#sectionsHow);
  2877. this.addService(ScrollerService, this.#sectionScroller);
  2878. this.#sectionScroller.dispatcher.on('out-of-range',
  2879. linkedInGlobals.focusOnSidebar);
  2880. this.#sectionScroller.dispatcher.on('activate',
  2881. this.#onSectionActivate);
  2882. this.#sectionScroller.dispatcher.on('change', this.#onSectionChange);
  2883.  
  2884. this.#lastScroller = this.#sectionScroller;
  2885. }
  2886.  
  2887. /**
  2888. * @implements {Scroller~uidCallback}
  2889. * @param {Element} element - Element to examine.
  2890. * @returns {string} - A value unique to this element.
  2891. */
  2892. static uniqueSectionIdentifier(element) {
  2893. const h2 = element.querySelector('h2');
  2894. let content = element.innerText;
  2895. if (h2?.innerText) {
  2896. content = h2.innerText;
  2897. }
  2898. return NH.base.strHash(content);
  2899. }
  2900.  
  2901. /**
  2902. * Complicated because there are so many variations.
  2903. * @implements {Scroller~uidCallback}
  2904. * @param {Element} element - Element to examine.
  2905. * @returns {string} - A value unique to this element.
  2906. */
  2907. static uniqueJobIdentifier(element) {
  2908. const ONE_ITEM = 1;
  2909. let content = element.innerText;
  2910. let options = element.querySelectorAll('a[data-control-id]');
  2911. if (options.length === ONE_ITEM) {
  2912. content = options[0].dataset.controlId;
  2913. } else {
  2914. options = element.querySelectorAll('a[id]');
  2915. if (options.length === ONE_ITEM) {
  2916. content = options[0].id;
  2917. } else {
  2918. let s = '';
  2919. for (const img of element.querySelectorAll('img[alt]')) {
  2920. s += img.alt;
  2921. }
  2922. if (s) {
  2923. content = s;
  2924. } else {
  2925. options = element
  2926. .querySelectorAll('.jobs-home-upsell-card__container');
  2927. if (options.length === ONE_ITEM) {
  2928. content = options[0].className;
  2929. }
  2930. }
  2931. }
  2932. }
  2933. return NH.base.strHash(content);
  2934. }
  2935.  
  2936. /** @type {Scroller} */
  2937. get _jobs() {
  2938. const me = 'get jobs';
  2939. this.logger.entered(me, this.#jobScroller);
  2940.  
  2941. if (!this.#jobScroller && this._sections.item) {
  2942. this.#jobScroller = new Scroller(
  2943. {base: this._sections.item, ...Jobs.#jobsWhat},
  2944. Jobs.#jobsHow
  2945. );
  2946. this.#jobScroller.dispatcher.on('change', this.#onJobChange);
  2947. this.#jobScroller.dispatcher.on('out-of-range',
  2948. this.#returnToSection);
  2949. }
  2950.  
  2951. this.logger.leaving(me, this.#jobScroller);
  2952. return this.#jobScroller;
  2953. }
  2954.  
  2955. /** @type {Scroller} */
  2956. get _sections() {
  2957. return this.#sectionScroller;
  2958. }
  2959.  
  2960. /** Reset the jobs scroller. */
  2961. #resetJobs = () => {
  2962. const me = 'resetJobs';
  2963. this.logger.entered(me, this.#jobScroller);
  2964. if (this.#jobScroller) {
  2965. this.#jobScroller.destroy();
  2966. this.#jobScroller = null;
  2967. }
  2968. this._jobs;
  2969. this.logger.leaving(me);
  2970. }
  2971.  
  2972. /**
  2973. * Reselects current section, triggering same actions as initial
  2974. * selection.
  2975. */
  2976. #returnToSection = () => {
  2977. this._sections.item = this._sections.item;
  2978. }
  2979.  
  2980. #onJobChange = () => {
  2981. this.#lastScroller = this._jobs;
  2982. }
  2983.  
  2984. #onSectionActivate = () => {
  2985. const me = 'onSectionActivate';
  2986. this.logger.entered(me);
  2987. this._sections.show();
  2988. this._sections.shine();
  2989. this.logger.leaving(me);
  2990. }
  2991.  
  2992. /**
  2993. * Updates {@link Jobs} specific watcher data and removes the jobs
  2994. * {@link Scroller}.
  2995. */
  2996. #onSectionChange = () => {
  2997. const me = 'onSectionChange';
  2998. this.logger.entered(me);
  2999. this.#resetJobs();
  3000. this.#lastScroller = this._sections;
  3001. this.logger.leaving(me);
  3002. }
  3003.  
  3004. /**
  3005. * Recover scroll position after elements were recreated.
  3006. * @param {number} topScroll - Where to scroll to.
  3007. */
  3008. #resetScroll = (topScroll) => {
  3009. const me = 'resetScroll';
  3010. this.logger.entered(me, topScroll);
  3011. // Explicitly setting jobs.item below will cause it to scroll to that
  3012. // item. We do not want to do that if the user is manually scrolling.
  3013. const savedJob = this._jobs?.item;
  3014. this._sections.shine();
  3015. // Section was probably rebuilt, assume jobs scroller is invalid.
  3016. this.#resetJobs();
  3017. if (savedJob) {
  3018. this._jobs.item = savedJob;
  3019. }
  3020. document.documentElement.scrollTop = topScroll;
  3021. this.logger.leaving(me);
  3022. }
  3023.  
  3024. _nextSection = new Shortcut('j', 'Next section', () => {
  3025. this._sections.next();
  3026. });
  3027.  
  3028. _prevSection = new Shortcut('k', 'Previous section', () => {
  3029. this._sections.prev();
  3030. });
  3031.  
  3032. _nextJob = new Shortcut('n', 'Next job', () => {
  3033. this._jobs.next();
  3034. });
  3035.  
  3036. _prevJob = new Shortcut('p', 'Previous job', () => {
  3037. this._jobs.prev();
  3038. });
  3039.  
  3040. _firstSectionOrJob = new Shortcut(
  3041. '<', 'Go to to first section or job', () => {
  3042. this.#lastScroller.first();
  3043. }
  3044. );
  3045.  
  3046. _lastSectionOrJob = new Shortcut(
  3047. '>', 'Go to last section or job currently loaded', () => {
  3048. this.#lastScroller.last();
  3049. }
  3050. );
  3051.  
  3052. _focusBrowser = new Shortcut(
  3053. 'f', 'Change browser focus to current section or job', () => {
  3054. this._sections.show();
  3055. this._jobs?.show();
  3056. NH.web.focusOnElement(this.#lastScroller.item);
  3057. }
  3058. );
  3059.  
  3060. _activateJob = new Shortcut(
  3061. 'Enter',
  3062. 'Activate the current job (click on it)',
  3063. () => {
  3064. const job = this._jobs?.item;
  3065. if (job) {
  3066. if (!NH.web.clickElement(job,
  3067. ['div[data-view-name]', 'a', 'button'])) {
  3068. this.spa.dumpInfoAboutElement(job, 'job');
  3069. }
  3070. } else {
  3071. // Again, because we use Enter as the hotkey for this action.
  3072. document.activeElement.click();
  3073. }
  3074. }
  3075. );
  3076.  
  3077. _loadMoreSections = new Shortcut(
  3078. 'l',
  3079. 'Load more sections (or <i>More jobs for you</i> items)',
  3080. async () => {
  3081. const savedScrollTop = document.documentElement.scrollTop;
  3082.  
  3083. /** Trigger function for {@link NH.web.otrot}. */
  3084. function trigger() {
  3085. NH.web.clickElement(document,
  3086. ['main button.scaffold-finite-scroll__load-button']);
  3087. }
  3088. const what = {
  3089. name: 'loadMoreSections',
  3090. base: document.querySelector('div.scaffold-finite-scroll__content'),
  3091. };
  3092. const how = {
  3093. trigger: trigger,
  3094. timeout: 3000,
  3095. };
  3096. await NH.web.otrot(what, how);
  3097. this.#resetScroll(savedScrollTop);
  3098. }
  3099. );
  3100.  
  3101. _toggleSaveJob = new Shortcut('S', 'Toggle saving job', () => {
  3102. const selector = [
  3103. 'button[aria-label^="Save job"]',
  3104. 'button[aria-label^="Unsave job"]',
  3105. ].join(',');
  3106. NH.web.clickElement(this._jobs?.item, [selector]);
  3107. });
  3108.  
  3109. _toggleDismissJob = new Shortcut('X',
  3110. 'Toggle dismissing job',
  3111. async () => {
  3112. const savedJob = this._jobs.item;
  3113.  
  3114. /** Trigger function for {@link NH.web.otrot}. */
  3115. function trigger() {
  3116. const selector = [
  3117. 'button[aria-label^="Dismiss job"]:not([disabled])',
  3118. 'button[aria-label$=" Undo"]',
  3119. ].join(',');
  3120. NH.web.clickElement(savedJob, [selector]);
  3121. }
  3122. if (savedJob) {
  3123. const what = {
  3124. name: 'toggleDismissJob',
  3125. base: savedJob,
  3126. };
  3127. const how = {
  3128. trigger: trigger,
  3129. timeout: 3000,
  3130. };
  3131. await NH.web.otrot(what, how);
  3132. this._jobs.item = savedJob;
  3133. }
  3134. });
  3135.  
  3136. /** @type {Page~PageDetails} */
  3137. static #details = {
  3138. pathname: '/jobs/',
  3139. pageReadySelector: LinkedInGlobals.asideSelector,
  3140. };
  3141.  
  3142. /** @type {Scroller~What} */
  3143. static #jobsWhat = {
  3144. name: 'Job entries',
  3145. selectors: [
  3146. [
  3147. // Most job entries
  3148. ':scope > ul > li',
  3149. // Show all button
  3150. 'div.discovery-templates-vertical-list__footer',
  3151. ].join(','),
  3152. ],
  3153. };
  3154.  
  3155. /** @type {Scroller~How} */
  3156. static #jobsHow = {
  3157. uidCallback: Jobs.uniqueJobIdentifier,
  3158. classes: ['dick'],
  3159. autoActivate: true,
  3160. snapToTop: false,
  3161. };
  3162.  
  3163. /** @type {Scroller~What} */
  3164. static #sectionsWhat = {
  3165. name: 'Jobs sections',
  3166. containerItems: [{container: 'main', items: 'section'}],
  3167. };
  3168.  
  3169. /** @type {Scroller~How} */
  3170. static #sectionsHow = {
  3171. uidCallback: Jobs.uniqueSectionIdentifier,
  3172. classes: ['tom'],
  3173. snapToTop: true,
  3174. };
  3175.  
  3176. #sectionScroller = null;
  3177. #jobScroller = null;
  3178. #lastScroller
  3179.  
  3180. #keyboardService
  3181.  
  3182. }
  3183.  
  3184. /** Class for handling Job collections. */
  3185. class JobCollections extends Page {
  3186.  
  3187. /**
  3188. * Create a JobCollections instance.
  3189. * @param {SPA} spa - SPA instance that manages this Page.
  3190. */
  3191. constructor(spa) {
  3192. super({spa: spa, ...JobCollections.#details});
  3193.  
  3194. this.#keyboardService = this.addService(VMKeyboardService);
  3195. this.#keyboardService.addInstance(this);
  3196.  
  3197. this.#jobCardScroller = new Scroller(JobCollections.#jobCardsWhat,
  3198. JobCollections.#jobCardsHow);
  3199. this.addService(ScrollerService, this.#jobCardScroller);
  3200. this.#jobCardScroller.dispatcher.on('activate',
  3201. this.#onJobCardActivate);
  3202. this.#jobCardScroller.dispatcher.on('change', this.#onJobCardChange);
  3203.  
  3204. this.#paginationScroller = new Scroller(
  3205. JobCollections.#paginationWhat, JobCollections.#paginationHow
  3206. );
  3207. this.addService(ScrollerService, this.#paginationScroller);
  3208. this.#paginationScroller.dispatcher.on('activate',
  3209. this.#onPaginationActivate);
  3210. this.#paginationScroller.dispatcher.on('change',
  3211. this.#onPaginationChange);
  3212.  
  3213. this.#lastScroller = this.#jobCardScroller;
  3214. }
  3215.  
  3216. /**
  3217. * @implements {Scroller~uidCallback}
  3218. * @param {Element} element - Element to examine.
  3219. * @returns {string} - A value unique to this element.
  3220. */
  3221. static uniqueJobIdentifier(element) {
  3222. let content = '';
  3223. if (element) {
  3224. content = element.dataset.occludableJobId;
  3225. }
  3226. return NH.base.strHash(content);
  3227. }
  3228.  
  3229. /**
  3230. * @implements {Scroller~uidCallback}
  3231. * @param {Element} element - Element to examine.
  3232. * @returns {string} - A value unique to this element.
  3233. */
  3234. static uniquePaginationIdentifier(element) {
  3235. let content = '';
  3236. if (element) {
  3237. content = element.innerText;
  3238. const label = element.getAttribute('aria-label');
  3239. if (label) {
  3240. content = label;
  3241. }
  3242. }
  3243. return NH.base.strHash(content);
  3244. }
  3245.  
  3246. #lastScroller
  3247.  
  3248. #jobCardScroller = null;
  3249.  
  3250. /** @type {Scroller} */
  3251. get _jobCards() {
  3252. return this.#jobCardScroller;
  3253. }
  3254.  
  3255. /** @type {Scroller~What} */
  3256. static #jobCardsWhat = {
  3257. name: 'Job cards',
  3258. containerItems: [
  3259. {
  3260. container: 'div.jobs-search-results-list > ul',
  3261. // This selector is also used in #onJobCardActivate.
  3262. items: ':scope > li',
  3263. },
  3264. ],
  3265. };
  3266.  
  3267. /** @type {Scroller~How} */
  3268. static #jobCardsHow = {
  3269. uidCallback: this.uniqueJobIdentifier,
  3270. classes: ['tom'],
  3271. snapToTop: false,
  3272. bottomMarginCSS: '3em',
  3273. };
  3274.  
  3275. #paginationScroller = null;
  3276.  
  3277. /** @type {Scroller} */
  3278. get paginator() {
  3279. return this.#paginationScroller;
  3280. }
  3281.  
  3282. /** @type {Scroller~What} */
  3283. static #paginationWhat = {
  3284. name: 'Results pagination',
  3285. base: document.body,
  3286. // This selector is also used in #onPaginationActivate.
  3287. selectors: ['div.jobs-search-results-list__pagination > ul > li'],
  3288. };
  3289.  
  3290. /** @type {Scroller~How} */
  3291. static #paginationHow = {
  3292. uidCallback: this.uniquePaginationIdentifier,
  3293. classes: ['dick'],
  3294. snapToTop: false,
  3295. bottomMarginCSS: '3em',
  3296. };
  3297.  
  3298. /** @type {Page~PageDetails} */
  3299. static #details = {
  3300. // eslint-disable-next-line prefer-regex-literals
  3301. pathname: RegExp('^/jobs/(?:collections|search)/.*', 'u'),
  3302. pageReadySelector: 'footer.global-footer-compact',
  3303. };
  3304.  
  3305. #onJobCardActivate = async () => {
  3306. const me = 'onJobCardActivate';
  3307. this.logger.entered(me);
  3308.  
  3309. const params = new URL(document.location).searchParams;
  3310. const jobId = params.get('currentJobId');
  3311. this.logger.log('Looking for job card for', jobId);
  3312.  
  3313. // Wait some amount of time for a job card to show up, if it ever does.
  3314. // Annoyingly enough, the selection of jobs that shows up on a reload
  3315. // may not include one for the current URL. Even if the user arrived at
  3316. // the URL moments ago.
  3317.  
  3318. try {
  3319. const timeout = 2000;
  3320. const item = await NH.web.waitForSelector(
  3321. `li[data-occludable-job-id="${jobId}"]`,
  3322. timeout
  3323. );
  3324. this._jobCards.gotoUid(JobCollections.uniqueJobIdentifier(item));
  3325. } catch (e) {
  3326. this.logger.log('Job card matching URL not found, staying put');
  3327. }
  3328.  
  3329. this.logger.leaving(me);
  3330. }
  3331.  
  3332. #onJobCardChange = () => {
  3333. const me = 'onJobCardChange';
  3334. this.logger.entered(me, this._jobCards.item);
  3335. NH.web.clickElement(this._jobCards.item, ['div[data-job-id]']);
  3336. this.#lastScroller = this._jobCards;
  3337. this.logger.leaving(me);
  3338. }
  3339.  
  3340. #onPaginationActivate = async () => {
  3341. const me = 'onPaginationActivate';
  3342. this.logger.entered(me);
  3343.  
  3344. try {
  3345. const timeout = 2000;
  3346. const item = await NH.web.waitForSelector(
  3347. 'div.jobs-search-results-list__pagination > ul > li.selected',
  3348. timeout
  3349. );
  3350. this.paginator.goto(item);
  3351. } catch (e) {
  3352. this.logger.log('Results paginator not found, staying put');
  3353. }
  3354.  
  3355. this.logger.leaving(me);
  3356. }
  3357.  
  3358. #onPaginationChange = () => {
  3359. const me = 'onResultsPageChange';
  3360. this.logger.entered(me, this.paginator.item);
  3361. this.#lastScroller = this.paginator;
  3362. this.logger.leaving(me);
  3363. }
  3364.  
  3365. nextJob = new Shortcut('j', 'Next job card', () => {
  3366. this._jobCards.next();
  3367. });
  3368.  
  3369. prevJob = new Shortcut('k', 'Previous job card', () => {
  3370. this._jobCards.prev();
  3371. });
  3372.  
  3373. nextResultsPage = new Shortcut('n', 'Next results page', () => {
  3374. this.paginator.next();
  3375. });
  3376.  
  3377. prevResultsPage = new Shortcut('p', 'Previous results page', () => {
  3378. this.paginator.prev();
  3379. });
  3380.  
  3381. firstItem = new Shortcut('<', 'Go to first job or results page', () => {
  3382. this.#lastScroller.first();
  3383. });
  3384.  
  3385. lastItem = new Shortcut(
  3386. '>', 'Go to last job currently loaded or results page', () => {
  3387. this.#lastScroller.last();
  3388. }
  3389. );
  3390.  
  3391. focusBrowser = new Shortcut(
  3392. 'f', 'Move browser focus to most recently selected item', () => {
  3393. NH.web.focusOnElement(this.#lastScroller.item);
  3394. }
  3395. );
  3396.  
  3397. detailsPane = new Shortcut('d', 'Jump to details pane', () => {
  3398. NH.web.focusOnElement(document.querySelector(
  3399. 'div.jobs-search__job-details--container'
  3400. ));
  3401. });
  3402.  
  3403. selectCurrentResultsPage = new Shortcut(
  3404. 'c', 'Select current results page', () => {
  3405. NH.web.clickElement(this.paginator.item, ['button']);
  3406. }
  3407. );
  3408.  
  3409. openShareMenu = new Shortcut('s', 'Open share menu', () => {
  3410. NH.web.clickElement(document, ['button[aria-label="Share"]']);
  3411. });
  3412.  
  3413. openMeatballMenu = new Shortcut(
  3414. '=', 'Open the <button class="spa-meatball">⋯</button> menu', () => {
  3415. // XXX: There are TWO buttons. The *first* one is hidden until the
  3416. // user scrolls down. This always triggers the first one.
  3417. NH.web.clickElement(document, ['.jobs-options button']);
  3418. }
  3419. );
  3420.  
  3421. applyToJob = new Shortcut(
  3422. 'A', 'Apply to job (or previous application)', () => {
  3423. // XXX: There are TWO apply buttons. The *second* one is hidden until
  3424. // the user scrolls down. This always triggers the first one.
  3425. const selectors = [
  3426. // Apply and Easy Apply buttons
  3427. 'button[aria-label*="Apply to"]',
  3428. // See application link
  3429. 'a[href^="/jobs/tracker"]',
  3430. ];
  3431. NH.web.clickElement(document, selectors);
  3432. }
  3433. );
  3434.  
  3435. toggleSaveJob = new Shortcut('S', 'Toggle saving job', () => {
  3436. // XXX: There are TWO buttons. The *first* one is hidden until the user
  3437. // scrolls down. This always triggers the first one.
  3438. NH.web.clickElement(document, ['button.jobs-save-button']);
  3439. });
  3440.  
  3441. toggleDismissJob = new Shortcut(
  3442. 'X', 'Toggle dismissing job, if available', () => {
  3443. // Currently these two are the same, but one never knows.
  3444. this.toggleThumbsDown();
  3445. }
  3446. );
  3447.  
  3448. toggleFollowCompany = new Shortcut(
  3449. 'F', 'Toggle following company', () => {
  3450. // The button toggles between Follow and Following
  3451. NH.web.clickElement(document, ['button[aria-label^="Follow"]']);
  3452. }
  3453. );
  3454.  
  3455. toggleAlert = new Shortcut(
  3456. 'L', 'Toggle the job search aLert, if available', () => {
  3457. NH.web.clickElement(document,
  3458. ['main .jobs-search-create-alert__artdeco-toggle']);
  3459. }
  3460. );
  3461.  
  3462. toggleThumbsUp = new Shortcut(
  3463. '+', 'Toggle thumbs up, if available', () => {
  3464. const selector = [
  3465. 'button[aria-label="Like job"]',
  3466. 'button[aria-label="Job is liked, undo"]',
  3467. ].join(',');
  3468. NH.web.clickElement(this._jobCards.item, [selector]);
  3469. }
  3470. );
  3471.  
  3472. toggleThumbsDown = new Shortcut(
  3473. '-', 'Toggle thumbs down, if available', () => {
  3474. const selector = [
  3475. 'button[aria-label="Dismiss job"]',
  3476. 'button[aria-label="Job is dismissed, undo"]',
  3477. ].join(',');
  3478. NH.web.clickElement(this._jobCards.item, [selector]);
  3479. }
  3480. );
  3481.  
  3482. #keyboardService
  3483.  
  3484. }
  3485.  
  3486. /** Class for handling the Messaging page. */
  3487. class Messaging extends Page {
  3488.  
  3489. /**
  3490. * Create a Messaging instance.
  3491. * @param {SPA} spa - SPA instance that manages this Page.
  3492. */
  3493. constructor(spa) {
  3494. super({spa: spa, ...Messaging.#details});
  3495.  
  3496. this.#keyboardService = this.addService(VMKeyboardService);
  3497. this.#keyboardService.addInstance(this);
  3498. }
  3499.  
  3500. /** @type {Page~PageDetails} */
  3501. static #details = {
  3502. // eslint-disable-next-line prefer-regex-literals
  3503. pathname: RegExp('^/messaging/.*', 'u'),
  3504. pageReadySelector: LinkedInGlobals.asideSelector,
  3505. };
  3506.  
  3507. loadMoreConversations = new Shortcut(
  3508. 'l', 'Load more conversations', () => {
  3509. const me = 'loadMoreConversations';
  3510. this.logger.entered(me);
  3511.  
  3512. // This button has no distinguishing features, so look for the text
  3513. // the nested span, then click the button.
  3514. const span = Array.from(document.querySelectorAll('button > span'))
  3515. .find(el => el.innerText === 'Load more conversations');
  3516. span?.parentElement?.click();
  3517.  
  3518. this.logger.leaving(me);
  3519. }
  3520. );
  3521.  
  3522. searchMessages = new Shortcut('s', 'Go to Search messages', () => {
  3523. const me = 'searchMessages';
  3524. this.logger.entered(me);
  3525.  
  3526. NH.web.focusOnElement(document.querySelector('#search-conversations'));
  3527.  
  3528. this.logger.leaving(me);
  3529. });
  3530.  
  3531. newMessage = new Shortcut('N', 'Compose a new message', () => {
  3532. const me = 'newMessage';
  3533. this.logger.entered(me);
  3534.  
  3535. NH.web.clickElement(document,
  3536. ['a[aria-label="Compose a new message"]']);
  3537.  
  3538. this.logger.leaving(me);
  3539. });
  3540.  
  3541. #keyboardService
  3542.  
  3543. }
  3544.  
  3545. /** Class for handling the Notifications page. */
  3546. class Notifications extends Page {
  3547.  
  3548. /**
  3549. * Create a Notifications instance.
  3550. * @param {SPA} spa - SPA instance that manages this Page.
  3551. */
  3552. constructor(spa) {
  3553. super({spa: spa, ...Notifications.#details});
  3554.  
  3555. this.#keyboardService = this.addService(VMKeyboardService);
  3556. this.#keyboardService.addInstance(this);
  3557.  
  3558. spa.details.navBarScrollerFixup(Notifications.#notificationsHow);
  3559.  
  3560. this.#notificationScroller = new Scroller(
  3561. Notifications.#notificationsWhat, Notifications.#notificationsHow
  3562. );
  3563. this.addService(ScrollerService, this.#notificationScroller);
  3564. this.#notificationScroller.dispatcher.on('activate',
  3565. this.#onNotifcationsActivate);
  3566. this.#notificationScroller.dispatcher.on('out-of-range',
  3567. linkedInGlobals.focusOnSidebar);
  3568. }
  3569.  
  3570. /**
  3571. * Complicated because there are so many variations in notification cards.
  3572. * We do not want to use reaction counts because they can change too
  3573. * quickly.
  3574. * @implements {Scroller~uidCallback}
  3575. * @param {Element} element - Element to examine.
  3576. * @returns {string} - A value unique to this element.
  3577. */
  3578. static uniqueIdentifier(element) {
  3579. // All known <articles> have three children: icon/presence indicator,
  3580. // content, and menu/timestamp.
  3581. const MAGIC_COUNT = 3;
  3582. const CONTENT_INDEX = 1;
  3583. let content = element.innerText;
  3584. if (element.childElementCount === MAGIC_COUNT) {
  3585. content = element.children[CONTENT_INDEX].innerText;
  3586. if (content.includes('Reactions')) {
  3587. for (const el of element.children[CONTENT_INDEX]
  3588. .querySelectorAll('*')) {
  3589. if (el.innerText) {
  3590. content = el.innerText;
  3591. break;
  3592. }
  3593. }
  3594. }
  3595. }
  3596. if (content.startsWith('Notification deleted.')) {
  3597. // Mix in something unique from the parent.
  3598. content += element.parentElement.dataset.finiteScrollHotkeyItem;
  3599. }
  3600. return NH.base.strHash(content);
  3601. }
  3602.  
  3603. #notificationScroller = null;
  3604.  
  3605. /** @type {Scroller~What} */
  3606. static #notificationsWhat = {
  3607. name: 'Notification cards',
  3608. containerItems: [
  3609. {
  3610. container: 'main section div.nt-card-list',
  3611. items: 'article',
  3612. },
  3613. ],
  3614. };
  3615.  
  3616. /** @type {Scroller-How} */
  3617. static #notificationsHow = {
  3618. uidCallback: Notifications.uniqueIdentifier,
  3619. classes: ['tom'],
  3620. snapToTop: false,
  3621. };
  3622.  
  3623. /** @type {Page~PageDetails} */
  3624. static #details = {
  3625. pathname: '/notifications/',
  3626. pageReadySelector: 'main section div.nt-card-list',
  3627. };
  3628.  
  3629. /** @inheritdoc */
  3630. #onNotifcationsActivate = () => {
  3631. this._notifications.shine();
  3632. this._notifications.show();
  3633. }
  3634.  
  3635. /** @type {Scroller} */
  3636. get _notifications() {
  3637. return this.#notificationScroller;
  3638. }
  3639.  
  3640. _nextNotification = new Shortcut('j', 'Next notification', () => {
  3641. this._notifications.next();
  3642. });
  3643.  
  3644. _prevNotification = new Shortcut('k', 'Previous notification', () => {
  3645. this._notifications.prev();
  3646. });
  3647.  
  3648. _firstNotification = new Shortcut('<', 'Go to first notification', () => {
  3649. this._notifications.first();
  3650. });
  3651.  
  3652. _lastNotification = new Shortcut('>', 'Go to last notification', () => {
  3653. this._notifications.last();
  3654. });
  3655.  
  3656. _focusBrowser = new Shortcut(
  3657. 'f', 'Change browser focus to current notification', () => {
  3658. this._notifications.show();
  3659. NH.web.focusOnElement(this._notifications.item);
  3660. }
  3661. );
  3662.  
  3663. _activateNotification = new Shortcut(
  3664. 'Enter', 'Activate the current notification (click on it)', () => {
  3665. const ONE_ITEM = 1;
  3666. const notification = this._notifications.item;
  3667. if (notification) {
  3668. // Because we are using Enter as the hotkey here, if the active
  3669. // element is inside the current card, we want that to take
  3670. // precedence.
  3671. if (document.activeElement.closest('article') === notification) {
  3672. return;
  3673. }
  3674.  
  3675. const elements = notification.querySelectorAll(
  3676. '.nt-card__headline'
  3677. );
  3678. if (elements.length === ONE_ITEM) {
  3679. elements[0].click();
  3680. } else {
  3681. const ba = notification.querySelectorAll('button,a');
  3682. if (ba.length === ONE_ITEM) {
  3683. ba[0].click();
  3684. } else {
  3685. this.spa.dumpInfoAboutElement(notification, 'notification');
  3686. }
  3687. }
  3688. } else {
  3689. // Again, because we use Enter as the hotkey for this action.
  3690. document.activeElement.click();
  3691. }
  3692. }
  3693. );
  3694.  
  3695. _loadMoreNotifications = new Shortcut(
  3696. 'l', 'Load more notifications', () => {
  3697. const savedScrollTop = document.documentElement.scrollTop;
  3698. let first = false;
  3699. const notifications = this._notifications;
  3700.  
  3701. /** Trigger function for {@link NH.web.otrot2}. */
  3702. function trigger() {
  3703. if (NH.web.clickElement(document,
  3704. ['button[aria-label^="Load new notifications"]'])) {
  3705. first = true;
  3706. } else {
  3707. NH.web.clickElement(document,
  3708. ['main button.scaffold-finite-scroll__load-button']);
  3709. }
  3710. }
  3711.  
  3712. /** Action function for {@link NH.web.otrot2}. */
  3713. const action = () => {
  3714. if (first) {
  3715. if (notifications.item) {
  3716. notifications.first();
  3717. }
  3718. } else {
  3719. document.documentElement.scrollTop = savedScrollTop;
  3720. this._notifications.shine();
  3721. }
  3722. };
  3723.  
  3724. const what = {
  3725. name: 'loadMoreNotifications',
  3726. base: document.querySelector('div.scaffold-finite-scroll__content'),
  3727. };
  3728. const how = {
  3729. trigger: trigger,
  3730. action: action,
  3731. duration: 2000,
  3732. };
  3733. NH.web.otrot2(what, how);
  3734. }
  3735. );
  3736.  
  3737. _openMeatballMenu = new Shortcut(
  3738. '=', 'Open the <button class="spa-meatball">⋯</button> menu', () => {
  3739. NH.web.clickElement(this._notifications.item,
  3740. ['button[aria-label^="Settings menu"]']);
  3741. }
  3742. );
  3743.  
  3744. _deleteNotification = new Shortcut(
  3745. 'X', 'Toggle current notification deletion', async () => {
  3746. const notification = this._notifications.item;
  3747.  
  3748. /** Trigger function for {@link NH.web.otrot}. */
  3749. function trigger() {
  3750. // Hah. Unlike in other places, these buttons already exist, just
  3751. // hidden under the menu.
  3752. const buttons = Array.from(notification.querySelectorAll('button'));
  3753. const button = buttons
  3754. .find(el => (/Delete .*notification/u).test(el.textContent));
  3755. if (button) {
  3756. button.click();
  3757. } else {
  3758. NH.web.clickElement(notification,
  3759. ['button[aria-label^="Undo notification deletion"]']);
  3760. }
  3761. }
  3762. if (notification) {
  3763. const what = {
  3764. name: 'deleteNotification',
  3765. base: document.querySelector(
  3766. 'div.scaffold-finite-scroll__content'
  3767. ),
  3768. };
  3769. const how = {
  3770. trigger: trigger,
  3771. timeout: 3000,
  3772. };
  3773. await NH.web.otrot(what, how);
  3774. this._notifications.shine();
  3775. }
  3776. }
  3777. );
  3778.  
  3779. #keyboardService
  3780.  
  3781. }
  3782.  
  3783. /** Base class for {@link SPA} instance details. */
  3784. class SPADetails {
  3785.  
  3786. /** Create a SPADetails instance. */
  3787. constructor() {
  3788. if (new.target === SPADetails) {
  3789. throw new TypeError('Abstract class; do not instantiate directly.');
  3790. }
  3791.  
  3792. this.#logger = new NH.base.Logger(this.constructor.name);
  3793. this.#id = NH.base.safeId(NH.base.uuId(this.constructor.name));
  3794. this.dispatcher = new NH.base.Dispatcher('errors', 'news');
  3795. }
  3796.  
  3797. /**
  3798. * An issue that happened during construction. SPA will ask for them and
  3799. * add them to the Errors tab.
  3800. * @typedef {object} SetupIssue
  3801. * @property {string[]} messages - What to pass to {@link SPA.addError}.
  3802. */
  3803.  
  3804. /** @type {SetupIssue[]} */
  3805. #setupIssues = [];
  3806.  
  3807. /**
  3808. * @type {string} - CSS selector to monitor if self-managing URL changes.
  3809. * The selector must resolve to an element that, once it exists, will
  3810. * continue to exist for the lifetime of the SPA.
  3811. */
  3812. urlChangeMonitorSelector = 'body';
  3813.  
  3814. /** @type {string} - Unique ID for this instance . */
  3815. get id() {
  3816. return this.#id;
  3817. }
  3818.  
  3819. /** @type {NH.base.Logger} - NH.base.Logger instance. */
  3820. get logger() {
  3821. return this.#logger;
  3822. }
  3823.  
  3824. /** @type {SetupIssue[]} */
  3825. get setupIssues() {
  3826. return this.#setupIssues;
  3827. }
  3828.  
  3829. /** @type {TabbedUI} */
  3830. get ui() {
  3831. return this.#ui;
  3832. }
  3833.  
  3834. /** @param {TabbedUI} val - UI instance. */
  3835. set ui(val) {
  3836. this.#ui = val;
  3837. }
  3838.  
  3839. /** @type {TabbedUI} */
  3840. #ui = null;
  3841.  
  3842. #id
  3843. #logger
  3844.  
  3845. /**
  3846. * Called by SPA instance during its construction to allow post
  3847. * instantiation stuff to happen. If overridden in a subclass, this
  3848. * should definitely be called via super.
  3849. */
  3850. init() {
  3851. this.dispatcher.on('errors', this._errors);
  3852. this.dispatcher.on('news', this._news);
  3853. }
  3854.  
  3855. /**
  3856. * Called by SPA instance when initialization is done. Subclasses should
  3857. * call via super.
  3858. */
  3859. done() {
  3860. const me = 'done (SPADetails)';
  3861. this.logger.entered(me);
  3862. this.logger.leaving(me);
  3863. }
  3864.  
  3865. /**
  3866. * Handles notifications about changes to the {@link SPA} Errors tab
  3867. * content.
  3868. * @implements {NH.base.Dispatcher~Handler}
  3869. * @param {string} eventType - Event type.
  3870. * @param {number} count - Number of errors currently logged.
  3871. */
  3872. _errors = (eventType, count) => {
  3873. this.logger.log('errors:', eventType, count);
  3874. }
  3875.  
  3876. /**
  3877. * Handles notifications about activity on the {@link SPA} News tab.
  3878. * @implements {NH.base.Dispatcher~Handler}
  3879. * @param {string} eventType - Event type.
  3880. * @param {object} data - Undefined at this time.
  3881. */
  3882. _news = (eventType, data) => {
  3883. this.logger.log('news', eventType, data);
  3884. }
  3885.  
  3886. /**
  3887. * Collects {SetupIssue}s for reporting.
  3888. * @param {...string} msgs - Text to report.
  3889. */
  3890. addSetupIssue(...msgs) {
  3891. for (const msg of msgs) {
  3892. this.logger.log('Setup issue:', msg);
  3893. }
  3894. this.#setupIssues.push(msgs);
  3895. }
  3896.  
  3897. /**
  3898. * @implements {SPA~TabGenerator}
  3899. * @returns {TabbedUI~TabDefinition} - Where to find documentation
  3900. * and file bugs.
  3901. */
  3902. docTab() {
  3903. this.logger.log('docTab is not implemented');
  3904. throw new Error('Not implemented.');
  3905. return { // eslint-disable-line no-unreachable
  3906. name: 'Not implemented.',
  3907. content: 'Not implemented.',
  3908. };
  3909. }
  3910.  
  3911. /**
  3912. * @implements {SPA~TabGenerator}
  3913. * @returns {TabbedUI~TabDefinition} - License information.
  3914. */
  3915. licenseTab() {
  3916. this.logger.log('licenseTab is not implemented');
  3917. throw new Error('Not implemented.');
  3918. return { // eslint-disable-line no-unreachable
  3919. name: 'Not implemented.',
  3920. content: 'Not implemented.',
  3921. };
  3922. }
  3923.  
  3924. }
  3925.  
  3926. /** LinkedIn specific information. */
  3927. class LinkedIn extends SPADetails {
  3928.  
  3929. /**
  3930. * @param {LinkedInGlobals} globals - Instance of a helper class to avoid
  3931. * circular dependencies.
  3932. */
  3933. constructor(globals) {
  3934. super();
  3935. this.#globals = globals;
  3936. this.ready = this.#waitUntilPageLoadedEnough();
  3937. }
  3938.  
  3939. urlChangeMonitorSelector = 'div.authentication-outlet';
  3940.  
  3941. static #icon =
  3942. '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">' +
  3943. '<defs>' +
  3944. '<mask id="a" maskContentUnits="objectBoundingBox">' +
  3945. '<path fill="#fff" d="M0 0h1v1H0z"/>' +
  3946. '<circle cx=".5" cy=".5" r=".25"/>' +
  3947. '</mask>' +
  3948. '<mask id="b" maskContentUnits="objectBoundingBox">' +
  3949. '<path fill="#fff" mask="url(#a)" d="M0 0h1v1H0z"/>' +
  3950. '<rect x="0.375" y="-0.05" height="0.35" width="0.25"' +
  3951. ' transform="rotate(30 0.5 0.5)"/>' +
  3952. '</mask>' +
  3953. '</defs>' +
  3954. '<rect x="9.5" y="7" width="5" height="10"' +
  3955. ' transform="rotate(45 12 12)"/>' +
  3956. '<circle cx="6" cy="18" r="5" mask="url(#a)"/>' +
  3957. '<circle cx="18" cy="6" r="5" mask="url(#b)"/>' +
  3958. '</svg>';
  3959.  
  3960. /** @type {string} - The element.id used to identify the info pop-up. */
  3961. get infoId() {
  3962. return this.#infoId;
  3963. }
  3964.  
  3965. /** @param {string} val - Set the value of the info element.id. */
  3966. set infoId(val) {
  3967. this.#infoId = val;
  3968. }
  3969.  
  3970. /**
  3971. * @typedef {object} LicenseData
  3972. * @property {string} name - Name of the license.
  3973. * @property {string} url - License URL.
  3974. */
  3975.  
  3976. /** @type {LicenseData} */
  3977. get licenseData() {
  3978. const me = 'licenseData';
  3979. this.logger.entered(me);
  3980.  
  3981. if (!this.#licenseData) {
  3982. try {
  3983. this.#licenseData = NH.userscript.licenseData();
  3984. } catch (e) {
  3985. if (e instanceof NH.userscript.UserscriptError) {
  3986. this.logger.log('e:', e);
  3987. this.addSetupIssue(e.message);
  3988. this.#licenseData = {
  3989. name: 'Unable to extract: Please file a bug',
  3990. url: '',
  3991. };
  3992. }
  3993. }
  3994. }
  3995.  
  3996. this.logger.leaving(me, this.#licenseData);
  3997. return this.#licenseData;
  3998. }
  3999.  
  4000. /** @inheritdoc */
  4001. done() {
  4002. super.done();
  4003. const me = 'done';
  4004. this.logger.entered(me);
  4005. const licenseEntry = this.ui.tabs.get('License');
  4006. licenseEntry.panel.addEventListener('expose', this.#licenseHandler);
  4007. VMKeyboardService.condition = '!inputFocus && !inDialog';
  4008. VMKeyboardService.start();
  4009. this.logger.leaving(me);
  4010. }
  4011.  
  4012. /**
  4013. * Many classes have some static {Scroller~How} items that need to be
  4014. * fixed up after the page loads enough that the values are available.
  4015. * They do that by calling this method.
  4016. * @param {Scroller~How} how - Object to be fixed up.
  4017. */
  4018. navBarScrollerFixup(how) {
  4019. const me = 'navBarScrollerFixup';
  4020. this.logger.entered(me, how);
  4021.  
  4022. how.topMarginPixels = this.#globals.navBarHeightPixels;
  4023. how.topMarginCSS = this.#globals.navBarHeightCSS;
  4024. how.bottomMarginCSS = '3em';
  4025.  
  4026. this.logger.leaving(me, how);
  4027. }
  4028.  
  4029. /** @inheritdoc */
  4030. _errors = (eventType, count) => {
  4031. const me = 'errors';
  4032. this.logger.entered(me, eventType, count);
  4033. const button = document.querySelector('#lit-nav-button');
  4034. const toggle = button.querySelector('.notification-badge');
  4035. const badge = button.querySelector('.notification-badge__count');
  4036. badge.innerText = `${count}`;
  4037. if (count) {
  4038. toggle.classList.add('notification-badge--show');
  4039. } else {
  4040. toggle.classList.remove('notification-badge--show');
  4041. }
  4042. this.logger.leaving(me);
  4043. }
  4044.  
  4045. /** @inheritdoc */
  4046. docTab() {
  4047. const me = 'docTab';
  4048. this.logger.entered(me);
  4049.  
  4050. const issuesLink = this.#globals.ghUrl('labels/linkedin-tool');
  4051. const newIssueLink = this.#globals.ghUrl('issues/new/choose');
  4052. const newGfIssueLink = this.#globals.gfUrl('feedback');
  4053. const releaseNotesLink = this.#globals.gfUrl('versions');
  4054.  
  4055. const content = [
  4056. `<p>This is information about the <b>${GM.info.script.name}</b> ` +
  4057. 'userscript, a type of add-on. It is not associated with ' +
  4058. 'LinkedIn Corporation in any way.</p>',
  4059. '<p>Documentation can be found on ' +
  4060. `<a href="${GM.info.script.supportURL}">GitHub</a>. Release ` +
  4061. 'notes are automatically generated on ' +
  4062. `<a href="${releaseNotesLink}">Greasy Fork</a>.</p>`,
  4063. '<p>Existing issues are also on GitHub ' +
  4064. `<a href="${issuesLink}">here</a>.</p>`,
  4065. '<p>New issues or feature requests can be filed on GitHub (account ' +
  4066. `required) <a href="${newIssueLink}">here</a>. Then select the ` +
  4067. 'appropriate issue template to get started. Or, on Greasy Fork ' +
  4068. `(account required) <a href="${newGfIssueLink}">here</a>. ` +
  4069. 'Review the <b>Errors</b> tab for any useful information.</p>',
  4070. '',
  4071. ];
  4072.  
  4073. const tab = {
  4074. name: 'About',
  4075. content: content.join('\n'),
  4076. };
  4077.  
  4078. this.logger.leaving(me, tab);
  4079. return tab;
  4080. }
  4081.  
  4082. /** @inheritdoc */
  4083. newsTab() {
  4084. const me = 'newsTab';
  4085. this.logger.entered(me);
  4086.  
  4087. const {dates, knownIssues} = this.#preprocessKnownIssues();
  4088.  
  4089. const content = [
  4090. '<p>The contains a manually curated list of changes over the last ' +
  4091. 'month or so that:',
  4092. '<ul>',
  4093. '<li>Added new features like support for new pages or more ' +
  4094. 'hotkeys</li>',
  4095. '<li>Explicitly fixed a bug</li>',
  4096. '<li>May cause a use noticeable change</li>',
  4097. '</ul>',
  4098. '</p>',
  4099. '<p>See the <b>About</b> tab for finding all changes by release.</p>',
  4100. ];
  4101.  
  4102. const dateHeader = 'h3';
  4103. const issueHeader = 'h4';
  4104.  
  4105. for (const [date, items] of dates) {
  4106. content.push(`<${dateHeader}>${date}</${dateHeader}>`);
  4107. for (const [issue, subjects] of items) {
  4108. content.push(
  4109. `<${issueHeader}>${knownIssues.get(issue)}</${issueHeader}>`
  4110. );
  4111. content.push('<ul>');
  4112. for (const subject of subjects) {
  4113. content.push(`<li>${subject}</li>`);
  4114. }
  4115. content.push('</ul>');
  4116. }
  4117. }
  4118.  
  4119. const tab = {
  4120. name: 'News',
  4121. content: content.join('\n'),
  4122. };
  4123.  
  4124. this.logger.leaving(me);
  4125. return tab;
  4126. }
  4127.  
  4128. /** @inheritdoc */
  4129. licenseTab() {
  4130. const me = 'licenseTab';
  4131. this.logger.entered(me);
  4132.  
  4133. const {name, url} = this.licenseData;
  4134. const tab = {
  4135. name: 'License',
  4136. content: `<p><a href="${url}">${name}</a></p>`,
  4137. };
  4138.  
  4139. this.logger.leaving(me, tab);
  4140. return tab;
  4141. }
  4142.  
  4143. #useOriginalInfoDialog = !NH.xunit.testing.enabled;
  4144.  
  4145. /** Hang out until enough HTML has been built to be useful. */
  4146. #waitUntilPageLoadedEnough = async () => {
  4147. const me = 'waitOnPageLoadedEnough';
  4148. this.logger.entered(me);
  4149.  
  4150. this.#navbar = await NH.web.waitForSelector('#global-nav', 0);
  4151. this.#finishConstruction();
  4152.  
  4153. this.logger.leaving(me);
  4154. }
  4155.  
  4156. /** Do the bits that were waiting on the page. */
  4157. #finishConstruction = () => {
  4158. const me = 'finishConstruction';
  4159. this.logger.entered(me);
  4160.  
  4161. this.#createInfoWidget();
  4162. this.#addInfoTabs();
  4163. this.#addLitStyle();
  4164. this.#addToolMenuItem();
  4165. this.#setNavBarInfo();
  4166.  
  4167. this.logger.leaving(me);
  4168. }
  4169.  
  4170. /**
  4171. * Lazily load license text when exposed.
  4172. * @param {Event} evt - The 'expose' event.
  4173. */
  4174. #licenseHandler = async (evt) => {
  4175. const me = 'licenseHandler';
  4176. this.logger.entered(me, evt.target);
  4177.  
  4178. // Probably should debounce this. If the user visits this tab twice
  4179. // fast enough, they end up with two copies loaded. Amusing, but
  4180. // probably should be resilient.
  4181. if (!this.#licenseLoaded) {
  4182. const info = document.createElement('p');
  4183. info.innerHTML = '<i>Loading license...</i>';
  4184. evt.target.append(info);
  4185. const {name, url} = this.licenseData;
  4186.  
  4187. const response = await fetch(url);
  4188. if (response.ok) {
  4189. const license = document.createElement('iframe');
  4190. license.style.flexGrow = 1;
  4191. license.title = name;
  4192. license.sandbox = '';
  4193. license.srcdoc = await response.text();
  4194. info.replaceWith(license);
  4195. this.#licenseLoaded = true;
  4196. }
  4197. }
  4198.  
  4199. this.logger.leaving(me);
  4200. }
  4201.  
  4202. #createInfoWidget = () => {
  4203. this.#infoWidget = new NH.widget.Info('LinkedIn Tool');
  4204. const widget = this.#infoWidget.container;
  4205. widget.classList.add('lit-info');
  4206. document.body.prepend(widget);
  4207. const dismissId = NH.base.safeId(`${widget.id}-dismiss`);
  4208.  
  4209. const name = this.#infoName(dismissId);
  4210. const instructions = this.#infoInstructions();
  4211.  
  4212. widget.append(name, instructions);
  4213.  
  4214. document.getElementById(dismissId).addEventListener('click', () => {
  4215. this.#infoWidget.close();
  4216. });
  4217.  
  4218. this.#infoKeyboard = new VM.shortcut.KeyboardService();
  4219. widget.addEventListener('open', this.#onOpenInfo);
  4220. widget.addEventListener('close', this.#onCloseInfo);
  4221. }
  4222.  
  4223. /**
  4224. * @param {string} dismissId - Element #id to give dismiss button.
  4225. * @returns {Element} - For the info widget name header.
  4226. */
  4227. #infoName = (dismissId) => {
  4228. const name = document.createElement('div');
  4229. name.classList.add('lit-justify');
  4230. const title = `<b>${GM.info.script.name}</b> - ` +
  4231. `v${GM.info.script.version}`;
  4232. const dismiss = `<button id=${dismissId}>X</button>`;
  4233. name.innerHTML = `<span>${title}</span><span>${dismiss}</span>`;
  4234.  
  4235. return name;
  4236. }
  4237.  
  4238. /** @returns {Element} - Instructions for navigating the info widget. */
  4239. #infoInstructions = () => {
  4240. const instructions = document.createElement('div');
  4241. instructions.classList.add('lit-justify');
  4242. instructions.classList.add('lit-instructions');
  4243. const left = VMKeyboardService.parseSeq('c-left');
  4244. const right = VMKeyboardService.parseSeq('c-right');
  4245. const esc = VMKeyboardService.parseSeq('esc');
  4246. instructions.innerHTML =
  4247. `<span>Use the ${left} and ${right} keys or click to select ` +
  4248. 'tab</span>' +
  4249. `<span>Hit ${esc} to close</span>`;
  4250.  
  4251. return instructions;
  4252. }
  4253.  
  4254. #onOpenInfo = () => {
  4255. VMKeyboardService.setKeyboardContext('inDialog', true);
  4256. this.#infoKeyboard.enable();
  4257. this.#buildShortcutsInfo();
  4258. this.logger.log('info opened');
  4259. }
  4260.  
  4261. #onCloseInfo = () => {
  4262. this.#infoKeyboard.disable();
  4263. VMKeyboardService.setKeyboardContext('inDialog', false);
  4264. this.logger.log('info closed');
  4265. }
  4266.  
  4267. /** Create CSS styles for stuff specific to LinkedIn Tool. */
  4268. #addLitStyle = () => { // eslint-disable-line max-lines-per-function
  4269. const style = document.createElement('style');
  4270. style.id = `${this.id}-style`;
  4271. const styles = [
  4272. '.lit-info:modal {' +
  4273. ' height: 100%;' +
  4274. ' width: 65rem;' +
  4275. ' display: flex;' +
  4276. ' flex-direction: column;' +
  4277. '}',
  4278. '.lit-info button {' +
  4279. ' border-width: 1px;' +
  4280. ' border-style: solid;' +
  4281. ' border-radius: 1em;' +
  4282. ' padding: 3px;' +
  4283. '}',
  4284. '.lit-news {' +
  4285. ' position: absolute;' +
  4286. ' bottom: 14px;' +
  4287. ' right: -5px;' +
  4288. ' width: 16px;' +
  4289. ' height: 16px;' +
  4290. ' border-radius: 50%;' +
  4291. ' border: 5px solid green;' +
  4292. '}',
  4293. '.lit-justify {' +
  4294. ' display: flex;' +
  4295. ' flex-direction: row;' +
  4296. ' justify-content: space-between;' +
  4297. '}',
  4298. '.lit-instructions {' +
  4299. ' padding-bottom: 1ex;' +
  4300. ' border-bottom: 1px solid black;' +
  4301. ' margin-bottom: 5px;' +
  4302. '}',
  4303. '.lit-info kbd > kbd {' +
  4304. ' font-size: 0.85em;' +
  4305. ' padding: 0.07em;' +
  4306. ' border-width: 1px;' +
  4307. ' border-style: solid;' +
  4308. '}',
  4309. '.lit-info th { text-align: left; }',
  4310. '.lit-info td:first-child {' +
  4311. ' white-space: nowrap;' +
  4312. ' text-align: right;' +
  4313. ' padding-right: 0.5em;' +
  4314. '}',
  4315. ];
  4316. style.textContent = styles.join('\n');
  4317. document.head.prepend(style);
  4318. }
  4319.  
  4320. #addInfoTabs = () => {
  4321. const me = 'addInfoTabs';
  4322. this.logger.entered(me);
  4323.  
  4324. const tabs = [
  4325. this.#shortcutsTab(),
  4326. this.docTab(),
  4327. this.newsTab(),
  4328. ];
  4329.  
  4330. this.#infoTabs = new TabbedUI('LinkedIn Tool');
  4331.  
  4332. for (const tab of tabs) {
  4333. this.#infoTabs.addTab(tab);
  4334. }
  4335. this.#infoTabs.goto(tabs[0].name);
  4336.  
  4337. this.#infoWidget.container.append(this.#infoTabs.container);
  4338.  
  4339. this.#infoKeyboard.register('c-right', this.#nextTab);
  4340. this.#infoKeyboard.register('c-left', this.#prevTab);
  4341.  
  4342. this.logger.leaving(me);
  4343. }
  4344.  
  4345. #nextTab = () => {
  4346. this.#infoTabs.next();
  4347. }
  4348.  
  4349. #prevTab = () => {
  4350. this.#infoTabs.prev();
  4351. }
  4352.  
  4353. /** Add a menu item to the global nav bar. */
  4354. #addToolMenuItem = () => {
  4355. const me = 'addToolMenuItem';
  4356. this.logger.entered(me);
  4357.  
  4358. const ul = document.querySelector('ul.global-nav__primary-items');
  4359. const li = document.createElement('li');
  4360. li.classList.add('global-nav__primary-item');
  4361. li.innerHTML =
  4362. '<button id="lit-nav-button" class="global-nav__primary-link">' +
  4363. ' <div class="global-nav__primary-link-notif ' +
  4364. 'artdeco-notification-badge">' +
  4365. ' <div class="notification-badge">' +
  4366. ' <span class="notification-badge__count"></span>' +
  4367. ' </div>' +
  4368. ` <div>${LinkedIn.#icon}</div>` +
  4369. ' <span class="lit-news-badge">TBD</span>' +
  4370. ' <span class="t-12 global-nav__primary-link-text">Tool</span>' +
  4371. ' </div>' +
  4372. '</button>';
  4373. const navMe = ul.querySelector('li .global-nav__me').closest('li');
  4374. if (navMe) {
  4375. navMe.after(li);
  4376. } else {
  4377. // If the site changed and we cannot insert ourself after the Me menu
  4378. // item, then go first.
  4379. ul.prepend(li);
  4380. this.addSetupIssue(
  4381. 'Unable to find the Profile navbar item.',
  4382. 'LIT menu installed in non-standard location.'
  4383. );
  4384. }
  4385. const button = li.querySelector('button');
  4386. button.addEventListener('click', () => {
  4387. if (this.#useOriginalInfoDialog) {
  4388. const info = document.querySelector(`#${this.infoId}`);
  4389. info.showModal();
  4390. info.dispatchEvent(new Event('open'));
  4391. } else {
  4392. this.#infoWidget.open();
  4393. }
  4394. if (NH.xunit.testing.enabled) {
  4395. this.#useOriginalInfoDialog = !this.#useOriginalInfoDialog;
  4396. }
  4397. });
  4398. this.logger.leaving(me);
  4399. }
  4400.  
  4401. /** Set some useful global variables. */
  4402. #setNavBarInfo = () => {
  4403. const fudgeFactor = 4;
  4404.  
  4405. this.#globals.navBarHeightPixels = this.#navbar.clientHeight +
  4406. fudgeFactor;
  4407. }
  4408.  
  4409. /**
  4410. * @returns {TabbedUI~TabDefinition} - Keyboard shortcuts listing.
  4411. */
  4412. #shortcutsTab = () => {
  4413. this.#shortcutsWidget = new AccordionTableWidget('Shortcuts');
  4414.  
  4415. const tab = {
  4416. name: 'Keyboard Shortcuts',
  4417. content: this.#shortcutsWidget.container,
  4418. };
  4419. return tab;
  4420. }
  4421.  
  4422. #buildShortcutsInfo = () => {
  4423. const me = 'buildShortcutsInfo';
  4424. this.logger.entered(me);
  4425.  
  4426. this.#shortcutsWidget.clear();
  4427. for (const service of VMKeyboardService.services) {
  4428. this.logger.log('service:', service.shortName, service.active);
  4429. // Works in progress may not have any shortcuts yet.
  4430. if (service.shortcuts.length) {
  4431. const name = NH.base.simpleParseWords(service.shortName).join(' ');
  4432. this.#shortcutsWidget.addSection(service.shortName);
  4433. this.#shortcutsWidget.addHeader(service.active, name);
  4434. for (const shortcut of service.shortcuts) {
  4435. this.logger.log('shortcut:', shortcut);
  4436. this.#shortcutsWidget.addData(
  4437. `${VMKeyboardService.parseSeq(shortcut.seq)}:`, shortcut.desc
  4438. );
  4439. }
  4440. }
  4441. }
  4442.  
  4443. this.logger.leaving(me);
  4444. }
  4445.  
  4446. static #knownIssues = [
  4447. ['Bob', 'Bob has no issues'],
  4448. ['', 'Minor internal improvement'],
  4449. ['#106', 'info view: more tabs: News, License'],
  4450. ['#130', 'Factor hotkey handling out of SPA'],
  4451. ['#140', 'Self registering keyboard shortcuts'],
  4452. ['#142', 'Support My Network view'],
  4453. ['#143', 'Support jobs search view'],
  4454. ['#144', 'Support Messaging view'],
  4455. ['#145', 'Logger: improved controls'],
  4456. ['#149', 'Scroller: support onclick natively'],
  4457. ['#150', 'Scroller: handle page reloads better'],
  4458. ['#151', 'MyNetwork: card scrolling does not work after return'],
  4459. ['#153', 'Support Invitation manager view'],
  4460. [
  4461. '#154', 'Notifications: <kbd><kbd>X</kbd></kbd> no longer works to ' +
  4462. 'dismiss',
  4463. ],
  4464. [
  4465. '#155', 'Jobs: The <i>More jobs few you</i> section no longer ' +
  4466. 'navigates jobs',
  4467. ],
  4468. [
  4469. '#157', 'InvitationManager: Invite not scrolling into view upon ' +
  4470. 'refresh',
  4471. ],
  4472. [
  4473. '#159', 'MyNetwork: Trying to <kbd><kbd>E</kbd></kbd>ngage does ' +
  4474. 'not always work',
  4475. ],
  4476. ['#165', 'Scroller: Wait until base shows up'],
  4477. ['#167', 'Refactor into libraries'],
  4478. [
  4479. '#168', 'JobCollections: <kbd><kbd>X</kbd></kbd> will not recover ' +
  4480. 'a dismissed job card',
  4481. ],
  4482. [
  4483. '#178', 'Migrate all Scrollers from <i>_refresh()</i> to ' +
  4484. '<i>#onScrollerActivate</i>',
  4485. ],
  4486. ];
  4487.  
  4488. static #newsContent = [
  4489. {
  4490. date: '2023-11-03',
  4491. issues: ['#130', '#180'],
  4492. subject: 'Bump lib/base to version 22',
  4493. },
  4494. {
  4495. date: '2023-11-02',
  4496. issues: ['#178'],
  4497. subject: 'Migrate <i>Notifications</i> from <i>refresh</i> ' +
  4498. 'to <i>onScrollerActivate</i>',
  4499. },
  4500. {
  4501. date: '2023-11-01',
  4502. issues: ['#178'],
  4503. subject: 'Migrate <i>InvitationManager</i> from <i>refresh</i> ' +
  4504. 'to <i>onScrollerActivate</i>',
  4505. },
  4506. {
  4507. date: '2023-11-01',
  4508. issues: ['#144'],
  4509. subject: 'Implement <kbd><kbd>s</kbd></kbd> to focus on the ' +
  4510. '<i>Search messages</i> input field',
  4511. },
  4512. {
  4513. date: '2023-10-30',
  4514. issues: ['#167'],
  4515. subject: 'Depend on lib/widget version 0',
  4516. },
  4517. {
  4518. date: '2023-10-30',
  4519. issues: ['#144'],
  4520. subject: 'Implement <kbd><kbd>N</kbd></kbd> to compose a New message',
  4521. },
  4522. {
  4523. date: '2023-10-30',
  4524. issues: ['#178'],
  4525. subject: 'Migrate <i>Feed</i> from using <i>refresh</i> to ' +
  4526. '<i>onScrollerActivate</i>',
  4527. },
  4528. {
  4529. date: '2023-10-30',
  4530. issues: ['#144'],
  4531. subject: 'Implement <kbd><kbd>l</kbd></kbd> to load more ' +
  4532. 'conversations',
  4533. },
  4534. {
  4535. date: '2023-10-27',
  4536. issues: ['#165'],
  4537. subject: 'Switch <i>MyNetwork</i> to using <i>containerItems</i> ' +
  4538. 'for primary <i>Scroller</i>',
  4539. },
  4540. {
  4541. date: '2023-10-27',
  4542. issues: ['#165'],
  4543. subject: 'Switch <i>Feed</i> to using <i>containerItems</i> ' +
  4544. 'primary <i>Scroller</i>',
  4545. },
  4546. {
  4547. date: '2023-10-22',
  4548. issues: ['#144'],
  4549. subject: 'Initial support for the <i>Messaging</i> page',
  4550. },
  4551. {
  4552. date: '2023-10-20',
  4553. issues: ['#106'],
  4554. subject: 'Basic <b>News</b> tab',
  4555. },
  4556. {
  4557. date: '2023-10-19',
  4558. issues: ['#167'],
  4559. subject: 'Bump lib/base to version 9, switch to that ' +
  4560. '<i>Dispatcher</i>',
  4561. },
  4562. {
  4563. date: '2023-10-18',
  4564. issues: ['#167'],
  4565. subject: 'Bump lib/base to version 8, for new features',
  4566. },
  4567. {
  4568. date: '2023-10-17',
  4569. issues: ['#167'],
  4570. subject: 'Bump lib/base to version 6, switch to that <i>uuId</i>, ' +
  4571. '<i>safeId</i>, and <i>strHash</i>',
  4572. },
  4573. {
  4574. date: '2023-10-16',
  4575. issues: ['#167'],
  4576. subject: 'Bump lib/base to version 5, switch to that <i>Logger</i>',
  4577. },
  4578. {
  4579. date: '2023-10-15',
  4580. issues: ['#168'],
  4581. subject: 'Implement <kbd><kbd>+</kbd></kbd> and ' +
  4582. '<kbd><kbd>-</kbd></kbd> for thumbs-up and thumbs-down on job ' +
  4583. 'cards',
  4584. },
  4585. {
  4586. date: '2023-10-15',
  4587. issues: ['#168'],
  4588. subject: 'Make a CSS selector case insensitive',
  4589. },
  4590. {
  4591. date: '2023-10-14',
  4592. issues: ['#167'],
  4593. subject: 'Bump lib/base to version 3, switch to that ' +
  4594. '<i>DefaultMap</i>',
  4595. },
  4596. {
  4597. date: '2023-10-13',
  4598. issues: ['#167'],
  4599. subject: 'Bump lib/base to version 2, still learning the ropes',
  4600. },
  4601. {
  4602. date: '2023-10-12',
  4603. issues: ['#167'],
  4604. subject: 'Include libraries and version numbers in the ' +
  4605. '<b>Errors</b> tab',
  4606. },
  4607. {
  4608. date: '2023-10-12',
  4609. issues: ['#167'],
  4610. subject: 'First use of the new base library',
  4611. },
  4612. {
  4613. date: '2023-10-12',
  4614. issues: ['#150'],
  4615. subject: 'Rip out <i>Jobs</i> Rube Goldberg device for monitoring ' +
  4616. 'changes',
  4617. },
  4618. {
  4619. date: '2023-10-11',
  4620. issues: ['#145'],
  4621. subject: 'Plug in loading saved <i>Logger</i> configs (dev only)',
  4622. },
  4623. {
  4624. date: '2023-10-11',
  4625. issues: ['#145'],
  4626. subject: 'Implement setting configs from an object',
  4627. },
  4628. {
  4629. date: '2023-10-11',
  4630. issues: ['#145'],
  4631. subject: 'Remove explicit setting of <i>Logger</i>s for debug ' +
  4632. 'reasons',
  4633. },
  4634. {
  4635. date: '2023-10-11',
  4636. issues: ['#145'],
  4637. subject: 'Initial implementation of saving <i>Logger</i> config ' +
  4638. '(dev only)',
  4639. },
  4640. {
  4641. date: '2023-10-10',
  4642. issues: ['#149'],
  4643. subject: 'Migrate all secondary <i>Scroller</i>s to using ' +
  4644. '<i>autoActivate</i>',
  4645. },
  4646. {
  4647. date: '2023-10-10',
  4648. issues: ['#149'],
  4649. subject: 'Give <i>Scroller</i> the option to call <i>activate</i> ' +
  4650. 'upon construction',
  4651. },
  4652. {
  4653. date: '2023-10-10',
  4654. issues: ['#149'],
  4655. subject: 'Have all secondary <i>Scroller</i>s created immediately',
  4656. },
  4657. {
  4658. date: '2023-10-09',
  4659. issues: ['#143'],
  4660. subject: 'Initial work selecting job card on initial page load',
  4661. },
  4662. {
  4663. date: '2023-10-09',
  4664. issues: ['#143'],
  4665. subject: 'Change the job card <i>Scroller</i> to use a different ' +
  4666. 'element',
  4667. },
  4668. {
  4669. date: '2023-10-09',
  4670. issues: ['#143'],
  4671. subject: 'Give the job cards a bottom scroll margin as well',
  4672. },
  4673. {
  4674. date: '2023-10-09',
  4675. issues: ['#143'],
  4676. subject: 'Change hotkey from <kbd><kbd>Enter</kbd></kbd> to ' +
  4677. '<kbd>c</kbd>',
  4678. },
  4679. {
  4680. date: '2023-10-09',
  4681. issues: ['#143'],
  4682. subject: 'Give pagination <i>Scroller</i> a bit of a buffer on the ' +
  4683. 'bottom',
  4684. },
  4685. {
  4686. date: '2023-10-09',
  4687. issues: ['#143'],
  4688. subject: 'Allow pagination <i>Scroller</i> to get focus',
  4689. },
  4690. {
  4691. date: '2023-10-09',
  4692. issues: ['#143'],
  4693. subject: 'Change the pagination <i>Scroller</i> to use a different ' +
  4694. 'element',
  4695. },
  4696. {
  4697. date: '2023-10-08',
  4698. issues: ['#143'],
  4699. subject: 'Implement toggle the job search ' +
  4700. 'a<kbd><kbd>L</kbd></kbd>ert',
  4701. },
  4702. {
  4703. date: '2023-10-08',
  4704. issues: ['#143'],
  4705. subject: 'Implement opening the job <kbd><kbd>s</kbd></kbd>hare menu',
  4706. },
  4707. {
  4708. date: '2023-10-08',
  4709. issues: ['#143'],
  4710. subject: 'Implement <kbd><kbd>A</kbd></kbd>pply to job',
  4711. },
  4712. {
  4713. date: '2023-10-08',
  4714. issues: ['#143'],
  4715. subject: 'Implement toggle to <kbd><kbd>F</kbd></kbd>ollow the ' +
  4716. 'company',
  4717. },
  4718. {
  4719. date: '2023-10-08',
  4720. issues: ['#143'],
  4721. subject: 'Implement <kbd><kbd>=</kbd></kbd> to open the job ' +
  4722. 'details menu',
  4723. },
  4724. {
  4725. date: '2023-10-08',
  4726. issues: ['#143'],
  4727. subject: 'Update focusing on the details pane',
  4728. },
  4729. {
  4730. date: '2023-10-08',
  4731. issues: ['#143'],
  4732. subject: 'Explicitly set <i>snapToTop</i> false',
  4733. },
  4734. {
  4735. date: '2023-10-08',
  4736. issues: ['#143'],
  4737. subject: 'Implement <kbd><kbd>S</kbd></kbd>ave job toggle',
  4738. },
  4739. {
  4740. date: '2023-10-08',
  4741. issues: ['#149'],
  4742. subject: 'Remove all click handling from <i>Page</i> and subclasses',
  4743. },
  4744. {
  4745. date: '2023-10-08',
  4746. issues: ['#149'],
  4747. subject: 'Enable click handling by default in <i>Scroller</i>',
  4748. },
  4749. {
  4750. date: '2023-10-08',
  4751. issues: ['#143'],
  4752. subject: 'Use <kbd><kbd>X</kbd></kbd> to toggle dismissing job',
  4753. },
  4754. {
  4755. date: '2023-10-07',
  4756. issues: ['#143'],
  4757. subject: 'Use <kbd><kbd>d</kbd></kbd> to jump to the job details ' +
  4758. 'pane',
  4759. },
  4760. {
  4761. date: '2023-10-07',
  4762. issues: ['#149'],
  4763. subject: 'Switch <i>Feed</i> to use the new <i>Scroller</i> click ' +
  4764. 'handler',
  4765. },
  4766. {
  4767. date: '2023-10-07',
  4768. issues: ['#143'],
  4769. subject: 'Support the <i>/jobs/{collections,search}/</i> pages',
  4770. },
  4771. {
  4772. date: '2023-10-06',
  4773. issues: [''],
  4774. subject: 'Rename <i>Information</i> tab to <i>About</i>',
  4775. },
  4776. {
  4777. date: '2023-10-06',
  4778. issues: ['#159'],
  4779. subject: 'The <i>MyNetwork</i> cards need more selectors',
  4780. },
  4781. {
  4782. date: '2023-10-06',
  4783. issues: ['#142'],
  4784. subject: 'Change <i>MyNetwork</i> card viewing from ' +
  4785. '<kbd><kbd>v</kbd></kbd> to <kbd><kbd>Enter</kbd></kbd>',
  4786. },
  4787. {
  4788. date: '2023-10-05',
  4789. issues: ['#157'],
  4790. subject: 'Enable some logging while working on this issue',
  4791. },
  4792. {
  4793. date: '2023-10-05',
  4794. issues: ['#157'],
  4795. subject: 'Additional logging',
  4796. },
  4797. {
  4798. date: '2023-10-04',
  4799. issues: ['#153'],
  4800. subject: 'Track current invite so it can be found again later',
  4801. },
  4802. {
  4803. date: '2023-10-04',
  4804. issues: ['#140'],
  4805. subject: 'Migrate <i>Notifications</i> to self-registering shortcuts',
  4806. },
  4807. ];
  4808.  
  4809. #globals
  4810. #infoId
  4811. #infoKeyboard
  4812. #infoTabs
  4813. #infoWidget
  4814. #licenseData
  4815. #licenseLoaded
  4816. #navbar
  4817. #shortcutsWidget
  4818.  
  4819. /** @returns {obj} - dates and known issues. */
  4820. #preprocessKnownIssues = () => {
  4821. const knownIssues = new Map(LinkedIn.#knownIssues);
  4822. const unknownIssues = new Set();
  4823. const unusedIssues = new Set(knownIssues.keys());
  4824.  
  4825. const dates = new NH.base.DefaultMap(
  4826. () => new NH.base.DefaultMap(Array)
  4827. );
  4828.  
  4829. for (const item of LinkedIn.#newsContent) {
  4830. for (const issue of item.issues) {
  4831. if (knownIssues.has(issue)) {
  4832. unusedIssues.delete(issue);
  4833. dates.get(item.date).get(issue)
  4834. .push(item.subject);
  4835. } else {
  4836. unknownIssues.add(issue);
  4837. }
  4838. }
  4839. }
  4840.  
  4841. this.logger.log('unknown', unknownIssues);
  4842. this.logger.log('unused', unusedIssues);
  4843.  
  4844. if (unknownIssues.size) {
  4845. throw new Error('Unknown issues were detected.');
  4846. }
  4847.  
  4848. return {
  4849. dates: dates,
  4850. knownIssues: knownIssues,
  4851. };
  4852. }
  4853.  
  4854. }
  4855.  
  4856. /**
  4857. * A userscript driver for working with a single-page application.
  4858. *
  4859. * Generally, a single instance of this class is created, and all instances
  4860. * of {Page} are registered to it. As the user navigates through the
  4861. * single-page application, this will react to it and enable and disable
  4862. * view specific handling as appropriate.
  4863. */
  4864. class SPA {
  4865.  
  4866. /** @param {SPADetails} details - Implementation specific details. */
  4867. constructor(details) {
  4868. this.#name = `${this.constructor.name}: ${details.constructor.name}`;
  4869. this.#id = NH.base.safeId(NH.base.uuId(this.#name));
  4870. this.#logger = new NH.base.Logger(this.#name);
  4871. this.#details = details;
  4872. this.#details.init(this);
  4873. this._installNavStyle();
  4874. this._initializeInfoView();
  4875. for (const issue of details.setupIssues) {
  4876. this.logger.log('issue:', issue);
  4877. for (const error of issue) {
  4878. this.addError(error);
  4879. }
  4880. this.addErrorMarker();
  4881. }
  4882. document.addEventListener('focus', this._onFocus, true);
  4883. document.addEventListener('urlchange', this.#onUrlChange, true);
  4884. this.#startUrlMonitor();
  4885. this.#details.done();
  4886. }
  4887.  
  4888. static _errorMarker = '---';
  4889.  
  4890. /**
  4891. * @implements {TabGenerator}
  4892. * @returns {TabbedUI~TabDefinition} - Initial table for the keyboard
  4893. * shortcuts.
  4894. */
  4895. static _shortcutsTab() {
  4896. return {
  4897. name: 'Keyboard shortcuts',
  4898. content: '<table data-spa-id="shortcuts"><tbody></tbody></table>',
  4899. };
  4900. }
  4901.  
  4902. /**
  4903. * Generate information about the current environment useful in bug
  4904. * reports.
  4905. * @returns {string} - Text with some wrapped in a `pre` element.
  4906. */
  4907. static _errorPlatformInfo() {
  4908. const header = 'Please consider including some of the following ' +
  4909. 'information in any bug report:';
  4910.  
  4911. const msgs = NH.userscript.environmentData();
  4912.  
  4913. return `${header}<pre>${msgs.join('\n')}</pre>`;
  4914. }
  4915.  
  4916. /**
  4917. * @implements {TabGenerator}
  4918. * @returns {TabbedUI~TabDefinition} - Initial placeholder for error
  4919. * logging.
  4920. */
  4921. static _errorTab() {
  4922. return {
  4923. name: 'Errors',
  4924. content: [
  4925. '<p>Any information in the text box below could be helpful in ' +
  4926. 'fixing a bug.</p>',
  4927. '<p>The content can be edited and then included in a bug ' +
  4928. 'report. Different errors should be separated by ' +
  4929. `"${SPA._errorMarker}".</p>`,
  4930. '<p><b>Please remove any identifying information before ' +
  4931. 'including it in a bug report!</b></p>',
  4932. SPA._errorPlatformInfo(),
  4933. '<textarea data-spa-id="errors" spellcheck="false" ' +
  4934. 'placeholder="No errors logged yet."></textarea>',
  4935. ].join(''),
  4936. };
  4937. }
  4938.  
  4939. #details
  4940. #id
  4941. #logger
  4942. #name
  4943. #oldUrl
  4944.  
  4945. /** @type {Set<Page>} - Currently active {Page}s. */
  4946. #activePages = new Set();
  4947.  
  4948. /** @type {Set<Page>} - Registered {Page}s. */
  4949. #pages = new Set();
  4950.  
  4951. /** @type {Element} - The most recent element to receive focus. */
  4952. _lastInputElement = null;
  4953.  
  4954. /** @type {KeyboardService} */
  4955. _tabUiKeyboard = null;
  4956.  
  4957. /** @type {SPADetails} */
  4958. get details() {
  4959. return this.#details;
  4960. }
  4961.  
  4962. /** @type {NH.base.Logger} */
  4963. get logger() {
  4964. return this.#logger;
  4965. }
  4966.  
  4967. /**
  4968. * Tampermonkey was the first(?) userscript manager to provide events
  4969. * about URLs changing. Hence the need for `@grant window.onurlchange` in
  4970. * the UserScript header.
  4971. * @fires Event#urlchange
  4972. */
  4973. #startUserscriptManagerUrlMonitor = () => {
  4974. this.logger.log('Using Userscript Manager provided URL monitor.');
  4975. window.addEventListener('urlchange', (info) => {
  4976. // The info that TM gives is not really an event. So we turn it into
  4977. // one and throw it again, this time onto `document` where something
  4978. // is listening for it.
  4979. const newUrl = new URL(info.url);
  4980. const evt = new CustomEvent('urlchange', {detail: {url: newUrl}});
  4981. document.dispatchEvent(evt);
  4982. });
  4983. }
  4984.  
  4985. /**
  4986. * Install a long lived MutationObserver that watches
  4987. * {SPADetails.urlChangeMonitorSelector}. Whenever it is triggered, it
  4988. * will check to see if the current URL has changed, and if so, send an
  4989. * appropriate event.
  4990. * @fires Event#urlchange
  4991. */
  4992. #startMutationObserverUrlMonitor = async () => {
  4993. this.logger.log('Using MutationObserver for monitoring URL changes.');
  4994.  
  4995. const observeOptions = {childList: true, subtree: true};
  4996.  
  4997. const element = await NH.web.waitForSelector(
  4998. this.#details.urlChangeMonitorSelector, 0
  4999. );
  5000. this.logger.log('element exists:', element);
  5001.  
  5002. this.#oldUrl = new URL(window.location);
  5003. new MutationObserver(() => {
  5004. const newUrl = new URL(window.location);
  5005. if (this.#oldUrl.href !== newUrl.href) {
  5006. const evt = new CustomEvent('urlchange', {detail: {url: newUrl}});
  5007. this.#oldUrl = newUrl;
  5008. document.dispatchEvent(evt);
  5009. }
  5010. }).observe(element, observeOptions);
  5011. }
  5012.  
  5013. /** Select which way to monitor the URL for changes and start it. */
  5014. #startUrlMonitor = () => {
  5015. if (window.onurlchange === null) {
  5016. this.#startUserscriptManagerUrlMonitor();
  5017. } else {
  5018. this.#startMutationObserverUrlMonitor();
  5019. }
  5020. }
  5021.  
  5022. /**
  5023. * Set the context (used by VM.shortcut) to a specific value.
  5024. * @param {string} context - The name of the context.
  5025. * @param {object} state - What the value should be.
  5026. */
  5027. _setKeyboardContext(context, state) {
  5028. const pages = Array.from(this.#pages.values());
  5029. for (const page of pages) {
  5030. page.keyboard.setContext(context, state);
  5031. }
  5032. }
  5033.  
  5034. /**
  5035. * Handle focus events to track whether we have gone into or left an area
  5036. * where we want to disable hotkeys.
  5037. * @param {Event} evt - Standard 'focus' event.
  5038. */
  5039. _onFocus = (evt) => {
  5040. if (this._lastInputElement && evt.target !== this._lastInputElement) {
  5041. this._lastInputElement = null;
  5042. this._setKeyboardContext('inputFocus', false);
  5043. }
  5044. if (NH.web.isInput(evt.target)) {
  5045. this._setKeyboardContext('inputFocus', true);
  5046. this._lastInputElement = evt.target;
  5047. }
  5048. }
  5049.  
  5050. /**
  5051. * Handle urlchange events that indicate a switch to a new page.
  5052. * @param {CustomEvent} evt - Custom 'urlchange' event.
  5053. */
  5054. #onUrlChange = (evt) => {
  5055. this.activate(evt.detail.url.pathname);
  5056. }
  5057.  
  5058. /** Configure handlers for the info view. */
  5059. _addInfoViewHandlers() {
  5060. const errors = document.querySelector(
  5061. `#${this._infoId} [data-spa-id="errors"]`
  5062. );
  5063. errors.addEventListener('change', (evt) => {
  5064. const count = evt.target.value.split('\n')
  5065. .filter(x => x === SPA._errorMarker).length;
  5066. this.#details.dispatcher.fire('errors', count);
  5067. this._updateInfoErrorsLabel(count);
  5068. });
  5069. }
  5070.  
  5071. /** Create the CSS styles used for indicating the current items. */
  5072. _installNavStyle() {
  5073. const style = document.createElement('style');
  5074. style.id = NH.base.safeId(`${this.#id}-nav-style`);
  5075. const styles = [
  5076. '.tom {' +
  5077. ' border-color: orange !important;' +
  5078. ' border-style: solid !important;' +
  5079. ' border-width: medium !important;' +
  5080. '}',
  5081. '.dick {' +
  5082. ' border-color: red !important;' +
  5083. ' border-style: solid !important;' +
  5084. ' border-width: thin !important;' +
  5085. '}',
  5086. '',
  5087. ];
  5088. style.textContent = styles.join('\n');
  5089. document.head.append(style);
  5090. }
  5091.  
  5092. /**
  5093. * Create and configure a separate {@link KeyboardService} for the info
  5094. * view.
  5095. */
  5096. _initializeTabUiKeyboard() {
  5097. this._tabUiKeyboard = new VM.shortcut.KeyboardService();
  5098. this._tabUiKeyboard.register('c-right', this._nextTab);
  5099. this._tabUiKeyboard.register('c-left', this._prevTab);
  5100. }
  5101.  
  5102. /**
  5103. * @callback TabGenerator
  5104. * @returns {TabbedUI~TabDefinition}
  5105. */
  5106.  
  5107. /** Add CSS styling for use with the info view. */
  5108. _addInfoStyle() { // eslint-disable-line max-lines-per-function
  5109. const style = document.createElement('style');
  5110. style.id = NH.base.safeId(`${this.#id}-info-style`);
  5111. const styles = [
  5112. `#${this._infoId}:modal {` +
  5113. ' height: 100%;' +
  5114. ' width: 65rem;' +
  5115. ' display: flex;' +
  5116. ' flex-direction: column;' +
  5117. '}',
  5118. `#${this._infoId} .left { text-align: left; }`,
  5119. `#${this._infoId} .right { text-align: right; }`,
  5120. `#${this._infoId} .spa-instructions {` +
  5121. ' display: flex;' +
  5122. ' flex-direction: row;' +
  5123. ' padding-bottom: 1ex;' +
  5124. ' border-bottom: 1px solid black;' +
  5125. ' margin-bottom: 5px;' +
  5126. '}',
  5127. `#${this._infoId} .spa-instructions > span { flex-grow: 1; }`,
  5128. `#${this._infoId} textarea[data-spa-id="errors"] {` +
  5129. ' flex-grow: 1;' +
  5130. ' resize: none;' +
  5131. '}',
  5132. `#${this._infoId} .spa-danger { background-color: red; }`,
  5133. `#${this._infoId} .spa-current-page { background-color: lightgray; }`,
  5134. `#${this._infoId} kbd > kbd {` +
  5135. ' font-size: 0.85em;' +
  5136. ' padding: 0.07em;' +
  5137. ' border-width: 1px;' +
  5138. ' border-style: solid;' +
  5139. '}',
  5140. `#${this._infoId} p { margin-bottom: 1em; }`,
  5141. `#${this._infoId} th { padding-top: 1em; text-align: left; }`,
  5142. `#${this._infoId} td:first-child {` +
  5143. ' white-space: nowrap;' +
  5144. ' text-align: right;' +
  5145. ' padding-right: 0.5em;' +
  5146. '}',
  5147. // The "color: unset" addresses dimming because these display-only
  5148. // buttons are disabled.
  5149. `#${this._infoId} button {` +
  5150. ' border-width: 1px;' +
  5151. ' border-style: solid;' +
  5152. ' border-radius: 1em;' +
  5153. ' color: unset;' +
  5154. ' padding: 3px;' +
  5155. '}',
  5156. `#${this._infoId} ul {` +
  5157. ' padding-inline: revert !important;' +
  5158. '}',
  5159. `#${this._infoId} button.spa-meatball { border-radius: 50%; }`,
  5160. '',
  5161. ];
  5162. style.textContent = styles.join('\n');
  5163. document.head.prepend(style);
  5164. }
  5165.  
  5166. /**
  5167. * Create the Info dialog and add some static information.
  5168. * @returns {Element} - Initialized dialog.
  5169. */
  5170. _initializeInfoDialog() {
  5171. const dialog = document.createElement('dialog');
  5172. dialog.id = this._infoId;
  5173. const name = document.createElement('div');
  5174. name.innerHTML = `<b>${GM.info.script.name}</b> - ` +
  5175. `v${GM.info.script.version}`;
  5176. const instructions = document.createElement('div');
  5177. instructions.classList.add('spa-instructions');
  5178. const left = VMKeyboardService.parseSeq('c-left');
  5179. const right = VMKeyboardService.parseSeq('c-right');
  5180. const esc = VMKeyboardService.parseSeq('esc');
  5181. instructions.innerHTML =
  5182. `<span class="left">Use the ${left} and ${right} keys or ` +
  5183. 'click to select tab</span>' +
  5184. `<span class="right">Hit ${esc} to close</span>`;
  5185. dialog.append(name, instructions);
  5186. return dialog;
  5187. }
  5188.  
  5189. /**
  5190. * Add basic dialog with an embedded tabbbed ui for the info view.
  5191. * @param {TabbedUI~TabDefinition[]} tabs - Array defining the info tabs.
  5192. */
  5193. _addInfoDialog(tabs) {
  5194. const dialog = this._initializeInfoDialog();
  5195.  
  5196. this._info = new TabbedUI(`${this.#name} Info`);
  5197. for (const tab of tabs) {
  5198. this._info.addTab(tab);
  5199. }
  5200. // Switches to the first tab.
  5201. this._info.goto(tabs[0].name);
  5202.  
  5203. dialog.append(this._info.container);
  5204. document.body.prepend(dialog);
  5205.  
  5206. // Dialogs do not have a real open event. We will fake it.
  5207. dialog.addEventListener('open', () => {
  5208. this._setKeyboardContext('inDialog', true);
  5209. VMKeyboardService.setKeyboardContext('inDialog', true);
  5210. this._tabUiKeyboard.enable();
  5211. for (const {panel} of this._info.tabs.values()) {
  5212. // 0, 0 is good enough
  5213. panel.scrollTo(0, 0);
  5214. }
  5215. });
  5216. dialog.addEventListener('close', () => {
  5217. this._setKeyboardContext('inDialog', false);
  5218. VMKeyboardService.setKeyboardContext('inDialog', false);
  5219. this._tabUiKeyboard.disable();
  5220. });
  5221. }
  5222.  
  5223. /** Set up everything necessary to get the info view going. */
  5224. _initializeInfoView() {
  5225. this._infoId = `info-${this.#id}`;
  5226. this.#details.infoId = this._infoId;
  5227. this._initializeTabUiKeyboard();
  5228.  
  5229. const tabGenerators = [
  5230. SPA._shortcutsTab(),
  5231. this.#details.docTab(),
  5232. this.#details.newsTab(),
  5233. SPA._errorTab(),
  5234. this.#details.licenseTab(),
  5235. ];
  5236.  
  5237. this._addInfoStyle();
  5238. this._addInfoDialog(tabGenerators);
  5239. this.#details.ui = this._info;
  5240. this._addInfoViewHandlers();
  5241. }
  5242.  
  5243. _nextTab = () => {
  5244. this._info.next();
  5245. }
  5246.  
  5247. _prevTab = () => {
  5248. this._info.prev();
  5249. }
  5250.  
  5251. /**
  5252. * Generate a unique id for page views.
  5253. * @param {Page} page - An instance of the Page class.
  5254. * @returns {string} - Unique identifier.
  5255. */
  5256. _pageInfoId(page) {
  5257. return `${this._infoId}-${page.infoHeader}`;
  5258. }
  5259.  
  5260. /**
  5261. * Add shortcut descriptions from the page to the shortcut tab.
  5262. * @param {Page} page - An instance of the Page class.
  5263. */
  5264. _addInfo(page) {
  5265. const shortcuts = document.querySelector(`#${this._infoId} tbody`);
  5266. const section = NH.base.simpleParseWords(page.infoHeader).join(' ');
  5267. const pageId = this._pageInfoId(page);
  5268. let s = `<tr id="${pageId}"><th></th><th>${section}</th></tr>`;
  5269. for (const {seq, desc} of page.allShortcuts) {
  5270. const keys = VMKeyboardService.parseSeq(seq);
  5271. s += `<tr><td>${keys}:</td><td>${desc}</td></tr>`;
  5272. }
  5273. // Don't include works in progress that have no keys yet.
  5274. if (page.allShortcuts.length) {
  5275. shortcuts.innerHTML += s;
  5276. for (const button of shortcuts.querySelectorAll('button')) {
  5277. button.disabled = true;
  5278. }
  5279. }
  5280. }
  5281.  
  5282. /**
  5283. * Update Errors tab label based upon value.
  5284. * @param {number} count - Number of errors currently logged.
  5285. */
  5286. _updateInfoErrorsLabel(count) {
  5287. const me = 'updateInfoErrorsLabel';
  5288. this.logger.entered(me, count);
  5289. const label = this._info.tabs.get('Errors').label;
  5290. if (count) {
  5291. this._info.goto('Errors');
  5292. label.classList.add('spa-danger');
  5293. } else {
  5294. label.classList.remove('spa-danger');
  5295. }
  5296. this.logger.leaving(me);
  5297. }
  5298.  
  5299. /**
  5300. * Get the hot keys tab header element for this page.
  5301. * @param {Page} page - Page to find.
  5302. * @returns {?Element} - Element that acts as the header.
  5303. */
  5304. _pageHeader(page) {
  5305. const me = 'pageHeader';
  5306. this.logger.entered(me, page);
  5307. let element = null;
  5308. if (page) {
  5309. const pageId = this._pageInfoId(page);
  5310. this.logger.log('pageId:', pageId);
  5311. element = document.querySelector(`#${pageId}`);
  5312. }
  5313. this.logger.leaving(me, element);
  5314. return element;
  5315. }
  5316.  
  5317. /**
  5318. * Highlight information about the page in the hot keys tab.
  5319. * @param {Page} page - Page to shine.
  5320. */
  5321. _shine(page) {
  5322. const me = 'shine';
  5323. this.logger.entered(me, page);
  5324. const element = this._pageHeader(page);
  5325. element?.classList.add('spa-current-page');
  5326. this.logger.leaving(me);
  5327. }
  5328.  
  5329. /**
  5330. * Remove highlights from this page in the hot keys tab.
  5331. * @param {Page} page - Page to dull.
  5332. */
  5333. _dull(page) {
  5334. const me = 'dull';
  5335. this.logger.entered(me, page);
  5336. const element = this._pageHeader(page);
  5337. element?.classList.remove('spa-current-page');
  5338. this.logger.leaving(me);
  5339. }
  5340.  
  5341. /**
  5342. * Add content to the Errors tab so the user can use it to file feedback.
  5343. * @param {string} content - Information to add.
  5344. */
  5345. addError(content) {
  5346. const errors = document.querySelector(
  5347. `#${this._infoId} [data-spa-id="errors"]`
  5348. );
  5349. errors.value += `${content}\n`;
  5350.  
  5351. if (content === SPA._errorMarker) {
  5352. const event = new Event('change');
  5353. errors.dispatchEvent(event);
  5354. }
  5355. }
  5356.  
  5357. /**
  5358. * Add a marker to the Errors tab so the user can see where different
  5359. * issues happened.
  5360. */
  5361. addErrorMarker() {
  5362. this.addError(SPA._errorMarker);
  5363. }
  5364.  
  5365. /**
  5366. * Add a new page to those supported by this instance.
  5367. * @param {function(SPA): Page} Klass - A {Page} class to instantiate.
  5368. */
  5369. register(Klass) {
  5370. if (Klass.prototype instanceof Page) {
  5371. const page = new Klass(this);
  5372. page.start();
  5373. this._addInfo(page);
  5374. this.#pages.add(page);
  5375. } else {
  5376. throw new Error(`${Klass.name} is not a Page`);
  5377. }
  5378. }
  5379.  
  5380. /**
  5381. * Dump a bunch of information about an HTML element.
  5382. * @param {Element} element - Element to get information about.
  5383. * @param {string} name - What area this information came from.
  5384. */
  5385. dumpInfoAboutElement(element, name) {
  5386. const msg = `An unsupported ${name} element was discovered:`;
  5387. this.addError(msg);
  5388. this.addError(element.outerHTML);
  5389. this.addErrorMarker();
  5390. }
  5391.  
  5392. /**
  5393. * Determine which page can handle this portion of the URL.
  5394. * @param {string} pathname - A {URL.pathname}.
  5395. * @returns {Set<Page>} - The pages to use.
  5396. */
  5397. _findPages(pathname) {
  5398. const pages = Array.from(this.#pages.values());
  5399. return new Set(pages.filter(page => page.pathname.test(pathname)));
  5400. }
  5401.  
  5402. /**
  5403. * Handle switching from the old page (if any) to the new one.
  5404. * @param {string} pathname - A {URL.pathname}.
  5405. */
  5406. activate(pathname) {
  5407. const pages = this._findPages(pathname);
  5408. const oldPages = new Set(this.#activePages);
  5409. const newPages = new Set(pages);
  5410. for (const page of oldPages) {
  5411. newPages.delete(page);
  5412. }
  5413. for (const page of pages) {
  5414. oldPages.delete(page);
  5415. }
  5416. for (const page of oldPages) {
  5417. page.deactivate();
  5418. this._dull(page);
  5419. }
  5420. for (const page of newPages) {
  5421. page.activate();
  5422. this._shine(page);
  5423. }
  5424. this.#activePages = pages;
  5425. }
  5426.  
  5427. }
  5428.  
  5429. NH.xunit.testing.run();
  5430.  
  5431. const linkedIn = new LinkedIn(linkedInGlobals);
  5432.  
  5433. // Inject some test errors
  5434. if (NH.xunit.testing.enabled) {
  5435. linkedIn.addSetupIssue('This is a dummy test issue.',
  5436. 'It was added because NH.xunit.testing is enabled.');
  5437. linkedIn.addSetupIssue('This is a second issue.',
  5438. 'We just want to make sure things count properly.');
  5439. }
  5440.  
  5441. await linkedIn.ready;
  5442. log.log('proceeding...');
  5443.  
  5444. const spa = new SPA(linkedIn);
  5445. spa.register(Global);
  5446. spa.register(Feed);
  5447. spa.register(MyNetwork);
  5448. spa.register(Messaging);
  5449. spa.register(InvitationManager);
  5450. spa.register(Jobs);
  5451. spa.register(JobCollections);
  5452. spa.register(Notifications);
  5453. spa.activate(window.location.pathname);
  5454.  
  5455. log.log('Initialization successful.');
  5456.  
  5457. })();