LinkedIn Tool

Minor enhancements to LinkedIn. Mostly just hotkeys.

当前为 2023-12-30 提交的版本,查看 最新版本

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