LinkedIn Tool

Minor enhancements to LinkedIn. Mostly just hotkeys.

Old: v2.20.0 - 2023-09-16 - Make selector for tab labels more precise. Closes #139. Loop through tabpanels on open to reset scroll. I do not think there is a way to make this work on iframes. Closes #138. Implement v R to view a posts reposts. Closes #109.
New: v2.20.1 - 2023-09-17 - Fix typo in comment. Factor out some code from the LinkedIn class into a helper class. While a bit of a nuisance, this does eliminate the current no-use-before-define linter issues. Issue #117. JSDoc: Normalize how @type items are described. @type/@param {TYPE} - Description. If possible, fit on a single line using /** ... */. If Emacs decides it wants to reflow onto two lines, then use blockquotes. Purposefully did not touch simple methods. Move URL monitoring into the SPA class. Closes #117. Switch many Notifications methods to arrow functions. Issue #97. Bump version number.

  • --- /tmp/diffy20250426-3316283-9q1nk9 2025-04-26 05:04:37.400807987 +0000
  • +++ /tmp/diffy20250426-3316283-10pzve 2025-04-26 05:04:37.400807987 +0000
  • @@ -3,7 +3,7 @@
  • // @namespace [email protected]
  • // @match https://www.linkedin.com/*
  • // @noframes
  • -// @version 2.20.0
  • +// @version 2.20.1
  • // @author Mike Castle
  • // @description Minor enhancements to LinkedIn. Mostly just hotkeys.
  • // @license GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0-standalone.html
  • @@ -64,32 +64,22 @@
  • this.trace = trace;
  • }
  • - /**
  • - * @type {string} - Name for this logger.
  • - */
  • + /** @type {string} - Name for this logger. */
  • get name() {
  • return this._name;
  • }
  • - /**
  • - * Whether logging is currently enabled.
  • - * @type {boolean}
  • - */
  • + /** @type {boolean} - Whether logging is currently enabled. */
  • get enabled() {
  • return this._enabled;
  • }
  • - /**
  • - * Indicates whether messages include a stack trace.
  • - * @type {boolean}
  • - */
  • + /** @type {boolean} - Indicates whether messages include a stack trace. */
  • get trace() {
  • return this._trace;
  • }
  • - /**
  • - * @param {boolean} val - Set inclusion of stack traces.
  • - */
  • + /** @param {boolean} val - Set inclusion of stack traces. */
  • set trace(val) {
  • this._trace = Boolean(val);
  • }
  • @@ -584,7 +574,7 @@
  • return this._container;
  • }
  • - /** Map<string,TabEntry> */
  • + /** @type {Map<string,TabEntry>} */
  • get tabs() {
  • const entries = new Map();
  • for (const label of this._nav.querySelectorAll(':scope > label[data-tabbed-name]')) {
  • @@ -933,10 +923,7 @@
  • return this._dispatcher;
  • }
  • - /**
  • - * Represents the current item.
  • - * @type {Element}
  • - */
  • + /** @type {Element} - Represents the current item. */
  • get item() {
  • const me = 'get item';
  • this._log.entered(me);
  • @@ -960,9 +947,7 @@
  • return item;
  • }
  • - /**
  • - * @param {Element} val - Update the current item with val.
  • - */
  • + /** @param {Element} val - Set the current item. */
  • set item(val) {
  • const me = 'set item';
  • this._log.entered(me, val);
  • @@ -1226,6 +1211,58 @@
  • }
  • /**
  • + * This class exists solely to avoid some `no-use-before-define`
  • + * linter issues.
  • + */
  • + class LinkedInGlobals {
  • +
  • + _navBarHeightPixels = 0;
  • +
  • + /** @type{number} - The height of the navbar in pixels. */
  • + get navBarHeightPixels() {
  • + return this._navBarHeightPixels;
  • + }
  • +
  • + /** @param {number} val - Set height of the navbar in pixels. */
  • + set navBarHeightPixels(val) {
  • + this._navBarHeightPixels = val;
  • + }
  • +
  • + /** @type {string} - The height of the navbar as CSS string. */
  • + get navBarHeightCss() {
  • + return `${this._navBarHeightPixels}px`;
  • + }
  • +
  • + /**
  • + * Scroll common sidebar into view and move focus to it.
  • + */
  • + focusOnSidebar = () => {
  • + const sidebar = document.querySelector('div.scaffold-layout__sidebar');
  • + if (sidebar) {
  • + sidebar.style.scrollMarginTop = this.navBarHeightCss;
  • + sidebar.scrollIntoView();
  • + focusOnElement(sidebar);
  • + }
  • + }
  • +
  • + /**
  • + * Scroll common aside (right-hand sidebar) into view and move
  • + * focus to it.
  • + */
  • + focusOnAside = () => {
  • + const aside = document.querySelector('aside.scaffold-layout__aside');
  • + if (aside) {
  • + aside.style.scrollMarginTop = this.navBarHeightCss;
  • + aside.scrollIntoView();
  • + focusOnElement(aside);
  • + }
  • + }
  • +
  • + }
  • +
  • + const linkedInGlobals = new LinkedInGlobals();
  • +
  • + /**
  • * Base class for handling various views of a single-page
  • * application.
  • *
  • @@ -1238,17 +1275,16 @@
  • // The immediate following can be set in subclasses.
  • /**
  • - * What pathname part of the URL this page should handle. The
  • - * special case of null is used by the {@link SPA} class to
  • - * represent global keys.
  • - * @type {string}
  • -
  • + * @type {string} - What pathname part of the URL this page should
  • + * handle. The special case of null is used by the {@link SPA}
  • + * class to represent global keys.
  • */
  • _pathname;
  • /**
  • - * CSS selector for capturing clicks on this page. If overridden,
  • - * then the class should also provide a _onClick() method.
  • - * @type {string}
  • -
  • + * @type {string} - CSS selector for capturing clicks on this
  • + * page. If overridden, then the class should also provide a
  • + * _onClick() method.
  • */
  • _onClickSelector = null;
  • @@ -1262,33 +1298,25 @@
  • * form of `this.methodName`.
  • */
  • - /**
  • - * List of {@link Shortcut}s to register automatically. The
  • - * function is bound to `this` before registering it with
  • - * VM.shortcut.
  • - * @type {Shortcut[]}
  • - */
  • + /** @type {Shortcut[]} - List of {@link Shortcut}s to register automatically. */
  • get _autoRegisteredKeys() { // eslint-disable-line class-methods-use-this
  • return [];
  • }
  • // Private members.
  • - /**
  • - * @type {KeyboardService}
  • - */
  • + /** @type {KeyboardService} */
  • _keyboard = new VM.shortcut.KeyboardService();
  • /**
  • - * Tracks which HTMLElement holds the `onclick` function.
  • - * @type {Element}
  • + * @type {Element} - Tracks which HTMLElement holds the `onclick`
  • + * function.
  • */
  • _onClickElement = null;
  • /**
  • - * Magic for VM.shortcut. This disables keys when focus is on an
  • - * input type field or when viewing the help.
  • - * @type {IShortcutOptions}
  • -
  • + * @type {IShortcutOptions} - Disables keys when focus is on an
  • + * element or info view.
  • +
  • */
  • static _navOption = {
  • caseSensitive: true,
  • @@ -1342,17 +1370,14 @@
  • this._disableOnClick();
  • }
  • - /**
  • - * Describes what the header should be.
  • - * @type {string}
  • - */
  • + /** @type {string} - Describes what the header should be. */
  • get helpHeader() {
  • return this.constructor.name;
  • }
  • /**
  • - * The `key` and `desc` properties are important here.
  • - * @type {Shortcut[]}
  • -
  • + * @type {Shortcut[]} - The `key` and `desc` properties are
  • + * important here.
  • */
  • get helpContent() {
  • return this._autoRegisteredKeys;
  • @@ -1455,8 +1480,8 @@
  • {seq: 'g p', desc: 'Go to Profile (aka, Me)', func: Global._gotoProfile},
  • {seq: 'g b', desc: 'Go to Business', func: Global._gotoBusiness},
  • {seq: 'g l', desc: 'Go to Learning', func: Global._gotoLearning},
  • - {seq: ',', desc: 'Focus on the left/top sidebar (not always present)', func: linkedIn.focusOnSidebar}, // eslint-disable-line no-use-before-define
  • - {seq: '.', desc: 'Focus on the right/bottom sidebar (not always present)', func: linkedIn.focusOnAside}, // eslint-disable-line no-use-before-define
  • + {seq: ',', desc: 'Focus on the left/top sidebar (not always present)', func: linkedInGlobals.focusOnSidebar},
  • + {seq: '.', desc: 'Focus on the right/bottom sidebar (not always present)', func: linkedInGlobals.focusOnAside},
  • ];
  • }
  • @@ -1622,7 +1647,7 @@
  • constructor() {
  • super();
  • this._postScroller = new Scroller(Feed._postsWhat, Feed._postsHow);
  • - this._postScroller.dispatcher.on('out-of-range', linkedIn.focusOnSidebar); // eslint-disable-line no-use-before-define
  • + this._postScroller.dispatcher.on('out-of-range', linkedInGlobals.focusOnSidebar);
  • this._postScroller.dispatcher.on('change', this._onPostChange);
  • }
  • @@ -1919,7 +1944,7 @@
  • */
  • static _gotoShare() {
  • const share = document.querySelector('div.share-box-feed-entry__top-bar').parentElement;
  • - share.style.scrollMarginTop = linkedIn.navBarHeightCss; // eslint-disable-line no-use-before-define
  • + share.style.scrollMarginTop = linkedInGlobals.navBarHeightCss;
  • share.scrollIntoView();
  • share.querySelector('button').focus();
  • }
  • @@ -2053,7 +2078,7 @@
  • constructor() {
  • super();
  • this._sectionScroller = new Scroller(Jobs._sectionsWhat, Jobs._sectionsHow);
  • - this._sectionScroller.dispatcher.on('out-of-range', linkedIn.focusOnSidebar); // eslint-disable-line no-use-before-define
  • + this._sectionScroller.dispatcher.on('out-of-range', linkedInGlobals.focusOnSidebar);
  • this._sectionScroller.dispatcher.on('change', this._onChange);
  • this._sectionsMO1 = new MutationObserver(this._mutationHandler);
  • this._sectionsMO2 = new MutationObserver(this._mutationHandler);
  • @@ -2425,7 +2450,7 @@
  • constructor() {
  • super();
  • this._notificationScroller = new Scroller(Notifications._notificationsWhat, Notifications._notificationsHow);
  • - this._notificationScroller.dispatcher.on('out-of-range', linkedIn.focusOnSidebar); // eslint-disable-line no-use-before-define
  • + this._notificationScroller.dispatcher.on('out-of-range', linkedInGlobals.focusOnSidebar);
  • }
  • /** @inheritdoc */
  • @@ -2482,21 +2507,21 @@
  • /**
  • * Select the next notification.
  • */
  • - _nextNotification() {
  • + _nextNotification = () => {
  • this._notifications.next();
  • }
  • /**
  • * Select the previous notification.
  • */
  • - _prevNotification() {
  • + _prevNotification = () => {
  • this._notifications.prev();
  • }
  • /**
  • * Change browser focus to the current notification.
  • */
  • - _focusBrowser() {
  • + _focusBrowser = () => {
  • this._notifications.show();
  • focusOnElement(this._notifications.item);
  • }
  • @@ -2504,28 +2529,28 @@
  • /**
  • * Select the first notification.
  • */
  • - _firstNotification() {
  • + _firstNotification = () => {
  • this._notifications.first();
  • }
  • /**
  • * Select the last notification.
  • */
  • - _lastNotification() {
  • + _lastNotification = () => {
  • this._notifications.last();
  • }
  • /**
  • * Open the (⋯) menu for the current notification.
  • */
  • - _openMeatballMenu() {
  • + _openMeatballMenu = () => {
  • clickElement(this._notifications.item, ['button[aria-label^="Settings menu"]']);
  • }
  • /**
  • * Activate the current notification.
  • */
  • - _activateNotification() {
  • + _activateNotification = () => {
  • const ONE_ITEM = 1;
  • const notification = this._notifications.item;
  • if (notification) {
  • @@ -2556,7 +2581,7 @@
  • /**
  • * Toggles deletion of the current notification.
  • */
  • - async _deleteNotification() {
  • + _deleteNotification = async () => {
  • const notification = this._notifications.item;
  • /**
  • @@ -2647,6 +2672,12 @@
  • /** @type {SetupIssue[]} */
  • _setupIssues = [];
  • + /**
  • + * @type {string} - CSS selector to monitor if self-managing URL
  • + * changes. The selector must resolve to an element that, once it
  • + * exists, will continue to exist for the lifetime of the SPA.
  • + */
  • + urlChangeMonitorSelector = 'body';
  • /** @type {TabbedUI} */
  • _ui = null;
  • @@ -2757,6 +2788,8 @@
  • /** LinkedIn specific information. */
  • class LinkedIn extends SPADetails {
  • + urlChangeMonitorSelector = 'div.authentication-outlet';
  • +
  • static _icon =
  • '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">' +
  • '<defs>' +
  • @@ -2774,8 +2807,6 @@
  • '<circle cx="18" cy="6" r="5" mask="url(#b)"/>' +
  • '</svg>';
  • - _navBarHeightPixels = 0;
  • -
  • _navBarScrollerFixups = [
  • Feed._postsHow,
  • Feed._commentsHow,
  • @@ -2784,9 +2815,14 @@
  • Notifications._notificationsHow,
  • ];
  • - /** Create a LinkedIn instance. */
  • - constructor() {
  • + /**
  • + * Create a LinkedIn instance.
  • + * @param {LinkedInGlobals} globals - Instance of a helper class to avoid
  • + * circular dependencies.
  • + */
  • + constructor(globals) {
  • super();
  • + this._globals = globals;
  • this.ready = this._waitUntilPageLoadedEnough();
  • }
  • @@ -2800,27 +2836,15 @@
  • this._log.leaving(me);
  • }
  • - /** @type{number} - The height of the navbar in pixels. */
  • - get navBarHeightPixels() {
  • - return this._navBarHeightPixels;
  • - }
  • -
  • - /** @type {string} - The height of the navbar as CSS string. */
  • - get navBarHeightCss() {
  • - return `${this._navBarHeightPixels}px`;
  • - }
  • -
  • /**
  • - * The element.id used to identify the help pop-up.
  • - * @type {string}
  • + * @type {string} - The element.id used to identify the help
  • + * pop-up.
  • */
  • get helpId() {
  • return this._helpId;
  • }
  • - /**
  • - * @param {string} val - Set the value of the help element.id.
  • - */
  • + /** @param {string} val - Set the value of the help element.id. */
  • set helpId(val) {
  • this._helpId = val;
  • }
  • @@ -2830,6 +2854,8 @@
  • * @property {string} name - Name of the license.
  • * @property {string} url - License URL.
  • */
  • +
  • + /** @type{LicenseData} */
  • get licenseData() {
  • const me = 'licenseData';
  • this._log.entered(me);
  • @@ -2997,12 +3023,12 @@
  • _setNavBarInfo() {
  • const fudgeFactor = 4;
  • - this._navBarHeightPixels = this._navbar.clientHeight + fudgeFactor;
  • + this._globals.navBarHeightPixels = this._navbar.clientHeight + fudgeFactor;
  • // XXX: These {Scroller~How} items are static, so they need to
  • // be configured after we figure out what the values should be.
  • for (const how of this._navBarScrollerFixups) {
  • - how.topMarginPixels = this.navBarHeightPixels;
  • - how.topMarginCss = this.navBarHeightCss;
  • + how.topMarginPixels = this._globals.navBarHeightPixels;
  • + how.topMarginCss = this._globals.navBarHeightCss;
  • how.bottomMarginCss = '3em';
  • }
  • }
  • @@ -3063,31 +3089,6 @@
  • return infoTab;
  • }
  • - /**
  • - * Scroll common sidebar into view and move focus to it.
  • - */
  • - focusOnSidebar = () => {
  • - const sidebar = document.querySelector('div.scaffold-layout__sidebar');
  • - if (sidebar) {
  • - sidebar.style.scrollMarginTop = this.navBarHeightCss;
  • - sidebar.scrollIntoView();
  • - focusOnElement(sidebar);
  • - }
  • - }
  • -
  • - /**
  • - * Scroll common aside (right-hand sidebar) into view and move
  • - * focus to it.
  • - */
  • - focusOnAside = () => {
  • - const aside = document.querySelector('aside.scaffold-layout__aside');
  • - if (aside) {
  • - aside.style.scrollMarginTop = this.navBarHeightCss;
  • - aside.scrollIntoView();
  • - focusOnElement(aside);
  • - }
  • - }
  • -
  • }
  • /**
  • @@ -3102,33 +3103,22 @@
  • static _errorMarker = '---';
  • - /**
  • - * A special {Page} that handles global keys.
  • - * @type {Page}
  • - */
  • + /** @type {Page} - A special {Page} that handles global keys. */
  • _global = null;
  • - /**
  • - * Current {Page}.
  • - * @type {Page}
  • - */
  • + /** @type {Page} - Current {Page}. */
  • _page = null;
  • /**
  • - * Collect of {Page} mapped by the pathname they support.
  • - * @type {Page}
  • + * @type {Map<string,Page>} - {Page}s mapped by the pathname they
  • + * support.
  • */
  • _pages = new Map();
  • - /**
  • - * The most recent element to receive focus.
  • - * @type {Element}
  • - */
  • + /** @type {Element} - The most recent element to receive focus. */
  • _lastInputElement = null;
  • - /**
  • - * @type {KeyboardService}
  • - */
  • + /** @type {KeyboardService} */
  • _helpKeyboard = null;
  • /**
  • @@ -3152,10 +3142,89 @@
  • }
  • document.addEventListener('focus', this._onFocus, true);
  • document.addEventListener('urlchange', this._onUrlChange, true);
  • + this._startUrlMonitor();
  • this._details.done();
  • }
  • /**
  • + * Tampermonkey was the first(?) userscript manager to provide
  • + * events about URLs changing. Hence the need for `@grant
  • + * window.onurlchange` in the UserScript header.
  • + * @fires Event#urlchange
  • + */
  • + _startUserscriptManagerUrlMonitor() {
  • + this._log.log('Using Userscript Manager provided URL monitor.');
  • + window.addEventListener('urlchange', (info) => {
  • + // The info that TM gives is not really an event. So we turn
  • + // it into one and throw it again, this time onto `document`
  • + // where something is listening for it.
  • + const newUrl = new URL(info.url);
  • + const evt = new CustomEvent('urlchange', {detail: {url: newUrl}});
  • + document.dispatchEvent(evt);
  • + });
  • + }
  • +
  • + /**
  • + * Install a long lived MutationObserver that watches
  • + * {SPADetails.urlChangeMonitorSelector}. Whenever it is
  • + * triggered, it will check to see if the current URL has changed,
  • + * and if so, send an appropriate event.
  • + * @fires Event#urlchange
  • + */
  • + async _startMutationObserverUrlMonitor() {
  • + this._log.log('Using MutationObserver for monitoring URL changes.');
  • +
  • + const observeOptions = {childList: true, subtree: true};
  • +
  • + /**
  • + * Watch for the initial {SPADetails.urlChangeMonitorSelector}
  • + * to show up.
  • + * @implements {Monitor}
  • + * @returns {Continuation} - Indicate whether done monitoring.
  • + */
  • + const monitor = () => {
  • + // The default selector is 'body', so we need to query
  • + // 'document', not 'document.body'.
  • + const element = document.querySelector(this._details.urlChangeMonitorSelector);
  • + if (element) {
  • + return {done: true, results: element};
  • + }
  • + return {done: false};
  • + };
  • + const what = {
  • + name: 'SPA URL initializer observer',
  • + base: document.body,
  • + };
  • + const how = {
  • + observeOptions: observeOptions,
  • + monitor: monitor,
  • + };
  • + const element = await otmot(what, how);
  • + this._log.log('element exists:', element);
  • +
  • + this._oldUrl = new URL(window.location);
  • + new MutationObserver(() => {
  • + const newUrl = new URL(window.location);
  • + if (this._oldUrl.href !== newUrl.href) {
  • + const evt = new CustomEvent('urlchange', {detail: {url: newUrl}});
  • + this._oldUrl = newUrl;
  • + document.dispatchEvent(evt);
  • + }
  • + }).observe(element, observeOptions);
  • + }
  • +
  • + /**
  • + * Select which way to monitor the URL for changes and start it.
  • + */
  • + _startUrlMonitor() {
  • + if (window.onurlchange === null) {
  • + this._startUserscriptManagerUrlMonitor();
  • + } else {
  • + this._startMutationObserverUrlMonitor();
  • + }
  • + }
  • +
  • + /**
  • * Set the context (used by VM.shortcut) to a specific value.
  • * @param {string} context - The name of the context.
  • * @param {object} state - What the value should be.
  • @@ -3603,7 +3672,7 @@
  • }
  • - const linkedIn = new LinkedIn();
  • + const linkedIn = new LinkedIn(linkedInGlobals);
  • linkedIn.ready.then(() => {
  • log.log('proceeding...');
  • const spa = new SPA(linkedIn);
  • @@ -3615,67 +3684,6 @@
  • spa.activate(window.location.pathname);
  • });
  • - if (window.onurlchange === null) {
  • - // We are likely running on Tampermonkey, so use native support.
  • - log.log('Using window.onurlchange for monitoring URL updates.');
  • - window.addEventListener('urlchange', (info) => {
  • - // The info that TM gives is not really an event. So we turn it
  • - // into one and throw it again, this time onto `document` where
  • - // `spa` is listening for it.
  • - const newUrl = new URL(info.url);
  • - const evt = new CustomEvent('urlchange', {detail: {url: newUrl}});
  • - document.dispatchEvent(evt);
  • - });
  • - } else {
  • - log.log('Using MutationObserver for monitoring URL updates.');
  • -
  • - let oldUrl = new URL(window.location);
  • -
  • - /**
  • - * Constantly watch the web page. Whenever anything changes,
  • - * compare the current URL to the previous one, and if change,
  • - * send out an event.
  • - * @param {Element} element - Element to observe, ideally the
  • - * smallest thing that stays consistent throughout the lifetime of
  • - * the app.
  • - */
  • - function createUrlObserver(element) { // eslint-disable-line no-inner-declarations
  • - const observer = new MutationObserver(() => {
  • - const newUrl = new URL(window.location);
  • - if (oldUrl.href !== newUrl.href) {
  • - const evt = new CustomEvent('urlchange', {detail: {url: newUrl}});
  • - oldUrl = newUrl;
  • - document.dispatchEvent(evt);
  • - }
  • - });
  • - observer.observe(element, {childList: true, subtree: true});
  • - }
  • -
  • - /**
  • - * Watch for the initial `authentication-outlet` to show up, then
  • - * attach the URL observer to it.
  • - * @implements {Monitor}
  • - * @returns {Continuation} - Indicate whether done monitoring.
  • - */
  • - function authenticationOutletMonitor() { // eslint-disable-line no-inner-declarations
  • - const div = document.body.querySelector('div.authentication-outlet');
  • - if (div) {
  • - return {done: true, results: div};
  • - }
  • - return {done: false};
  • - }
  • -
  • - const authOutletWhat = {
  • - name: 'authOutletMonitor',
  • - base: document.body,
  • - };
  • - const autoOutletHow = {
  • - observeOptions: {childList: true, subtree: true},
  • - monitor: authenticationOutletMonitor,
  • - };
  • - otmot(authOutletWhat, autoOutletHow).then(el => createUrlObserver(el));
  • - }
  • -
  • if (_runTests) {
  • for (const test of _tests) {
  • test();