LinkedIn Tool

Minor enhancements to LinkedIn. Mostly just hotkeys.

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

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