// ==UserScript==
// @name LinkedIn Tool
// @namespace [email protected]
// @match https://www.linkedin.com/*
// @version 2.4.4
// @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
// ==/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]
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.
function clickElement(base, selectorArray) {
if (base) {
for (const selector of selectorArray) {
const el = base.querySelector(selector);
if (el) {
el.click();
return true;
}
}
}
return false;
}
function isInput(element) {
let tagName = '';
if ('tagName' in element) {
tagName = element.tagName.toLowerCase();
}
return (element.isContentEditable || ['input', 'textarea'].includes(tagName));
}
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');
}
}
}
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.
// base - element to observe
// trigger - function to call that triggers observable events, can be null
// timeout - time to wait for completion in milliseconds, 0 disables
// Returns promise that 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
// base - element to observe
// options - MutationObserver().observe options
// monitor - function that takes [MutationRecord] and returns a {done, results} object
// trigger - function to call that triggers observable results, can be null
// timeout - time to wait for completion in milliseconds, 0 disables
// Returns promise that will resolve with the results from monitor.
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.
* This is NOT an error condition, but rather a design feature.
*/
class Scroller {
_dispatcher = new Dispatcher('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);
}
/**
* @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;
}
set item(val) {
this._msg('Entered set item with', val);
if (this.item) {
this.item.classList.remove(...this._classes);
}
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) {
val.classList.add(...this._classes);
this._scrollToCurrentItem();
}
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() {
const item = this.item;
this._msg('Entered scrollToCurrentItem with', this._snapToTop);
item.style.scrollMarginTop = navBarHeightCss;
if (this._snapToTop) {
item.scrollIntoView();
} 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) {
item.scrollIntoView(false);
}
if (rect.top < navBarHeightPixels) {
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);
}
/**
* 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},
];
_currentPostId = null;
_commentScroller = null;
_onClick(evt) {
const post = evt.target.closest('div[data-id]');
if (post) {
this._post = post;
}
}
get _post() {
return this._getPosts().find(this._matchPost.bind(this));
}
set _post(val) {
if (val === this._post && this._comment.item) {
return;
}
if (this._post) {
this._post.classList.remove('tom');
}
this._currentPostId = this._uniqueIdentifier(val);
this._comment = null;
if (val) {
val.classList.add('tom');
this._scrollToCurrentPost();
}
}
get _comment() {
if (!this._commentScroller && this._post) {
this._commentScroller = new Scroller(this._post, ['article.comments-comment-item'], this._uniqueIdentifier, ['dick'], false);
this._commentScroller.dispatcher.on('out-of-range', this._returnToPost.bind(this));
}
return this._commentScroller;
}
set _comment(val) {
if (this._commentScroller) {
this._commentScroller.destroy();
this._commentScroller = null;
}
}
get _activeComment() {
return this._comment && this._comment.item;
}
_getPosts() {
return Array.from(document.querySelectorAll('main div[data-id]'));
}
_uniqueIdentifier(element) {
if (element) {
return element.dataset.id;
} else {
return null;
}
}
_matchPost(el) {
return this._currentPostId === this._uniqueIdentifier(el);
}
_scrollToCurrentPost() {
if (this._post) {
this._post.style.scrollMarginTop = navBarHeightCss;
this._post.scrollIntoView();
}
}
_scrollBy(n) {
const posts = this._getPosts();
if (posts.length) {
let post = null;
let idx = posts.findIndex(this._matchPost.bind(this)) + n;
if (idx < -1) {
idx = posts.length - 1;
}
if (idx === -1 || idx >= posts.length) {
focusOnSidebar();
} else {
// Some posts are hidden (ads, suggestions). Skip over thoses.
post = posts[idx];
while (!post.clientHeight) {
idx += n;
post = posts[idx];
}
}
this._post = post;
}
}
_returnToPost() {
this._post = this._post;
}
_nextPost() {
this._scrollBy(1);
}
_nextPostPlus() {
function f() {
this._togglePost();
this._nextPost();
}
// XXX Need to remove the highlight before otrot sees it because
// it affects the .clientHeight.
this._post.classList.remove('tom');
otrot(this._post, f.bind(this), 3000).then(() => {
this._scrollToCurrentPost();
}).catch(e => console.error(e)); // eslint-disable-line no-console
}
_prevPost() {
this._scrollBy(-1);
}
_prevPostPlus() {
this._togglePost();
this._prevPost();
}
_nextComment() {
this._comment.next();
}
_prevComment() {
this._comment.prev();
}
_togglePost() {
clickElement(this._post, ['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._comment.item)) {
clickElement(this._post, ['button[aria-label*="comment"]']);
}
}
_seeMore() {
const el = this._comment.item ?? this._post;
clickElement(el, ['button[aria-label^="see more"]']);
}
_likePostOrComment() {
const el = this._comment.item ?? this._post;
clickElement(el, ['button[aria-label^="Open reactions menu"]']);
}
_jumpToPost(first) {
const posts = this._getPosts();
if (posts.length) {
let idx = first ? 0 : (posts.length - 1);
let post = posts[idx];
// Post content can be loaded lazily and can be detected by
// having no innerText yet. So go to the last one that is
// loaded. By the time we scroll to it, the next posts may
// have content, but it will close.
if (!first) {
while (!post.innerText.length) {
idx--;
post = posts[idx];
}
}
this._post = post;
}
}
_firstPostOrComment() {
if (this._activeComment) {
this._comment.first();
} else {
this._jumpToPost(true);
}
}
_lastPostOrComment() {
if (this._activeComment) {
this._comment.last();
} else {
this._jumpToPost(false);
}
}
_loadMorePosts() {
const container = document.querySelector('div.scaffold-finite-scroll__content');
function f() {
const posts = this._getPosts();
if (clickElement(posts[0], ['div.feed-new-update-pill button'])) {
this._post = posts[0];
} else {
clickElement(document, ['main button.scaffold-finite-scroll__load-button']);
}
}
otrot(container, f.bind(this), 3000).then(() => {
this._scrollToCurrentPost();
});
}
_gotoShare() {
const share = document.querySelector('div.share-box-feed-entry__top-bar').parentElement;
share.style.scrollMarginTop = navBarHeightCss;
share.scrollIntoView();
share.querySelector('button').focus();
}
_openMeatballMenu() {
if (this._comment.item) {
// XXX In this case, the aria-label is on the svg element, not
// the button, so use the parentElement.
const button = this._comment.item.querySelector('[aria-label^="Open options"]').parentElement;
button.click();
} else if (this._post) {
// Yeah, I don't get it. This one isn't the button either,
// but the click works.
clickElement(this._post, ['[aria-label^="Open control menu"]']);
}
}
_focusBrowser() {
const el = this._comment.item ?? this._post;
focusOnElement(el);
}
_viewPost() {
if (this._post) {
const urn = this._post.dataset.id;
const id = `lt-${urn.replaceAll(':', '-')}`;
let a = this._post.querySelector(`#${id}`);
if (!a) {
a = document.createElement('a');
a.href = `/feed/update/${urn}/`;
a.id = id;
this._post.append(a);
}
a.click();
}
}
_viewReactions() {
// Bah! The queries are annoyingly different.
if (this._comment.item) {
clickElement(this._comment.item, ['button.comments-comment-social-bar__reactions-count']);
} else if (this._post) {
clickElement(this._post, ['button.social-details-social-counts__count-value']);
}
}
}
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('href', this._onHref.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;
}
}
_onHref(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) => {
return 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`;
});
let oldUrl = new URL(window.location);
function registerUrlMonitor(element) {
const observer = new MutationObserver(() => {
const newUrl = new URL(window.location);
if (oldUrl.href !== newUrl.href) {
const evt = new CustomEvent('href', {detail: {url: newUrl}})
oldUrl = newUrl;
document.dispatchEvent(evt);
}
});
observer.observe(element, {childList: true, subtree: true});
}
function authenticationOutletMonitor() {
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('Parsing successful.'); // eslint-disable-line no-console
})();