- // ==UserScript==
- // @name LinkedIn Tool
- // @namespace dalgoda@gmail.com
- // @match https://www.linkedin.com/*
- // @noframes
- // @version 2.5.3
- // @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.txt
- // @supportURL https://github.com/nexushoratio/userscripts/blob/main/linkedin-tool.md
- // @require https://cdn.jsdelivr.net/npm/@violentmonkey/shortcut@1
- // @require https://cdn.jsdelivr.net/npm/@violentmonkey/dom@2
- // @grant window.onurlchange
- // ==/UserScript==
-
- /* global VM */
-
- (function () {
- 'use strict';
-
- let navBarHeightPixels = 0;
- let navBarHeightCss = '0';
-
- /**
- * Java's hashCode: s[0]*31(n-1) + s[1]*31(n-2) + ... + s[n-1]
- * @param {string} s - String to hash.
- * @returns {string} - Hash value.
- */
- function strHash(s) {
- let hash = 0;
- for (let i = 0; i < s.length; i++) {
- hash = ((hash << 5) - hash) + s.charCodeAt(i) | 0;
- }
- return `${hash}`;
- }
-
- /**
- * Run querySelector to get an element, then click it.
- * @param {Element} base - Where to start looking.
- * @param {string[]} selectorArray - CSS selectors to use to find an
- * element.
- * @returns {boolean} - Whether an element could be found.
- */
- function clickElement(base, selectorArray) {
- if (base) {
- for (const selector of selectorArray) {
- const el = base.querySelector(selector);
- if (el) {
- el.click();
- return true;
- }
- }
- }
- return false;
- }
-
- /**
- * Determines if the element accepts keyboard input.
- * @param {Element} element - HTML Element to examine.
- * @returns {boolean} - Indicating whether the element accepts keyboard input.
- */
- function isInput(element) {
- let tagName = '';
- if ('tagName' in element) {
- tagName = element.tagName.toLowerCase();
- }
- return (element.isContentEditable || ['input', 'textarea'].includes(tagName));
- }
-
- /**
- * Bring the Brower's focus onto element.
- * @param {Element} element - HTML Element to focus on.
- * @returns {void}
- */
- function focusOnElement(element) {
- if (element) {
- const tabIndex = element.getAttribute('tabindex');
- element.setAttribute('tabindex', 0);
- element.focus();
- if (tabIndex) {
- element.setAttribute('tabindex', tabIndex);
- } else {
- element.removeAttribute('tabindex');
- }
- }
- }
-
- /**
- * Scroll's LinkedIn common sidebar into view and moves focus to it.
- * @returns {void}
- */
- function focusOnSidebar() {
- const sidebar = document.querySelector('div.scaffold-layout__sidebar');
- sidebar.style.scrollMarginTop = navBarHeightCss;
- sidebar.scrollIntoView();
- sidebar.focus();
- }
-
- /**
- * One time resize observer with timeout.
- * Will resolve automatically upon resize change.
- * @param {Element} base - Element to observe.
- * @param {?function()} trigger - Function to call that triggers observable events.
- * @param {number} timeout - Time to wait for completion in milliseconds, 0 disables.
- * @returns {promise} - Will resolve with the results from monitor.
- */
- function otrot(base, trigger, timeout) {
- const prom = new Promise((resolve, reject) => {
- let timeoutID = null;
- const initialHeight = base.clientHeight;
- const initialWidth = base.clientWidth;
- trigger = trigger || function () {};
- const observer = new ResizeObserver(() => {
- if (base.clientHeight !== initialHeight || base.clientWidth !== initialWidth) {
- observer.disconnect();
- clearTimeout(timeoutID);
- resolve(base);
- }
- });
- if (timeout) {
- timeoutID = setTimeout(() => {
- observer.disconnect();
- reject('timed out');
- }, timeout);
- }
- observer.observe(base);
- trigger();
- });
- return prom;
- }
-
- /**
- * One time mutation observer with timeout.
- * @param {Element} base - Element to observe.
- * @param {object} options - MutationObserver().observe() options.
- * @param {function(MutationRecord[]): {done: boolean, results:
- * object}} monitor - Callback used to process all MutationObserver
- * records.
- * @param {?function()} trigger - Function to call that triggers
- * observable resultsl
- * @param {number} timeout - Time to wait for completion in
- * milliseconds, 0 disables.
- * @returns {promise} - Will resolve with the results from monitor
- * when done is true.
- */
- function otmot(base, options, monitor, trigger, timeout) {
- const prom = new Promise((resolve, reject) => {
- let timeoutID = null;
- trigger = trigger || function () {};
- const observer = new MutationObserver((records) => {
- const {done, results} = monitor(records);
- if (done) {
- observer.disconnect();
- clearTimeout(timeoutID);
- resolve(results);
- }
- });
- if (timeout) {
- timeoutID = setTimeout(() => {
- observer.disconnect();
- reject('timed out');
- }, timeout);
- }
- observer.observe(base, options);
- trigger();
- });
- return prom;
- }
-
- // I'm lazy. The version of emacs I'm using does not support
- // #private variables out of the box, so using underscores until I
- // get a working configuration.
-
- /** Simple dispatcher. It takes a fixed list of event types upon
- * construction and attempts to use an unknown event will throw an
- * error.
- */
- class Dispatcher {
- _handlers = new Map();
-
- /**
- * @param{...string} eventTypes - Event types this instance can handle.
- */
- constructor(...eventTypes) {
- for (const eventType of eventTypes) {
- this._handlers.set(eventType, []);
- }
- }
-
- /**
- * Look up array of handlers by event type.
- * @param {string} eventType - Event type to look up.
- * @throws {Error} - When eventType was not registered during instantiation.
- * @returns {function[]} - Handlers currently registered for this eventType.
- */
- _getHandlers(eventType) {
- const handlers = this._handlers.get(eventType);
- if (!handlers) {
- throw new Error(`Unknown event type: ${eventType}`);
- }
- return handlers;
- }
-
- /**
- * Attach a function to an eventType.
- * @param {string} eventType - Event type to connect with.
- * @param {function} func - Single argument function to call.
- * @returns {void}
- */
- on(eventType, func) {
- const handlers = this._getHandlers(eventType);
- handlers.push(func);
- }
-
- /**
- * Remove all instances of a function registered to an eventType.
- * @param {string} eventType - Event type to disconnect from.
- * @param {function} func - Function to remove.
- * @returns {void}
- */
- off(eventType, func) {
- const handlers = this._getHandlers(eventType)
- let index = 0;
- while ((index = handlers.indexOf(func)) !== -1) {
- handlers.splice(index, 1);
- }
- }
-
- /**
- * Calls all registered functions for the given eventType.
- * @param {string} eventType - Event type to use.
- * @param {object} data - Data to pass to each function.
- * @returns {void}
- */
- fire(eventType, data) {
- const handlers = this._getHandlers(eventType);
- for (const handler of handlers) {
- handler(data);
- }
- }
- }
-
- /**
- * An ordered collection of HTMLElements for a user to scroll through.
- *
- * The dispatcher can be used the handle the following events:
- * - 'out-of-range' - Scrolling went past one end of the collection.
- * - 'change' - The value of item has changed.
- * This is NOT an error condition, but rather a design feature.
- */
- class Scroller {
- _dispatcher = new Dispatcher('change', 'out-of-range');
- _currentItemId = null;
- _historicalIdToIndex = new Map();
-
- /**
- * @param {Element} base - The container element.
- * @param {string[]} selectors - Array of CSS selectors to find
- * elements to collect, calling base.querySelectorAll().
- * @param {function(Element): string} uidCallback - Function that,
- * given an element, returns a unique identifier for it.
- * @param {string[]} classes - Array of CSS classes to add/remove
- * from an element as it becomes current.
- * @param {boolean} snapToTop - Whether items should snap to the
- * top of the window when coming into view.
- * @param {object} [debug={}] - Debug options
- * @param {boolean} [debug.enabled=false] - Enable messages.
- * @param {boolean} [debug.stackTrace=false] - Include stack traces.
- */
- constructor(base, selectors, uidCallback, classes, snapToTop, debug) {
- if (!(base instanceof Element)) {
- throw new TypeError(`Invalid base: ${base}`);
- }
- this._destroyed = false;
- this._base = base;
- this._selectors = selectors;
- this._uidCallback = uidCallback;
- this._classes = classes;
- this._snapToTop = snapToTop;
- this._debug = debug ?? {};
- this._debug.enabled ??= false;
- this._debug.stackTrace ??= false;
- this._msg('Scroller constructed', this);
- }
-
- /**
- * Fancy-ish debug messages.
- * console message groups can be started and ended using magic
- * keywords in messages.
- * - 'Entered' - Starts new group named with the rest of the string.
- * - 'Starting' - Starts a new collapsed group (useful for loops).
- * - 'Leaving' and 'Finished' - Both end the most recent group,
- * with Leaving for the function, and Finished for the loop
- * (though not enforced).
- * @example
- * foo(x) {
- * this._msg('Entered foo', x);
- * ... do stuff ...
- * this._msg('Leaving foo with', y);
- * return y;
- * }
- * @param {string} msg - Debug message to send to the console.
- * @returns {void}
- */
- _msg(msg, ...rest) {
- /* eslint-disable no-console */
- if (this._debug.enabled) {
- if (this._debug.stackTrace) {
- console.groupCollapsed('call stack');
- console.trace();
- console.groupEnd();
- }
- if (typeof msg === 'string' && msg.startsWith('Entered')) {
- console.group(msg.substr(msg.indexOf(' ') + 1));
- } else if (typeof msg === 'string' && msg.startsWith('Starting')) {
- console.groupCollapsed(`${msg.substr(msg.indexOf(' ') + 1)} (collapsed)`);
- }
- console.debug(msg, ...rest);
- if (typeof msg === 'string' && (/^(Leaving|Finished)/).test(msg)) {
- console.groupEnd();
- }
- }
- /* eslint-enable */
- }
-
- /**
- * @type {Dispatcher} - Accessor for dipatcher.
- */
- get dispatcher() {
- return this._dispatcher;
- }
-
- /**
- * @type {Element} - Represents the current item.
- */
- get item() {
- this._msg('Entered get item');
- if (this._destroyed) {
- const msg = `Tried to work with destroyed ${this.constructor.name} on ${this._base}`;
- this._msg(msg);
- throw new Error(msg);
- }
- const items = this._getItems();
- let item = items.find(this._matchItem.bind(this));
- if (!item) {
- // We couldn't find the old id, so maybe it was rebuilt. Make
- // a guess by trying the old index.
- const idx = this._historicalIdToIndex.get(this._currentItemId);
- if (typeof idx === 'number' && 0 <= idx && idx < items.length) {
- item = items[idx];
- this._bottomHalf(item);
- }
- }
- this._msg('Leaving get item with', item);
- return item;
- }
-
- /**
- * @param {Element} val - Update the current item with val.
- */
- set item(val) {
- this._msg('Entered set item with', val);
- if (this.item) {
- this.dull();
- }
- this._bottomHalf(val);
- this._msg('Leaving set item');
- }
-
- /**
- * Since the getter will try to validate the current item (since
- * it could have changed out from under us), it too can update
- * information.
- * @param {Element} val - Element to make current.
- * @returns {void}
- */
- _bottomHalf(val) {
- this._msg('Entered bottomHalf with', val);
- this._currentItemId = this._uid(val);
- const idx = this._getItems().indexOf(val);
- this._historicalIdToIndex.set(this._currentItemId, idx);
- if (val) {
- this.shine();
- this._scrollToCurrentItem();
- }
- this.dispatcher.fire('change', {});
- this._msg('Leaving bottomHalf');
- }
-
- /**
- * Builds the list of using the registered CSS selectors.
- * @returns {Elements[]} - Items to scroll through.
- */
- _getItems() {
- this._msg('Entered getItems');
- const items = [];
- for (const selector of this._selectors) {
- this._msg(`considering ${selector}`);
- items.push(...this._base.querySelectorAll(selector));
- }
- if (this._debug) {
- this._msg('Starting items');
- for (const item of items) {
- this._msg(item);
- }
- this._msg('Finished items');
- }
- this._msg(`Leaving getItems with ${items.length} items`);
- return items;
- }
-
- /**
- * Returns the uid for the current element. Will use the
- * registered uidCallback function for this.
- * @param {Element} element - Element to identify.
- * @returns {string} - Computed uid for element.
- */
- _uid(element) {
- this._msg('Entered uid with', element);
- let uid = null;
- if (element) {
- if (!element.dataset.litId) {
- element.dataset.litId = this._uidCallback(element);
- }
- uid = element.dataset.litId;
- }
- this._msg('Leaving uid with', uid);
- return uid;
- }
-
- /**
- * Checks if the element is the current one. Useful as a callback
- * to Array.find.
- * @param {Element} element - Element to check.
- * @returns {boolean} - Whether or not element is the current one.
- */
- _matchItem(element) {
- this._msg('Entered matchItem');
- const res = this._currentItemId === this._uid(element);
- this._msg('Leaving matchItem with', res);
- return res;
- }
-
- /**
- * Scroll the current item into the view port. Depending on the
- * instance configuration, this could snap to the top, snap to the
- * bottom, or be a no-op.
- * @returns {void}
- */
- _scrollToCurrentItem() {
- this._msg('Entered scrollToCurrentItem with', this._snapToTop);
- const item = this.item;
- item.style.scrollMarginTop = navBarHeightCss;
- if (this._snapToTop) {
- this._msg('snapping to top');
- item.scrollIntoView(true);
- } else {
- item.style.scrollMarginBottom = '3em';
- const rect = item.getBoundingClientRect();
- // If both scrolling happens, it means the item is too tall to
- // fit on the page, so the top is preferred.
- if (rect.bottom > document.documentElement.clientHeight) {
- this._msg('scrolling up onto page');
- item.scrollIntoView(false);
- }
- if (rect.top < navBarHeightPixels) {
- this._msg('scrolling down onto page');
- item.scrollIntoView(true);
- }
- }
- this._msg('Leaving scrollToCurrentItem');
- }
-
- /**
- * Jump an item on the end of the collection.
- * @param {boolean} first - If true, the first item in the
- * collection, else, the last.
- * @returns {void}
- */
- _jumpToEndItem(first) {
- // Reset in case item was heavily modified
- this.item;
-
- const items = this._getItems();
- if (items.length) {
- let idx = first ? 0 : (items.length - 1);
- let item = items[idx];
-
- // Content of items is sometimes loaded lazily and can be
- // detected by having no innerText yet. So start at the end
- // and work our way up to the last one loaded.
- if (!first) {
- while (!item.innerText.length) {
- idx--;
- item = items[idx];
- }
- }
- this.item = item;
- }
- }
-
- /**
- * Move forward or backwards in the collection by at least n.
- * @param {number} n - How many items to move and the intended direction.
- * @fires 'out-of-range'
- * @returns {void}
- */
- _scrollBy(n) {
- this._msg('Entered scrollBy', n);
- // Reset in case item was heavily modified
- this.item;
-
- const items = this._getItems();
- if (items.length) {
- let idx = items.findIndex(this._matchItem.bind(this));
- this._msg('starting idx', idx);
- idx += n;
- if (idx < -1) {
- idx = items.length - 1;
- }
- if (idx === -1 || idx >= items.length) {
- this._msg('left the container');
- this.item = null;
- this.dispatcher.fire('out-of-range', null);
- } else {
- // Skip over empty items
- let item = items[idx];
- while (!item.clientHeight) {
- this._msg('skipping empty item', item);
- idx += n;
- item = items[idx];
- }
- this._msg('final idx', idx);
- this.item = item;
- }
- }
- this._msg('Leaving scrollBy');
- }
-
- /**
- * Move to the next item in the collection.
- * @returns {void}
- */
- next() {
- this._scrollBy(1);
- }
-
- /**
- * Move to the previous item in the collection.
- * @returns {void}
- */
- prev() {
- this._scrollBy(-1);
- }
-
- /**
- * Jump to the first item in the collection.
- * @returns {void}
- */
- first() {
- this._jumpToEndItem(true);
- }
-
- /**
- * Jump to last item in the collection.
- * @returns {void}
- */
- last() {
- this._jumpToEndItem(false);
- }
-
- /**
- * Adds the registered CSS classes to the current element.
- * @returns {void}
- */
- shine() {
- this.item.classList.add(...this._classes);
- }
-
- /**
- * Removes the registered CSS classes from the current element.
- * @returns {void}
- */
- dull() {
- this.item.classList.remove(...this._classes);
- }
-
- /**
- * Bring current item back into view.
- * @returns {void}
- */
- show() {
- this._scrollToCurrentItem();
- }
-
- /**
- * Mark instance as inactive and do any internal cleanup.
- * @returns {void}
- */
- destroy() {
- this._msg('Entered destroy');
- this.item = null;
- this._destroyed = true;
- this._msg('Leaving destroy');
- }
- }
-
- class Page {
- // The immediate following can be set if derived classes
-
- // What pathname part of the URL this page should handle. The
- // special case of null is used by the Pages 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.
- _on_click_selector = null;
-
- // List of keystrokes to register automatically. They are objects
- // with keys of `seq`, `desc`, and `func`. The `seq` is used to
- // define they keystroke sequence to trigger the function. The
- // `desc` is used to create the help screen. The `func` is a
- // function, usually in the form of `this.methodName`. The
- // function is bound to `this` before registering it with
- // VM.shortcut.
- _auto_keys = [];
-
- // Private members.
-
- _keyboard = new VM.shortcut.KeyboardService();
-
- // Tracks which HTMLElement holds the `onclick` function.
- _on_click_element = null;
-
- // Magic for VM.shortcut. This disables keys when focus is on an
- // input type field.
- static _navOption = {
- caseSensitive: true,
- condition: '!inputFocus',
- };
-
- constructor() {
- this._boundOnClick = this._onClick.bind(this);
- }
-
- start() {
- for (const {seq, func} of this._auto_keys) {
- this._addKey(seq, func.bind(this));
- }
- }
-
- get pathname() {
- return this._pathname;
- }
-
- get keyboard() {
- return this._keyboard;
- }
-
- activate() {
- this._keyboard.enable();
- this._enableOnClick();
- }
-
- deactivate() {
- this._keyboard.disable();
- this._disableOnClick();
- }
-
- get helpHeader() {
- return this.constructor.name;
- }
-
- get helpContent() {
- return this._auto_keys;
- }
-
- _addKey(seq, func) {
- this._keyboard.register(seq, func, Page._navOption);
- }
-
- _enableOnClick() {
- if (this._on_click_selector) {
- // Page is dynamically building, so keep watching it until the
- // element shows up.
- VM.observe(document.body, () => {
- const element = document.querySelector(this._on_click_selector);
- if (element) {
- this._on_click_element = element;
- this._on_click_element.addEventListener('click', this._boundOnClick);
-
- return true;
- }
- });
- }
- }
-
- _disableOnClick() {
- if (this._on_click_element) {
- this._on_click_element.removeEventListener('click', this._boundOnClick);
- this._on_click_element = null;
- }
- }
-
- // Override this function in derived classes that want to react to
- // random clicks on a page, say to update current element in
- // focus.
- _onClick(evt) { // eslint-disable-line no-unused-vars
- alert(`Found a bug! ${this.constructor.name} wants to handle clicks, but forgot to create a handler.`);
- }
-
- }
-
- class Global extends Page {
- _pathname = null;
- _auto_keys = [
- {seq: '?', desc: 'Show keyboard help', func: this._help},
- {seq: '/', desc: 'Go to Search box', func: this._gotoSearch},
- {seq: 'g h', desc: 'Go Home (aka, Feed)', func: this._goHome},
- {seq: 'g m', desc: 'Go to My Network', func: this._gotoMyNetwork},
- {seq: 'g j', desc: 'Go to Jobs', func: this._gotoJobs},
- {seq: 'g g', desc: 'Go to Messaging', func: this._gotoMessaging},
- {seq: 'g n', desc: 'Go to Notifications', func: this._gotoNotifications},
- {seq: 'g p', desc: 'Go to Profile (aka, Me)', func: this._gotoProfile},
- {seq: 'g b', desc: 'Go to Business', func: this._gotoBusiness},
- {seq: 'g l', desc: 'Go to Learning', func: this._gotoLearning},
- ];
-
- get helpId() {
- return this._helpId;
- }
-
- set helpId(val) {
- this._helpId = val;
- }
-
- _gotoNavLink(item) {
- clickElement(document, [`#global-nav a[href*="/${item}"`]);
- }
-
- _gotoNavButton(item) {
- const buttons = Array.from(document.querySelectorAll('#global-nav button'));
- const button = buttons.find(el => el.textContent.includes(item));
- if (button) {
- button.click();
- }
- }
-
- _help() {
- const help = document.querySelector(`#${this.helpId}`);
- help.showModal();
- help.focus();
- }
-
- _gotoSearch() {
- clickElement(document, ['#global-nav-search button']);
- }
-
- _goHome() {
- this._gotoNavLink('feed');
- }
-
- _gotoMyNetwork() {
- this._gotoNavLink('mynetwork');
- }
-
- _gotoJobs() {
- this._gotoNavLink('jobs');
- }
-
- _gotoMessaging() {
- this._gotoNavLink('messaging');
- }
-
- _gotoNotifications() {
- this._gotoNavLink('notifications');
- }
-
- _gotoProfile() {
- this._gotoNavButton('Me');
- }
-
- _gotoBusiness() {
- this._gotoNavButton('Business');
- }
-
- _gotoLearning() {
- this._gotoNavLink('learning');
- }
-
- }
-
- class Feed extends Page {
- _pathname = '/feed/';
- _on_click_selector = 'main';
- _auto_keys = [
- {seq: 'X', desc: 'Toggle hiding current post', func: this._togglePost},
- {seq: 'j', desc: 'Next post', func: this._nextPost},
- {seq: 'J', desc: 'Toggle hiding then next post', func: this._nextPostPlus},
- {seq: 'k', desc: 'Previous post', func: this._prevPost},
- {seq: 'K', desc: 'Toggle hiding then previous post', func: this._prevPostPlus},
- {seq: 'm', desc: 'Show more of the post or comment', func: this._seeMore},
- {seq: 'c', desc: 'Show comments', func: this._showComments},
- {seq: 'n', desc: 'Next comment', func: this._nextComment},
- {seq: 'p', desc: 'Previous comment', func: this._prevComment},
- {seq: 'l', desc: 'Load more posts (if the <button>New Posts</button> button is available, load those)', func: this._loadMorePosts},
- {seq: 'L', desc: 'Like post or comment', func: this._likePostOrComment},
- {seq: '<', desc: 'Go to first post or comment', func: this._firstPostOrComment},
- {seq: '>', desc: 'Go to last post or comment currently loaded', func: this._lastPostOrComment},
- {seq: 'f', desc: 'Change browser focus to current post or comment', func: this._focusBrowser},
- {seq: 'v p', desc: 'View the post directly', func: this._viewPost},
- {seq: 'v r', desc: 'View reactions on current post or comment', func: this._viewReactions},
- {seq: 'P', desc: 'Go to the share box to start a post or <kbd>TAB</kbd> to the other creator options', func: this._gotoShare},
- {seq: '=', desc: 'Open the (⋯) menu', func: this._openMeatballMenu},
- ];
-
- _postScroller = null;
- _commentScroller = null;
-
- constructor() {
- super();
- this._postScroller = new Scroller(document.body, ['main div[data-id]'], this._uniqueIdentifier, ['tom'], true, {enabled: true, stackTrace: true});
- this._postScroller.dispatcher.on('out-of-range', focusOnSidebar);
- this._postScroller.dispatcher.on('change', this._changedPost.bind(this));
- }
-
- _onClick(evt) {
- const post = evt.target.closest('div[data-id]');
- if (post && post !== this._posts.item) {
- this._posts.item = post;
- }
- }
-
- get _posts() {
- return this._postScroller;
- }
-
- get _comments() {
- if (!this._commentScroller && this._posts.item) {
- this._commentScroller = new Scroller(this._posts.item, ['article.comments-comment-item'], this._uniqueIdentifier, ['dick'], false);
- this._commentScroller.dispatcher.on('out-of-range', this._returnToPost.bind(this));
- }
- return this._commentScroller;
- }
-
- set _comments(val) {
- if (this._commentScroller) {
- this._commentScroller.destroy();
- this._commentScroller = null;
- }
- }
-
- get _hasActiveComment() {
- return this._comments && this._comments.item;
- }
-
- _uniqueIdentifier(element) {
- if (element) {
- return element.dataset.id;
- } else {
- return null;
- }
- }
-
- _returnToPost() {
- this._posts.item = this._posts.item;
- }
-
- _changedPost() {
- this._comments = null;
- }
-
- _nextPost() {
- this._posts.next();
- }
-
- _nextPostPlus() {
- function f() {
- this._togglePost();
- this._nextPost();
- }
- // XXX Need to remove the highlight before otrot sees it because
- // it affects the .clientHeight.
- this._posts.dull();
- otrot(this._posts.item, f.bind(this), 3000).then(() => {
- this._posts.show();
- }).catch(e => console.error(e)); // eslint-disable-line no-console
- }
-
- _prevPost() {
- this._posts.prev();
- }
-
- _prevPostPlus() {
- this._togglePost();
- this._prevPost();
- }
-
- _nextComment() {
- this._comments.next();
- }
-
- _prevComment() {
- this._comments.prev();
- }
-
- _togglePost() {
- clickElement(this._posts.item, ['button[aria-label^="Dismiss post"]', 'button[aria-label^="Undo and show"]']);
- }
-
- _showComments() {
- function tryComment(comment) {
- if (comment) {
- const buttons = Array.from(comment.querySelectorAll('button'));
- const button = buttons.find(el => el.textContent.includes('Load previous replies'));
- if (button) {
- button.click();
- return true;
- }
- }
- return false;
- }
-
- if (!tryComment(this._comments.item)) {
- clickElement(this._posts.item, ['button[aria-label*="comment"]']);
- }
- }
-
- _seeMore() {
- const el = this._comments.item ?? this._posts.item;
- clickElement(el, ['button[aria-label^="see more"]']);
- }
-
- _likePostOrComment() {
- const el = this._comments.item ?? this._posts.item;
- clickElement(el, ['button[aria-label^="Open reactions menu"]']);
- }
-
- _firstPostOrComment() {
- if (this._hasActiveComment) {
- this._comments.first();
- } else {
- this._posts.first();
- }
- }
-
- _lastPostOrComment() {
- if (this._hasActiveComment) {
- this._comments.last();
- } else {
- this._posts.last();
- }
- }
-
- _loadMorePosts() {
- const container = document.querySelector('div.scaffold-finite-scroll__content');
- function f() {
- const initialPost = this._posts.item;
- this._posts.first();
- if (!clickElement(this._posts.item, ['div.feed-new-update-pill button'])) {
- this._posts.item = initialPost;
- clickElement(document, ['main button.scaffold-finite-scroll__load-button']);
- }
- }
- otrot(container, f.bind(this), 3000).then(() => {
- this._posts.item = this._posts.item;
- });
- }
-
- _gotoShare() {
- const share = document.querySelector('div.share-box-feed-entry__top-bar').parentElement;
- share.style.scrollMarginTop = navBarHeightCss;
- share.scrollIntoView();
- share.querySelector('button').focus();
- }
-
- _openMeatballMenu() {
- // XXX In this case, the identifier is on an svg element, not
- // the button, so use the parentElement. When Firefox [fully
- // supports](https://bugzilla.mozilla.org/show_bug.cgi?id=418039)
- // the `:has()` pseudo-selector, we can probably use that and
- // use `clickElement()`.
- if (this._comments.item) {
- const button = this._comments.item.querySelector('[aria-label^="Open options"]').parentElement;
- button.click();
- } else if (this._posts.item) {
- const button = this._posts.item.querySelector('[a11y-text^="Open control menu"]').parentElement;
- button.click();
- }
- }
-
- _focusBrowser() {
- const el = this._comments.item ?? this._posts.item;
- focusOnElement(el);
- }
-
- _viewPost() {
- const post = this._posts.item;
- if (post) {
- const urn = post.dataset.id;
- const id = `lt-${urn.replaceAll(':', '-')}`;
- let a = post.querySelector(`#${id}`);
- if (!a) {
- a = document.createElement('a');
- a.href = `/feed/update/${urn}/`;
- a.id = id;
- post.append(a);
- }
- a.click();
- }
- }
-
- _viewReactions() {
- // Bah! The queries are annoyingly different.
- if (this._comments.item) {
- clickElement(this._comments.item, ['button.comments-comment-social-bar__reactions-count']);
- } else if (this._posts.item) {
- clickElement(this._posts.item, ['button.feed-shared-social-action-bar-counts']);
- }
- }
-
- }
-
- class Jobs extends Page {
- _pathname = '/jobs/';
- _auto_keys = [
- {seq: 'j', desc: 'Next section', func: this._nextSection},
- {seq: 'k', desc: 'Previous section', func: this._prevSection},
- {seq: '<', desc: 'Go to to first section', func: this._firstSection},
- {seq: '>', desc: 'Go to last section currently loaded', func: this._lastSection},
- {seq: 'f', desc: 'Change browser focus to current section', func: this._focusBrowser},
- {seq: 'l', desc: 'Load more sections', func: this._loadMoreSections},
- ];
-
- _currentSectionId = null;
-
- get _section() {
- return this._getSections().find(this._match.bind(this));
- }
-
- set _section(val) {
- if (this._section) {
- this._section.classList.remove('tom');
- }
- this._currentSectionId = this._uniqueIdentifier(val);
- if (val) {
- val.classList.add('tom');
- this._scrollToCurrentSection();
- }
- }
-
- _getSections() {
- return Array.from(document.querySelectorAll('main section'));
- }
-
- _uniqueIdentifier(element) {
- if (element) {
- const h2 = element.querySelector('h2');
- if (h2) {
- return h2.innerText;
- } else {
- return element.innerText;
- }
- } else {
- return null;
- }
- }
-
- _match(el) {
- return this._currentSectionId === this._uniqueIdentifier(el);
- }
-
- _scrollToCurrentSection() {
- if (this._section) {
- this._section.style.scrollMarginTop = navBarHeightCss;
- this._section.scrollIntoView();
- }
- }
-
- _scrollBy(n) {
- const sections = this._getSections();
- if (sections.length) {
- let idx = sections.findIndex(this._match.bind(this)) + n;
- if (idx < -1) {
- idx = sections.length - 1;
- }
- if (idx === -1 || idx >= sections.length) {
- focusOnSidebar();
- this._section = null;
- } else {
- this._section = sections[idx];
- }
- }
- }
-
- _nextSection() {
- this._scrollBy(1);
- }
-
- _prevSection() {
- this._scrollBy(-1);
- }
-
- _jumpToSection(first) {
- const sections = this._getSections();
- if (sections.length) {
- const idx = first ? 0 : (sections.length - 1);
- this._section = sections[idx];
- }
- }
-
- _firstSection() {
- this._jumpToSection(true);
- }
-
- _lastSection() {
- this._jumpToSection(false);
- }
-
- _focusBrowser() {
- focusOnElement(this._section);
- }
-
- _loadMoreSections() {
- const container = document.querySelector('div.scaffold-finite-scroll__content');
- function f() {
- clickElement(document, ['main button.scaffold-finite-scroll__load-button']);
- }
- otrot(container, f.bind(this), 3000).then(() => {
- // This forces a scrolling and highlighting because the
- // elements were remade.
- this._section = this._section;
- });
- }
- }
-
- class JobsCollections extends Page {
- _pathname = '/jobs/collections/';
- }
-
- class Notifications extends Page {
- _pathname = '/notifications/';
- _on_click_selector = 'main';
- _auto_keys = [
- {seq: 'j', desc: 'Next notification', func: this._nextNotification},
- {seq: 'k', desc: 'Previous notification', func: this._prevNotification},
- {seq: 'Enter', desc: 'Activate the notification (click on it)', func: this._activateNotification},
- {seq: 'X', desc: 'Toggle current notification deletion', func: this._deleteNotification},
- {seq: 'l', desc: 'Load more notifications', func: this._loadMoreNotifications},
- {seq: 'f', desc: 'Change browser focus to current notification', func: this._focusBrowser},
- {seq: '<', desc: 'Go to first notification', func: this._firstNotification},
- {seq: '>', desc: 'Go to last notification', func: this._lastNotification},
- {seq: '=', desc: 'Open the (⋯) menu', func: this._openMeatballMenu},
- ];
-
- // Ugh. When notification cards are deleted or restored, the
- // entire element, and parent elements, are deleted and replaced
- // by new elements. And the deleted versions have nothing in
- // common with the original ones. Since we can't detect when a
- // card is deleted, we need to track where it was so maybe we can
- // refind it again later.
- _currentNotificationId = null;
- _historicalNotificationIdToIndex = new Map();
-
- _onClick(evt) {
- const notification = evt.target.closest('div.nt-card-list article');
- if (notification) {
- this._notification = notification;
- }
- }
-
- get _notification() {
- const notifications = this._getNotifications();
- let notification = notifications.find(this._match.bind(this));
- if (!notification) {
- // Couldn't find the old id. Maybe the card was modified, so
- // try the old index.
- const idx = this._historicalNotificationIdToIndex.get(this._currentNotificationId);
- if (typeof idx === 'number' && 0 <= idx && idx < notifications.length) {
- notification = notifications[idx];
- this._setBottomHalf(notification);
- }
- }
- return notification;
- }
-
- set _notification(val) {
- if (this._notification) {
- this._notification.classList.remove('tom');
- }
- this._setBottomHalf(val);
- }
-
- _setBottomHalf(val) {
- this._currentNotificationId = this._uniqueIdentifier(val);
- const idx = this._getNotifications().indexOf(val);
- this._historicalNotificationIdToIndex.set(this._currentNotificationId, idx);
- if (val) {
- val.classList.add('tom');
- this._scrollToCurrentNotification();
- }
- }
-
- _getNotifications() {
- return Array.from(document.querySelectorAll('main section div.nt-card-list article'));
- }
-
- // Complicated because there are so many variations in
- // notification cards. We do not want to use reaction counts
- // because they can change too quickly.
- _uniqueIdentifier(element) {
- if (element) {
- if (!element.dataset.litId) {
- let content = element.innerText;
- if (element.childElementCount === 3) {
- let content = element.children[1].innerText;
- if (content.includes('Reactions')) {
- for (const el of element.children[1].querySelectorAll('*')) {
- if (el.innerText) {
- content = el.innerText;
- break;
- }
- }
- }
- }
- if (content.startsWith('Notification deleted.')) {
- // Mix in something unique from the parent.
- content += element.parentElement.dataset.finiteScrollHotkeyItem;
- }
- element.dataset.litId = strHash(content);
- }
- return element.dataset.litId;
- } else {
- return null;
- }
- }
-
- _match(el) {
- return this._currentNotificationId === this._uniqueIdentifier(el);
- }
-
- _scrollToCurrentNotification() {
- const rect = this._notification.getBoundingClientRect();
- this._notification.style.scrollMarginTop = navBarHeightCss;
- this._notification.style.scrollMarginBottom = '3em';
- if (rect.bottom > document.documentElement.clientHeight) {
- this._notification.scrollIntoView(false);
- }
- if (rect.top < navBarHeightPixels) {
- this._notification.scrollIntoView();
- }
- }
-
- _scrollBy(n) {
- // XXX This standalone line invokes the magic code in the
- // getter. Necessary when scrolling is the first thing after
- // deleting a card.
- this._notification;
- const notifications = this._getNotifications();
- if (notifications.length) {
- let idx = notifications.findIndex(this._match.bind(this)) + n;
- if (idx < -1) {
- idx = notifications.length - 1;
- }
- if (idx === -1 || idx >= notifications.length) {
- focusOnSidebar();
- this._notification = null;
- } else {
- this._notification = notifications[idx];
- }
- }
- }
-
- _nextNotification() {
- this._scrollBy(1);
- }
-
- _prevNotification() {
- this._scrollBy(-1);
- }
-
- _jumpToNotification(first) {
- const notifications = this._getNotifications();
- if (notifications.length) {
- const idx = first ? 0 : (notifications.length - 1);
- this._notification = notifications[idx];
- }
- }
-
- _focusBrowser() {
- focusOnElement(this._notification);
- }
-
- _firstNotification() {
- this._jumpToNotification(true);
- }
-
- _lastNotification() {
- this._jumpToNotification(false);
- }
-
- _openMeatballMenu() {
- clickElement(this._notification, ['button[aria-label^="Settings menu"]']);
- }
-
- _activateNotification() {
- if (this._notification) {
- // Because we are using Enter as the hotkey here, if the
- // active element is inside the current card, we want that to
- // take precedence.
- if (document.activeElement.closest('article') === this._notification) {
- return;
- }
- // Every notification is different.
- // It may be that notifications are settling on 'a.nt-card__headline'.
- function matchesKnownText(el) { // eslint-disable-line no-inner-declarations
- if (el.innerText === 'Apply early') return true;
- return false;
- }
-
- // Debugging code.
- if (this._notification.querySelectorAll('a.nt-card__headline').length === 1 && this._notification.querySelector('button.message-anywhere-button')) {
- console.debug(this._notification); // eslint-disable-line no-console
- alert('Yes, can be simplified');
- }
-
- if (!clickElement(this._notification, ['button.message-anywhere-button'])) {
- const buttons = Array.from(this._notification.querySelectorAll('button'));
- const button = buttons.find(matchesKnownText);
- if (button) {
- button.click();
- } else {
- const links = this._notification.querySelectorAll('a.nt-card__headline');
- if (links.length === 1) {
- links[0].click();
- } else {
- console.debug(this._notification); // eslint-disable-line no-console
- for (const el of this._notification.querySelectorAll('*')) {
- console.debug(el); // eslint-disable-line no-console
- }
- const msg = [
- 'You tried to activate an unsupported notification',
- 'element. Please file a bug. If you are comfortable',
- 'with using the browser\'s Developer Tools (often the',
- 'F12 key), consider sharing the information just logged',
- 'in the console / debug view.',
- ];
- alert(msg.join(' '));
- }
- }
- }
- } else {
- // Again, because we use Enter as the hotkey for this action.
- document.activeElement.click();
- }
- }
-
- _deleteNotification() {
- if (this._notification) {
- // Hah. Unlike in other places, these buttons already exist,
- // just hidden under the menu.
- const buttons = Array.from(this._notification.querySelectorAll('button'));
- const button = buttons.find(el => el.textContent.includes('Delete this notification'));
- if (button) {
- button.click();
- } else {
- clickElement(this._notification, ['button[aria-label^="Undo notification deletion"]']);
- }
- }
- }
-
- _loadMoreNotifications() {
- const buttons = Array.from(document.querySelectorAll('main section button'));
- const button = buttons.find(el => el.textContent.includes('Show more results'));
- if (button) {
- button.click();
- }
- }
-
- }
-
- class Pages {
- _global = null;
- _page = null;
- _pages = new Map();
-
- _lastInputElement = null;
-
- constructor() {
- this._id = crypto.randomUUID();
- this._installNavStyle();
- this._initializeHelpMenu();
- document.addEventListener('focus', this._onFocus.bind(this), true);
- document.addEventListener('urlchange', this._onUrlChange.bind(this), true);
- }
-
- _setInputFocus(state) {
- const pages = Array.from(this._pages.values());
- pages.push(this._global);
- for (const page of pages) {
- if (page) {
- page.keyboard.setContext('inputFocus', state);
- }
- }
- }
-
- _onFocus(evt) {
- if (this._lastInputElement && evt.target !== this._lastInputElement) {
- this._lastInputElement = null
- this._setInputFocus(false);
- }
- if (isInput(evt.target)) {
- this._setInputFocus(true);
- this._lastInputElement = evt.target;
- }
- }
-
- _onUrlChange(evt) {
- this.activate(evt.detail.url.pathname);
- }
-
- _installNavStyle() {
- const style = document.createElement('style');
- style.textContent += '.tom { border-color: orange !important; border-style: solid !important; border-width: medium !important; }';
- style.textContent += '.dick { border-color: red !important; border-style: solid !important; border-width: thin !important; }';
- document.head.append(style);
- }
-
- _initializeHelpMenu() {
- this._helpId = `help-${this._id}`;
- const style = document.createElement('style');
- style.textContent += `#${this._helpId} kbd {font-size: 0.85em; padding: 0.07em; border-width: 1px; border-style: solid; }`;
- style.textContent += `#${this._helpId} th { padding-top: 1em; text-align: left; }`;
- style.textContent += `#${this._helpId} td:first-child { white-space: nowrap; text-align: right; padding-right: 0.5em; }`;
- style.textContent += `#${this._helpId} button { border-width: 1px; border-style: solid; border-radius: 0.25em; }`;
- document.head.prepend(style);
- const dialog = document.createElement('dialog');
- dialog.id = this._helpId
- dialog.innerHTML = '<table><caption>' +
- '<span style="float: left">Keyboard shortcuts</span>' +
- '<span style="float: right">Hit <kbd>ESC</kbd> to close</span>' +
- '</caption><tbody></tbody></table>';
- document.body.prepend(dialog);
- }
-
- // ThisPage -> This Page
- _parseHeader(text) {
- return text.replace(/([A-Z])/g, ' $1').trim();
- }
-
- // 'a b' -> '<kbd>a</kbd> then <kbd>b</kbd>'
- _parseSeq(seq) {
- const letters = seq.split(' ').map(w => `<kbd>${w}</kbd>`);
- const s = letters.join(' then ');
- return s;
- }
-
- _addHelp(page) {
- const help = document.querySelector(`#${this._helpId} tbody`);
- const section = this._parseHeader(page.helpHeader);
- let s = `<tr><th></th><th>${section}</th></tr>`;
- for (const {seq, desc} of page.helpContent) {
- const keys = this._parseSeq(seq);
- s += `<tr><td>${keys}:</td><td>${desc}</td></tr>`;
- }
- // Don't include works in progress that have no keys yet.
- if (page.helpContent.length) {
- help.innerHTML += s;
- }
- }
-
- register(page) {
- page.start();
- this._addHelp(page);
- if (page.pathname === null) {
- page.helpId = this._helpId
- this._global = page;
- this._global.activate();
- } else {
- this._pages.set(page.pathname, page);
- }
- }
-
- _findPage(pathname) {
- const pathnames = Array.from(this._pages.keys());
- const candidates = pathnames.filter(p => pathname.startsWith(p));
- const candidate = candidates.reduce((a, b) => (a.length > b.length ? a : b), '');
- return this._pages.get(candidate) || null;
- }
-
- activate(pathname) {
- if (this._page) {
- this._page.deactivate();
- }
- const page = this._findPage(pathname);
- this._page = page;
- if (page) {
- page.activate();
- }
- }
- }
-
- const pages = new Pages();
- pages.register(new Global());
- pages.register(new Feed());
- pages.register(new Jobs());
- pages.register(new JobsCollections());
- pages.register(new Notifications());
- pages.activate(window.location.pathname);
-
-
- function navBarMonitor() {
- const navbar = document.querySelector('#global-nav');
- if (navbar) {
- return {done: true, results: navbar};
- }
- return {done: false, results: null};
- }
-
- // In this case, the trigger was the page load. It already happened
- // by the time we got here.
- otmot(document.body, {childList: true, subtree: true}, navBarMonitor,
- null, 0)
- .then((el) => {
- navBarHeightPixels = el.clientHeight + 4;
- navBarHeightCss = `${navBarHeightPixels}px`;
- });
-
- if (window.onurlchange === null) {
- // We are likely running on Tampermonkey, so use native support.
- console.debug('Using window.onurlchange for monitoring URL updates.'); // eslint-disable-line no-console
- 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` there
- // `pages` is listening for it.
- const newUrl = new URL(info.url);
- const evt = new CustomEvent('urlchange', {detail: {url: newUrl}});
- document.dispatchEvent(evt);
- });
- } else {
- console.debug('Using MutationObserver for monitoring URL updates.'); // eslint-disable-line no-console
-
- let oldUrl = new URL(window.location);
- function registerUrlMonitor(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});
- }
-
- 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, results: null};
- }
-
- otmot(document.body, {childList: true, subtree: true}, authenticationOutletMonitor, null, 0)
- .then((el) => registerUrlMonitor(el));
- }
-
- console.debug('Initialization successful.'); // eslint-disable-line no-console
-
- })();