LinkedIn Tool

Minor enhancements to LinkedIn. Mostly just hotkeys.

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

  1. // ==UserScript==
  2. // @name LinkedIn Tool
  3. // @namespace dalgoda@gmail.com
  4. // @match https://www.linkedin.com/*
  5. // @version 2.1.2
  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. console.debug('Parsing successful.');
  20.  
  21. // I'm lazy. The version of emacs I'm using does not support
  22. // #private variables out of the box, so using underscores until I
  23. // get a working configuration.
  24. class Page {
  25. // The immediate following can be set if derived classes
  26.  
  27. // What pathname part of the URL this page should handle. The
  28. // special case of null is used by the Pages class to represent
  29. // global keys.
  30. _pathname;
  31.  
  32. // CSS selector for capturing clicks on this page. If overridden,
  33. // then the class should also provide a _clickHandler() method.
  34. _click_handler_selector = null;
  35.  
  36. // List of keystrokes to register automatically. They are objects
  37. // with keys of `seq`, `desc`, and `func`. The `seq` is used to
  38. // define they keystroke sequence to trigger the function. The
  39. // `desc` is used to create the help screen. The `func` is a
  40. // function, usually in the form of `this.methodName`. The
  41. // function is bound to `this` before registering it with
  42. // VM.shortcut.
  43. _auto_keys = [];
  44.  
  45. // Private members.
  46.  
  47. _keyboard = new VM.shortcut.KeyboardService();
  48.  
  49. // Tracks which HTMLElement holds the `onclick` function.
  50. _click_handler_element = null;
  51.  
  52. // Magic for VM.shortcut. This disables keys when focus is on an
  53. // input type field.
  54. static _navOption = {
  55. caseSensitive: true,
  56. condition: '!inputFocus',
  57. };
  58.  
  59. constructor() {
  60. this._boundClickHandler = this._clickHandler.bind(this);
  61. }
  62.  
  63. start() {
  64. for (const {seq, func} of this._auto_keys) {
  65. this._addKey(seq, func.bind(this));
  66. }
  67. }
  68.  
  69. get pathname() {
  70. return this._pathname;
  71. }
  72.  
  73. get keyboard() {
  74. return this._keyboard;
  75. }
  76.  
  77. activate() {
  78. this._keyboard.enable();
  79. this._enableClickHandler();
  80. }
  81.  
  82. deactivate() {
  83. this._keyboard.disable();
  84. this._disableClickHandler();
  85. }
  86.  
  87. get helpHeader() {
  88. return this.constructor.name;
  89. }
  90.  
  91. get helpContent() {
  92. return this._auto_keys;
  93. }
  94.  
  95. _addKey(seq, func) {
  96. this._keyboard.register(seq, func, Page._navOption);
  97. }
  98.  
  99. _enableClickHandler() {
  100. if (this._click_handler_selector) {
  101. // Page is dynamically building, so keep watching it until the
  102. // element shows up.
  103. VM.observe(document.body, () => {
  104. const element = document.querySelector(this._click_handler_selector);
  105. if (element) {
  106. this._click_handler_element = element;
  107. this._click_handler_element.addEventListener('click', this._boundClickHandler);
  108.  
  109. return true;
  110. }
  111. });
  112. }
  113. }
  114.  
  115. _disableClickHandler() {
  116. if (this._click_handler_element) {
  117. this._click_handler_element.removeEventListener('click', this._boundClickHandler);
  118. this._click_handler_element = null
  119. }
  120. }
  121.  
  122. // Override this function in derived classes that want to react to
  123. // random clicks on a page, say to update current element in
  124. // focus.
  125. _clickHandler(evt) {
  126. alert(`Found a bug! ${this.constructor.name} wants to handle clicks, but forgot to create a handler.`);
  127. }
  128.  
  129. }
  130.  
  131. class Global extends Page {
  132. _pathname = null;
  133. _auto_keys = [
  134. {seq: '?', desc: 'Show keyboard help', func: this._help},
  135. {seq: '/', desc: 'Go to Search box', func: this._gotoSearch},
  136. {seq: 'g h', desc: 'Go Home (aka, Feed)', func: this._goHome},
  137. {seq: 'g m', desc: 'Go to My Network', func: this._gotoMyNetwork},
  138. {seq: 'g j', desc: 'Go to Jobs', func: this._gotoJobs},
  139. {seq: 'g g', desc: 'Go to Messaging', func: this._gotoMessaging},
  140. {seq: 'g n', desc: 'Go to Notifications', func: this._gotoNotifications},
  141. {seq: 'g p', desc: 'Go to Profile (aka, Me)', func: this._gotoProfile},
  142. {seq: 'g b', desc: 'Go to Business', func: this._gotoBusiness},
  143. {seq: 'g l', desc: 'Go to Learning', func: this._gotoLearning},
  144. ];
  145.  
  146. get helpId() {
  147. return this._helpId;
  148. }
  149.  
  150. set helpId(val) {
  151. this._helpId = val;
  152. }
  153.  
  154. _gotoNavLink(item) {
  155. clickElement(document, [`#global-nav a[href*="/${item}"`]);
  156. }
  157.  
  158. _gotoNavButton(item) {
  159. const buttons = Array.from(document.querySelectorAll('#global-nav button'));
  160. const button = buttons.find(el => el.textContent.includes(item));
  161. if (button) {
  162. button.click();
  163. }
  164. }
  165.  
  166. _help() {
  167. const help = document.querySelector(`#${this.helpId}`);
  168. help.showModal();
  169. help.focus();
  170. }
  171.  
  172. _gotoSearch() {
  173. clickElement(document, ['#global-nav-search button']);
  174. }
  175.  
  176. _goHome() {
  177. this._gotoNavLink('feed');
  178. }
  179.  
  180. _gotoMyNetwork() {
  181. this._gotoNavLink('mynetwork');
  182. }
  183.  
  184. _gotoJobs() {
  185. this._gotoNavLink('jobs');
  186. }
  187.  
  188. _gotoMessaging() {
  189. this._gotoNavLink('messaging');
  190. }
  191.  
  192. _gotoNotifications() {
  193. this._gotoNavLink('notifications');
  194. }
  195.  
  196. _gotoProfile() {
  197. this._gotoNavButton('Me');
  198. }
  199.  
  200. _gotoBusiness() {
  201. this._gotoNavButton('Business');
  202. }
  203.  
  204. _gotoLearning() {
  205. this._gotoNavLink('learning');
  206. }
  207.  
  208. }
  209.  
  210. class Feed extends Page {
  211. _pathname = '/feed/';
  212. _click_handler_selector = 'main';
  213. _auto_keys = [
  214. {seq: 'X', desc: 'Toggle hiding current post', func: this._togglePost},
  215. {seq: 'j', desc: 'Next post', func: this._nextPost},
  216. {seq: 'J', desc: 'Toggle hiding then next post', func: this._nextPostPlus},
  217. {seq: 'k', desc: 'Previous post', func: this._prevPost},
  218. {seq: 'K', desc: 'Toggle hiding then previous post', func: this._prevPostPlus},
  219. {seq: 'm', desc: 'Show more of the post or comment', func: this._seeMore},
  220. {seq: 'c', desc: 'Show comments', func: this._showComments},
  221. {seq: 'n', desc: 'Next comment', func: this._nextComment},
  222. {seq: 'p', desc: 'Previous comment', func: this._prevComment},
  223. {seq: 'l', desc: 'Load more posts (if the <button>New Posts</button> button is available, load those)', func: this._loadMorePosts},
  224. {seq: 'L', desc: 'Like post or comment', func: this._likePostOrComment},
  225. {seq: '<', desc: 'First post or comment', func: this._firstPostOrComment},
  226. {seq: '>', desc: 'Last post or comment currently loaded', func: this._lastPostOrComment},
  227. {seq: 'f', desc: 'Change browser focus to current post or comment', func: this._focusBrowser},
  228. {seq: 'v p', desc: 'View the post directly', func: this._viewPost},
  229. {seq: 'v r', desc: 'View reactions on current post or comment', func: this._viewReactions},
  230. {seq: 'P', desc: 'Go to the share box to start a post or <kbd>TAB</kbd> to the other creator options', func: this._gotoShare},
  231. {seq: '=', desc: 'Open the (⋯) menu', func: this._openMeatballMenu},
  232. ];
  233.  
  234. _currentPostId = null;
  235. _currentCommentId = null;
  236.  
  237. _clickHandler(evt) {
  238. const post = evt.target.closest('div[data-id]');
  239. if (post) {
  240. this._post = post;
  241. }
  242. }
  243.  
  244. get _post() {
  245. return this._getPosts().find(this._matchPost.bind(this));
  246. }
  247.  
  248. set _post(val) {
  249. if (val === this._post && this._comment) {
  250. return;
  251. }
  252. if (this._post) {
  253. this._post.classList.remove('tom');
  254. }
  255. this._currentPostId = this._uniqueIdentifier(val);
  256. this._comment = null;
  257. if (val) {
  258. val.classList.add('tom');
  259. this._scrollToCurrentPost();
  260. }
  261. }
  262.  
  263. get _comment() {
  264. return this._getComments().find(this._matchComment.bind(this));
  265. }
  266.  
  267. set _comment(val) {
  268. if (this._comment) {
  269. this._comment.classList.remove('dick');
  270. }
  271. this._currentCommentId = this._uniqueIdentifier(val);
  272. if (val) {
  273. val.classList.add('dick');
  274. this._scrollToCurrentComment();
  275. }
  276. }
  277.  
  278. _getPosts() {
  279. return Array.from(document.querySelectorAll('main div[data-id]'));
  280. }
  281.  
  282. _getComments() {
  283. if (this._post) {
  284. return Array.from(this._post.querySelectorAll('article.comments-comment-item'));
  285. } else {
  286. return [];
  287. }
  288. }
  289.  
  290. _uniqueIdentifier(element) {
  291. if (element) {
  292. return element.dataset.id;
  293. } else {
  294. return null;
  295. }
  296. }
  297.  
  298. _matchPost(el) {
  299. return this._currentPostId === this._uniqueIdentifier(el);
  300. }
  301.  
  302. _matchComment(el) {
  303. return this._currentCommentId === this._uniqueIdentifier(el);
  304. }
  305.  
  306. _scrollToCurrentPost() {
  307. if (this._post) {
  308. this._post.style.scrollMarginTop = navBarHeightCss;
  309. this._post.scrollIntoView();
  310. }
  311. }
  312.  
  313. _scrollToCurrentComment() {
  314. const rect = this._comment.getBoundingClientRect();
  315. this._comment.style.scrollMarginTop = navBarHeightCss;
  316. this._comment.style.scrollMarginBottom = '3em';
  317. // If both scrolling happens, that means the comment is too long
  318. // to fit on the page, so the top is preferred.
  319. if (rect.bottom > document.documentElement.clientHeight) {
  320. this._comment.scrollIntoView(false);
  321. }
  322. if (rect.top < navBarHeightPixels) {
  323. this._comment.scrollIntoView();
  324. }
  325. }
  326.  
  327. _scrollBy(n) {
  328. const posts = this._getPosts();
  329. if (posts.length) {
  330. let post = null;
  331. let idx = posts.findIndex(this._matchPost.bind(this)) + n;
  332. if (idx < -1) {
  333. idx = posts.length - 1;
  334. }
  335. if (idx === -1 || idx >= posts.length) {
  336. focusOnSidebar();
  337. } else {
  338. // Some posts are hidden (ads, suggestions). Skip over thoses.
  339. post = posts[idx];
  340. while (!post.clientHeight) {
  341. idx += n;
  342. post = posts[idx];
  343. }
  344. }
  345. this._post = post;
  346. }
  347. }
  348.  
  349. _scrollCommentsBy(n) {
  350. const comments = this._getComments();
  351. if (comments.length) {
  352. let idx = comments.findIndex(this._matchComment.bind(this)) + n;
  353. if (idx < -1) {
  354. idx = comments.length - 1
  355. }
  356. if (idx === -1 || idx >= comments.length) {
  357. // focus back to post
  358. this._comment = null;
  359. this._post = this._post;
  360. } else {
  361. this._comment = comments[idx];
  362. }
  363. }
  364. }
  365.  
  366. _nextPost() {
  367. this._scrollBy(1);
  368. }
  369.  
  370. _nextPostPlus() {
  371. const lastPost = this._post;
  372. function f() {
  373. this._togglePost();
  374. this._nextPost();
  375. }
  376. // XXX Need to remove the highlight before otrot sees it because
  377. // it affects the .clientHeight.
  378. this._post.classList.remove('tom');
  379. otrot(this._post, f.bind(this), 3000).then(() => {
  380. this._scrollToCurrentPost();
  381. }).catch(e => console.error(e));
  382. }
  383.  
  384. _prevPost() {
  385. this._scrollBy(-1);
  386. }
  387.  
  388. _prevPostPlus() {
  389. this._togglePost();
  390. this._prevPost();
  391. }
  392.  
  393. _nextComment() {
  394. this._scrollCommentsBy(1);
  395. }
  396.  
  397. _prevComment() {
  398. this._scrollCommentsBy(-1);
  399. }
  400.  
  401. _togglePost() {
  402. clickElement(this._post, ['button[aria-label^="Dismiss post"]', 'button[aria-label^="Undo and show"]']);
  403. }
  404.  
  405. _showComments() {
  406. function tryComment(comment) {
  407. if (comment) {
  408. const buttons = Array.from(comment.querySelectorAll('button'));
  409. const button = buttons.find(el => el.textContent.includes('Load previous replies'));
  410. if (button) {
  411. button.click();
  412. return true;
  413. }
  414. }
  415. return false;
  416. }
  417.  
  418. if (!tryComment(this._comment)) {
  419. clickElement(this._post, ['button[aria-label*="comment"]']);
  420. }
  421. }
  422.  
  423. _seeMore() {
  424. const el = this._comment ? this._comment : this._post;
  425. clickElement(el, ['button[aria-label^="see more"]']);
  426. }
  427.  
  428. _likePostOrComment() {
  429. const el = this._comment ? this._comment : this._post;
  430. clickElement(el, ['button[aria-label^="Open reactions menu"]']);
  431. }
  432.  
  433. _jumpToPostOrComment(first) {
  434. if (this._comment) {
  435. var comments = this._getComments();
  436. if (comments.length) {
  437. const idx = first ? 0 : (comments.length - 1);
  438. this._comment = comments[idx];
  439. }
  440. } else {
  441. const posts = this._getPosts();
  442. if (posts.length) {
  443. let idx = first ? 0 : (posts.length - 1);
  444. let post = posts[idx];
  445. // Post content can be loaded lazily and can be detected by
  446. // having no innerText yet. So go to the last one that is
  447. // loaded. By the time we scroll to it, the next posts may
  448. // have content, but it will close.
  449. while (!post.innerText.length && !first) {
  450. idx--;
  451. post = posts[idx];
  452. }
  453. this._post = post;
  454. }
  455. }
  456. }
  457.  
  458. _firstPostOrComment() {
  459. this._jumpToPostOrComment(true);
  460. }
  461.  
  462. _lastPostOrComment() {
  463. this._jumpToPostOrComment(false);
  464. }
  465.  
  466. _loadMorePosts() {
  467. const container = document.querySelector('div.scaffold-finite-scroll__content');
  468. function f() {
  469. const posts = this._getPosts();
  470. if (clickElement(posts[0], ['div.feed-new-update-pill button'])) {
  471. this._post = posts[0];
  472. } else {
  473. clickElement(document, ['main button.scaffold-finite-scroll__load-button']);
  474. }
  475. }
  476. otrot(container, f.bind(this), 3000).then(() => {
  477. this._scrollToCurrentPost();
  478. });
  479. }
  480.  
  481. _gotoShare() {
  482. const share = document.querySelector('div.share-box-feed-entry__top-bar').parentElement;
  483. share.style.scrollMarginTop = navBarHeightCss;
  484. share.scrollIntoView();
  485. share.querySelector('button').focus();
  486. }
  487.  
  488. _openMeatballMenu() {
  489. if (this._comment) {
  490. // XXX In this case, the aria-label is on the svg element, not
  491. // the button, so use the parentElement.
  492. const button = this._comment.querySelector('[aria-label^="Open options"]').parentElement;
  493. button.click();
  494. } else if (this._post) {
  495. // Yeah, I don't get it. This one isn't the button either,
  496. // but the click works.
  497. clickElement(this._post, ['[aria-label^="Open control menu"]']);
  498. }
  499. }
  500.  
  501. _focusBrowser() {
  502. const el = this._comment ? this._comment : this._post;
  503. focusOnElement(el);
  504. }
  505.  
  506. _viewPost() {
  507. if (this._post) {
  508. const urn = this._post.dataset.id;
  509. const id = `lt-${urn.replaceAll(':', '-')}`;
  510. let a = this._post.querySelector(`#${id}`);
  511. if (!a) {
  512. a = document.createElement('a');
  513. a.href = `/feed/update/${urn}/`;
  514. a.id = id;
  515. this._post.append(a);
  516. }
  517. a.click();
  518. }
  519. }
  520.  
  521. _viewReactions() {
  522. // Bah! The queries are annoyingly different.
  523. if (this._comment) {
  524. clickElement(this._comment, ['button.comments-comment-social-bar__reactions-count']);
  525. } else if (this._post) {
  526. clickElement(this._post, ['button.social-details-social-counts__count-value']);
  527. }
  528. }
  529.  
  530. }
  531.  
  532. class Jobs extends Page {
  533. _pathname = '/jobs/';
  534. _auto_keys = [
  535. {seq: 'j', desc: 'Next section', func: this._nextSection},
  536. {seq: 'k', desc: 'Previous section', func: this._prevSection},
  537. {seq: 'f', desc: 'Change browser focus to current section', func: this._focusBrowser},
  538. ];
  539.  
  540. _currentSectionId = null;
  541.  
  542. get _section() {
  543. return this._getSections().find(this._match.bind(this));
  544. }
  545.  
  546. set _section(val) {
  547. if (this._section) {
  548. this._section.classList.remove('tom');
  549. }
  550. this._currentSectionId = this._uniqueIdentifier(val);
  551. if (val) {
  552. val.classList.add('tom');
  553. this._scrollToCurrentSection();
  554. }
  555. }
  556.  
  557. _getSections() {
  558. return Array.from(document.querySelectorAll('main section'));
  559. }
  560.  
  561. _uniqueIdentifier(element) {
  562. if (element) {
  563. const h2 = element.querySelector('h2');
  564. if (h2) {
  565. return h2.innerText;
  566. } else {
  567. return element.innerText;
  568. }
  569. } else {
  570. return null;
  571. }
  572. }
  573.  
  574. _match(el) {
  575. const res = this._currentSectionId === this._uniqueIdentifier(el);
  576. return res;
  577. }
  578.  
  579. _scrollToCurrentSection() {
  580. if (this._section) {
  581. this._section.style.scrollMarginTop = navBarHeightCss;
  582. this._section.scrollIntoView();
  583. }
  584. }
  585.  
  586. _scrollBy(n) {
  587. const sections = this._getSections();
  588. if (sections.length) {
  589. let idx = sections.findIndex(this._match.bind(this)) + n;
  590. if (idx < -1) {
  591. idx = sections.length - 1;
  592. }
  593. if (idx === -1 || idx >= sections.length) {
  594. focusOnSidebar();
  595. this._section = null;
  596. } else {
  597. this._section = sections[idx];
  598. }
  599. }
  600. }
  601.  
  602. _nextSection() {
  603. this._scrollBy(1);
  604. }
  605.  
  606. _prevSection() {
  607. this._scrollBy(-1);
  608. }
  609.  
  610. _focusBrowser() {
  611. focusOnElement(this._section);
  612. }
  613. }
  614.  
  615. class JobsCollections extends Page {
  616. _pathname = '/jobs/collections/';
  617. }
  618.  
  619. class Notifications extends Page {
  620. _pathname = '/notifications/';
  621. _click_handler_selector = 'main';
  622. _auto_keys = [
  623. {seq: 'j', desc: 'Next notification', func: this._nextNotification},
  624. {seq: 'k', desc: 'Previous notification', func: this._prevNotification},
  625. {seq: 'Enter', desc: 'Activate the notification (click on it)', func: this._activateNotification},
  626. {seq: 'X', desc: 'Toggle current notification deletion', func: this._deleteNotification},
  627. {seq: 'l', desc: 'Load more notifications', func: this._loadMoreNotifications},
  628. {seq: 'f', desc: 'Change browser focus to current notification', func: this._focusBrowser},
  629. {seq: '<', desc: 'First notification', func: this._firstNotification},
  630. {seq: '>', desc: 'Last notification', func: this._lastNotification},
  631. {seq: '=', desc: 'Open the (⋯) menu', func: this._openMeatballMenu},
  632. ];
  633.  
  634. // Ugh. When notifications are deleted, the entire element, and
  635. // parent elements, are deleted and replaced by new elements. So
  636. // the only way to track them is by array position.
  637. _currentNotificationIndex = -1;
  638.  
  639. _clickHandler(evt) {
  640. const notification = evt.target.closest('div.nt-card-list article');
  641. if (notification) {
  642. this._notification = notification;
  643. }
  644. }
  645.  
  646. get _notification() {
  647. if (this._currentNotificationIndex >= 0) {
  648. return this._getNotifications()[this._currentNotificationIndex];
  649. } else {
  650. return null;
  651. }
  652. }
  653.  
  654. set _notification(val) {
  655. if (this._notification) {
  656. this._notification.classList.remove('tom');
  657. }
  658. const notifications = this._getNotifications();
  659. this._currentNotificationIndex = notifications.indexOf(val);
  660. if (val) {
  661. val.classList.add('tom');
  662. this._scrollToCurrentNotification();
  663. }
  664. }
  665.  
  666. _getNotifications() {
  667. return Array.from(document.querySelectorAll('main section div.nt-card-list article'));
  668. }
  669.  
  670. _scrollToCurrentNotification() {
  671. const rect = this._notification.getBoundingClientRect();
  672. this._notification.style.scrollMarginTop = navBarHeightCss;
  673. this._notification.style.scrollMarginBottom = '3em';
  674. if (rect.bottom > document.documentElement.clientHeight) {
  675. this._notification.scrollIntoView(false);
  676. }
  677. if (rect.top < navBarHeightPixels) {
  678. this._notification.scrollIntoView();
  679. }
  680. }
  681.  
  682. _scrollBy(n) {
  683. const notifications = this._getNotifications();
  684. if (notifications.length) {
  685. let idx = this._currentNotificationIndex + n;
  686. if (idx < -1) {
  687. idx = notifications.length - 1;
  688. }
  689. if (idx === -1 || idx >= notifications.length) {
  690. focusOnSidebar();
  691. this._notification = null;
  692. } else {
  693. this._notification = notifications[idx];
  694. }
  695. }
  696. }
  697.  
  698. _nextNotification() {
  699. this._scrollBy(1);
  700. }
  701.  
  702. _prevNotification() {
  703. this._scrollBy(-1);
  704. }
  705.  
  706. _jumpToNotification(first) {
  707. const notifications = this._getNotifications();
  708. if (notifications.length) {
  709. const idx = first ? 0 : (notifications.length - 1);
  710. this._notification = notifications[idx];
  711. }
  712. }
  713.  
  714. _focusBrowser() {
  715. focusOnElement(this._notification);
  716. }
  717.  
  718. _firstNotification() {
  719. this._jumpToNotification(true);
  720. }
  721.  
  722. _lastNotification() {
  723. this._jumpToNotification(false);
  724. }
  725.  
  726. _openMeatballMenu() {
  727. clickElement(this._notification, ['button[aria-label^="Settings menu"]']);
  728. }
  729.  
  730. _activateNotification() {
  731. if (this._notification) {
  732. // Because we are using Enter as the hotkey here, if the
  733. // active element is inside the current card, we want that to
  734. // take precedence.
  735. if (document.activeElement.closest('article') === this._notification) {
  736. return;
  737. }
  738. // Every notification is different.
  739. // It may be that notifications are settling on 'a.nt-card__headline'.
  740. function matchesKnownText(el) {
  741. if (el.innerText === 'Apply early') return true;
  742. return false;
  743. }
  744.  
  745. // Debugging code.
  746. if (this._notification.querySelectorAll('a.nt-card__headline').length === 1 && this._notification.querySelector('button.message-anywhere-button')) {
  747. console.debug(this._notification);
  748. alert('Yes, can be simplified');
  749. }
  750.  
  751. if (!clickElement(this._notification, ['button.message-anywhere-button'])) {
  752. const buttons = Array.from(this._notification.querySelectorAll('button'));
  753. const button = buttons.find(matchesKnownText);
  754. if (button) {
  755. button.click();
  756. } else {
  757. const links = this._notification.querySelectorAll('a.nt-card__headline');
  758. if (links.length === 1) {
  759. links[0].click();
  760. } else {
  761. console.debug(this._notification);
  762. for (const el of this._notification.querySelectorAll('*')) {
  763. console.debug(el);
  764. }
  765. const msg = [
  766. 'You tried to activate an unsupported notification',
  767. 'element. Please file a bug. If you are comfortable',
  768. 'with using the browser\'s Developer Tools (often the',
  769. 'F12 key), consider sharing the information just logged',
  770. 'in the console / debug view.',
  771. ];
  772. alert(msg.join(' '));
  773. }
  774. }
  775. }
  776. } else {
  777. // Again, because we use Enter as the hotkey for this action.
  778. document.activeElement.click();
  779. }
  780. }
  781.  
  782. _deleteNotification() {
  783. if (this._notification) {
  784. // Hah. Unlike in other places, these buttons already exist,
  785. // just hidden under the menu.
  786. const buttons = Array.from(this._notification.querySelectorAll('button'));
  787. const button = buttons.find(el => el.textContent.includes('Delete this notification'));
  788. if (button) {
  789. button.click();
  790. } else {
  791. clickElement(this._notification, ['button[aria-label^="Undo notification deletion"]']);
  792. }
  793. }
  794. }
  795.  
  796. _loadMoreNotifications() {
  797. const buttons = Array.from(document.querySelectorAll('main section button'));
  798. const button = buttons.find(el => el.textContent.includes('Show more results'));
  799. if (button) {
  800. button.click();
  801. }
  802. }
  803.  
  804. }
  805.  
  806. class Pages {
  807. _global = null;
  808. _page = null;
  809. _pages = new Map();
  810.  
  811. _lastInputElement = null;
  812.  
  813. constructor() {
  814. this._id = crypto.randomUUID();
  815. this._installNavStyle();
  816. this._initializeHelpMenu();
  817. document.addEventListener('focus', this._onFocus.bind(this), true);
  818. document.addEventListener('href', this._onHref.bind(this), true);
  819. }
  820.  
  821. _setInputFocus(state) {
  822. const pages = Array.from(this._pages.values());
  823. pages.push(this._global);
  824. for (const page of pages) {
  825. if (page) {
  826. page.keyboard.setContext('inputFocus', state);
  827. }
  828. }
  829. }
  830.  
  831. _onFocus(evt) {
  832. if (this._lastInputElement && evt.target !== this._lastInputElement) {
  833. this._lastInputElement = null
  834. this._setInputFocus(false);
  835. }
  836. if (isInput(evt.target)) {
  837. this._setInputFocus(true);
  838. this._lastInputElement = evt.target;
  839. }
  840. }
  841.  
  842. _onHref(evt) {
  843. this.activate(evt.detail.url.pathname);
  844. }
  845.  
  846. _installNavStyle() {
  847. const style = document.createElement('style');
  848. style.textContent += '.tom { border-color: orange !important; border-style: solid !important; border-width: medium !important; }';
  849. style.textContent += '.dick { border-color: red !important; border-style: solid !important; border-width: thin !important; }';
  850. document.head.append(style);
  851. }
  852.  
  853. _initializeHelpMenu() {
  854. this._helpId = `help-${this._id}`;
  855. const style = document.createElement('style');
  856. style.textContent += `#${this._helpId} kbd {font-size: 0.85em; padding: 0.07em; border-width: 1px; border-style: solid; }`;
  857. style.textContent += `#${this._helpId} th { padding-top: 1em; text-align: left; }`;
  858. style.textContent += `#${this._helpId} td:first-child { white-space: nowrap; text-align: right; padding-right: 0.5em; }`;
  859. style.textContent += `#${this._helpId} button { border-width: 1px; border-style: solid; border-radius: 0.25em; }`;
  860. document.head.prepend(style);
  861. const dialog = document.createElement('dialog');
  862. dialog.id = this._helpId
  863. dialog.innerHTML = '<table><caption>' +
  864. '<span style="float: left">Keyboard shortcuts</span>' +
  865. '<span style="float: right">Hit <kbd>ESC</kbd> to close</span>' +
  866. '</caption><tbody></tbody></table>';
  867. document.body.prepend(dialog);
  868. }
  869.  
  870. // ThisPage -> This Page
  871. _parseHeader(text) {
  872. return text.replace(/([A-Z])/g, ' $1').trim();
  873. }
  874.  
  875. // 'a b' -> '<kbd>a</kbd> then <kbd>b</kbd>'
  876. _parseSeq(seq) {
  877. const letters = seq.split(' ').map(w => `<kbd>${w}</kbd>`);
  878. const s = letters.join(' then ');
  879. return s;
  880. }
  881.  
  882. _addHelp(page) {
  883. const help = document.querySelector(`#${this._helpId} tbody`);
  884. const section = this._parseHeader(page.helpHeader);
  885. let s = `<tr><th></th><th>${section}</th></tr>`;
  886. for (const {seq, desc} of page.helpContent) {
  887. const keys = this._parseSeq(seq);
  888. s += `<tr><td>${keys}:</td><td>${desc}</td></tr>`;
  889. }
  890. // Don't include works in progress that have no keys yet.
  891. if (page.helpContent.length) {
  892. help.innerHTML += s;
  893. }
  894. }
  895.  
  896. register(page) {
  897. page.start();
  898. this._addHelp(page);
  899. if (page.pathname === null) {
  900. page.helpId = this._helpId
  901. this._global = page;
  902. this._global.activate();
  903. } else {
  904. this._pages.set(page.pathname, page);
  905. }
  906. }
  907.  
  908. _findPage(pathname) {
  909. const pathnames = Array.from(this._pages.keys());
  910. const candidates = pathnames.filter(p => pathname.startsWith(p));
  911. const candidate = candidates.reduce((a, b) => {
  912. return a.length > b.length ? a : b;
  913. }, '');
  914. return this._pages.get(pathname) || null;
  915. }
  916.  
  917. activate(pathname) {
  918. if (this._page) {
  919. this._page.deactivate();
  920. }
  921. const page = this._findPage(pathname);
  922. this._page = page;
  923. if (page) {
  924. page.activate();
  925. }
  926. }
  927. }
  928.  
  929. const pages = new Pages();
  930. pages.register(new Global());
  931. pages.register(new Feed());
  932. pages.register(new Jobs());
  933. pages.register(new JobsCollections());
  934. pages.register(new Notifications());
  935. pages.activate(window.location.pathname);
  936.  
  937. function isInput(element) {
  938. let tagName = '';
  939. if ('tagName' in element) {
  940. tagName = element.tagName.toLowerCase();
  941. }
  942. return (element.isContentEditable || ['input', 'textarea'].includes(tagName));
  943. }
  944.  
  945. // Run querySelector to get an element, then click it.
  946. function clickElement(base, selectorArray) {
  947. if (base) {
  948. for (const selector of selectorArray) {
  949. const el = base.querySelector(selector);
  950. if (el) {
  951. el.click();
  952. return true;
  953. }
  954. }
  955. }
  956. return false;
  957. }
  958.  
  959. function focusOnElement(element) {
  960. if (element) {
  961. const tabIndex = element.getAttribute('tabindex');
  962. element.setAttribute('tabindex', 0);
  963. element.focus();
  964. if (tabIndex) {
  965. element.setAttribute('tabindex', tabIndex);
  966. } else {
  967. element.removeAttribute('tabindex');
  968. }
  969. }
  970. }
  971.  
  972. function focusOnSidebar() {
  973. const sidebar = document.querySelector('div.scaffold-layout__sidebar');
  974. sidebar.style.scrollMarginTop = navBarHeightCss;
  975. sidebar.scrollIntoView();
  976. sidebar.focus();
  977. }
  978.  
  979. // One time mutation observer with timeout
  980. // base - element to observe
  981. // options - MutationObserver().observe options
  982. // monitor - function that takes [MutationRecord] and returns a {done, results} object
  983. // trigger - function to call that triggers observable results, can be null
  984. // timeout - time to wait for completion in milliseconds, 0 disables
  985. // Returns promise that will resolve with the results from monitor.
  986. function otmot(base, options, monitor, trigger, timeout) {
  987. const prom = new Promise((resolve, reject) => {
  988. let timeoutID = null;
  989. trigger = trigger || function () {};
  990. const observer = new MutationObserver((records) => {
  991. const {done, results} = monitor(records);
  992. if (done) {
  993. observer.disconnect();
  994. clearTimeout(timeoutID);
  995. resolve(results);
  996. }
  997. });
  998. if (timeout) {
  999. timeoutID = setTimeout(() => {
  1000. observer.disconnect();
  1001. reject('timed out');
  1002. }, timeout);
  1003. }
  1004. observer.observe(base, options);
  1005. trigger();
  1006. });
  1007. return prom;
  1008. }
  1009.  
  1010. // One time resize observer with timeout
  1011. // Will resolve automatically upon resize change.
  1012. // base - element to observe
  1013. // trigger - function to call that triggers observable events, can be null
  1014. // timeout - time to wait for completion in milliseconds, 0 disables
  1015. // Returns promise that will resolve with the results from monitor.
  1016. function otrot(base, trigger, timeout) {
  1017. const prom = new Promise((resolve, reject) => {
  1018. let timeoutID = null;
  1019. const initialHeight = base.clientHeight;
  1020. const initialWidth = base.clientWidth;
  1021. trigger = trigger || function () {};
  1022. const observer = new ResizeObserver(() => {
  1023. if (base.clientHeight !== initialHeight || base.clientWidth !== initialWidth) {
  1024. observer.disconnect();
  1025. clearTimeout(timeoutID);
  1026. resolve(base);
  1027. }
  1028. });
  1029. if (timeout) {
  1030. timeoutID = setTimeout(() => {
  1031. observer.disconnect();
  1032. reject('timed out');
  1033. }, timeout);
  1034. }
  1035. observer.observe(base);
  1036. trigger();
  1037. });
  1038. return prom;
  1039. }
  1040.  
  1041. function navBarMonitor(records) {
  1042. const navbar = document.querySelector('#global-nav');
  1043. if (navbar) {
  1044. return {done: true, results: navbar};
  1045. }
  1046. return {done: false, results: null};
  1047. }
  1048.  
  1049. let navBarHeightPixels = 0;
  1050. let navBarHeightCss = '0';
  1051.  
  1052. // In this case, the trigger was the page load. It already happened
  1053. // by the time we got here.
  1054. otmot(document.body, {childList: true, subtree: true}, navBarMonitor,
  1055. null, 0)
  1056. .then((el) => {
  1057. navBarHeightPixels = el.clientHeight + 4;
  1058. navBarHeightCss = `${navBarHeightPixels}px`;
  1059. });
  1060.  
  1061. let oldUrl = new URL(window.location);
  1062. function registerUrlMonitor(element) {
  1063. const observer = new MutationObserver((records) => {
  1064. const newUrl = new URL(window.location);
  1065. if (oldUrl.href !== newUrl.href) {
  1066. const evt = new CustomEvent('href', {detail: {url: newUrl}})
  1067. oldUrl = newUrl;
  1068. document.dispatchEvent(evt);
  1069. }
  1070. });
  1071. observer.observe(element, {childList: true, subtree: true});
  1072. }
  1073.  
  1074. function authenticationOutletMonitor() {
  1075. const div = document.body.querySelector('div.authentication-outlet');
  1076. if (div) {
  1077. return {done: true, results: div};
  1078. }
  1079. return {done: false, results: null};
  1080. }
  1081.  
  1082. otmot(document.body, {childList: true, subtree: true}, authenticationOutletMonitor, null, 0)
  1083. .then((el) => registerUrlMonitor(el));
  1084.  
  1085. })();