// ==UserScript==
// @name LinkedIn Tool
// @namespace [email protected]
// @match https://www.linkedin.com/*
// @noframes
// @version 5.4.5
// @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
// @supportURL https://github.com/nexushoratio/userscripts/blob/main/linkedin-tool.md
// @require https://cdn.jsdelivr.net/npm/@violentmonkey/shortcut@1
// @require https://greasyfork.org/scripts/477290-nh-base/code/NH_base.js?version=1263984
// @grant window.onurlchange
// ==/UserScript==
/* global VM, NexusHoratio */
// eslint-disable-next-line max-lines-per-function
(async () => {
'use strict';
const NH = NexusHoratio;
// TODO(#167): DefaultMap moving to lib/base.
/**
* Subclass of {Map} similar to Python's defaultdict.
*
* First argument is a factory function that will create a new default value
* for the key if not already present in the container.
*/
class DefaultMap extends Map {
#factory
/**
* @param {function() : *} factory - Function that creates a new default
* value if a requested key is not present.
* @param {Iterable} [iterable] - Passed to {Map} super().
*/
constructor(factory, iterable) {
if (!(factory instanceof Function)) {
throw new TypeError('The factory argument MUST be of ' +
`type Function, not ${typeof factory}.`);
}
super(iterable);
this.#factory = factory;
}
/** @inheritdoc */
get(key) {
if (!this.has(key)) {
this.set(key, this.#factory());
}
return super.get(key);
}
}
/* eslint-disable max-lines-per-function */
/* eslint-disable no-magic-numbers */
/* eslint-disable no-unused-vars */
/** Test case. */
function testDefaultMap() {
/**
* @typedef {object} DefaultMapTest
* @property {function()} test - Function to execute.
* @property {*} expected - Expected results.
*/
/** @type {Map<string,DefaultMapTest>} */
const tests = new Map();
tests.set('noFactory', {test: () => {
try {
const dummy = new DefaultMap();
} catch (e) {
if (e instanceof TypeError) {
return 'caught';
}
}
return 'oops';
},
expected: 'caught'});
tests.set('badFactory', {test: () => {
try {
const dummy = new DefaultMap('a');
} catch (e) {
if (e instanceof TypeError) {
return 'caught';
}
}
return 'oops';
},
expected: 'caught'});
tests.set('withIterable', {test: () => {
const dummy = new DefaultMap(Number, [[1, 'one'], [2, 'two']]);
dummy.set(3, ['a', 'b']);
dummy.get(4);
return JSON.stringify(Array.from(dummy.entries()));
},
expected: '[[1,"one"],[2,"two"],[3,["a","b"]],[4,0]]'});
tests.set('counter', {test: () => {
const dummy = new DefaultMap(Number);
dummy.get('a');
dummy.set('b', dummy.get('b') + 1);
dummy.set('b', dummy.get('b') + 1);
dummy.get('c');
return JSON.stringify(Array.from(dummy.entries()));
},
expected: '[["a",0],["b",2],["c",0]]'});
tests.set('array', {test: () => {
const dummy = new DefaultMap(Array);
dummy.get('a').push(1, 2, 3);
dummy.get('b').push(4, 5, 6);
dummy.get('a').push('one', 'two', 'three');
return JSON.stringify(Array.from(dummy.entries()));
},
expected: '[["a",[1,2,3,"one","two","three"]],["b",[4,5,6]]]'});
for (const [name, {test, expected}] of tests) {
const actual = test();
const passed = actual === expected;
const msg = `t:${name} e:${expected} a:${actual} p:${passed}`;
NH.base.testing.log.log(msg);
if (!passed) {
throw new Error(msg);
}
}
}
/* eslint-enable */
NH.base.testing.funcs.push(testDefaultMap);
/** Enum/helper for Logger groups. */
class GroupMode {
#name
#greeting
#farewell
#func
static #known = new Map();
static Silenced = new GroupMode('silenced');
static Opened = new GroupMode('opened', 'Entered', 'Leaving', 'group');
static Closed = new GroupMode(
'closed', 'Starting', 'Finished', 'groupCollapsed'
);
/** @type {string} - Mode name. */
get name() {
return this.#name;
}
/** @type {string} - Greeting when opening group. */
get greeting() {
return this.#greeting;
}
/** @type {string} - Farewell when closing group. */
get farewell() {
return this.#farewell;
}
/** @type {string} - console.func to use for opening group. */
get func() {
return this.#func;
}
/**
* @param {string} name - Mode name.
* @param {string} [greeting] - Greeting when opening group.
* @param {string} [farewell] - Salutation when closing group.
* @param {string} [func] - console.func to use for opening group.
*/
constructor(name, greeting, farewell, func) { // eslint-disable-line max-params
this.#name = name;
this.#greeting = greeting;
this.#farewell = farewell;
this.#func = func;
GroupMode.#known.set(name, this);
}
/**
* Find GroupMode by name.
* @param {string} name - Mode name.
* @returns {GroupMode} - Mode, if found.
*/
static byName(name) {
return this.#known.get(name);
}
}
Object.freeze(GroupMode);
/** Test case. */
function testGroupMode() {
const tests = new Map();
tests.set('isFrozen', {test: () => {
try {
GroupMode.Bob = {};
} catch (e) {
if (e instanceof TypeError) {
return 'cold';
}
}
return 'hot';
},
expected: 'cold'});
tests.set('byName', {test: () => {
const gm = GroupMode.byName('closed');
return gm;
},
expected: GroupMode.Closed});
tests.set('byNameBad', {test: () => {
const gm = GroupMode.byName('bob');
if (!gm) {
return 'expected-missing-bob';
}
return 'confused-bob';
},
expected: 'expected-missing-bob'});
for (const [name, {test, expected}] of tests) {
const actual = test();
const passed = actual === expected;
const msg = `t:${name} e:${expected} a:${actual} p:${passed}`;
NH.base.testing.log.log(msg);
if (!passed) {
throw new Error(msg);
}
}
}
NH.base.testing.funcs.push(testGroupMode);
/**
* Fancy-ish log messages.
*
* Console message groups can be started and ended using special methods.
*
* @example
* const log = new Logger('Bob');
* foo(x) {
* const me = 'foo';
* log.entered(me, x);
* ... do stuff ...
* log.starting('loop');
* for (const item in items) {
* log.log(`Processing ${item}`);
* ...
* }
* log.finished('loop');
* log.leaving(me, y);
* return y;
* }
*
* Logger.config('Bob').enabled = true;
* Logger.config('Bob').groups.set('foo', GroupMode.Silenced);
*/
class Logger {
#name
#config
#groupStack = [];
static Config = class {
#enabled = false;
#trace = false;
#groups = new Map();
/** @type {boolean} - Whether logging is currently enabled. */
get enabled() {
return this.#enabled;
}
/** @param {boolean} val - Set whether logging is currently enabled. */
set enabled(val) {
this.#enabled = Boolean(val);
}
/** @type {boolean} - Whether messages include a stack trace. */
get trace() {
return this.#trace;
}
/** @param {boolean} val - Set inclusion of stack traces. */
set trace(val) {
this.#trace = Boolean(val);
}
/**
* @param {string} name - Name of the group to get.
* @param {GroupMode} mode - Default mode if not seen before.
* @returns {GroupMode} - Mode for this group.
*/
groupMode(name, mode) {
if (!this.#groups.has(name)) {
this.#groups.set(name, mode);
}
return this.#groups.get(name);
}
/** @type {Map<string,GroupMode>} - Per group settings. */
get groups() {
return this.#groups;
}
/** @returns {object} - Config as a plain object. */
toPojo() {
const pojo = {
enabled: this.enabled,
trace: this.trace,
groups: {},
};
for (const [k, v] of this.groups) {
pojo.groups[k] = v.name;
}
return pojo;
}
/** @param {object} pojo - Config as a plain object. */
fromPojo(pojo) {
if (Object.hasOwn(pojo, 'enabled')) {
this.enabled = pojo.enabled;
}
if (Object.hasOwn(pojo, 'trace')) {
this.trace = pojo.trace;
}
if (Object.hasOwn(pojo, 'groups')) {
for (const [k, v] of Object.entries(pojo.groups)) {
const gm = GroupMode.byName(v);
if (gm) {
this.groupMode(k, gm);
}
}
}
}
}
static #loggers = new DefaultMap(Array);
static #configs = new DefaultMap(() => new Logger.Config());
/** @returns {object} - Logger.#configs as a plain object. */
static toPojo() {
const pojo = {
type: 'LoggerConfigs',
entries: {},
};
for (const [k, v] of this.#configs.entries()) {
pojo.entries[k] = v.toPojo();
}
return pojo;
}
/**
* Set Logger configs from a plain object.
* @param {object} pojo - Created from {Logger.toPojo}.
*/
static fromPojo(pojo) {
if (pojo && pojo.type === 'LoggerConfigs') {
this.resetConfigs();
for (const [k, v] of Object.entries(pojo.entries)) {
this.#configs.get(k).fromPojo(v);
}
}
}
/** Reset all configs to an empty state. */
static resetConfigs() {
this.#configs.clear();
}
/** @param {string} name - Name for this logger. */
constructor(name) {
this.#name = name;
this.#config = Logger.config(name);
Logger.#loggers.get(this.#name).push(new WeakRef(this));
}
/** @type {string[]} - Known loggers. */
static get loggers() {
return Array.from(this.#loggers.keys());
}
/** @type {object} - Logger configurations. */
static get configs() {
return Logger.toPojo();
}
/** @param {object} val - Logger configurations. */
static set configs(val) {
Logger.fromPojo(val);
}
/**
* @param {string} name - Logger configuration to get.
* @returns {Logger.Config} - Current config for that Logger.
*/
static config(name) {
return this.#configs.get(name);
}
/** @type {string} - Name for this logger. */
get name() {
return this.#name;
}
/** @type {boolean} - Whether logging is currently enabled. */
get enabled() {
return this.#config.enabled;
}
/** @type {boolean} - Indicates whether messages include a stack trace. */
get trace() {
return this.#config.trace;
}
/** @type {boolean} - Indicates whether current group is silenced. */
get silenced() {
let ret = false;
const group = this.#groupStack.at(-1);
if (group) {
const mode = this.#config.groupMode(group);
ret = mode === GroupMode.Silenced;
}
return ret;
}
/* eslint-disable no-console */
/** Clear the console. */
static clear() {
console.clear();
}
/**
* Introduces a specific group.
* @param {string} group - Group being created.
* @param {GroupMode} defaultMode - Mode to use if new.
* @param {...*} rest - Arbitrary items to pass to console.debug.
*/
#intro = (group, defaultMode, ...rest) => {
this.#groupStack.push(group);
const mode = this.#config.groupMode(group, defaultMode);
if (this.enabled && mode !== GroupMode.Silenced) {
console[mode.func](`${this.name}: ${group}`);
if (rest.length) {
const msg = `${mode.greeting} ${group} with`;
this.log(msg, ...rest);
}
}
}
/**
* Concludes a specific group.
* @param {string} group - Group leaving.
* @param {...*} rest - Arbitrary items to pass to console.debug.
*/
#outro = (group, ...rest) => {
const lastGroup = this.#groupStack.pop();
if (group !== lastGroup) {
console.error(`${this.name}: Group mismatch! Passed ` +
`"${group}", expected to see "${lastGroup}"`);
}
const mode = this.#config.groupMode(group);
if (this.enabled && mode !== GroupMode.Silenced) {
let msg = `${mode.farewell} ${group}`;
if (rest.length) {
msg += ' with:';
}
this.log(msg, ...rest);
console.groupEnd();
}
}
/**
* Log a specific message.
* @param {string} msg - Message to send to console.debug.
* @param {...*} rest - Arbitrary items to pass to console.debug.
*/
log(msg, ...rest) {
if (this.enabled && !this.silenced) {
if (this.trace) {
console.groupCollapsed(`${this.name} call stack`);
console.trace();
console.groupEnd();
}
console.debug(`${this.name}: ${msg}`, ...rest);
}
}
/* eslint-enable */
/**
* Entered a specific group.
* @param {string} group - Group that was entered.
* @param {...*} rest - Arbitrary items to pass to console.debug.
*/
entered(group, ...rest) {
this.#intro(group, GroupMode.Opened, ...rest);
}
/**
* Leaving a specific group.
* @param {string} group - Group leaving.
* @param {...*} rest - Arbitrary items to pass to console.debug.
*/
leaving(group, ...rest) {
this.#outro(group, ...rest);
}
/**
* Starting a specific collapsed group.
* @param {string} group - Group that is being started.
* @param {...*} rest - Arbitrary items to pass to console.debug.
*/
starting(group, ...rest) {
this.#intro(group, GroupMode.Closed, ...rest);
}
/**
* Finished a specific collapsed group.
* @param {string} group - Group that was entered.
* @param {...*} rest - Arbitrary items to pass to console.debug.
*/
finished(group, ...rest) {
this.#outro(group, ...rest);
}
}
/* eslint-disable max-lines-per-function */
/** Test case. */
function testLogger() {
const tests = new Map();
tests.set('testReset', {test: () => {
Logger.config('UncleBob').enabled = true;
Logger.resetConfigs();
return JSON.stringify(Logger.configs.entries);
},
expected: '{}'});
tests.set('defaultDisabled', {test: () => {
const config = Logger.config('Bob');
return config.enabled;
},
expected: false});
tests.set('defaultNoStackTraces', {test: () => {
const config = Logger.config('Bob');
return config.trace;
},
expected: false});
tests.set('defaultNoGroups', {test: () => {
const config = Logger.config('Bob');
return config.groups.size;
},
expected: 0});
tests.set('openedGroup', {test: () => {
const logger = new Logger('Bob');
logger.entered('ent');
return Logger.config('Bob').groups.get('ent').name;
},
expected: 'opened'});
tests.set('closedGroup', {test: () => {
const logger = new Logger('Bob');
logger.starting('start');
return Logger.config('Bob').groups.get('start').name;
},
expected: 'closed'});
tests.set('mismatchedGroup', {test: () => {
// This test requires manual verification that an error message was
// logged:
// Bob: Group mismatch! Passed "two", expected to see "one"
const logger = new Logger('Bob');
logger.entered('one');
logger.leaving('two');
return 'x';
},
expected: 'x'});
tests.set('restoreConfigs', {test: () => {
const results = [];
Logger.config('Bob').trace = true;
results.push(Logger.config('Bob').trace);
const oldConfigs = Logger.configs;
Logger.resetConfigs();
results.push(Logger.config('Bob').trace);
// Pat is not in oldConfigs, so should go back to the default (false)
// after restoring the configs.
Logger.config('Pat').enabled = true;
Logger.configs = oldConfigs;
results.push(Logger.config('Bob').trace);
results.push(Logger.config('Pat').enabled);
return JSON.stringify(results);
},
expected: '[true,false,true,false]'});
const savedConfigs = Logger.configs;
for (const [name, {test, expected}] of tests) {
Logger.resetConfigs();
const actual = test();
const passed = actual === expected;
const msg = `t:${name} e:${expected} a:${actual} p:${passed}`;
NH.base.testing.log.log(msg);
if (!passed) {
throw new Error(msg);
}
}
Logger.configs = savedConfigs;
}
/* eslint-enable */
NH.base.testing.funcs.push(testLogger);
// TODO(#145): The if test is just here while developing.
if (NH.base.testing.enabled) {
Logger.configs = await GM.getValue('Logger');
} else {
Logger.config('Default').enabled = true;
}
const log = new Logger('Default');
/**
* 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.
* @param {boolean} [matchSelf=false] - If a CSS selector would match base,
* then use it.
* @returns {boolean} - Whether an element could be found.
*/
function clickElement(base, selectorArray, matchSelf = false) {
if (base) {
for (const selector of selectorArray) {
let el = null;
if (matchSelf && base.matches(selector)) {
el = base;
} else {
el = base.querySelector(selector);
}
if (el) {
el.click();
return true;
}
}
}
return false;
}
/**
* Bring the Browser's focus onto element.
* @param {Element} element - HTML Element to focus on.
*/
function focusOnElement(element) {
if (element) {
const magicTabIndex = -1;
const tabIndex = element.getAttribute('tabindex');
element.setAttribute('tabindex', magicTabIndex);
element.focus();
if (tabIndex) {
element.setAttribute('tabindex', tabIndex);
} else {
element.removeAttribute('tabindex');
}
}
}
/**
* 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();
}
// eslint-disable-next-line no-extra-parens
return (element.isContentEditable ||
['input', 'textarea'].includes(tagName));
}
/**
* @typedef {object} Continuation
* @property {boolean} done - Indicate whether the monitor is done
* processing.
* @property {object} [results] - Optional results object.
*/
/**
* @callback Monitor
* @param {MutationRecord[]} records - Standard mutation records.
* @returns {Continuation} - Indicate whether done monitoring.
*/
/**
* Simple function that takes no parameters and returns nothing.
* @callback SimpleFunction
*/
/**
* @typedef {object} OtmotWhat
* @property {string} name - The name for this observer.
* @property {Element} base - Element to observe.
*/
/**
* @typedef {object} OtmotHow
* @property {object} observeOptions - MutationObserver().observe() options.
* @property {SimpleFunction} [trigger] - Function to call that triggers
* observable results.
* @property {Monitor} monitor - Callback used to process MutationObserver
* records.
* @property {number} [timeout] - Time to wait for completion in
* milliseconds, default of 0 disables.
*/
/**
* One time mutation observer with timeout.
* @param {OtmotWhat} what - What to observe.
* @param {OtmotHow} how - How to observe.
* @returns {Promise<Continuation.results>} - Will resolve with the results
* from monitor when done is true.
*/
function otmot(what, how) {
const prom = new Promise((resolve, reject) => {
const {
name,
base,
} = what;
const {
observeOptions,
trigger = () => {}, // eslint-disable-line no-empty-function
monitor,
timeout = 0,
} = how;
const logger = new Logger(`otmot ${name}`);
let timeoutID = null;
let observer = null;
/** @param {MutationRecord[]} records - Standard mutation records. */
const moCallback = (records) => {
const {done, results} = monitor(records);
logger.log('monitor:', done, results);
if (done) {
observer.disconnect();
clearTimeout(timeoutID);
logger.log('resolving');
resolve(results);
}
};
/** Standard setTimeout callback. */
const toCallback = () => {
observer.disconnect();
logger.log('rejecting after timeout');
reject(new Error(`otmot ${name} timed out`));
};
observer = new MutationObserver(moCallback);
if (timeout) {
timeoutID = setTimeout(toCallback, timeout);
}
observer.observe(base, observeOptions);
trigger();
logger.log('running');
});
return prom;
}
/**
* @typedef {object} OtrotWhat
* @property {string} name - The name for this observer.
* @property {Element} base - Element to observe.
*/
/**
* @typedef {object} OtrotHow
* @property {SimpleFunction} [trigger] - Function to call that triggers
* observable events.
* @property {number} timeout - Time to wait for completion in milliseconds.
*/
/**
* One time resize observer with timeout. Will resolve automatically upon
* first resize change.
* @param {OtrotWhat} what - What to observe.
* @param {OtrotHow} how - How to observe.
* @returns {Promise<OtrotWhat>} - Will resolve with the what parameter.
*/
function otrot(what, how) {
const prom = new Promise((resolve, reject) => {
const {
name,
base,
} = what;
const {
trigger = () => {}, // eslint-disable-line no-empty-function
timeout,
} = how;
const {
clientHeight: initialHeight,
clientWidth: initialWidth,
} = base;
const logger = new Logger(`otrot ${name}`);
let timeoutID = null;
let observer = null;
logger.log('initial dimensions:', initialWidth, initialHeight);
/** Standard ResizeObserver callback. */
const roCallback = () => {
const {clientHeight, clientWidth} = base;
logger.log('observed dimensions:', clientWidth, clientHeight);
if (clientHeight !== initialHeight || clientWidth !== initialWidth) {
observer.disconnect();
clearTimeout(timeoutID);
logger.log('resolving');
resolve(what);
}
};
/** Standard setTimeout callback. */
const toCallback = () => {
observer.disconnect();
logger.log('rejecting after timeout');
reject(new Error(`otrot ${name} timed out`));
};
observer = new ResizeObserver(roCallback);
timeoutID = setTimeout(toCallback, timeout);
observer.observe(base);
trigger();
logger.log('running');
});
return prom;
}
/**
* @callback ResizeAction
* @param {ResizeObserverEntry[]} entries - Standard resize entries.
*/
/**
* @typedef {object} Otrot2How
* @property {SimpleFunction} [trigger] - Function to call that triggers
* observable events.
* @property {ResizeAction} action - Function to call upon each event
* observed and also at the end of duration.
* @property {number} duration - Time to run in milliseconds.
*/
/**
* One time resize observer with action callback and duration. Will resolve
* upon duration expiration. Uses the same what parameter as {@link otrot}.
* @param {OtrotWhat} what - What to observe.
* @param {Otrow2How} how - How to observe.
* @returns {Promise<string>} - Will resolve after duration expires.
*/
function otrot2(what, how) {
const prom = new Promise((resolve) => {
const {
name,
base,
} = what;
const {
trigger = () => {}, // eslint-disable-line no-empty-function
action,
duration,
} = how;
const logger = new Logger(`otrot2 ${name}`);
let observer = null;
/** @param {ResizeObserverEntry[]} entries - Standard entries. */
const roCallback = (entries) => {
logger.log('calling action');
action(entries);
};
/** Standard setTimeout callback. */
const toCallback = () => {
observer.disconnect();
action([]);
logger.log('resolving');
resolve(`otrot2 ${name} finished`);
};
observer = new ResizeObserver(roCallback);
setTimeout(toCallback, duration);
observer.observe(base);
trigger();
logger.log('running');
});
return prom;
}
/**
* Create a UUID-like string with a base.
* @param {string} base - Base value for the string.
* @returns {string} - A unique string.
*/
function uuId(base) {
return `${base}-${crypto.randomUUID()}`;
}
/**
* Normalizes a string to be safe to use as an HTML element id.
* @param {string} input - The string to normalize.
* @returns {string} - Normlized string.
*/
function safeId(input) {
let result = input
.replaceAll(' ', '-')
.replaceAll('.', '_')
.replaceAll(',', '__comma__')
.replaceAll(':', '__colon__');
if (!(/^[a-z_]/iu).test(result)) {
result = `a${result}`;
}
return result;
}
/** Test case. */
function testSafeId() {
const tests = [
{test: 'Tabby Cat', expected: 'Tabby-Cat'},
{test: '_', expected: '_'},
{test: '', expected: 'a'},
{test: '0', expected: 'a0'},
{test: 'a.b.c', expected: 'a_b_c'},
{test: 'a,b,c', expected: 'a__comma__b__comma__c'},
{test: 'a:b::c', expected: 'a__colon__b__colon____colon__c'},
];
for (const {test, expected} of tests) {
const actual = safeId(test);
const passed = actual === expected;
const msg = `${test} ${expected} ${actual}, ${passed}`;
NH.base.testing.log.log(msg);
if (!passed) {
throw new Error(msg);
}
}
}
NH.base.testing.funcs.push(testSafeId);
/**
* 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 += 1) {
// eslint-disable-next-line no-magic-numbers
hash = (hash * 31) + s.charCodeAt(i) | 0;
}
return `${hash}`;
}
/**
* Implement HTML for a tabbed user interface.
*
* This version uses radio button/label pairs to select the active panel.
*
* @example
* const tabby = new TabbedUI('Tabby Cat');
* document.body.append(tabby.container);
* tabby.addTab(helpTabDefinition);
* tabby.addTab(docTabDefinition);
* tabby.addTab(contactTabDefinition);
* tabby.goto(helpTabDefinition.name); // Set initial tab
* tabby.next();
* const entry = tabby.tabs.get(contactTabDefinition);
* entry.classList.add('random-css');
* entry.innerHTML += '<p>More contact info.</p>';
*/
class TabbedUI {
#container
#id
#idName
#log
#name
#nav
#navSpacer
#nextButton
#prevButton
#style
/**
* @typedef {object} TabDefinition
* @property {string} name - Tab name.
* @property {string} content - HTML to be used as initial content.
*/
/**
* @typedef {object} TabEntry
* @property {string} name - Tab name.
* @property {Element} label - Tab label, so CSS can be applied.
* @property {Element} panel - Tab panel, so content can be updated.
*/
/**
* @param {string} name - Used to distinguish HTML elements and CSS
* classes.
*/
constructor(name) {
this.#log = new Logger(`TabbedUI ${name}`);
this.#name = name;
this.#idName = safeId(name);
this.#id = uuId(this.#idName);
this.#container = document.createElement('section');
this.#container.id = `${this.#id}-container`;
this.#installControls();
this.#container.append(this.#nav);
this.#installStyle();
this.#log.log(`${this.#name} constructed`);
}
/** Installs navigational control elements. */
#installControls = () => {
this.#nav = document.createElement('nav');
this.#nav.id = `${this.#id}-controls`;
this.#navSpacer = document.createElement('span');
this.#navSpacer.classList.add('spacer');
this.#prevButton = document.createElement('button');
this.#nextButton = document.createElement('button');
this.#prevButton.innerText = '←';
this.#nextButton.innerText = '→';
this.#prevButton.dataset.name = 'prev';
this.#nextButton.dataset.name = 'next';
this.#prevButton.addEventListener('click', () => this.prev());
this.#nextButton.addEventListener('click', () => this.next());
// XXX: Cannot get 'button' elements to style nicely, so cheating by
// wrapping them in a label.
const prevLabel = document.createElement('label');
const nextLabel = document.createElement('label');
prevLabel.append(this.#prevButton);
nextLabel.append(this.#nextButton);
this.#nav.append(this.#navSpacer, prevLabel, nextLabel);
}
/** @type {Element} */
get container() {
return this.#container;
}
/** @type {Map<string,TabEntry>} */
get tabs() {
const entries = new Map();
for (const label of this.#nav.querySelectorAll(
':scope > label[data-tabbed-name]'
)) {
entries.set(label.dataset.tabbedName, {label: label});
}
for (const panel of this.container.querySelectorAll(
`:scope > .${this.#idName}-panel`
)) {
entries.get(panel.dataset.tabbedName).panel = panel;
}
return entries;
}
/** Installs basic CSS styles for the UI. */
#installStyle = () => {
this.#style = document.createElement('style');
this.#style.id = `${this.#id}-style`;
const styles = [
`#${this.container.id} {` +
' flex-grow: 1; overflow-y: hidden; display: flex;' +
' flex-direction: column;' +
'}',
`#${this.container.id} > input { display: none; }`,
`#${this.container.id} > nav { display: flex; flex-direction: row; }`,
`#${this.container.id} > nav button { border-radius: 50%; }`,
`#${this.container.id} > nav > label {` +
' cursor: pointer;' +
' margin-top: 1ex; margin-left: 1px; margin-right: 1px;' +
' padding: unset; color: unset !important;' +
'}',
`#${this.container.id} > nav > .spacer {` +
' margin-left: auto; margin-right: auto;' +
' border-right: 1px solid black;' +
'}',
`#${this.container.id} label::before { all: unset; }`,
`#${this.container.id} label::after { all: unset; }`,
// Panels are both flex items AND flex containers.
`#${this.container.id} .${this.#idName}-panel {` +
' display: none; overflow-y: auto; flex-grow: 1;' +
' flex-direction: column;' +
'}',
'',
];
this.#style.textContent = styles.join('\n');
document.head.prepend(this.#style);
}
/**
* Get the tab controls currently in the container.
* @returns {Element[]} - Control elements for the tabs.
*/
#getTabControls = () => {
const controls = Array.from(this.container.querySelectorAll(
':scope > input'
));
return controls;
}
/**
* Switch to an adjacent tab.
* @param {number} direction - Either 1 or -1.
* @fires Event#change
*/
#switchTab = (direction) => {
const me = 'switchTab';
this.#log.entered(me, direction);
const controls = this.#getTabControls();
this.#log.log('controls:', controls);
let idx = controls.findIndex(item => item.checked);
if (idx === NH.base.NOT_FOUND) {
idx = 0;
} else {
idx = (idx + direction + controls.length) % controls.length;
}
controls[idx].click();
this.#log.leaving(me);
}
/**
* @param {string} name - Human readable name for tab.
* @param {string} idName - Normalized to be CSS class friendly.
* @returns {Element} - Input portion of the tab.
*/
#createInput = (name, idName) => {
const me = 'createInput';
this.#log.entered(me);
const input = document.createElement('input');
input.id = `${this.#idName}-input-${idName}`;
input.name = `${this.#idName}`;
input.dataset.tabbedId = `${this.#idName}-input-${idName}`;
input.dataset.tabbedName = name;
input.type = 'radio';
this.#log.leaving(me, input);
return input;
}
/**
* @param {string} name - Human readable name for tab.
* @param {Element} input - Input element associated with this label.
* @param {string} idName - Normalized to be CSS class friendly.
* @returns {Element} - Label portion of the tab.
*/
#createLabel = (name, input, idName) => {
const me = 'createLabel';
this.#log.entered(me);
const label = document.createElement('label');
label.dataset.tabbedId = `${this.#idName}-label-${idName}`;
label.dataset.tabbedName = name;
label.htmlFor = input.id;
label.innerText = `[${name}]`;
this.#log.leaving(me, label);
return label;
}
/**
* @param {string} name - Human readable name for tab.
* @param {string} idName - Normalized to be CSS class friendly.
* @param {string} content - Raw HTML content to put into the panel.
* @returns {Element} - Panel portion of the tab.
*/
#createPanel = (name, idName, content) => {
const me = 'createPanel';
this.#log.entered(me);
const panel = document.createElement('div');
panel.dataset.tabbedId = `${this.#idName}-panel-${idName}`;
panel.dataset.tabbedName = name;
panel.classList.add(`${this.#idName}-panel`);
panel.innerHTML = content;
this.#log.leaving(me, panel);
return panel;
}
/**
* Event handler for change events. When the active tab changes, this
* will resend an 'expose' event to the associated panel.
* @param {Element} panel - The panel associated with this tab.
* @param {Event} evt - The original change event.
* @fires Event#expose
*/
#onChange = (panel, evt) => {
const me = 'onChange';
this.#log.entered(me, evt, panel);
panel.dispatchEvent(new Event('expose'));
this.#log.leaving(me);
}
/** @param {TabDefinition} tab - The new tab. */
addTab(tab) {
const me = 'addTab';
this.#log.entered(me, tab);
const {
name,
content,
} = tab;
const idName = safeId(name);
const input = this.#createInput(name, idName);
const label = this.#createLabel(name, input, idName);
const panel = this.#createPanel(name, idName, content);
input.addEventListener('change', this.#onChange.bind(this, panel));
this.#nav.before(input);
this.#navSpacer.before(label);
this.container.append(panel);
const inputChecked =
`#${this.container.id} > ` +
`input[data-tabbed-name="${name}"]:checked`;
this.#style.textContent +=
`${inputChecked} ~ nav > [data-tabbed-name="${name}"] {` +
' border-bottom: 3px solid black;' +
'}\n';
this.#style.textContent +=
`${inputChecked} ~ div[data-tabbed-name="${name}"] {` +
' display: flex;' +
'}\n';
this.#log.leaving(me);
}
/** Activate the next tab. */
next() {
const me = 'next';
this.#log.entered(me);
this.#switchTab(1);
this.#log.leaving(me);
}
/** Activate the previous tab. */
prev() {
const me = 'prev';
this.#log.entered(me);
this.#switchTab(-1);
this.#log.leaving(me);
}
/** @param {string} name - Name of the tab to activate. */
goto(name) {
const me = 'goto';
this.#log.entered(me, name);
const controls = this.#getTabControls();
const control = controls.find(item => item.dataset.tabbedName === name);
control.click();
this.#log.leaving(me);
}
}
/**
* 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 {
/**
* @callback Handler
* @param {string} eventType - Event type.
* @param {*} data - Event data.
*/
#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 {Handler[]} - 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 {Handler} func - Single argument function to call.
*/
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 {Handler} func - Function to remove.
*/
off(eventType, func) {
const handlers = this.#getHandlers(eventType);
let index = 0;
while ((index = handlers.indexOf(func)) !== NH.base.NOT_FOUND) {
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.
*/
fire(eventType, data) {
const handlers = this.#getHandlers(eventType);
for (const handler of handlers) {
handler(eventType, data);
}
}
}
/**
* An ordered collection of HTMLElements for a user to continuously 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.
* - 'activate' - The Scroller was activated.
* - 'deactivate' - The Scroller was deactivated.
* This is NOT an error condition, but rather a design feature.
*/
class Scroller {
#destroyed = false;
#dispatcher = new Dispatcher(
'change', 'out-of-range', 'activate', 'deactivate'
);
#currentItemId = null;
#historicalIdToIndex = new Map();
#autoActivate
#base
#bottomMarginCSS
#bottomMarginPixels
#classes
#handleClicks
#logger
#mutationObserver
#name
#onClickElement
#selectors
#snapToTop
#stackTrace
#topMarginCSS
#topMarginPixels
#uidCallback
/** @type {Logger} */
get logger() {
return this.#logger;
}
/** @type {Dispatcher} */
get dispatcher() {
return this.#dispatcher;
}
/**
* Function that generates a, preferably, reproducible unique identifier
* for an Element.
* @callback uidCallback
* @param {Element} element - Element to examine.
* @returns {string} - A value unique to this element.
*/
/**
* @typedef {object} What
* @property {string} name - Name for this scroller, used for logging.
* @property {Element} base - The container to use as a base for selecting
* elements.
* @property {string[]} selectors - Array of CSS selectors to find
* elements to collect, calling base.querySelectorAll().
*/
/**
* @typedef {object} How
* @property {uidCallback} uidCallback - Callback to generate a uid.
* @property {string[]} [classes=[]] - Array of CSS classes to add/remove
* from an element as it becomes current.
* @property {boolean} [handleClicks=true] - Whether the scroller should
* watch for clicks and if one is inside an item, select it.
* @property {boolean} [autoActivate=false] - Whether to call the activate
* method at the end of construction.
* @property {boolean} [snapToTop=false] - Whether items should snap to
* the top of the window when coming into view.
* @property {number} [topMarginPixels=0] - Used to determine if scrolling
* should happen when {snapToTop} is false.
* @property {number} [bottomMarginPixels=0] - Used to determin if
* scrolling should happen when {snapToTop} is false.
* @property {string} [topMarginCSS='0'] - CSS applied to
* `scrollMarginTop`.
* @property {string} [bottomMarginCSS='0'] - CSS applied to
* `scrollMarginBottom`.
*/
/**
* @param {What} what - What we want to scroll.
* @param {How} how - How we want to scroll.
* @throws {TypeError} - When base is not an Element.
*/
constructor(what, how) {
({
name: this.#name = 'Unnamed scroller',
base: this.#base,
selectors: this.#selectors,
} = what);
if (!(this.#base instanceof Element)) {
throw new TypeError(
`Invalid base ${this.#base} given for ${this.#name}`
);
}
({
uidCallback: this.#uidCallback,
classes: this.#classes = [],
handleClicks: this.#handleClicks = true,
autoActivate: this.#autoActivate = false,
snapToTop: this.#snapToTop = false,
topMarginPixels: this.#topMarginPixels = 0,
bottomMarginPixels: this.#bottomMarginPixels = 0,
topMarginCSS: this.#topMarginCSS = '0',
bottomMarginCSS: this.#bottomMarginCSS = '0',
} = how);
this.#mutationObserver = new MutationObserver(this.#mutationHandler);
this.#logger = new Logger(`{${this.#name}}`);
this.logger.log('Scroller constructed', this);
if (this.#autoActivate) {
this.activate();
}
}
/**
* If an item is clicked, switch to it.
* @param {Event} evt - Standard 'click' event.
*/
#onClick = (evt) => {
const me = 'onClick';
this.logger.entered(me, evt);
for (const item of this.#getItems()) {
if (item.contains(evt.target)) {
this.logger.log('found:', item);
if (item !== this.item) {
this.item = item;
}
}
}
this.logger.leaving(me);
}
/** @param {MutationRecord[]} records - Standard mutation records. */
#mutationHandler = (records) => {
const me = 'mutationHandler';
this.logger.entered(
me, `records: ${records.length} type: ${records[0].type}`
);
for (const record of records) {
if (record.type === 'childList') {
this.logger.log('childList record');
} else if (record.type === 'attributes') {
this.logger.log('attribute records');
}
}
this.logger.leaving(me);
}
/** @type {string} - Current item's uid. */
get itemUid() {
return this.#currentItemId;
}
/** @type {Element} - Represents the current item. */
get item() {
const me = 'get item';
this.logger.entered(me);
if (this.#destroyed) {
const msg = `Tried to work with destroyed ${Scroller.name} ` +
`on ${this.#base}`;
this.logger.log(msg);
throw new Error(msg);
}
const items = this.#getItems();
let item = items.find(this.#matchItem);
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.logger.leaving(me, item);
return item;
}
/** @param {Element} val - Set the current item. */
set item(val) {
const me = 'set item';
this.logger.entered(me, val);
this.dull();
this.#bottomHalf(val);
this.logger.leaving(me);
}
/**
* 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.
*/
#bottomHalf = (val) => {
const me = 'bottomHalf';
this.logger.entered(me, val);
this.#currentItemId = this.#uid(val);
const idx = this.#getItems().indexOf(val);
this.#historicalIdToIndex.set(this.#currentItemId, idx);
this.shine();
this.#scrollToCurrentItem();
this.dispatcher.fire('change', {});
this.logger.leaving(me);
}
/**
* Determines if the item can be viewed. Usually this means the content
* is being loaded lazily and is not ready yet.
* @param {Element} item - The item to inspect.
* @returns {boolean} - Whether the item has viewable content.
*/
static #isItemViewable(item) {
return item.clientHeight && item.innerText.length;
}
/**
* Builds the list of elements using the registered CSS selectors.
* @returns {Elements[]} - Items to scroll through.
*/
#getItems = () => {
const me = 'getItems';
this.logger.entered(me);
const items = [];
for (const selector of this.#selectors) {
this.logger.log(`considering ${selector}`);
items.push(...this.#base.querySelectorAll(selector));
}
this.logger.starting('items');
for (const item of items) {
this.logger.log('item:', item);
}
this.logger.finished('items');
this.logger.leaving(me, `${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) => {
const me = 'uid';
this.logger.entered(me, element);
let uid = null;
if (element) {
if (!element.dataset.scrollerId) {
element.dataset.scrollerId = this.#uidCallback(element);
}
uid = element.dataset.scrollerId;
}
this.logger.leaving(me, 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) => {
const me = 'matchItem';
this.logger.entered(me);
const res = this.#currentItemId === this.#uid(element);
this.logger.leaving(me, 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.
*/
#scrollToCurrentItem = () => {
const me = 'scrollToCurrentItem';
this.logger.entered(me, `snaptoTop: ${this.#snapToTop}`);
const {item} = this;
if (item) {
item.style.scrollMarginTop = this.#topMarginCSS;
if (this.#snapToTop) {
this.logger.log('snapping to top');
item.scrollIntoView(true);
} else {
this.logger.log('not snapping to top');
item.style.scrollMarginBottom = this.#bottomMarginCSS;
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.
const allowedBottom = document.documentElement.clientHeight -
this.#bottomMarginPixels;
if (rect.bottom > allowedBottom) {
this.logger.log('scrolling up onto page');
item.scrollIntoView(false);
}
if (rect.top < this.#topMarginPixels) {
this.logger.log('scrolling down onto page');
item.scrollIntoView(true);
}
// XXX: The following was added to support horizontal scrolling in
// carousels. Nothing seemed to break. TODO(#132): Did find a side
// effect though: it can cause an item being *left* to shift up if
// the scrollMarginBottom has been set.
item.scrollIntoView({block: 'nearest', inline: 'nearest'});
}
}
this.logger.leaving(me);
}
/**
* Jump an item on an end of the collection.
* @param {boolean} first - If true, the first item in the collection,
* else, the last.
*/
#jumpToEndItem = (first) => {
const me = 'jumpToEndItem';
this.logger.entered(me, `first=${first}`);
// Reset in case item was heavily modified
this.item = this.item;
const items = this.#getItems();
if (items.length) {
// eslint-disable-next-line no-extra-parens
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 (!Scroller.#isItemViewable(item)) {
this.logger.log('skipping item', item);
idx -= 1;
item = items[idx];
}
}
this.item = item;
}
this.logger.leaving(me);
}
/**
* 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'
*/
#scrollBy = (n) => { // eslint-disable-line max-statements
const me = 'scrollBy';
this.logger.entered(me, n);
// Reset in case item was heavily modified
this.item = this.item;
const items = this.#getItems();
if (items.length) {
let idx = items.findIndex(this.#matchItem);
this.logger.log('initial idx', idx);
idx += n;
if (idx < NH.base.NOT_FOUND) {
idx = items.length - 1;
}
if (idx === NH.base.NOT_FOUND || idx >= items.length) {
this.item = null;
this.dispatcher.fire('out-of-range', null);
} else {
// Skip over empty items
let item = items[idx];
while (!Scroller.#isItemViewable(item)) {
this.logger.log('skipping item', item);
idx += n;
item = items[idx];
}
this.logger.log('final idx', idx);
this.item = item;
}
}
this.logger.leaving(me);
}
/** Move to the next item in the collection. */
next() {
this.#scrollBy(1);
}
/** Move to the previous item in the collection. */
prev() {
this.#scrollBy(-1);
}
/** Jump to the first item in the collection. */
first() {
this.#jumpToEndItem(true);
}
/** Jump to last item in the collection. */
last() {
this.#jumpToEndItem(false);
}
/**
* Move to a specific item if possible.
* @param {Element} item - Item to go to.
*/
goto(item) {
this.item = item;
}
/**
* Move to a specific item if possible, by uid.
* @param {string} uid - The uid of a specific item.
* @returns {boolean} - Was able to goto the item.
*/
gotoUid(uid) {
const me = 'goto';
this.logger.entered(me, uid);
const items = this.#getItems();
const item = items.find(el => uid === this.#uid(el));
let success = false;
if (item) {
this.item = item;
success = true;
}
this.logger.leaving(me, success, item);
return success;
}
/** Adds the registered CSS classes to the current element. */
shine() {
this.item?.classList.add(...this.#classes);
}
/** Removes the registered CSS classes from the current element. */
dull() {
this.item?.classList.remove(...this.#classes);
}
/** Bring current item back into view. */
show() {
this.#scrollToCurrentItem();
}
/**
* Activate the scroller.
* @fires 'out-of-range'
*/
activate() {
if (this.#handleClicks) {
this.#onClickElement = this.#base;
this.#onClickElement.addEventListener('click', this.#onClick);
}
this.#mutationObserver.observe(this.#base, {childList: true});
this.dispatcher.fire('activate', null);
}
/**
* Deactivate the scroller (but do not destroy it).
* @fires 'out-of-range'
*/
deactivate() {
this.#mutationObserver.disconnect();
this.#onClickElement?.removeEventListener('click', this.#onClick);
this.#onClickElement = null;
this.dispatcher.fire('deactivate', null);
}
/** Mark instance as inactive and do any internal cleanup. */
destroy() {
const me = 'destroy';
this.logger.entered(me);
this.deactivate();
this.item = null;
this.#destroyed = true;
this.logger.leaving(me);
}
}
/**
* 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);
}
}
}
/**
* A widget that can be opened and closed on demand, designed for fairly
* persistent information.
*
* Currently built on the dialog element, the content become part of the
* DOM.
*/
class InfoWidget {
#dialog
#id
#logger
#name
/** @param {string} name - Name for this view. */
constructor(name) {
this.#name = `${this.constructor.name} ${name}`;
this.#id = uuId(this.#name);
this.#logger = new Logger(this.constructor.name);
this.#dialog = document.createElement('dialog');
this.#dialog.id = safeId(this.#id);
document.body.prepend(this.#dialog);
this.logger.log('Constructed.', this);
}
/** @type {Logger} */
get logger() {
return this.#logger;
}
/** @type {Element} */
get element() {
return this.#dialog;
}
}
const linkedInGlobals = new LinkedInGlobals();
/**
* Self-decorating class useful for integrating with a hotkey service.
*
* @example
* // Wrap an arrow function:
* foo = new Shortcut('c-c', 'Clear the console.', () => {
* console.clear();
* console.log('I did it!', this);
* });
*
* // Search for instances:
* const keys = [];
* for (const prop of Object.values(this)) {
* if (prop instanceof Shortcut) {
* keys.push({seq: prop.seq, desc: prop.seq, func: prop});
* }
* }
* ... Send keys off to service ...
*/
class Shortcut extends Function {
/**
* Wrap a function.
* @param {string} seq - Key sequence to activate this function.
* @param {string} desc - Human readable documenation about this function.
* @param {SimpleFunction} func - Function to wrap, usually in the form of
* an arrow function. Keep JS `this` magic in mind!
*/
constructor(seq, desc, func) {
super('return this.func();');
const self = this.bind(this);
self.seq = seq;
self.desc = desc;
this.func = func;
return self;
}
}
/**
* Base class for building services to go with {@link SPA}.
*
* This should be subclassed to implement services that instances of {@link
* Page} will instantiate, initialize, active and deactivate at appropriate
* times.
*
* It is expected that each {Page} subclass will have individual instances
* of the services, though nothing will enforce that.
*
* @example
* class DummyService extends Service {
* ... implement methods ...
* }
*
* class CustomPage extends Page {
* constructor() {
* this.addService(DummyService);
* }
* }
*/
class Service {
#logger
#name
/** @type {Logger} - Logger instance. */
get logger() {
return this.#logger;
}
/** @type {string} - Instance name. */
get name() {
return this.#name;
}
/** @param {string} name - Custom portion of this instance. */
constructor(name) {
if (new.target === Service) {
throw new TypeError('Abstract class; do not instantiate directly.');
}
this.#name = `${this.constructor.name}: ${name}`;
this.#logger = new Logger(this.#name);
}
/** @param {string} name - Name of method that was not implemented. */
#notImplemented(name) {
const msg = `Class ${this.constructor.name} did not implement ` +
`method "${name}".`;
this.logger.log(msg);
throw new Error(msg);
}
/** Called each time service is activated. */
activate() {
this.#notImplemented('activate');
}
/** Called each time service is deactivated. */
deactivate() {
this.#notImplemented('deactivate');
}
}
/** Toy service for experimenting. */
class DummyService extends Service {
/** @inheritdoc */
activate() {
this.logger.log('Dummy activate');
}
/** @inheritdoc */
deactivate() {
this.logger.log('Dummy deactivate');
}
}
/** Manage a {Scroller} via {Service}. */
class ScrollerService extends Service {
#scroller
/**
* @param {string} name - Custom portion of this instance.
* @param {Scroller} scroller - Scroller instance to manage.
*/
constructor(name, scroller) {
super(name);
this.#scroller = scroller;
}
/** @inheritdoc */
activate() {
this.#scroller.activate();
}
/** @inheritdoc */
deactivate() {
this.#scroller.deactivate();
}
}
/**
* @external VMShortcuts
* @see {@link https://violentmonkey.github.io/guide/keyboard-shortcuts/}
*/
/**
* Integrates {@link external:VMShortcuts} with {@link Shortcut}s.
*
* NB {Shortcut} was designed to work natively with {external:VMShortcuts},
* but there should be no known technical reason preventing other
* implementations from being used, would have have to write a different
* service.
*
* Instances of classes that have {@link Shortcut} properties on them can be
* added and removed to each instance of this service. The shortcuts will
* be enabled and disabled as the service is activated/deactived. This can
* allow each service to have different groups of shortcuts present.
*
* All Shortcuts can react to VM.shortcut style conditions. These
* conditions are added once during each call to addService(), and default
* to '!inputFocus'.
*
* The built in handler for 'inputFocus' can be enabled by executing:
*
* @example
* VMKeyboardService.start();
*/
class VMKeyboardService extends Service {
/**
* @type {VM.shortcut.IShortcutOptions} - Disables keys when focus is on
* an element or info view.
*/
static #navOption = {
condition: '!inputFocus',
caseSensitive: true,
};
/** @param {string} val - New condition. */
static set condition(val) {
this.#navOption.condition = val;
}
static #focusOption = {
capture: true,
};
#keyboards = new Map();
static #services = new Set();
static #lastFocusedElement = null
#shortcuts = [];
/** @inheritdoc */
constructor(name) {
super(name);
VMKeyboardService.#services.add(this);
}
/** @inheritdoc */
activate() {
for (const keyboard of this.#keyboards.values()) {
this.logger.log('would enable keyboard', keyboard);
// TODO: keyboard.enable();
}
}
/** @inheritdoc */
deactivate() {
for (const keyboard of this.#keyboards.values()) {
this.logger.log('would disable keyboard', keyboard);
// TODO: keyboard.disable();
}
}
/** Add listener. */
static start() {
document.addEventListener('focus', this.#onFocus, this.#focusOption);
}
/** Remove listener. */
static stop() {
document.removeEventListener('focus', this.#onFocus, this.#focusOption);
}
/** @param {*} instance - Object with {Shortcut} properties. */
addInstance(instance) {
const me = 'addInstance';
this.logger.entered(me, instance);
if (this.#keyboards.has(instance)) {
this.logger.log('Already registered');
} else {
const keyboard = new VM.shortcut.KeyboardService();
for (const prop of Object.values(instance)) {
if (prop instanceof Shortcut) {
// While we are here, give the function a name.
Object.defineProperty(prop, 'name', {value: name});
this.#shortcuts.push(prop);
keyboard.register(prop.seq, prop, VMKeyboardService.#navOption);
}
}
this.#keyboards.set(instance, keyboard);
}
this.logger.leaving(me);
}
/** @param {*} instance - Object with {Shortcut} properties. */
removeInstance(instance) {
const me = 'removeInstance';
this.logger.entered(me, instance);
if (this.#keyboards.has(instance)) {
const keyboard = this.#keyboards.get(instance);
keyboard.disable();
this.#keyboards.delete(instance);
} else {
this.logger.log('Was not registered');
}
this.logger.leaving(me);
}
/**
* Handle focus event to determine if shortcuts should be disabled.
* @param {Event} evt - Standard 'focus' event.
*/
static #onFocus = (evt) => {
if (this.#lastFocusedElement &&
evt.target !== this.#lastFocusedElement) {
this.#lastFocusedElement = null;
this.setKeyboardContext('inputFocus', false);
}
if (isInput(evt.target)) {
this.setKeyboardContext('inputFocus', true);
this.#lastFocusedElement = evt.target;
}
}
/**
* Set the keyboard context to a specific value.
* @param {string} context - The name of the context.
* @param {object} state - What the value should be.
*/
static setKeyboardContext = (context, state) => {
for (const service of this.#services) {
for (const keyboard of service.#keyboards.values()) {
keyboard.setContext(context, state);
}
}
}
}
/**
* Base class for handling various views of a single-page application.
*
* Generally, new classes should subclass this, override a few properties
* and methods, and then register themselves with an instance of the {@link
* SPA} class.
*/
class Page {
#pageReadySelector
/** @type {SPA} - SPA instance managing this instance. */
#spa;
/** @type {Logger} - Logger instance. */
#logger;
/** @type {RegExp} - Computed RegExp version of _pathname. */
#pathnameRE;
/** @type {KeyboardService} */
#keyboard = new VM.shortcut.KeyboardService();
#services = new Set();
/**
* @type {IShortcutOptions} - Disables keys when focus is on an element or
* info view.
*/
static #navOption = {
caseSensitive: true,
condition: '!inputFocus && !inDialog',
};
/**
* Turn a pathname into a RegExp.
* @param {string|RegExp} pathname - A pathname to convert.
* @returns {RegExp} - A converted pathname.
*/
#computePathname = (pathname) => {
const me = 'computePath';
this.logger.entered(me, pathname);
let pathnameRE = /.*/u;
if (pathname instanceof RegExp) {
pathnameRE = pathname;
} else if (pathname) {
pathnameRE = RegExp(`^${pathname}$`, 'u');
}
this.logger.leaving(me, pathnameRE);
return pathnameRE;
}
/**
* @typedef {object} PageDetails
* @property {string|RegExp} [pathname=RegExp(.*)] - Pathname portion of
* the URL this page should handle.
* @property {string} [pageReadySelector='body'] - CSS selector that is
* used to detect that the page is loaded enough to activate.
*/
/** @param {PageDetails} details - Details about the instance. */
constructor(details = {}) {
if (new.target === Page) {
throw new TypeError('Abstract class; do not instantiate directly.');
}
this.#logger = new Logger(this.constructor.name);
this.#pathnameRE = this.#computePathname(details.pathname);
({
pageReadySelector: this.#pageReadySelector = 'body',
} = details);
this.#logger.log('Base page constructed', this);
}
/**
* Register a new {@link Service}.
* @param {function(): Service} Klass - A service class to instantiate.
* @param {...*} rest - Arbitrary objects to pass to constructor.
* @returns {Service} - Instance of Klass.
*/
addService(Klass, ...rest) {
const me = 'addService';
let instance = null;
this.logger.entered(me, Klass, ...rest);
if (Klass.prototype instanceof Service) {
instance = new Klass(this.constructor.name, ...rest);
this.#services.add(instance);
} else {
this.logger.log('Bad class was passed.');
throw new Error(`${Klass.name} is not a Service`);
}
this.logger.leaving(me, instance);
return instance;
}
/**
* Called when registered via {@link SPA}.
* @param {SPA} spa - SPA instance that manages this Page.
*/
start(spa) {
this.#spa = spa;
for (const shortcut of this.allShortcuts) {
this.#addKey(shortcut);
}
}
/** @type {Shortcut[]} - List of {@link Shortcut}s to register. */
get allShortcuts() {
const shortcuts = [];
for (const prop of Object.values(this)) {
if (prop instanceof Shortcut) {
shortcuts.push(prop);
// While we are here, give the function a name.
Object.defineProperty(prop, 'name', {value: name});
}
}
return shortcuts;
}
/** @type {RegExp} */
get pathname() {
return this.#pathnameRE;
}
/** @type {SPA} */
get spa() {
return this.#spa;
}
/** @type {Logger} */
get logger() {
return this.#logger;
}
/** @type {KeyboardService} */
get keyboard() {
return this.#keyboard;
}
/**
* Wait until the page has loaded enough to continue.
* @returns {Element} - The element matched by #pageReadySelector.
*/
#waitUntilReady = async () => {
const me = 'waitUntilReady';
this.logger.entered(me);
/**
* @implements {Monitor}
* @returns {Continuation} - Indicate whether done monitoring.
*/
const monitor = () => {
const element = document.querySelector(this.#pageReadySelector);
if (element) {
return {done: true, results: element};
}
return {done: false};
};
const what = {
name: 'waitUntilReady',
base: document,
};
const how = {
observeOptions: {childList: true, subtree: true},
monitor: monitor,
};
const element = await otmot(what, how);
this.logger.leaving(me, element);
return element;
}
/**
* Turns on this Page's features. Called by {@link SPA} when this becomes
* the current view.
*/
async activate() {
this.#keyboard.enable();
await this.#waitUntilReady();
// TODO(#150): Will be removed.
this._refresh();
for (const service of this.#services) {
service.activate();
}
}
/**
* Turns off this Page's features. Called by {@link SPA} when this is no
* longer the current view.
*/
deactivate() {
this.#keyboard.disable();
for (const service of this.#services) {
service.deactivate();
}
}
/** @type {string} - Describes what the header should be. */
get infoHeader() {
return this.constructor.name;
}
/**
* Registers a specific key sequence with a function with VM.shortcut.
* @param {Shortcut} shortcut - Shortcut to register.
*/
#addKey(shortcut) {
this.#keyboard.register(shortcut.seq, shortcut, Page.#navOption);
}
/**
* Override this function in subclasses to take action upon becoming the
* current view again.
*/
_refresh() {
this.logger.log('In base refresh.');
}
}
/** Class for holding keystrokes that simplify debugging. */
class DebugKeys {
clearConsole = new Shortcut('c-c c-c', 'Clear the debug console', () => {
Logger.clear();
});
}
/**
* Class for handling aspects common across LinkedIn.
*
* This includes things like the global nav bar, information view, etc.
*/
class Global extends Page {
#keyboardService
/** Create a Global instance. */
constructor() {
super();
this.#keyboardService = this.addService(VMKeyboardService);
this.#keyboardService.addInstance(this);
if (NH.base.testing.enabled) {
this.#keyboardService.addInstance(new DebugKeys());
}
}
/**
* Click on the requested link in the global nav bar.
* @param {string} item - Portion of the link to match.
*/
static #gotoNavLink = (item) => {
clickElement(document, [`#global-nav a[href*="/${item}"`]);
}
/**
* Click on the requested button in the global nav bar.
* @param {string} item - Text on the button to look for.
*/
static #gotoNavButton = (item) => {
const buttons = Array.from(
document.querySelectorAll('#global-nav button')
);
const button = buttons.find(el => el.textContent.includes(item));
button?.click();
}
info = new Shortcut('?', 'Show this information view', () => {
Global.#gotoNavButton('Tool');
});
gotoSearch = new Shortcut('/', 'Go to Search box', () => {
clickElement(document, ['#global-nav-search button']);
});
goHome = new Shortcut('g h', 'Go Home (aka, Feed)', () => {
Global.#gotoNavLink('feed');
});
gotoMyNetwork = new Shortcut('g m', 'Go to My Network', () => {
Global.#gotoNavLink('mynetwork');
});
gotoJobs = new Shortcut('g j', 'Go to Jobs', () => {
Global.#gotoNavLink('jobs');
});
gotoMessaging = new Shortcut('g g', 'Go to Messaging', () => {
Global.#gotoNavLink('messaging');
});
gotoNotifications = new Shortcut('g n', 'Go to Notifications', () => {
Global.#gotoNavLink('notifications');
});
gotoProfile = new Shortcut('g p', 'Go to Profile (aka, Me)', () => {
Global.#gotoNavButton('Me');
});
gotoBusiness = new Shortcut('g b', 'Go to Business', () => {
Global.#gotoNavButton('Business');
});
gotoLearning = new Shortcut('g l', 'Go to Learning', () => {
Global.#gotoNavLink('learning');
});
focusOnSidebar = new Shortcut(
',', 'Focus on the left/top sidebar (not always present)', () => {
linkedInGlobals.focusOnSidebar();
}
);
focusOnAside = new Shortcut(
'.', 'Focus on the right/bottom sidebar (not always present)', () => {
linkedInGlobals.focusOnAside();
}
);
}
/** Class for handling the Posts feed. */
class Feed extends Page {
#tabSnippet = SPA._parseSeq2('tab'); // eslint-disable-line no-use-before-define
#postScroller = null;
#commentScroller = null;
#lastScroller
#dummy
/** @type {Scroller~What} */
static #postsWhat = {
name: 'Feed posts',
base: document.body,
selectors: ['main div[data-id]'],
};
/** @type {Scroller~How} */
static _postsHow = {
uidCallback: Feed._uniqueIdentifier,
classes: ['tom'],
snapToTop: true,
};
/** @type {Scroller~What} */
static #commentsWhat = {
name: 'Feed comments',
selectors: ['article.comments-comment-item'],
};
/** @type {Scroller~How} */
static _commentsHow = {
uidCallback: Feed._uniqueIdentifier,
classes: ['dick'],
autoActivate: true,
snapToTop: false,
};
/** @type {Page~PageDetails} */
static #details = {
pathname: '/feed/',
pageReadySelector: 'main',
};
/** Create a Feed instance. */
constructor() {
super(Feed.#details);
this.#dummy = this.addService(DummyService);
this.#postScroller = new Scroller(Feed.#postsWhat, Feed._postsHow);
this.addService(ScrollerService, this.#postScroller);
this.#postScroller.dispatcher.on(
'out-of-range', linkedInGlobals.focusOnSidebar
);
this.#postScroller.dispatcher.on('change', this.#onPostChange);
this.#lastScroller = this.#postScroller;
}
/** @inheritdoc */
_refresh() {
/**
* Wait for the post to be reloaded.
* @implements {Monitor}
* @param {MutationRecord[]} records - Standard mutation records.
* @returns {Continuation} - Indicate whether done monitoring.
*/
function monitor(records) {
for (const record of records) {
if (record.oldValue.includes('has-occluded-height')) {
return {done: true};
}
}
return {done: false};
}
if (this._posts.item) {
const what = {
name: 'Feed._refresh',
base: this._posts.item,
};
const how = {
observeOptions: {
attributeFilter: ['class'],
attributes: true,
attributeOldValue: true,
},
monitor: monitor,
timeout: 5000,
};
otmot(what, how).finally(() => {
this._posts.shine();
this._posts.show();
});
}
}
/** @type {Scroller} */
get _posts() {
return this.#postScroller;
}
/** @type {Scroller} */
get _comments() {
const me = 'get comments';
this.logger.entered(me, this.#commentScroller, this._posts.item);
if (!this.#commentScroller && this._posts.item) {
this.#commentScroller = new Scroller(
{base: this._posts.item, ...Feed.#commentsWhat}, Feed._commentsHow
);
this.#commentScroller.dispatcher.on(
'out-of-range', this.#returnToPost
);
this.#commentScroller.dispatcher.on('change', this.#onCommentChange);
}
this.logger.leaving(me, this.#commentScroller);
return this.#commentScroller;
}
/** Reset the comment scroller. */
#resetComments() {
if (this.#commentScroller) {
this.#commentScroller.destroy();
this.#commentScroller = null;
}
this._comments;
}
/**
* @implements {Scroller~uidCallback}
* @param {Element} element - Element to examine.
* @returns {string} - A value unique to this element.
*/
static _uniqueIdentifier(element) {
if (element) {
return element.dataset.id;
}
return null;
}
#onCommentChange = () => {
this.#lastScroller = this._comments;
}
/**
* Reselects current post, triggering same actions as initial selection.
*/
#returnToPost = () => {
this._posts.item = this._posts.item;
}
/** Resets the comments {@link Scroller}. */
#onPostChange = () => {
const me = 'onPostChange';
this.logger.entered(me, this._posts.item);
this.#resetComments();
this.#lastScroller = this._posts;
this.logger.leaving(me);
}
_nextPost = new Shortcut('j', 'Next post', () => {
this._posts.next();
});
_prevPost = new Shortcut('k', 'Previous post', () => {
this._posts.prev();
});
_nextComment = new Shortcut('n', 'Next comment', () => {
this._comments.next();
});
_prevComment = new Shortcut('p', 'Previous comment', () => {
this._comments.prev();
});
_firstItem = new Shortcut('<', 'Go to first post or comment', () => {
this.#lastScroller.first();
});
_lastItem = new Shortcut(
'>', 'Go to last post or comment currently loaded', () => {
this.#lastScroller.last();
}
);
_focusBrowser = new Shortcut(
'f', 'Change browser focus to current item', () => {
const el = this.#lastScroller.item;
this._posts.show();
this._comments?.show();
focusOnElement(el);
}
);
_showComments = new Shortcut('c', 'Show comments', () => {
if (!clickElement(this._comments.item, ['button.show-prev-replies'])) {
clickElement(this._posts.item, ['button[aria-label*="comment"]']);
}
});
_seeMore = new Shortcut(
'm', 'Show more of current post or comment', () => {
const el = this.#lastScroller.item;
clickElement(el, ['button[aria-label^="see more"]']);
}
);
_loadMorePosts = new Shortcut(
'l',
'Load more posts (if the <button>New Posts</button> button ' +
'is available, load those)', () => {
const savedScrollTop = document.documentElement.scrollTop;
let first = false;
const posts = this._posts;
/** Trigger function for {@link otrot2}. */
function trigger() {
// The topButton only shows up when the app detects new posts. In
// that case, going back to the first post is appropriate.
const topButton = 'main div.feed-new-update-pill button';
// If there is not top button, there should always be a button at
// the bottom the click.
const botButton = 'main button.scaffold-finite-scroll__load-button';
if (clickElement(document, [topButton])) {
first = true;
} else {
clickElement(document, [botButton]);
}
}
/** Action function for {@link otrot2}. */
function action() {
if (first) {
if (posts.item) {
posts.first();
}
} else {
document.documentElement.scrollTop = savedScrollTop;
}
}
const what = {
name: 'loadMorePosts',
base: document.querySelector('div.scaffold-finite-scroll__content'),
};
const how = {
trigger: trigger,
action: action,
duration: 2000,
};
otrot2(what, how);
}
);
_viewPost = new Shortcut('v p', 'View current post directly', () => {
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 = new Shortcut(
'v r', 'View reactions on current post or comment', () => {
const el = this.#lastScroller.item;
const selector = [
// Button on a comment
'button.comments-comment-social-bar__reactions-count',
// Original button on a post
'button.feed-shared-social-action-bar-counts',
// Possibly new button on a post
'button.social-details-social-counts__count-value',
].join(',');
clickElement(el, [selector]);
}
);
_viewReposts = new Shortcut(
'v R', 'View reposts of current post', () => {
clickElement(this._posts.item, ['button[aria-label*="repost"]']);
}
);
_openMeatballMenu = new Shortcut(
'=',
'Open closest <button class="spa-meatball">⋯</button> menu',
() => {
// 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()`.
const el = this.#lastScroller.item;
const selector = [
// Comment variant
'[aria-label^="Open options"]',
// Original post variant
'[aria-label^="Open control menu"]',
// Maybe new post variant
'[a11y-text^="Open control menu"]',
].join(',');
const button = el.querySelector(selector).parentElement;
button?.click();
}
);
_likeItem = new Shortcut('L', 'Like current post or comment', () => {
const el = this.#lastScroller.item;
clickElement(el, ['button[aria-label^="Open reactions menu"]']);
});
_commentOnItem = new Shortcut(
'C', 'Comment on current post or comment', () => {
// Order of the queries matters here. If a post has visible comments,
// the wrong button could be selected.
clickElement(this.#lastScroller.item, [
'button[aria-label^="Comment"]',
'button[aria-label^="Reply"]',
]);
}
);
_repost = new Shortcut('R', 'Repost current post', () => {
const el = this._posts.item;
clickElement(el, ['button.social-reshare-button']);
});
_sendPost = new Shortcut('S', 'Send current post privately', () => {
const el = this._posts.item;
clickElement(el, ['button.send-privately-button']);
});
_gotoShare = new Shortcut(
'P',
`Go to the share box to start a post or ${this.#tabSnippet} ` +
'to the other creator options',
() => {
const share = document.querySelector(
'div.share-box-feed-entry__top-bar'
).parentElement;
share.style.scrollMarginTop = linkedInGlobals.navBarHeightCSS;
share.scrollIntoView();
share.querySelector('button').focus();
}
);
_togglePost = new Shortcut('X', 'Toggle hiding current post', () => {
clickElement(
this._posts.item,
[
'button[aria-label^="Dismiss post"]',
'button[aria-label^="Undo and show"]',
]
);
});
_nextPostPlus = new Shortcut(
'J', 'Toggle hiding then next post', async () => {
/** Trigger function for {@link otrot}. */
const trigger = () => {
this._togglePost();
this._nextPost();
};
// XXX: Need to remove the highlights before otrot sees it because it
// affects the .clientHeight.
this._posts.dull();
this._comments?.dull();
if (this._posts.item) {
const what = {
name: 'nextPostPlus',
base: this._posts.item,
};
const how = {
trigger: trigger,
timeout: 3000,
};
await otrot(what, how);
this._posts.show();
} else {
trigger();
}
}
);
_prevPostPlus = new Shortcut(
'K', 'Toggle hiding then previous post', () => {
this._togglePost();
this._prevPost();
}
);
}
/**
* Class for handling the base MyNetwork page.
*
* This page takes 3-4 seconds to load every time. Revisits are
* likely to take a while.
*/
class MyNetwork extends Page {
#sectionScroller
#cardScroller
#lastScroller
#currentSectionText
/** @type {Scroller~What} */
static #sectionsWhat = {
name: 'MyNetwork sections',
base: document.body,
// See https://stackoverflow.com/questions/77146570
selectors: [
[
// Invitations
'main > section',
// Most sections
'main > ul > li',
// More suggestions for you section
'main > div > section',
].join(','),
],
};
/** @type {Scroller~How} */
static _sectionsHow = {
uidCallback: MyNetwork._uniqueIdentifier,
classes: ['tom'],
snapToTop: true,
};
/** @type {Scroller~What} */
static #cardsWhat = {
name: 'MyNetwork cards',
selectors: [
[
// Invitations -> See all
':scope > header > a',
// Other sections -> See all
':scope > div > button',
// Most cards
':scope > ul > li',
// More suggestions for you cards
':scope > section ul > li section',
].join(','),
],
};
/** @type {Scroller~How} */
static _cardsHow = {
uidCallback: MyNetwork._uniqueCardsIdentifier,
classes: ['dick'],
autoActivate: true,
snapToTop: false,
};
/** @type {Page~PageDetails} */
static #details = {
pathname: '/mynetwork/',
pageReadySelector: 'main > ul',
};
/** Create a MyNetwork instance. */
constructor() {
super(MyNetwork.#details);
this.#sectionScroller = new Scroller(MyNetwork.#sectionsWhat,
MyNetwork._sectionsHow);
this.addService(ScrollerService, this.#sectionScroller);
this.#sectionScroller.dispatcher.on('out-of-range',
linkedInGlobals.focusOnSidebar);
this.#sectionScroller.dispatcher.on('change', this.#onSectionChange);
this.#lastScroller = this.#sectionScroller;
}
/** @inheritdoc */
async _refresh() {
/**
* Wait for sections to eventually show up to see if our current one
* comes back. It may not.
* @implements {Monitor}
* @param {MutationRecord[]} records - Standard mutation records.
* @returns {Continuation} - Indicate whether done monitoring.
*/
const monitor = (records) => {
for (const record of records) {
if (record.type === 'childList') {
for (const node of record.addedNodes) {
const newText = node.innerText?.trim().split('\n')[0];
if (newText && newText === this.#currentSectionText) {
return {done: true};
}
}
}
}
return {done: false};
};
const what = {
name: 'MyNetwork._refresh',
base: document.body.querySelector('main'),
};
const how = {
observeOptions: {childList: true, subtree: true},
monitor: monitor,
timeout: 10000,
};
if (this.#currentSectionText) {
await otmot(what, how);
this._sections.shine();
this._sections.show();
this.#resetCards();
}
}
/**
* @implements {Scroller~uidCallback}
* @param {Element} element - Element to examine.
* @returns {string} - A value unique to this element.
*/
static _uniqueIdentifier(element) {
const h2 = element.querySelector('h2');
let content = element.innerText;
if (h2?.innerText) {
content = h2.innerText;
}
return strHash(content);
}
/**
* @implements {Scroller~uidCallback}
* @param {Element} element - Element to examine.
* @returns {string} - A value unique to this element.
*/
static _uniqueCardsIdentifier(element) {
const content = element.innerText;
return strHash(content);
}
/** @type {Scroller} */
get _sections() {
return this.#sectionScroller;
}
/** @type {Scroller} */
get _cards() {
if (!this.#cardScroller && this._sections.item) {
this.#cardScroller = new Scroller(
{base: this._sections.item, ...MyNetwork.#cardsWhat},
MyNetwork._cardsHow
);
this.#cardScroller.dispatcher.on('change', this.#onCardChange);
this.#cardScroller.dispatcher.on(
'out-of-range', this.#returnToSection
);
}
return this.#cardScroller;
}
#resetCards = () => {
if (this.#cardScroller) {
this.#cardScroller.destroy();
this.#cardScroller = null;
}
this._cards;
}
#onCardChange = () => {
this.#lastScroller = this._cards;
}
#onSectionChange = () => {
this.#currentSectionText = this._sections.item?.innerText
.trim().split('\n')[0];
this.#resetCards();
this.#lastScroller = this._sections;
}
#returnToSection = () => {
this._sections.item = this._sections.item;
}
nextSection = new Shortcut('j', 'Next section', () => {
this._sections.next();
});
prevSection = new Shortcut('k', 'Previous section', () => {
this._sections.prev();
});
nextCard = new Shortcut('n', 'Next card in section', () => {
this._cards.next();
});
prevCard = new Shortcut('p', 'Previous card in section', () => {
this._cards.prev();
});
firstItem = new Shortcut('<', 'Go to the first section or card', () => {
this.#lastScroller.first();
});
lastItem = new Shortcut('>', 'Go to the last section or card', () => {
this.#lastScroller.last();
});
focusBrowser = new Shortcut(
'f', 'Change browser focus to current item', () => {
focusOnElement(this.#lastScroller.item);
}
);
viewItem = new Shortcut('Enter', 'View the current item', () => {
const card = this._cards?.item;
if (card) {
if (!clickElement(card, ['a', 'button'], true)) {
this.spa.dumpInfoAboutElement(card, 'network card');
}
} else {
document.activeElement.click();
}
});
enagageCard = new Shortcut(
'E', 'Engage the card (Connect, Follow, Join, etc)', () => {
const me = 'enagageCard';
this.logger.entered(me);
const selector = [
// Connect w/ Person, Join Group, View event
'footer > button',
// Follow person, Follow page
'div.discover-entity-type-card__container-bottom > button',
// Subscribe to newsletter
'div.p3 > button',
].join(',');
this.logger.log('button?', this._cards.item.querySelector(selector));
clickElement(this._cards?.item, [selector]);
this.logger.leaving(me);
}
);
dismissCard = new Shortcut('X', 'Dismiss current card', () => {
clickElement(this._cards?.item, ['button.artdeco-card__dismiss']);
});
}
/** Class for handling the Invitation manager page. */
class InvitationManager extends Page {
#inviteScroller
#currentInviteText
/** @type {Scroller~What} */
static #invitesWhat = {
name: 'Invitation cards',
base: document.body,
selectors: [
[
// Actual invites
'main > section section > ul > li',
].join(','),
],
};
static _invitesHow = {
uidCallback: InvitationManager._uniqueIdentifier,
classes: ['tom'],
};
/**
* @implements {Scroller~uidCallback}
* @param {Element} element - Element to examine.
* @returns {string} - A value unique to this element.
*/
static _uniqueIdentifier(element) {
let content = element.innerText;
const anchor = element.querySelector('a');
if (anchor?.href) {
content = anchor.href;
}
return strHash(content);
}
/** @type {Scroller} */
get _invites() {
return this.#inviteScroller;
}
/** @type {Page~PageDetails} */
static #details = {
pathname: '/mynetwork/invitation-manager/',
pageReadySelector: 'main',
};
/** Create a InvitationManager instance. */
constructor() {
super(InvitationManager.#details);
this.#inviteScroller = new Scroller(
InvitationManager.#invitesWhat, InvitationManager._invitesHow
);
this.addService(ScrollerService, this.#inviteScroller);
this.#inviteScroller.dispatcher.on('change', this.#onChange);
}
/** @inheritdoc */
async _refresh() {
const me = 'refresh';
this.logger.entered(me);
/**
* Wait for current invitation to show back up.
* @implements {Monitor}
* @returns {Continuation} - Indicate whether done monitoring.
*/
const monitor = () => {
for (const el of document.body.querySelectorAll(
'main > section section > ul > li'
)) {
const text = el.innerText.trim().split('\n')[0];
if (text === this.#currentInviteText) {
return {done: true};
}
}
return {done: false};
};
const what = {
name: 'InviteManager refresh',
base: document.body.querySelector('main'),
};
const how = {
observeOptions: {childList: true, subtree: true},
monitor: monitor,
timeout: 3000,
};
if (this.#currentInviteText) {
this.logger.log(`We will look for ${this.#currentInviteText}`);
await otmot(what, how);
this._invites.shine();
this._invites.show();
}
this.logger.leaving(me);
}
#onChange = () => {
const me = 'onChange';
this.logger.entered(me);
this.#currentInviteText = this._invites.item?.innerText
.trim().split('\n')[0];
this.logger.log('current', this.#currentInviteText);
this.logger.leaving(me);
}
nextInvite = new Shortcut('j', 'Next invitation', () => {
this._invites.next();
});
prevInvite = new Shortcut('k', 'Previous invitation', () => {
this._invites.prev();
});
firstInvite = new Shortcut('<', 'Go to the first invitation', () => {
this._invites.first();
});
lastInvite = new Shortcut('>', 'Go to the last invitation', () => {
this._invites.last();
});
focusBrowser = new Shortcut(
'f', 'Change browser focus to current item', () => {
const item = this._invites.item;
focusOnElement(item);
}
);
seeMore = new Shortcut(
'm', 'Toggle seeing more of current invite', () => {
clickElement(
this._invites?.item,
['a.lt-line-clamp__more, a.lt-line-clamp__less']
);
}
);
viewInviter = new Shortcut('i', 'View inviter', () => {
clickElement(this._invites?.item,
['a.app-aware-link:not(.invitation-card__picture)']);
});
viewTarget = new Shortcut(
't',
'View invitation target ' +
'(may not be the same as inviter, e.g., Newsletter)',
() => {
clickElement(this._invites?.item, ['a.invitation-card__picture']);
}
);
openMeatballMenu = new Shortcut(
'=', 'Open <button class="spa-meatball">⋯</button> menu', () => {
this._invites?.item
.querySelector('svg[aria-label^="Report message"]')
?.closest('button')
?.click();
}
);
acceptInvite = new Shortcut('A', 'Accept invite', () => {
clickElement(this._invites?.item, ['button[aria-label^="Accept"]']);
});
ignoreInvite = new Shortcut('I', 'Ignore invite', () => {
clickElement(this._invites?.item, ['button[aria-label^="Ignore"]']);
});
messageInviter = new Shortcut('M', 'Message inviter', () => {
clickElement(this._invites?.item, ['button[aria-label*=" message"]']);
});
}
/**
* Class for handling the base Jobs page.
*
* This particular page requires a lot of careful monitoring. Unlike other
* pages, this one will destroy and recreate HTML elements, often with the
* exact same content, every time something interesting happens. Like
* loading more sections or jobs, or toggling state of a job.
*/
class Jobs extends Page {
#sectionScroller = null;
#jobScroller = null;
/** @type {Scroller~What} */
static #sectionsWhat = {
name: 'Jobs sections',
base: document.body,
selectors: ['main section'],
};
/** @type {Scroller~How} */
static _sectionsHow = {
uidCallback: Jobs._uniqueIdentifier,
classes: ['tom'],
snapToTop: true,
};
/** @type {Scroller~What} */
static #jobsWhat = {
name: 'Job entries',
selectors: [
[
// Most job entries
':scope > ul > li',
// Show all button
'div.discovery-templates-vertical-list__footer',
].join(','),
],
};
/** @type {Scroller~How} */
static _jobsHow = {
uidCallback: Jobs._uniqueJobIdentifier,
classes: ['dick'],
autoActivate: true,
snapToTop: false,
};
/** @type {Page~PageDetails} */
static #details = {
pathname: '/jobs/',
pageReadySelector: 'main',
};
/** Create a Jobs instance. */
constructor() {
super(Jobs.#details);
this.#sectionScroller = new Scroller(Jobs.#sectionsWhat,
Jobs._sectionsHow);
this.addService(ScrollerService, this.#sectionScroller);
this.#sectionScroller.dispatcher.on('out-of-range',
linkedInGlobals.focusOnSidebar);
this.#sectionScroller.dispatcher.on('change', this.#onChange);
}
/** @inheritdoc */
_refresh() {
this._sections.show();
}
/** @type {Scroller} */
get _sections() {
return this.#sectionScroller;
}
/** @type {Scroller} */
get _jobs() {
const me = 'get jobs';
this.logger.entered(me, this.#jobScroller);
if (!this.#jobScroller && this._sections.item) {
this.#jobScroller = new Scroller(
{base: this._sections.item, ...Jobs.#jobsWhat},
Jobs._jobsHow
);
this.#jobScroller.dispatcher.on('out-of-range',
this.#returnToSection);
}
this.logger.leaving(me, this.#jobScroller);
return this.#jobScroller;
}
/** Reset the jobs scroller. */
#resetJobs = () => {
const me = 'resetJobs';
this.logger.entered(me, this.#jobScroller);
if (this.#jobScroller) {
this.#jobScroller.destroy();
this.#jobScroller = null;
}
this._jobs;
this.logger.leaving(me);
}
/** @type {boolean} */
get _hasActiveJob() {
return Boolean(this._jobs?.item);
}
/**
* @implements {Scroller~uidCallback}
* @param {Element} element - Element to examine.
* @returns {string} - A value unique to this element.
*/
static _uniqueIdentifier(element) {
const h2 = element.querySelector('h2');
let content = element.innerText;
if (h2?.innerText) {
content = h2.innerText;
}
return strHash(content);
}
/**
* Complicated because there are so many variations.
* @implements {Scroller~uidCallback}
* @param {Element} element - Element to examine.
* @returns {string} - A value unique to this element.
*/
static _uniqueJobIdentifier(element) {
const ONE_ITEM = 1;
let content = element.innerText;
let options = element.querySelectorAll('a[data-control-id]');
if (options.length === ONE_ITEM) {
content = options[0].dataset.controlId;
} else {
options = element.querySelectorAll('a[id]');
if (options.length === ONE_ITEM) {
content = options[0].id;
} else {
let s = '';
for (const img of element.querySelectorAll('img[alt]')) {
s += img.alt;
}
if (s) {
content = s;
} else {
options = element
.querySelectorAll('.jobs-home-upsell-card__container');
if (options.length === ONE_ITEM) {
content = options[0].className;
}
}
}
}
return strHash(content);
}
/**
* Reselects current section, triggering same actions as initial
* selection.
*/
#returnToSection = () => {
this._sections.item = this._sections.item;
}
/**
* Updates {@link Jobs} specific watcher data and removes the jobs
* {@link Scroller}.
*/
#onChange = () => {
const me = 'onChange';
this.logger.entered(me);
this.#resetJobs();
this.logger.leaving(me);
}
/**
* Recover scroll position after elements were recreated.
* @param {number} topScroll - Where to scroll to.
*/
#resetScroll = (topScroll) => {
const me = 'resetScroll';
this.logger.entered(me, topScroll);
// Explicitly setting jobs.item below will cause it to scroll to that
// item. We do not want to do that if the user is manually scrolling.
const savedJob = this._jobs?.item;
this._sections.shine();
// Section was probably rebuilt, assume jobs scroller is invalid.
this.#resetJobs();
if (savedJob) {
this._jobs.item = savedJob;
}
document.documentElement.scrollTop = topScroll;
this.logger.leaving(me);
}
_nextSection = new Shortcut('j', 'Next section', () => {
this._sections.next();
});
_prevSection = new Shortcut('k', 'Previous section', () => {
this._sections.prev();
});
_nextJob = new Shortcut('n', 'Next job', () => {
this._jobs.next();
});
_prevJob = new Shortcut('p', 'Previous job', () => {
this._jobs.prev();
});
_firstSectionOrJob = new Shortcut(
'<', 'Go to to first section or job', () => {
if (this._hasActiveJob) {
this._jobs.first();
} else {
this._sections.first();
}
}
);
_lastSectionOrJob = new Shortcut(
'>', 'Go to last section or job currently loaded', () => {
if (this._hasActiveJob) {
this._jobs.last();
} else {
this._sections.last();
}
}
);
_focusBrowser = new Shortcut(
'f', 'Change browser focus to current section or job', () => {
const el = this._jobs.item ?? this._sections.item;
this._sections.show();
this._jobs?.show();
focusOnElement(el);
}
);
_activateJob = new Shortcut(
'Enter',
'Activate the current job (click on it)',
() => {
const job = this._jobs?.item;
if (job) {
if (!clickElement(job, ['div[data-view-name]', 'a', 'button'])) {
this.spa.dumpInfoAboutElement(job, 'job');
}
} else {
// Again, because we use Enter as the hotkey for this action.
document.activeElement.click();
}
}
);
_loadMoreSections = new Shortcut(
'l',
'Load more sections (or <i>More jobs for you</i> items)',
async () => {
const savedScrollTop = document.documentElement.scrollTop;
/** Trigger function for {@link otrot}. */
function trigger() {
clickElement(document,
['main button.scaffold-finite-scroll__load-button']);
}
const what = {
name: 'loadMoreSections',
base: document.querySelector('div.scaffold-finite-scroll__content'),
};
const how = {
trigger: trigger,
timeout: 3000,
};
await otrot(what, how);
this.#resetScroll(savedScrollTop);
}
);
_toggleSaveJob = new Shortcut('S', 'Toggle saving job', () => {
const selector = [
'button[aria-label^="Save job"]',
'button[aria-label^="Unsave job"]',
].join(',');
clickElement(this._jobs?.item, [selector]);
});
_toggleDismissJob = new Shortcut('X',
'Toggle dismissing job',
async () => {
const savedJob = this._jobs.item;
/** Trigger function for {@link otrot}. */
function trigger() {
const selector = [
'button[aria-label^="Dismiss job"]:not([disabled])',
'button[aria-label$=" Undo"]',
].join(',');
clickElement(savedJob, [selector]);
}
if (savedJob) {
const what = {
name: 'toggleDismissJob',
base: savedJob,
};
const how = {
trigger: trigger,
timeout: 3000,
};
await otrot(what, how);
this._jobs.item = savedJob;
}
});
}
/** Class for handling Job collections. */
class JobCollections extends Page {
#lastScroller
#jobCardScroller = null;
/** @type {Scroller} */
get _jobCards() {
return this.#jobCardScroller;
}
/** @type {Scroller~What} */
static #jobCardsWhat = {
name: 'Job cards',
base: document.body,
// This selector is also used in #onJobCardActivate.
selectors: ['div.jobs-search-results-list > ul > li'],
};
/** @type {Scroller~How} */
static #jobCardsHow = {
uidCallback: this._uniqueJobIdentifier,
classes: ['tom'],
snapToTop: false,
bottomMarginCSS: '3em',
};
#resultsPageScroller = null;
/** @type {Scroller} */
get _resultsPages() {
return this.#resultsPageScroller;
}
/** @type {Scroller~What} */
static #resultsPagesWhat = {
name: 'Results pages',
base: document.body,
// This selector is also used in #onResultsPageActivate.
selectors: ['div.jobs-search-results-list__pagination li'],
};
/** @type {Scroller~How} */
static #resultsPagesHow = {
uidCallback: this._uniqueResultsPageIdentifier,
classes: ['dick'],
snapToTop: false,
bottomMarginCSS: '3em',
};
/** @type {Page~PageDetails} */
static #details = {
// eslint-disable-next-line prefer-regex-literals
pathname: RegExp('^/jobs/(?:collections|search)/.*', 'u'),
pageReadySelector: 'footer.global-footer-compact',
};
/** Create a JobCollections instance. */
constructor() {
super(JobCollections.#details);
this.#jobCardScroller = new Scroller(JobCollections.#jobCardsWhat,
JobCollections.#jobCardsHow);
this.addService(ScrollerService, this.#jobCardScroller);
this.#jobCardScroller.dispatcher.on('activate',
this.#onJobCardActivate);
this.#jobCardScroller.dispatcher.on('change', this.#onJobCardChange);
this.#resultsPageScroller = new Scroller(
JobCollections.#resultsPagesWhat, JobCollections.#resultsPagesHow
);
this.addService(ScrollerService, this.#resultsPageScroller);
this.#resultsPageScroller.dispatcher.on('activate',
this.#onResultsPageActivate);
this.#resultsPageScroller.dispatcher.on('change',
this.#onResultsPageChange);
this.#lastScroller = this.#jobCardScroller;
}
/**
* @implements {Scroller~uidCallback}
* @param {Element} element - Element to examine.
* @returns {string} - A value unique to this element.
*/
static _uniqueJobIdentifier(element) {
let content = '';
if (element) {
content = element.dataset.occludableJobId;
}
return strHash(content);
}
/**
* @implements {Scroller~uidCallback}
* @param {Element} element - Element to examine.
* @returns {string} - A value unique to this element.
*/
static _uniqueResultsPageIdentifier(element) {
let content = '';
if (element) {
content = element.innerText;
const label = element.getAttribute('aria-label');
if (label) {
content = label;
}
}
return strHash(content);
}
#onJobCardActivate = async () => {
const me = 'onJobActivate';
this.logger.entered(me);
const params = new URL(document.location).searchParams;
const jobId = params.get('currentJobId');
this.logger.log('Looking for job card for', jobId);
// Wait some amount of time for a job card to show up, if it ever does.
// Annoyingly enough, the selection of jobs that shows up on a reload
// may not include one for the current URL. Even if the user arrived at
// the URL moments ago.
/**
* @implements {Monitor}
* @returns {Continuation} - Indicate whether done monitoring.
*/
const monitor = () => {
// This selector must match what is used by the Scroller.
const el = document.body.querySelector(
`li[data-occludable-job-id="${jobId}"]`
);
if (el) {
return {done: true, results: el};
}
this.logger.log('No job card found yet...');
return {done: false};
};
const what = {
name: 'onJobActivateObserver',
base: document.body,
};
const how = {
observeOptions: {childList: true, subtree: true},
monitor: monitor,
timeout: 2000,
};
try {
const item = await otmot(what, how);
this._jobCards.gotoUid(JobCollections._uniqueJobIdentifier(item));
} catch (e) {
this.logger.log('Job card matching URL not found, staying put');
}
this.logger.leaving(me);
}
#onJobCardChange = () => {
const me = 'onJobCardChange';
this.logger.entered(me, this._jobCards.item);
clickElement(this._jobCards.item, ['div[data-job-id]']);
this.#lastScroller = this._jobCards;
this.logger.leaving(me);
}
#onResultsPageActivate = async () => {
const me = 'onResultsPageActivate';
this.logger.entered(me);
/**
* @implements {Monitor}
* @returns {Continuation} - Indicate whether done monitoring.
*/
const monitor = () => {
// This selector must match what is used by the Scroller.
const el = document.body.querySelector(
'div.jobs-search-results-list__pagination li.selected'
);
if (el) {
return {done: true, results: el};
}
this.logger.log('No results paginator found yet...');
return {done: false};
};
const what = {
name: 'onResultsPageActivateObserver',
base: document.body,
};
const how = {
observeOptions: {childList: true, subtree: true},
monitor: monitor,
timeout: 3000,
};
try {
const item = await otmot(what, how);
this._resultsPages.goto(item);
} catch (e) {
this.logger.log('Results paginator not found, staying put');
}
this.logger.leaving(me);
}
#onResultsPageChange = () => {
const me = 'onResultsPageChange';
this.logger.entered(me, this._resultsPages.item);
this.#lastScroller = this._resultsPages;
this.logger.leaving(me);
}
nextJob = new Shortcut('j', 'Next job card', () => {
this._jobCards.next();
});
prevJob = new Shortcut('k', 'Previous job card', () => {
this._jobCards.prev();
});
nextResultsPage = new Shortcut('n', 'Next results page', () => {
this._resultsPages.next();
});
prevResultsPage = new Shortcut('p', 'Previous results page', () => {
this._resultsPages.prev();
});
firstItem = new Shortcut('<', 'Go to first job or results page', () => {
this.#lastScroller.first();
});
lastItem = new Shortcut(
'>', 'Go to last job currently loaded or results page', () => {
this.#lastScroller.last();
}
);
focusBrowser = new Shortcut(
'f', 'Move browser focus to most recently selected item', () => {
focusOnElement(this.#lastScroller.item);
}
);
detailsPane = new Shortcut('d', 'Jump to details pane', () => {
focusOnElement(document.querySelector(
'div.jobs-search__job-details--container'
));
});
selectCurrentResultsPage = new Shortcut(
'c', 'Select current results page', () => {
clickElement(this._resultsPages.item, ['button']);
}
);
openShareMenu = new Shortcut('s', 'Open share menu', () => {
clickElement(document, ['button[aria-label="Share"]']);
});
openMeatballMenu = new Shortcut(
'=', 'Open the <button class="spa-meatball">⋯</button> menu', () => {
// XXX: There are TWO buttons. The *first* one is hidden until the
// user scrolls down. This always triggers the first one.
clickElement(document, ['.jobs-options button']);
}
);
applyToJob = new Shortcut(
'A', 'Apply to job (or previous application)', () => {
// XXX: There are TWO apply buttons. The *second* one is hidden until
// the user scrolls down. This always triggers the first one.
const selectors = [
// Apply and Easy Apply buttons
'button[aria-label*="Apply to"]',
// See application link
'a[href^="/jobs/tracker"]',
];
clickElement(document, selectors);
}
);
toggleSaveJob = new Shortcut('S', 'Toggle saving job', () => {
// XXX: There are TWO buttons. The *first* one is hidden until the user
// scrolls down. This always triggers the first one.
clickElement(document, ['button.jobs-save-button']);
});
toggleDismissJob = new Shortcut('X', 'Toggle dismissing job', () => {
const selector = [
'button[aria-label^="Dismiss job"]:not([disabled])',
'button[aria-label$=" Undo"]',
].join(',');
clickElement(this._jobCards.item, [selector]);
});
toggleFollowCompany = new Shortcut(
'F', 'Toggle following company', () => {
// The button toggles between Follow and Following
clickElement(document, ['button[aria-label^="Follow"]']);
}
);
toggleAlert = new Shortcut(
'L', 'Toggle the job search aLert, if available', () => {
clickElement(document,
['main .jobs-search-create-alert__artdeco-toggle']);
}
);
}
/** Class for handling the Notifications page. */
class Notifications extends Page {
#notificationScroller = null;
/** @type {Scroller~What} */
static #notificationsWhat = {
name: 'Notification cards',
base: document.body,
selectors: ['main section div.nt-card-list article'],
};
/** @type {Scroller-How} */
static _notificationsHow = {
uidCallback: Notifications._uniqueIdentifier,
classes: ['tom'],
snapToTop: false,
};
/** @type {Page~PageDetails} */
static #details = {
pathname: '/notifications/',
pageReadySelector: 'main section div.nt-card-list',
};
/** Create a Notifications instance. */
constructor() {
super(Notifications.#details);
this.#notificationScroller = new Scroller(
Notifications.#notificationsWhat, Notifications._notificationsHow
);
this.addService(ScrollerService, this.#notificationScroller);
this.#notificationScroller.dispatcher.on('out-of-range',
linkedInGlobals.focusOnSidebar);
}
/** @inheritdoc */
_refresh() {
this._notifications.shine();
this._notifications.show();
}
/** @type {Scroller} */
get _notifications() {
return this.#notificationScroller;
}
/**
* Complicated because there are so many variations in notification cards.
* We do not want to use reaction counts because they can change too
* quickly.
* @implements {Scroller~uidCallback}
* @param {Element} element - Element to examine.
* @returns {string} - A value unique to this element.
*/
static _uniqueIdentifier(element) {
// All known <articles> have three children: icon/presence indicator,
// content, and menu/timestamp.
const MAGIC_COUNT = 3;
const CONTENT_INDEX = 1;
let content = element.innerText;
if (element.childElementCount === MAGIC_COUNT) {
content = element.children[CONTENT_INDEX].innerText;
if (content.includes('Reactions')) {
for (const el of element.children[CONTENT_INDEX]
.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;
}
return strHash(content);
}
_nextNotification = new Shortcut('j', 'Next notification', () => {
this._notifications.next();
});
_prevNotification = new Shortcut('k', 'Previous notification', () => {
this._notifications.prev();
});
_firstNotification = new Shortcut('<', 'Go to first notification', () => {
this._notifications.first();
});
_lastNotification = new Shortcut('>', 'Go to last notification', () => {
this._notifications.last();
});
_focusBrowser = new Shortcut(
'f', 'Change browser focus to current notification', () => {
this._notifications.show();
focusOnElement(this._notifications.item);
}
);
_activateNotification = new Shortcut(
'Enter', 'Activate the current notification (click on it)', () => {
const ONE_ITEM = 1;
const notification = this._notifications.item;
if (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') === notification) {
return;
}
const elements = notification.querySelectorAll(
'.nt-card__headline'
);
if (elements.length === ONE_ITEM) {
elements[0].click();
} else {
const ba = notification.querySelectorAll('button,a');
if (ba.length === ONE_ITEM) {
ba[0].click();
} else {
this.spa.dumpInfoAboutElement(notification, 'notification');
}
}
} else {
// Again, because we use Enter as the hotkey for this action.
document.activeElement.click();
}
}
);
_loadMoreNotifications = new Shortcut(
'l', 'Load more notifications', () => {
const savedScrollTop = document.documentElement.scrollTop;
let first = false;
const notifications = this._notifications;
/** Trigger function for {@link otrot2}. */
function trigger() {
if (clickElement(document,
['button[aria-label^="Load new notifications"]'])) {
first = true;
} else {
clickElement(document,
['main button.scaffold-finite-scroll__load-button']);
}
}
/** Action function for {@link otrot2}. */
const action = () => {
if (first) {
if (notifications.item) {
notifications.first();
}
} else {
document.documentElement.scrollTop = savedScrollTop;
this._notifications.shine();
}
};
const what = {
name: 'loadMoreNotifications',
base: document.querySelector('div.scaffold-finite-scroll__content'),
};
const how = {
trigger: trigger,
action: action,
duration: 2000,
};
otrot2(what, how);
}
);
_openMeatballMenu = new Shortcut(
'=', 'Open the <button class="spa-meatball">⋯</button> menu', () => {
clickElement(this._notifications.item,
['button[aria-label^="Settings menu"]']);
}
);
_deleteNotification = new Shortcut(
'X', 'Toggle current notification deletion', async () => {
const notification = this._notifications.item;
/** Trigger function for {@link otrot}. */
function trigger() {
// Hah. Unlike in other places, these buttons already exist, just
// hidden under the menu.
const buttons = Array.from(notification.querySelectorAll('button'));
const button = buttons
.find(el => (/Delete .*notification/u).test(el.textContent));
if (button) {
button.click();
} else {
clickElement(notification,
['button[aria-label^="Undo notification deletion"]']);
}
}
if (notification) {
const what = {
name: 'deleteNotification',
base: document.querySelector(
'div.scaffold-finite-scroll__content'
),
};
const how = {
trigger: trigger,
timeout: 3000,
};
await otrot(what, how);
this._notifications.shine();
}
}
);
}
/** Base class for {@link SPA} instance details. */
class SPADetails {
/**
* An issue that happened during construction. SPA will ask for them and
* add them to the Errors tab.
* @typedef {object} SetupIssue
* @property {string[]} messages - What to pass to {@link SPA.addError}.
*/
/** @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;
#id
#logger
/** @type {string} - Unique ID for this instance . */
get id() {
return this.#id;
}
/** @type {Logger} - Logger instance. */
get logger() {
return this.#logger;
}
/** Create a SPADetails instance. */
constructor() {
if (new.target === SPADetails) {
throw new TypeError('Abstract class; do not instantiate directly.');
}
this.#logger = new Logger(this.constructor.name);
this.#id = safeId(uuId(this.constructor.name));
this.dispatcher = new Dispatcher('errors', 'news');
}
/**
* Called by SPA instance during its construction to allow post
* instantiation stuff to happen. If overridden in a subclass, this
* should definitely be called via super.
*/
init() {
this.dispatcher.on('errors', this._errors);
this.dispatcher.on('news', this._news);
}
/**
* Called by SPA instance when initialization is done. Subclasses should
* call via super.
*/
done() {
const me = 'done (SPADetails)';
this.logger.entered(me);
this.logger.leaving(me);
}
/** @type {TabbedUI} */
get ui() {
return this.#ui;
}
/** @param {TabbedUI} val - UI instance. */
set ui(val) {
this.#ui = val;
}
/**
* Handles notifications about changes to the {@link SPA} Errors tab
* content.
* @implements {Dispatcher~Handler}
* @param {string} eventType - Event type.
* @param {number} count - Number of errors currently logged.
*/
_errors = (eventType, count) => {
this.logger.log('errors:', eventType, count);
}
/**
* Handles notifications about activity on the {@link SPA} News tab.
* @implements {Dispatcher~Handler}
* @param {string} eventType - Event type.
* @param {object} data - Undefined at this time.
*/
_news = (eventType, data) => {
this.logger.log('news', eventType, data);
}
/** @type {SetupIssue[]} */
get setupIssues() {
return this.#setupIssues;
}
/**
* Collects {SetupIssue}s for reporting.
* @param {...string} msgs - Text to report.
*/
addSetupIssue(...msgs) {
for (const msg of msgs) {
this.logger.log('Setup issue:', msg);
}
this.#setupIssues.push(msgs);
}
/**
* @implements {SPA~TabGenerator}
* @returns {TabbedUI~TabDefinition} - Where to find documentation
* and file bugs.
*/
docTab() {
this.logger.log('docTab is not implemented');
throw new Error('Not implemented.');
return { // eslint-disable-line no-unreachable
name: 'Not implemented.',
content: 'Not implemented.',
};
}
/**
* @implements {SPA~TabGenerator}
* @returns {TabbedUI~TabDefinition} - License information.
*/
licenseTab() {
this.logger.log('licenseTab is not implemented');
throw new Error('Not implemented.');
return { // eslint-disable-line no-unreachable
name: 'Not implemented.',
content: 'Not implemented.',
};
}
}
/** LinkedIn specific information. */
class LinkedIn extends SPADetails {
#globals
#infoId
#infoWidget
#licenseData
#licenseLoaded
#navbar
urlChangeMonitorSelector = 'div.authentication-outlet';
static #icon =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">' +
'<defs>' +
'<mask id="a" maskContentUnits="objectBoundingBox">' +
'<path fill="#fff" d="M0 0h1v1H0z"/>' +
'<circle cx=".5" cy=".5" r=".25"/>' +
'</mask>' +
'<mask id="b" maskContentUnits="objectBoundingBox">' +
'<path fill="#fff" mask="url(#a)" d="M0 0h1v1H0z"/>' +
'<rect x="0.375" y="-0.05" height="0.35" width="0.25"' +
' transform="rotate(30 0.5 0.5)"/>' +
'</mask>' +
'</defs>' +
'<rect x="9.5" y="7" width="5" height="10"' +
' transform="rotate(45 12 12)"/>' +
'<circle cx="6" cy="18" r="5" mask="url(#a)"/>' +
'<circle cx="18" cy="6" r="5" mask="url(#b)"/>' +
'</svg>';
#navBarScrollerFixups = [
Feed._postsHow,
Feed._commentsHow,
MyNetwork._sectionsHow,
MyNetwork._cardsHow,
InvitationManager._invitesHow,
Jobs._sectionsHow,
Jobs._jobsHow,
Notifications._notificationsHow,
];
/**
* @param {LinkedInGlobals} globals - Instance of a helper class to avoid
* circular dependencies.
*/
constructor(globals) {
super();
this.#globals = globals;
this.ready = this.#waitUntilPageLoadedEnough();
}
/** @inheritdoc */
done() {
super.done();
const me = 'done';
this.logger.entered(me);
const licenseEntry = this.ui.tabs.get('License');
licenseEntry.panel.addEventListener('expose', this.#licenseHandler);
VMKeyboardService.condition = '!inputFocus && !inDialog';
VMKeyboardService.start();
this.logger.leaving(me);
}
/** @type {string} - The element.id used to identify the info pop-up. */
get infoId() {
return this.#infoId;
}
/** @param {string} val - Set the value of the info element.id. */
set infoId(val) {
this.#infoId = val;
}
/**
* @typedef {object} LicenseData
* @property {string} name - Name of the license.
* @property {string} url - License URL.
*/
/** @type {LicenseData} */
get licenseData() {
const me = 'licenseData';
this.logger.entered(me);
if (!this.#licenseData) {
// Different userscript managers do this differently.
let license = GM.info.script.license;
if (!license) {
const magic = '// @license ';
// Try Tampermonkey's way.
const header = GM.info.script.header;
if (header) {
const line = header.split('\n').find(l => l.startsWith(magic));
if (line) {
license = line.slice(magic.length).trim();
}
}
}
if (!license) {
this.addSetupIssue(
'Unable to extract license information from the userscript.',
JSON.stringify(GM.info.script, null, 2) // eslint-disable-line no-magic-numbers
);
license = 'Unable to extract: Please file a bug;';
}
const [name, url] = license.split(';');
this.#licenseData = {
name: name.trim(),
url: url.trim(),
};
}
this.logger.leaving(me, this.#licenseData);
return this.#licenseData;
}
/** Hang out until enough HTML has been built to be useful. */
#waitUntilPageLoadedEnough = async () => {
const me = 'waitOnPageLoadedEnough';
this.logger.entered(me);
/**
* Monitor for waiting for the navbar to show up.
* @implements {Monitor}
* @returns {Continuation} - Indicate whether done monitoring.
*/
function navBarMonitor() {
const navbar = document.querySelector('#global-nav');
if (navbar) {
return {done: true, results: navbar};
}
return {done: false};
}
// In this case, the trigger was the page load. It already happened by
// the time we got here.
const navWhat = {
name: 'navBarObserver',
base: document.body,
};
const navHow = {
observeOptions: {childList: true, subtree: true},
monitor: navBarMonitor,
};
this.#navbar = await otmot(navWhat, navHow);
this.#finishConstruction();
this.logger.leaving(me);
}
/** Do the bits that were waiting on the page. */
#finishConstruction = () => {
const me = 'finishConstruction';
this.logger.entered(me);
this.#createInfoWidget();
this.#addLitStyle();
this.#addToolMenuItem();
this.#setNavBarInfo();
this.logger.leaving(me);
}
/**
* Lazily load license text when exposed.
* @param {Event} evt - The 'expose' event.
*/
#licenseHandler = async (evt) => {
const me = 'licenseHandler';
this.logger.entered(me, evt.target);
// Probably should debounce this. If the user visits this tab twice
// fast enough, they end up with two copies loaded. Amusing, but
// probably should be resilient.
if (!this.#licenseLoaded) {
const info = document.createElement('p');
info.innerHTML = '<i>Loading license...</i>';
evt.target.append(info);
const {name, url} = this.licenseData;
const response = await fetch(url);
if (response.ok) {
const license = document.createElement('iframe');
license.style.flexGrow = 1;
license.title = name;
license.sandbox = '';
license.srcdoc = await response.text();
info.replaceWith(license);
this.#licenseLoaded = true;
}
}
this.logger.leaving(me);
}
#createInfoWidget = () => {
this.#infoWidget = new InfoWidget('LinkedIn Tool');
const name = document.createElement('div');
name.innerHTML = `<b>${GM.info.script.name}</b> - ` +
`v${GM.info.script.version}`;
const instructions = document.createElement('div');
instructions.classList.add('spa-instructions');
const left = SPA._parseSeq2('c-left'); // eslint-disable-line no-use-before-define
const right = SPA._parseSeq2('c-right'); // eslint-disable-line no-use-before-define
const esc = SPA._parseSeq2('esc'); // eslint-disable-line no-use-before-define
instructions.innerHTML =
`<span class="left">Use the ${left} and ${right} keys or ` +
'click to select tab</span>' +
`<span class="right">Hit ${esc} to close</span>`;
this.#infoWidget.element.append(name, instructions);
// TODO(#130): Once there is a little bit of information in the widget,
// make the button handler toggle which one it shows.
}
/** Create CSS styles for stuff specific to LinkedIn Tool. */
#addLitStyle = () => {
const style = document.createElement('style');
style.id = `${this.id}-style`;
style.textContent +=
'.lit-news {' +
' position: absolute;' +
' bottom: 14px;' +
' right: -5px;' +
' width: 16px;' +
' height: 16px;' +
' border-radius: 50%;' +
' border: 5px solid green;' +
'}\n';
document.head.prepend(style);
}
/** Add a menu item to the global nav bar. */
#addToolMenuItem = () => {
const me = 'addToolMenuItem';
this.logger.entered(me);
const ul = document.querySelector('ul.global-nav__primary-items');
const li = document.createElement('li');
li.classList.add('global-nav__primary-item');
li.innerHTML =
'<button id="lit-nav-button" class="global-nav__primary-link">' +
' <div class="global-nav__primary-link-notif ' +
'artdeco-notification-badge">' +
' <div class="notification-badge">' +
' <span class="notification-badge__count"></span>' +
' </div>' +
` <div>${LinkedIn.#icon}</div>` +
' <span class="lit-news-badge">TBD</span>' +
' <span class="t-12 global-nav__primary-link-text">Tool</span>' +
' </div>' +
'</button>';
const navMe = ul.querySelector('li .global-nav__me').closest('li');
if (navMe) {
navMe.after(li);
} else {
// If the site changed and we cannot insert ourself after the Me menu
// item, then go first.
ul.prepend(li);
this.addSetupIssue(
'Unable to find the Profile navbar item.',
'LIT menu installed in non-standard location.'
);
}
const button = li.querySelector('button');
button.addEventListener('click', () => {
// TODO(#130): Make this toggle which item it opens
const info = document.querySelector(`#${this.infoId}`);
info.showModal();
info.dispatchEvent(new Event('open'));
});
this.logger.leaving(me);
}
/** Set some useful global variables. */
#setNavBarInfo = () => {
const fudgeFactor = 4;
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.#globals.navBarHeightPixels;
how.topMarginCSS = this.#globals.navBarHeightCSS;
how.bottomMarginCSS = '3em';
}
}
/** @inheritdoc */
_errors = (eventType, count) => {
const me = 'errors';
this.logger.entered(me, eventType, count);
const button = document.querySelector('#lit-nav-button');
const toggle = button.querySelector('.notification-badge');
const badge = button.querySelector('.notification-badge__count');
badge.innerText = `${count}`;
if (count) {
toggle.classList.add('notification-badge--show');
} else {
toggle.classList.remove('notification-badge--show');
}
this.logger.leaving(me);
}
/** @inheritdoc */
docTab() {
const me = 'docTab';
this.logger.entered(me);
const baseGhUrl = 'https://github.com/nexushoratio/userscripts';
const baseGfUrl = 'https://greasyfork.org/en/scripts/472097-linkedin-tool';
const issuesLink = `${baseGhUrl}/labels/linkedin-tool`;
const newIssueLink = `${baseGhUrl}/issues/new/choose`;
const newGfIssueLink = `${baseGfUrl}/feedback`;
const releaseNotesLink = `${baseGfUrl}/versions`;
const content = [
`<p>This is information about the <b>${GM.info.script.name}</b> ` +
'userscript, a type of add-on. It is not associated with ' +
'LinkedIn Corporation in any way.</p>',
'<p>Documentation can be found on ' +
`<a href="${GM.info.script.supportURL}">GitHub</a>. Release ` +
'notes are automatically generated on ' +
`<a href="${releaseNotesLink}">Greasy Fork</a>.</p>`,
'<p>Existing issues are also on GitHub ' +
`<a href="${issuesLink}">here</a>.</p>`,
'<p>New issues or feature requests can be filed on GitHub (account ' +
`required) <a href="${newIssueLink}">here</a>. Then select the ` +
'appropriate issue template to get started. Or, on Greasy Fork ' +
`(account required) <a href="${newGfIssueLink}">here</a>. ` +
'Review the <b>Errors</b> tab for any useful information.</p>',
'',
];
const tab = {
name: 'About',
content: content.join('\n'),
};
this.logger.leaving(me, tab);
return tab;
}
/** @inheritdoc */
licenseTab() {
const me = 'licenseTab';
this.logger.entered(me);
const {name, url} = this.licenseData;
const tab = {
name: 'License',
content: `<p><a href="${url}">${name}</a></p>`,
};
this.logger.leaving(me, tab);
return tab;
}
}
/**
* A userscript driver for working with a single-page application.
*
* Generally, a single instance of this class is created, and all instances
* of {Page} are registered to it. As the user navigates through the
* single-page application, this will react to it and enable and disable
* view specific handling as appropriate.
*/
class SPA {
static _errorMarker = '---';
#details
#id
#logger
#name
#oldUrl
/** @type {Logger} */
get logger() {
return this.#logger;
}
/** @type {Set<Page>} - Currently active {Page}s. */
#activePages = new Set();
/** @type {Set<Page>} - Registered {Page}s. */
#pages = new Set();
/** @type {Element} - The most recent element to receive focus. */
_lastInputElement = null;
/** @type {KeyboardService} */
_tabUiKeyboard = null;
/** @param {SPADetails} details - Implementation specific details. */
constructor(details) {
this.#name = `${this.constructor.name}: ${details.constructor.name}`;
this.#id = safeId(uuId(this.#name));
this.#logger = new Logger(this.#name);
this.#details = details;
this.#details.init(this);
this._installNavStyle();
this._initializeInfoView();
for (const issue of details.setupIssues) {
this.logger.log('issue:', issue);
for (const error of issue) {
this.addError(error);
}
this.addErrorMarker();
}
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.logger.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
*/
#startMutationObserverUrlMonitor = async () => {
this.logger.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.logger.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.
*/
_setKeyboardContext(context, state) {
const pages = Array.from(this.#pages.values());
for (const page of pages) {
page.keyboard.setContext(context, state);
}
}
/**
* Handle focus events to track whether we have gone into or left an area
* where we want to disable hotkeys.
* @param {Event} evt - Standard 'focus' event.
*/
_onFocus = (evt) => {
if (this._lastInputElement && evt.target !== this._lastInputElement) {
this._lastInputElement = null;
this._setKeyboardContext('inputFocus', false);
}
if (isInput(evt.target)) {
this._setKeyboardContext('inputFocus', true);
this._lastInputElement = evt.target;
}
}
/**
* Handle urlchange events that indicate a switch to a new page.
* @param {CustomEvent} evt - Custom 'urlchange' event.
*/
#onUrlChange = (evt) => {
this.activate(evt.detail.url.pathname);
}
/** Configure handlers for the info view. */
_addInfoViewHandlers() {
const errors = document.querySelector(
`#${this._infoId} [data-spa-id="errors"]`
);
errors.addEventListener('change', (evt) => {
const count = evt.target.value.split('\n')
.filter(x => x === SPA._errorMarker).length;
this.#details.dispatcher.fire('errors', count);
this._updateInfoErrorsLabel(count);
});
}
/** Create the CSS styles used for indicating the current items. */
_installNavStyle() {
const style = document.createElement('style');
style.id = safeId(`${this.#id}-nav-style`);
const styles = [
'.tom {' +
' border-color: orange !important;' +
' border-style: solid !important;' +
' border-width: medium !important;' +
'}',
'.dick {' +
' border-color: red !important;' +
' border-style: solid !important;' +
' border-width: thin !important;' +
'}',
'',
];
style.textContent = styles.join('\n');
document.head.append(style);
}
/**
* Create and configure a separate {@link KeyboardService} for the info
* view.
*/
_initializeTabUiKeyboard() {
this._tabUiKeyboard = new VM.shortcut.KeyboardService();
this._tabUiKeyboard.register('c-right', this._nextTab);
this._tabUiKeyboard.register('c-left', this._prevTab);
}
/**
* @callback TabGenerator
* @returns {TabbedUI~TabDefinition}
*/
/** Add CSS styling for use with the info view. */
_addInfoStyle() { // eslint-disable-line max-lines-per-function
const style = document.createElement('style');
style.id = safeId(`${this.#id}-info-style`);
const styles = [
`#${this._infoId}:modal {` +
' height: 100%;' +
' width: 65rem;' +
' display: flex;' +
' flex-direction: column;' +
'}',
`#${this._infoId} .left { text-align: left; }`,
`#${this._infoId} .right { text-align: right; }`,
`#${this._infoId} .spa-instructions {` +
' display: flex;' +
' flex-direction: row;' +
' padding-bottom: 1ex;' +
' border-bottom: 1px solid black;' +
' margin-bottom: 5px;' +
'}',
`#${this._infoId} .spa-instructions > span { flex-grow: 1; }`,
`#${this._infoId} textarea[data-spa-id="errors"] {` +
' flex-grow: 1;' +
' resize: none;' +
'}',
`#${this._infoId} .spa-danger { background-color: red; }`,
`#${this._infoId} .spa-current-page { background-color: lightgray; }`,
`#${this._infoId} kbd > kbd {` +
' font-size: 0.85em;' +
' padding: 0.07em;' +
' border-width: 1px;' +
' border-style: solid;' +
'}',
`#${this._infoId} p { margin-bottom: 1em; }`,
`#${this._infoId} th { padding-top: 1em; text-align: left; }`,
`#${this._infoId} td:first-child {` +
' white-space: nowrap;' +
' text-align: right;' +
' padding-right: 0.5em;' +
'}',
// The "color: unset" addresses dimming because these display-only
// buttons are disabled.
`#${this._infoId} button {` +
' border-width: 1px;' +
' border-style: solid;' +
' border-radius: 1em;' +
' color: unset;' +
' padding: 3px;' +
'}',
`#${this._infoId} button.spa-meatball { border-radius: 50%; }`,
'',
];
style.textContent = styles.join('\n');
document.head.prepend(style);
}
/**
* Create the Info dialog and add some static information.
* @returns {Element} - Initialized dialog.
*/
_initializeInfoDialog() {
const dialog = document.createElement('dialog');
dialog.id = this._infoId;
const name = document.createElement('div');
name.innerHTML = `<b>${GM.info.script.name}</b> - ` +
`v${GM.info.script.version}`;
const instructions = document.createElement('div');
instructions.classList.add('spa-instructions');
const left = SPA._parseSeq2('c-left');
const right = SPA._parseSeq2('c-right');
const esc = SPA._parseSeq2('esc');
instructions.innerHTML =
`<span class="left">Use the ${left} and ${right} keys or ` +
'click to select tab</span>' +
`<span class="right">Hit ${esc} to close</span>`;
dialog.append(name, instructions);
return dialog;
}
/**
* Add basic dialog with an embedded tabbbed ui for the info view.
* @param {TabbedUI~TabDefinition[]} tabs - Array defining the info tabs.
*/
_addInfoDialog(tabs) {
const dialog = this._initializeInfoDialog();
this._info = new TabbedUI(`${this.#name} Info`);
for (const tab of tabs) {
this._info.addTab(tab);
}
// Switches to the first tab.
this._info.goto(tabs[0].name);
dialog.append(this._info.container);
document.body.prepend(dialog);
// Dialogs do not have a real open event. We will fake it.
dialog.addEventListener('open', () => {
this._setKeyboardContext('inDialog', true);
VMKeyboardService.setKeyboardContext('inDialog', true);
this._tabUiKeyboard.enable();
for (const {panel} of this._info.tabs.values()) {
// 0, 0 is good enough
panel.scrollTo(0, 0);
}
// TODO(#145): Just here while developing
if (NH.base.testing.enabled) {
GM.setValue('Logger', Logger.configs);
}
});
dialog.addEventListener('close', () => {
this._setKeyboardContext('inDialog', false);
VMKeyboardService.setKeyboardContext('inDialog', false);
this._tabUiKeyboard.disable();
});
}
/**
* @implements {TabGenerator}
* @returns {TabbedUI~TabDefinition} - Initial table for the keyboard
* shortcuts.
*/
static _shortcutsTab() {
return {
name: 'Keyboard shortcuts',
content: '<table data-spa-id="shortcuts"><tbody></tbody></table>',
};
}
/**
* Generate information about the current environment useful in bug
* reports.
* @returns {string} - Text with some wrapped in a `pre` element.
*/
static _errorPlatformInfo() {
const gm = GM.info;
const header = 'Please consider including some of the following ' +
'information in any bug report:';
const msgs = [`${gm.script.name}: ${gm.script.version}`];
for (const [lib, obj] of Object.entries(NH)) {
if (Object.hasOwn(obj, 'version')) {
msgs.push(` ${lib}: ${obj.version}`);
} else {
msgs.push(` ${lib}: Unknown version`);
}
}
msgs.push(`Userscript manager: ${gm.scriptHandler} ${gm.version}`);
if (gm.injectInto) {
msgs.push(` injected into "${gm.injectInto}"`);
}
// Violentmonkey
if (gm.platform) {
msgs.push(`Platform: ${gm.platform.browserName} ` +
`${gm.platform.browserVersion} ${gm.platform.os} ` +
`${gm.platform.arch}`);
}
// Tampermonkey
if (gm.userAgentData) {
let msg = 'Platform: ';
for (const brand of gm.userAgentData.brands.values()) {
msg += `${brand.brand} ${brand.version} `;
}
msg += `${gm.userAgentData?.platform} `;
msg +=
`${gm.userAgentData?.architecture}-${gm.userAgentData?.bitness}`;
msgs.push(msg);
}
return `${header}<pre>${msgs.join('\n')}</pre>`;
}
/**
* @implements {TabGenerator}
* @returns {TabbedUI~TabDefinition} - Initial placeholder for error
* logging.
*/
static _errorTab() {
return {
name: 'Errors',
content: [
'<p>Any information in the text box below could be helpful in ' +
'fixing a bug.</p>',
'<p>The content can be edited and then included in a bug ' +
'report. Different errors should be separated by ' +
`"${SPA._errorMarker}".</p>`,
'<p><b>Please remove any identifying information before ' +
'including it in a bug report!</b></p>',
SPA._errorPlatformInfo(),
'<textarea data-spa-id="errors" spellcheck="false" ' +
'placeholder="No errors logged yet."></textarea>',
].join(''),
};
}
/** Set up everything necessary to get the info view going. */
_initializeInfoView() {
this._infoId = `info-${this.#id}`;
this.#details.infoId = this._infoId;
this._initializeTabUiKeyboard();
const tabGenerators = [
SPA._shortcutsTab(),
this.#details.docTab(),
SPA._errorTab(),
this.#details.licenseTab(),
];
this._addInfoStyle();
this._addInfoDialog(tabGenerators);
this.#details.ui = this._info;
this._addInfoViewHandlers();
}
_nextTab = () => {
this._info.next();
}
_prevTab = () => {
this._info.prev();
}
/**
* Convert a string in CamelCase to separate words, like Camel Case.
* @param {string} text - Text to parse.
* @returns {string} - Parsed text.
*/
static _parseHeader(text) {
// Word Up!
return text.replace(/(?<cameo>[A-Z])/gu, ' $<cameo>').trim();
}
static keyMap = new Map([
['LEFT', '←'],
['UP', '↑'],
['RIGHT', '→'],
['DOWN', '↓'],
]);
/**
* Parse a {@link Shortcut.seq} and wrap it in HTML.
* @example
* 'a c-b' ->
* '<kbd><kbd>a</kbd> then <kbd>Ctrl</kbd> + <kbd>b</kbd></kbd>'
* @param {Shortcut.seq} seq - Keystroke sequence.
* @returns {string} - Appropriately wrapped HTML.
*/
static _parseSeq2(seq) {
/**
* Convert a VM.shortcut style into an HTML snippet.
* @param {IShortcutKey} key - A particular key press.
* @returns {string} - HTML snippet.
*/
function reprKey(key) {
if (key.base.length === 1) {
if ((/\p{Uppercase_Letter}/u).test(key.base)) {
key.base = key.base.toLowerCase();
key.modifierState.s = true;
}
} else {
key.base = key.base.toUpperCase();
const mapped = SPA.keyMap.get(key.base);
if (mapped) {
key.base = mapped;
}
}
const sequence = [];
if (key.modifierState.c) {
sequence.push('Ctrl');
}
if (key.modifierState.a) {
sequence.push('Alt');
}
if (key.modifierState.s) {
sequence.push('Shift');
}
sequence.push(key.base);
return sequence.map(c => `<kbd>${c}</kbd>`).join('+');
}
const res = VM.shortcut.normalizeSequence(seq, true)
.map(key => reprKey(key))
.join(' then ');
return `<kbd>${res}</kbd>`;
}
/**
* Generate a unique id for page views.
* @param {Page} page - An instance of the Page class.
* @returns {string} - Unique identifier.
*/
_pageInfoId(page) {
return `${this._infoId}-${page.infoHeader}`;
}
/**
* Add shortcut descriptions from the page to the shortcut tab.
* @param {Page} page - An instance of the Page class.
*/
_addInfo(page) {
const shortcuts = document.querySelector(`#${this._infoId} tbody`);
const section = SPA._parseHeader(page.infoHeader);
const pageId = this._pageInfoId(page);
let s = `<tr id="${pageId}"><th></th><th>${section}</th></tr>`;
for (const {seq, desc} of page.allShortcuts) {
const keys = SPA._parseSeq2(seq);
s += `<tr><td>${keys}:</td><td>${desc}</td></tr>`;
}
// Don't include works in progress that have no keys yet.
if (page.allShortcuts.length) {
shortcuts.innerHTML += s;
for (const button of shortcuts.querySelectorAll('button')) {
button.disabled = true;
}
}
}
/**
* Update Errors tab label based upon value.
* @param {number} count - Number of errors currently logged.
*/
_updateInfoErrorsLabel(count) {
const me = 'updateInfoErrorsLabel';
this.logger.entered(me, count);
const label = this._info.tabs.get('Errors').label;
if (count) {
this._info.goto('Errors');
label.classList.add('spa-danger');
} else {
label.classList.remove('spa-danger');
}
this.logger.leaving(me);
}
/**
* Get the hot keys tab header element for this page.
* @param {Page} page - Page to find.
* @returns {?Element} - Element that acts as the header.
*/
_pageHeader(page) {
const me = 'pageHeader';
this.logger.entered(me, page);
let element = null;
if (page) {
const pageId = this._pageInfoId(page);
this.logger.log('pageId:', pageId);
element = document.querySelector(`#${pageId}`);
}
this.logger.leaving(me, element);
return element;
}
/**
* Highlight information about the page in the hot keys tab.
* @param {Page} page - Page to shine.
*/
_shine(page) {
const me = 'shine';
this.logger.entered(me, page);
const element = this._pageHeader(page);
element?.classList.add('spa-current-page');
this.logger.leaving(me);
}
/**
* Remove highlights from this page in the hot keys tab.
* @param {Page} page - Page to dull.
*/
_dull(page) {
const me = 'dull';
this.logger.entered(me, page);
const element = this._pageHeader(page);
element?.classList.remove('spa-current-page');
this.logger.leaving(me);
}
/**
* Add content to the Errors tab so the user can use it to file feedback.
* @param {string} content - Information to add.
*/
addError(content) {
const errors = document.querySelector(
`#${this._infoId} [data-spa-id="errors"]`
);
errors.value += `${content}\n`;
if (content === SPA._errorMarker) {
const event = new Event('change');
errors.dispatchEvent(event);
}
}
/**
* Add a marker to the Errors tab so the user can see where different
* issues happened.
*/
addErrorMarker() {
this.addError(SPA._errorMarker);
}
/**
* Add a new page to those supported by this instance.
* @param {function(): Page} Klass - A {Page} class to instantiate.
*/
register(Klass) {
if (Klass.prototype instanceof Page) {
const page = new Klass();
page.start(this);
this._addInfo(page);
this.#pages.add(page);
} else {
throw new Error(`${Klass.name} is not a Page`);
}
}
/**
* Dump a bunch of information about an HTML element.
* @param {Element} element - Element to get information about.
* @param {string} name - What area this information came from.
*/
dumpInfoAboutElement(element, name) {
const msg = `An unsupported ${name} element was discovered:`;
this.addError(msg);
this.addError(element.outerHTML);
this.addErrorMarker();
}
/**
* Determine which page can handle this portion of the URL.
* @param {string} pathname - A {URL.pathname}.
* @returns {Set<Page>} - The pages to use.
*/
_findPages(pathname) {
const pages = Array.from(this.#pages.values());
return new Set(pages.filter(page => page.pathname.test(pathname)));
}
/**
* Handle switching from the old page (if any) to the new one.
* @param {string} pathname - A {URL.pathname}.
*/
activate(pathname) {
const pages = this._findPages(pathname);
const oldPages = new Set(this.#activePages);
const newPages = new Set(pages);
for (const page of oldPages) {
newPages.delete(page);
}
for (const page of pages) {
oldPages.delete(page);
}
for (const page of oldPages) {
page.deactivate();
this._dull(page);
}
for (const page of newPages) {
page.activate();
this._shine(page);
}
this.#activePages = pages;
}
}
/** Test case. */
function testParseSeq() {
const tests = [
{test: 'q', expected: '<kbd><kbd>q</kbd></kbd>'},
{test: 's-q', expected: '<kbd><kbd>Shift</kbd>+<kbd>q</kbd></kbd>'},
{test: 'Q', expected: '<kbd><kbd>Shift</kbd>+<kbd>q</kbd></kbd>'},
{test: 'a b', expected: '<kbd><kbd>a</kbd> then <kbd>b</kbd></kbd>'},
{test: '<', expected: '<kbd><kbd><</kbd></kbd>'},
{test: 'C-q', expected: '<kbd><kbd>Ctrl</kbd>+<kbd>q</kbd></kbd>'},
{test: 'c-q', expected: '<kbd><kbd>Ctrl</kbd>+<kbd>q</kbd></kbd>'},
{test: 'c-a-t',
expected: '<kbd><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+' +
'<kbd>t</kbd></kbd>'},
{test: 'a-c-T',
expected: '<kbd><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+' +
'<kbd>Shift</kbd>+<kbd>t</kbd></kbd>'},
{test: 'c-down esc',
expected: '<kbd><kbd>Ctrl</kbd>+<kbd>↓</kbd> ' +
'then <kbd>ESC</kbd></kbd>'},
{test: 'alt-up tab',
expected: '<kbd><kbd>Alt</kbd>+<kbd>↑</kbd> ' +
'then <kbd>TAB</kbd></kbd>'},
{test: 'shift-X control-alt-del',
expected: '<kbd><kbd>Shift</kbd>+<kbd>x</kbd> ' +
'then <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>DEL</kbd></kbd>'},
{test: 'c-x c-v',
expected: '<kbd><kbd>Ctrl</kbd>+<kbd>x</kbd> ' +
'then <kbd>Ctrl</kbd>+<kbd>v</kbd></kbd>'},
{test: 'a-x enter',
expected: '<kbd><kbd>Alt</kbd>+<kbd>x</kbd> ' +
'then <kbd>ENTER</kbd></kbd>'},
{test: 'up up down down left right left right b shift-a enter',
expected: '<kbd><kbd>↑</kbd> then <kbd>↑</kbd> then <kbd>↓</kbd> ' +
'then <kbd>↓</kbd> then <kbd>←</kbd> then <kbd>→</kbd> ' +
'then <kbd>←</kbd> then <kbd>→</kbd> then <kbd>b</kbd> ' +
'then <kbd>Shift</kbd>+<kbd>a</kbd> then <kbd>ENTER</kbd></kbd>'},
];
for (const {test, expected} of tests) {
const actual = SPA._parseSeq2(test);
const passed = actual === expected;
const msg = `t:${test} e:${expected} a:${actual}, p:${passed}`;
NH.base.testing.log.log(msg);
if (!passed) {
throw new Error(msg);
}
}
}
NH.base.testing.funcs.push(testParseSeq);
const linkedIn = new LinkedIn(linkedInGlobals);
// Inject some test errors
if (NH.base.testing.enabled) {
linkedIn.addSetupIssue('This is a dummy test issue.',
'It was added because NH.base.testing is enabled.');
linkedIn.addSetupIssue('This is a second issue.',
'We just want to make sure things count properly.');
}
linkedIn.ready.then(() => {
log.log('proceeding...');
const spa = new SPA(linkedIn);
spa.register(Global);
spa.register(Feed);
spa.register(MyNetwork);
spa.register(InvitationManager);
spa.register(Jobs);
spa.register(JobCollections);
spa.register(Notifications);
spa.activate(window.location.pathname);
});
if (NH.base.testing.enabled) {
const me = 'Running tests';
// eslint-disable-next-line require-atomic-updates
NH.base.testing.log = new Logger('Testing');
NH.base.testing.log.entered(me);
for (const test of NH.base.testing.funcs) {
NH.base.testing.log.starting(test.name);
test();
NH.base.testing.log.finished(test.name);
}
NH.base.testing.log.leaving(me);
NH.base.testing.log.log('All tests passed.');
}
log.log('Initialization successful.');
})();