// ==UserScript==
// @name LinkedIn Tool
// @namespace dalgoda@gmail.com
// @match https://www.linkedin.com/*
// @inject-into content
// @noframes
// @version 114
// @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
// @downloadURL https://github.com/nexushoratio/userscripts/raw/main/linkedin-tool.user.js
// @supportURL https://github.com/nexushoratio/userscripts/blob/main/linkedin-tool.md
// @require https://cdn.jsdelivr.net/npm/@violentmonkey/shortcut@1
// @require https://cdn.jsdelivr.net/npm/commonmark@0.31.2
// @require https://update.greasyfork.org/scripts/478188/1884975/NH_xunit.js
// @require https://update.greasyfork.org/scripts/477290/1885316/NH_base.js
// @require https://update.greasyfork.org/scripts/478349/1884974/NH_userscript.js
// @require https://update.greasyfork.org/scripts/478440/1904784/NH_web.js
// @require https://update.greasyfork.org/scripts/478676/1890585/NH_widget.js
// @require https://update.greasyfork.org/scripts/570146/1900843/NH_spa.js
// @grant GM.addValueChangeListener
// @grant GM.removeValueChangeListener
// @grant GM.getValue
// @grant GM.setValue
// @grant window.onurlchange
// ==/UserScript==
/**
* @file LinkedIn Tool
* @module linkedin-tool
* @version 114
* @license [GPL-3.0-or-later]{@link https://www.gnu.org/licenses/gpl-3.0-standalone.html}
*/
/* global VM, commonmark */
// eslint-disable-next-line max-lines-per-function
(async () => {
'use strict';
const NH = window.NexusHoratio.base.ensure([
{name: 'xunit', minVersion: 63},
{name: 'base', minVersion: 74},
{name: 'userscript', minVersion: 18},
{name: 'web', minVersion: 17},
{name: 'widget', minVersion: 52},
{name: 'spa', minVersion: 15},
]);
const APP_LONG = GM.info.script.name;
const CKEY = 'componentkey';
const OPTIONS = 'Options';
const APP_SHORT = APP_LONG.split(' ')
.at(NH.base.LAST_ITEM);
/**
* Save options to storage.
*
* @param {object} options - Options key/value pairs.
*/
function saveOptions(options) {
NH.userscript.setValue(OPTIONS, options);
}
/**
* Load options from storage.
*
* @todo Over engineer this into having a schema that could be used for
* building an edit widget.
*
* Saved options will be augmented by any new defaults and resaved.
* @returns {object} Options key/value pairs.
*/
async function loadOptions() {
const defaultOptions = {
enableDevMode: false,
enableAlertOldNews: false,
enableAlertUnsupportedPages: false,
enableAlertUnknownProfileSections: false,
enableWatchPage: false,
enableIssue289Monitoring: false,
fakeErrorRate: 0.8,
latestNewsRead: '',
};
const savedOptions = await NH.userscript.getValue(OPTIONS, {});
const options = {
...defaultOptions,
...savedOptions,
};
saveOptions(options);
return options;
}
const litOptions = await loadOptions();
// eslint-disable-next-line require-atomic-updates
NH.xunit.testing.enabled = litOptions.enableDevMode;
// Inject some test errors
if (litOptions.enableDevMode && Math.random() < litOptions.fakeErrorRate) {
NH.base.issues.post('This is a dummy test issue.',
'It was added because enableDevMode is true and' +
` the fakeErrorRate is ${litOptions.fakeErrorRate}.`);
NH.base.issues.post('This is a second issue.',
'We just want to make sure things count properly.');
}
await NH.userscript.setAutoManageLoggerConfigs(true);
/**
* @todo [(#145)](https://github.com/nexushoratio/userscripts/issues/145)
* The if test is just here while developing.
*/
if (!litOptions.enableDevMode) {
NH.base.Logger.config('Default').enabled = true;
}
const issuesForLinkedIn = new NH.base.MessageQueue();
/** @param {...object} items - Posted issues. */
function issueListener(...issues) {
issuesForLinkedIn.post(...issues);
}
NH.base.issues.listen(issueListener);
const log = new NH.base.Logger('Default');
/** Encapsulate a GitHub (or similar) issue. */
class GitHubIssue {
/**
* @param {string} issueId - Issue identifier, usually a GitHub number.
* @param {string} title - The GitHub issue title.
* @param {string} date - A Date parsable string of the last time the
* issue was verified opened
*/
constructor(issueId, title, date) {
this.#issueId = issueId;
this.#title = title;
this.#date = new Date(date);
}
/** @type {Date} */
get date() {
return this.#date;
}
/** @type {string} */
get issueId() {
return this.#issueId;
}
/** @type {string} */
get title() {
return this.#title;
}
/** @returns {string} A string representing this object. */
toString() {
return `${this.issueId}: ${this.title}`;
}
#date
#issueId
#title
}
/**
* @param {string} issueId - Issue identifier, usually a GitHub number.
* @param {string} title - The GitHub issue title.
* @param {string} date - A Date parsable string of the last time the
* issue was verified opened.
* @returns {GitHubIssue} New object.
*/
function ish(issueId, title, date) {
return new GitHubIssue(issueId, title, date);
}
const globalIssues = [
ish('', 'Minor internal improvements', '9999'),
ish('167', 'Refactor into libraries', '2026-08-25'),
ish('209', 'Support **Search Results People** view', '2026-08-09'),
ish('236', 'Support **Events** page', '2026-08-10'),
ish(
'295',
'Navigating from *Style-2* page to *Style-1* page breaks LIT',
'2026-08-06'
),
ish('302', '<b>Profile</b>: Entries need tuning', '2026-04-24'),
ish('303', 'Keys are captured while editing text', '2026-08-21'),
ish('360', 'Support **Search Results All** page', '2026-08-03'),
ish(
'372',
'`Scroller`: New item cache fails on at least one page',
'2026-07-19'
),
ish(
'379',
'**Profile**: Multiple failures when navigating between profiles',
'2026-08-15'
),
ish(
'382',
'**Info**: Give the *Errors* tab priority over *News*',
'2026-08-20'
),
ish('253', 'Support **Manage Events** page', '2026-08-21'),
ish('237', 'Support **Specific Event** pages', '2026-08-23'),
ish('381', '**Profile**: Make use of `UidMode` consistent', '2026-08-24'),
ish(
'129',
'**Info**: *Shortcuts* Consider a way to implement subsections in' +
' keystroke menu',
'2026-08-24'
),
ish('383', 'LIT Does not load', '2026-08-27'),
ish(
'384',
'**Profile**: Layout updated and navigation fails',
'2026-08-29'
),
ish(
'385',
'Capturing the `Enter` key causes as much harm as good.',
'2026-08-29'
),
ish('386', 'Support **Games** pages', '2026-08-29'),
ish(
'387', '`PagesToDo`: The generated `pathname` has issues', '2026-09-03'
),
ish('260', 'Support **Job tracker** page', '2026-09-03'),
ish('388', '**Profile**: *Topcard* scroller is failing', '2026-09-03'),
ish(
'396',
'**Profile**: *Suggested for You* scroller is failing',
'2026-09-03'
),
ish('408', 'Support **Connections** pages', '2026-09-08'),
];
const globalNewsContent = [
{
date: '2026-09-08',
issues: ['408'],
subject: 'Acknowledge **Connections** page',
},
{
date: '2026-09-08',
issues: ['388'],
subject: 'Update the `UidMode`\'s post layout update',
},
{
date: '2026-09-08',
issues: ['388'],
subject: 'Capture external links again',
},
{
date: '2026-09-08',
issues: ['388'],
subject: 'Fine tune the premium footer "ad" selector',
},
{
date: '2026-09-08',
issues: ['388'],
subject: 'Handle the private edit footer carousel again',
},
{
date: '2026-09-08',
issues: ['388'],
subject: 'Fine tune the common *Topcard* selector',
},
{
date: '2026-09-08',
issues: [''],
subject: 'More partial ordering pairs for **Profile**',
},
{
date: '2026-09-06',
issues: [''],
subject: 'More partial ordering pairs for **Profile**',
},
{
date: '2026-09-06',
issues: ['237'],
subject: 'Introduce a temporary variable for `itemUid`',
},
{
date: '2026-09-06',
issues: ['209', '237'],
subject: 'Annotate that some pages are works-in-progress',
},
{
date: '2026-09-06',
issues: ['237'],
subject: 'Initial support for the *Networking* (a.k.a., cohorts)' +
' sections',
},
{
date: '2026-09-03',
issues: ['360'],
subject: 'Update issue note to match source title',
},
{
date: '2026-09-03',
issues: ['387'],
subject: 'Fix the `RegExp()` and update affected URLs',
},
{
date: '2026-09-03',
issues: ['260'],
subject: 'Update URL and name for **Job tracker**',
},
{
date: '2026-09-03',
issues: ['388'],
subject: 'Partial update for section *Topcard*',
},
{
date: '2026-09-03',
issues: ['396'],
subject: 'Partial update for section *Suggested for you*',
},
{
date: '2026-09-01',
issues: ['372'],
subject: 'Only post the timeout if we are still observing',
},
{
date: '2026-09-01',
issues: ['372'],
subject: 'Filter out `undefined` containers',
},
{
date: '2026-09-01',
issues: ['372'],
subject: 'Add logging to `deactivate()`',
},
{
date: '2026-09-01',
issues: ['372'],
subject: 'Improve logging to reduce confusion',
},
{
date: '2026-09-01',
issues: ['237'],
subject: 'Focus on the current tab when tablist is selected',
},
{
date: '2026-08-31',
issues: ['236'],
subject: 'Improve support for the *Your events* section',
},
{
date: '2026-08-29',
issues: ['237'],
subject: 'Initial `A`ccept, `S`hare, and `=` (menu) support',
},
{
date: '2026-08-29',
issues: ['385'],
subject: 'Remove the `Enter` key shortcut from **My Network**',
},
{
date: '2026-08-29',
issues: ['386'],
subject: 'Acknowledge the **Games** pages',
},
{
date: '2026-08-29',
issues: ['384'],
subject: 'Replace `h2` UID method with an *Interests* specific one',
},
{
date: '2026-08-29',
issues: ['384'],
subject: 'Match the *Analytics* and *Interests* sections',
},
{
date: '2026-08-29',
issues: ['384'],
subject: 'Updates to **Profile**\'s primary scroller',
},
{
date: '2026-08-28',
issues: [''],
subject: 'Fix minor code formatting',
},
{
date: '2026-08-28',
issues: ['237'],
subject: 'Support the *Speakers* section',
},
{
date: '2026-08-28',
issues: ['237'],
subject: 'Tweak the `showMore` short cut description',
},
{
date: '2026-08-27',
issues: ['383'],
subject: 'Update the *Style-2* `#primaryNavSelector`',
},
{
date: '2026-08-26',
issues: ['237'],
subject: 'Support the *About* (aka "description") section',
},
{
date: '2026-08-25',
issues: ['381'],
subject: 'Retire unused `UidMode.IMG`',
},
{
date: '2026-08-25',
issues: ['237'],
subject: 'Initial work on secondary scroller, just *Topcard*',
},
{
date: '2026-08-25',
issues: ['237'],
subject: 'Trim off cruft on the section UIDs',
},
{
date: '2026-08-24',
issues: ['381'],
subject: 'Retire `UidMode.ID`',
},
{
date: '2026-08-24',
issues: [''],
subject: 'Partial ordering pair clean up for **Profile**',
},
{
date: '2026-08-24',
issues: [''],
subject: 'More partial ordering pairs for **Profile**',
},
{
date: '2026-08-24',
issues: ['237'],
subject: 'Initial support for **Specific Event** pages',
},
{
date: '2026-08-24',
issues: ['129'],
subject: 'Explicitly set `VMKeyboardService` short name for some pages',
},
{
date: '2026-08-24',
issues: [''],
subject: 'Make certain shortcuts consistent',
},
{
date: '2026-08-24',
issues: ['253'],
subject: 'Update name to match the title for the page',
},
{
date: '2026-08-23',
issues: ['236'],
subject: '**Events** is fully supported, retire the `@todo`',
},
{
date: '2026-08-23',
issues: ['237'],
subject: 'Acknowledge **Events Specific** page',
},
{
date: '2026-08-23',
issues: ['236', '253'],
subject: 'Conflated **My Network Events** with **Events**',
},
{
date: '2026-08-21',
issues: ['253'],
subject: 'Retire **Events** from `PagesToDo`',
},
{
date: '2026-08-21',
issues: ['372'],
subject: 'Update referenced bugs in a notification; the original is' +
' closed',
},
{
date: '2026-08-21',
issues: ['253', '295'],
subject: 'Move `#onHybridActivate()` to trigger on the details' +
' dispatcher',
},
{
date: '2026-08-21',
issues: ['253'],
subject: 'Change the LIT CSS styles to be appended rather than' +
' prepended',
},
{
date: '2026-08-21',
issues: ['253'],
subject: 'Set `#lastScroller` on page initialization',
},
{
date: '2026-08-21',
issues: ['253'],
subject: 'Bump initial timeout to 4s',
},
{
date: '2026-08-20',
issues: ['382'],
subject: 'Swap order of tab checks when the menu item is connected',
},
{
date: '2026-08-20',
issues: ['379'],
subject: 'Only look for the UID prefix when **Profile** is the' +
' current page',
},
{
date: '2026-08-17',
issues: ['379'],
subject: 'Update to latest `lib/web` and make `#scrollerFinder()`' +
' async',
},
{
date: '2026-08-16',
issues: ['379'],
subject: 'Trigger the `StyleService` after the toolbar transitions',
},
{
date: '2026-08-16',
issues: ['379'],
subject: 'Move some logging around',
},
{
date: '2026-08-16',
issues: [''],
subject: 'Another **Profile** partial ordering pair',
},
{
date: '2026-08-15',
issues: ['379'],
subject: 'Update how section UID prefix is obtained',
},
{
date: '2026-08-14',
issues: ['302'],
subject: 'Retire transition support shim',
},
{
date: '2026-08-14',
issues: ['302'],
subject: 'Retire old name for the *Highlights* section',
},
{
date: '2026-08-13',
issues: ['302'],
subject: 'Update to latest `lib/spa` to use the new' +
' `readySelectorTimeout`',
},
{
date: '2026-08-12',
issues: ['302'],
subject: 'Wait for the *Topcard* section to show up',
},
{
date: '2026-08-11',
issues: ['302'],
subject: 'Update `UidMode`s for the *Interests* section',
},
];
/**
* 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.name);
* entry.classList.add('random-css');
* entry.innerHTML += '<p>More contact info.</p>';
*/
class TabbedUI {
/* XXX: This class is going away, so not bothering with fixing. */
/* eslint-disable no-shadow */
/**
* @param {string} name - Used to distinguish HTML elements and CSS
* classes.
*/
constructor(name) {
this.#log = new NH.base.Logger(`TabbedUI ${name}`);
this.#name = name;
this.#idName = NH.base.safeId(name);
this.#id = NH.base.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`);
}
/** @type {external:Element} */
get container() {
return this.#container;
}
/**
* @typedef {object} TabEntry
* @memberof module:linkedin-tool~TabbedUI~
* @property {string} name - Tab name.
* @property {external:Element} label - Tab label, so CSS can be applied.
* @property {external:Element} panel - Tab panel, so content can be
* updated.
*/
/** @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;
}
/**
* A string of HTML or a prebuilt Element.
* @typedef {(string|Element)} TabContent
* @memberof module:linkedin-tool~TabbedUI~
*/
/**
* @typedef {object} TabDefinition
* @memberof module:linkedin-tool~TabbedUI~
* @property {string} name - Tab name.
* @property {TabContent} content - Initial content.
*/
/** @param {TabDefinition} tab - The new tab. */
addTab(tab) {
const me = 'addTab';
this.#log.entered(me, tab);
const {
name,
content,
} = tab;
const idName = NH.base.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(NH.base.ONE_ITEM);
this.#log.leaving(me);
}
/** Activate the previous tab. */
prev() {
const me = 'prev';
this.#log.entered(me);
this.#switchTab(-NH.base.ONE_ITEM);
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);
}
#container
#id
#idName
#log
#name
#nav
#navSpacer
#nextButton
#prevButton
#style
/**
* Installs basic CSS styles for the UI.
*
* @method
*/
#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;' +
'}',
`#${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.
*
* @method
* @returns {Element[]} Control elements for the tabs.
*/
#getTabControls = () => {
const controls = Array.from(this.container.querySelectorAll(
':scope > input'
));
return controls;
}
/**
* Switch to an adjacent tab.
*
* @method
* @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);
}
/**
* @method
* @param {string} name - Human readable name for tab.
* @param {string} idName - Normalized to be CSS class friendly.
* @returns {external: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;
}
/**
* @method
* @param {string} name - Human readable name for tab.
* @param {external:Element} input - Input element associated with this
* label.
* @param {string} idName - Normalized to be CSS class friendly.
* @returns {external: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;
}
/**
* @method
* @param {string} name - Human readable name for tab.
* @param {string} idName - Normalized to be CSS class friendly.
* @param {TabContent} content - Initial content.
* @returns {external: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`);
if (content instanceof Element) {
panel.append(content);
} else {
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.
*
* @method
* @param {external: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);
}
/**
* Installs navigational control elements.
*
* @method
*/
#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);
}
}
/**
* 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. This
* is NOT an error condition, but rather a design feature.
* - 'change' - The value of item has changed.
* - 'activate' - The Scroller was activated.
* - 'deactivate' - The Scroller was deactivated.
* - 'focus' - Before the focus is set.
* - 'focused' - After the focus is set.
*/
class Scroller {
/**
* Function that generates a, preferably, reproducible unique identifier
* for an Element.
*
* The method {@link defaultUid} exists to both provide an example and
* fallback implementation. However, it may not always be reproducible
* (consider items that consist of counts for reads and likes). It may
* also not be unique within a particular instance.
*
* It is a good practice to verify the stability and uniqueness of
* callbacks across page reloads. Built in logging will identify
* duplicates.
*
* @callback uidCallback
* @memberof module:linkedin-tool~Scroller~
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
/**
* Contains CSS selectors to first find a base element, then items that it
* contains.
* @typedef {object} ContainerItemsSelector
* @memberof module:linkedin-tool~Scroller~
* @property {string} container - CSS selector to find the container
* element.
* @property {string} items - CSS selector to find the items inside the
* container.
*/
/**
* Function that finds a DOM element based upon another one.
*
* Useful for cases where CSS selectors are not sufficient.
* @callback ElementFinder
* @memberof module:linkedin-tool~Scroller~
* @param {external:Element} element - Starting point.
* @returns {external:Element} Found element.
*/
/**
* Common config for finding a clickable element inside the current item.
*
* Use only one of selectorArray or finder.
*
* @typedef {object} ClickConfig
* @memberof module:linkedin-tool~Scroller~
* @property {string[]} [selectorArray] - CSS selectors to use to find an
* element, passed to {@link NexusHoratio.web.clickElement}.
* @property {boolean} [matchSelf=false] - If a CSS selector would match
* base, then use it, {@link NexusHoratio.web.clickElement}.
* @property {ElementFinder} [finder] - Function to find the appropriate
* clickable element, when a selectorArray is too simplistic.
*/
/**
* There are two ways to describe what elements go into a Scroller:
* 1. An explicit container (base) element and selectors stemming from it.
* 2. An array of ContainerItemsSelector that can allow for multiple
* containers with items. This approach will also allow the Scroller to
* automatically wait for all container elements to exist during
* activation.
* @typedef {object} What
* @memberof module:linkedin-tool~Scroller~
* @property {string} name - Name for this Scroller, used for logging.
* @property {external: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().
* @property {ContainerItemsSelector[]} containerItems - Array of
* ContainerItemsSelectors.
*/
/**
* @typedef {object} How
* @memberof module:linkedin-tool~Scroller~
* @property {uidCallback} uidCallback - Callback to generate a uid.
* @property {number} [maxUidLength=20] - Max length for default uid text.
* @property {string[]} [classes=[]] - Array of CSS classes to add/remove
* from an element as it becomes current.
* @property {boolean} [watchForClicks=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} [observeAttributes=false] - Whether the built in
* {@link external:MutationObserver} should also observer node attributes
* (useful if the uid depends on attributes).
* @property {boolean} [snapToTop=false] - Whether items should snap to
* the top of the window when coming into view.
* should happen when {snapToTop} is false.
* @property {number} [waitForItemTimeout=3000] - Time to wait, in
* milliseconds, for existing item to reappear upon reactivation.
* @property {number} [containerTimeout=0] - Time to wait, in
* milliseconds, for a {ContainerItemsSelector.container} to show up.
* Some pages may not always provide all identified containers. The
* default of 0 disables timing out. NB: Any containers that timeout will
* not handle further activate() processing, such as watchForClicks.
* @property {ClickConfig} [clickConfig={}] - Configures how the click()
* method operates.
*/
/**
* @param {What} what - What we want to scroll.
* @param {How} how - How we want to scroll.
* @throws {Error} On many construction problems.
*/
constructor(what, how) {
({
name: this.#name,
base: this.#base,
selectors: this.#selectors,
containerItems: this.#containerItems = [],
} = what);
({
uidCallback: this.#uidCallback,
maxUidLength: this.#maxUidLength = Scroller.#defaults.MAX_UID_LENGTH,
classes: this.#classes = [],
watchForClicks: this.#watchForClicks =
Scroller.#defaults.WATCH_FOR_CLICKS,
autoActivate: this.#autoActivate = false,
observeAttributes: this.#observeAttributes =
Scroller.#defaults.OBSERVE_ATTRIBUTES,
snapToTop: this.#snapToTop = false,
waitForItemTimeout: this.#waitForItemTimeout =
Scroller.#defaults.WAIT_FOR_ITEM,
containerTimeout: this.#containerTimeout = 0,
clickConfig: this.#clickConfig = {},
} = how);
this.#validateInstance();
this.#containersMutationObserver = new MutationObserver(
this.#containersMutationHandler
);
this.#pageMutationObserver = new MutationObserver(
this.#pageMutationHandler
);
this.#logger = new NH.base.Logger(`{${this.#name}}`);
this.logger.log('Scroller constructed', this);
if (this.#autoActivate) {
this.activate();
}
}
/** @type {NexusHoratio.base.Dispatcher} */
get dispatcher() {
return this.#dispatcher;
}
/** @type {external:Element} */
get item() {
const me = 'get item';
this.logger.entered(me);
if (this.#destroyed) {
const msg = 'Tried to work with destroyed scroller';
const opts = {
cause: {
code: NH.base.Code.FAILED_PRECONDITION,
reason: 'Destroyed',
scroller: this.name,
},
};
throw new Error(msg, opts);
}
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;
}
// eslint-disable-next-line require-jsdoc
set item(val) {
const me = 'set item';
this.logger.entered(me, val);
this.dull();
this.#bottomHalf(val);
this.logger.leaving(me);
}
/** @type {string} */
get itemUid() {
return this.#currentItemId;
}
/** @type {NexusHoratio.base.Logger} */
get logger() {
return this.#logger;
}
/** @type {string} */
get name() {
return this.#name;
}
/**
* Return normalized text for an element.
*
* Like HTMLElement.innerText, but cleaner and mostly deduped.
*
* @param {external:Element} element - Element to examine.
* @returns {string} The normalized text.
*/
defaultUid(element) {
const me = this.defaultUid.name;
this.logger.entered(me, element);
const texts = new Set();
/**
* @param {Node} node - Node to process.
* @param {number} height - Height of last node that was an Element.
*/
const recurse = (node, height) => {
const currHeight = this.#realHeight(node) || height;
if (node.nodeType === Node.TEXT_NODE) {
const text = node.nodeValue.trim();
if (text && currHeight > 1) {
texts.add(text);
}
}
for (const nextNode of node.childNodes) {
recurse(nextNode, currHeight);
}
};
recurse(element, this.#realHeight(element));
let content = [...texts].join(' ');
if (content.length > this.#maxUidLength) {
this.logger.log(
'exceeded maxUidLength', content.length, this.#maxUidLength
);
content = NH.base.strHash(content);
}
this.logger.leaving(me, content);
return content;
}
/** Click either the current item OR document.activeElement. */
click() {
const me = this.click.name;
const item = this.item;
this.logger.entered(me, item);
if (item) {
if (this.#clickConfig.finder) {
const result = this.#clickConfig.finder(item);
if (result) {
result.click();
} else {
NH.web.postInfoAboutElement(item,
`the clickConfig function for ${this.name}`);
}
} else if (this.#clickConfig.selectorArray) {
if (!NH.web.clickElement(
item,
this.#clickConfig.selectorArray,
this.#clickConfig.matchSelf
)) {
NH.web.postInfoAboutElement(item,
`the clickConfig selectorArray for ${this.name}`);
}
} else {
NH.base.issues.post(
`Scroller.click() for "${this.name}" was called without` +
' a configuration'
);
}
} else {
document.activeElement.click();
}
this.logger.leaving(me);
}
/** Move to the next item in the collection. */
next() {
this.#scrollBy(NH.base.ONE_ITEM);
}
/** Move to the previous item in the collection. */
prev() {
this.#scrollBy(-NH.base.ONE_ITEM);
}
/** 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 {external: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 = this.gotoUid.name;
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();
}
/**
* Focus on current item.
* @fires 'focus' 'focused'
*/
focus() {
const me = this.focus.name;
this.logger.entered(me);
this.dispatcher.fire('focus', null);
this.shine();
this.show();
NH.web.focusOnTree(this.item);
this.logger.leaving(me);
this.dispatcher.fire('focused', null);
}
/**
* Activate the scroller.
* @fires 'activate'
*/
async activate() {
const me = this.activate.name;
this.logger.entered(me);
this.#pageMutationObserver.observe(
document.body, {childList: true, subtree: true}
);
this.#pageMutationObserver.observing = true;
await this.#startContainers();
// The logging statement is useful for debugging. Keep it.
this.logger.log('watcher:', await this.#currentItemWatcher());
this.#mutationDispatcher.on('attributes', this.#attributesHandler);
this.#mutationDispatcher.on('childList', this.#monitorConnectedness);
this.dispatcher.fire('activate', null);
this.logger.leaving(me);
}
/**
* Deactivate the scroller (but do not destroy it).
* @fires 'deactivate'
*/
deactivate() {
const me = this.deactivate.name;
this.logger.entered(me);
this.#pageMutationObserver.disconnect();
this.#pageMutationObserver.observing = false;
this.#mutationDispatcher.off('attributes', this.#attributesHandler);
this.#mutationDispatcher.off('childList', this.#monitorConnectedness);
this.#stopContainers();
this.dispatcher.fire('deactivate', null);
this.logger.leaving(me);
}
/** Mark instance as inactive and do any internal cleanup. */
destroy() {
const me = this.destroy.name;
this.logger.entered(me);
this.deactivate();
this.item = null;
this.#destroyed = true;
this.logger.leaving(me);
}
static #defaults = Object.freeze({
MAX_UID_LENGTH: 20,
OBSERVE_ATTRIBUTES: false,
WAIT_FOR_ITEM: 3000,
WATCH_FOR_CLICKS: true,
});
#autoActivate
#base
#classes
#clickConfig
#clickOptions = {capture: true};
#containerItems
#containerTimeout
#containers = new Set();
#containersMutationObserver
#currentItem = null;
#currentItemId = null;
#destroyed = false;
#dispatcher = new NH.base.Dispatcher(
'change', 'out-of-range', 'activate', 'deactivate', 'focus', 'focused'
);
#historicalIdToIndex = new Map();
#itemCache
#logger
#maxUidLength
#mutationDispatcher = new NH.base.Dispatcher('attributes', 'childList');
#name
#observeAttributes
#onClickElements = new Set();
#pageMutationObserver
#selectors
#snapToTop
#uidCallback
#waitForItemTimeout
#watchForClicks
/**
* Currently removes `scrollerId` at the drop of a hat.
*
* XXX: This was originally intended to clear scrollerId before
* duplications were detected. But such detection happens inside {@link
* #getItems()}, so this does not help with that. Still, might be useful
* in cases where the uid depends on attributes, even if duplicates are
* not involved.
*
* @method
* @implements {NexusHoratio.base.Dispatcher~Handler}
* @param {string} type - Event type.
* @param {MutationRecords[]} records - Standard MutationRecords.
*/
#attributesHandler = (type, records) => {
const me = this.#attributesHandler.name;
this.logger.entered(me, type, records.length);
for (const item of this.#getItems()) {
for (const record of records) {
if (record.attributeName !== 'data-scroller-id') {
if (item.contains(record.target)) {
delete item.dataset.scrollerId;
this.logger.log('reset item', item);
break;
}
}
}
}
this.logger.leaving(me);
}
/**
* Determine if the item can be viewed.
*
* Often this means the content is being loaded lazily and is not ready
* yet.
*
* @method
* @param {external:Element} item - The item to inspect.
* @returns {boolean} Whether the item has viewable content.
*/
#isItemViewable = (item) => {
const me = this.#isItemViewable.name;
this.logger.entered(me, item);
const result = Boolean(item.clientHeight);
this.logger.leaving(me, result);
return result;
}
#startContainers = async () => {
const me = this.#startContainers.name;
this.logger.entered(me);
this.#stopContainers();
const found = await this.#waitForContainers();
found.filter(x => x)
.map(x => this.#containers.add(x));
if (this.#base) {
this.#containers.add(this.#base);
}
const observeOptions = {
childList: true,
subtree: true,
attributes: this.#observeAttributes,
};
for (const container of this.#containers) {
if (this.#watchForClicks) {
this.#onClickElements.add(container);
container.addEventListener('click',
this.#onClick,
this.#clickOptions);
}
this.logger.log('observing with', container, observeOptions);
this.#containersMutationObserver.observe(container, observeOptions);
}
this.logger.leaving(me);
}
#stopContainers = () => {
this.#containersMutationObserver.disconnect();
for (const container of this.#onClickElements) {
container.removeEventListener('click',
this.#onClick,
this.#clickOptions);
}
this.#onClickElements.clear();
this.#itemCache = null;
this.#containers.clear();
}
/**
* If an item is clicked, switch to it.
*
* @method
* @param {Event} evt - Standard 'click' event.
*/
#onClick = (evt) => {
const me = this.#onClick.name;
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.focus();
} else {
this.item = item;
}
}
}
this.logger.leaving(me);
}
/**
* Return the computed height of an element.
*
* The usual element.clientHeight is too unpredictable.
*
* @method
* @param {external:Element} element - Element to examine.
* @returns {number} The height of the element.
*/
#realHeight = (element) => {
const me = this.#realHeight.name;
this.logger.entered(me, element);
const height = element.getBoundingClientRect?.().height;
this.logger.leaving(me, height);
return height;
}
#pageMutationHandler = async () => {
const me = this.#pageMutationHandler.name;
this.logger.entered(me, this.#containers);
const needsConnection = this.#containers.values()
.some(x => !x.isConnected);
if (needsConnection) {
this.logger.log('restarting containers');
await this.activate();
this.logger.log('containers restarted');
}
this.logger.leaving(me);
}
/**
* @method
* @param {MutationRecord[]} records - Standard mutation records.
* @fires 'childList'
*/
#containersMutationHandler = (records) => {
const me = this.#containersMutationHandler.name;
this.logger.entered(me, `records: ${records.length}`);
const types = new NH.base.DefaultMap(Array);
for (const record of records) {
types.get(record.type)
.push(record);
}
for (const [type, items] of types) {
this.#itemCache = null;
this.#mutationDispatcher.fire(type, items);
}
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.
*
* @method
* @param {external:Element} val - Element to make current.
* @fires 'change'
*/
#bottomHalf = (val) => {
const me = this.#bottomHalf.name;
this.logger.entered(me, val);
this.#currentItem = val;
this.#currentItemId = this.#uid(val);
const idx = this.#getItems()
.indexOf(val);
this.#historicalIdToIndex.set(this.#currentItemId, idx);
this.focus();
this.dispatcher.fire('change', {});
this.logger.leaving(me);
}
/**
* Builds the list of elements using the registered CSS selectors.
*
* @method
* @returns {Elements[]} Items to scroll through.
*/
#getItems = () => {
const me = this.#getItems.name;
this.logger.entered(me);
if (!this.#itemCache) {
// This needs to be ordered, so does not use #containers.
const items = [];
if (this.#base) {
for (const selector of this.#selectors) {
this.logger.log(`considering ${selector}`);
items.push(...this.#base.querySelectorAll(selector));
}
} else {
for (const {container, items: selector} of this.#containerItems) {
this.logger.log(`considering ${container} with ${selector}`);
const base = document.querySelector(container);
if (base) {
items.push(...base.querySelectorAll(selector));
}
}
}
this.#itemCache = this.#postProcessItems(items);
}
this.logger.leaving(me, this.#itemCache.length);
return this.#itemCache;
}
/**
* Log items and do any fixups on them.
*
* @method
* @param {Element[]} items - Elements in the Scroller.
* @returns {Element[]} Post processed items.
*/
#postProcessItems = (items) => {
const me = this.#postProcessItems.name;
this.logger.starting(me, `count: ${items.length}`);
const filtered = items.filter(this.#isItemViewable);
const uids = new NH.base.DefaultMap(Array);
for (const item of filtered) {
this.logger.log('item:', item);
const uid = this.#uid(item);
uids.get(uid)
.push(item);
}
for (const [uid, list] of uids.entries()) {
if (list.length > NH.base.ONE_ITEM) {
this.logger.log(`${list.length} duplicates with "${uid}"`);
for (const item of list) {
// Try again, maybe they can be de-duped this time. The overall
// experience seems to work better if the uid is recalculated
// right away, but yeah, a bit of a hack.
delete item.dataset.scrollerId;
this.#uid(item);
}
}
}
this.logger.finished(me, `count: ${filtered.length}`);
return filtered;
}
/**
* Returns the uid for the current element. Will use the registered
* uidCallback function for this.
*
* @method
* @param {external:Element} element - Element to identify.
* @returns {string} Computed uid for element.
*/
#uid = (element) => {
const me = this.#uid.name;
this.logger.entered(me, element);
let uid = null;
if (element) {
if (!element.dataset.scrollerId) {
element.dataset.scrollerId = this.#uidCallback(this, 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.
*
* @method
* @param {external:Element} element - Element to check.
* @returns {boolean} Whether or not element is the current one.
*/
#matchItem = (element) => {
const me = this.#matchItem.name;
this.logger.entered(me);
const res = this.#currentItemId === this.#uid(element);
this.logger.leaving(me, res);
return res;
}
/**
* If necessary, scroll the bottom into view, then same for top.
*
* @method
* @param {external:Element} item - The item to scroll into view.
*/
#gentlyScrollIntoView = (item) => {
const me = this.#gentlyScrollIntoView.name;
this.logger.entered(me, item);
let rect = item.getBoundingClientRect();
const allowedBottom = document.documentElement.clientHeight;
if (rect.bottom > allowedBottom) {
this.logger.log('scrolling up onto page');
item.scrollIntoView(false);
}
rect = item.getBoundingClientRect();
if (rect.top < 0) {
this.logger.log('scrolling down onto page');
item.scrollIntoView(true);
}
item.scrollIntoView({block: 'nearest', inline: 'nearest'});
this.logger.leaving(me);
};
/**
* 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.
*
* @method
*/
#scrollToCurrentItem = () => {
const me = this.#scrollToCurrentItem.name;
this.logger.entered(me, `snapToTop: ${this.#snapToTop}`);
const item = this.item;
if (item) {
if (this.#snapToTop) {
this.logger.log('snapping to top');
item.scrollIntoView(true);
} else {
this.#gentlyScrollIntoView(item);
}
}
this.logger.leaving(me);
}
/**
* Jump an item on an end of the collection.
*
* @method
* @param {boolean} first - If true, the first item in the collection,
* else, the last.
*/
#jumpToEndItem = (first) => {
const me = this.#jumpToEndItem.name;
this.logger.entered(me, `first=${first}`);
const items = this.#getItems();
this.logger.log('length', items.length);
if (items.length) {
// eslint-disable-next-line no-extra-parens
let idx = first ? 0 : (items.length - NH.base.ONE_ITEM);
this.logger.log('idx', idx);
let item = items[idx];
this.logger.log('item', item);
// 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 (!this.#isItemViewable(item)) {
this.logger.log('skipping item', item);
idx -= NH.base.ONE_ITEM;
item = items[idx];
}
}
this.item = item;
}
this.logger.leaving(me);
}
/**
* Move forward or backwards in the collection by at least n.
*
* @method
* @param {number} n - How many items to move and the intended direction.
* @fires 'out-of-range'
*/
#scrollBy = (n) => {
const me = this.#scrollBy.name;
this.logger.entered(me, n);
/**
* Keep viewable items and the current one.
*
* The current item may not yet be viewable after a reload, but give it
* a chance.
*
* @param {external:Element} item - Item to check.
* @fires 'out-of-range'
* @returns {boolean} Whether to keep or not.
*/
const filterItem = (item) => {
if (this.#isItemViewable(item)) {
return true;
}
if (this.#uid(item) === this.#currentItemId) {
return true;
}
return false;
};
const items = this.#getItems()
.filter(item => filterItem(item));
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 - NH.base.ONE_ITEM;
}
if (idx === NH.base.NOT_FOUND || idx >= items.length) {
this.item = null;
this.dispatcher.fire('out-of-range', null);
} else {
this.item = items[idx];
}
}
this.logger.leaving(me);
}
/**
* @method
* @throws {Error} On many validation issues.
*/
#validateInstance = () => {
this.#validateWhat();
this.#validateHow();
}
/**
* @method
* @throws {Error} On many validation issues.
*/
#validateWhat = () => { // eslint-disable-line max-statements
let msg = '';
const opts = {
cause: {
code: NH.base.Code.INVALID_ARGUMENT,
scroller: this.name,
},
};
if (!this.#name) {
msg = 'Scroller requires a name';
opts.cause.reason = 'MissingName';
throw new Error(msg, opts);
}
if (this.#base && this.#containerItems.length) {
msg = 'Cannot have both base AND containerItems';
opts.cause.reason = 'BaseAndContainerItems';
throw new Error(msg, opts);
}
if (!this.#base && !this.#containerItems.length) {
msg = 'Needs either base OR containerItems';
opts.cause.reason = 'BaseOrContainerItems';
throw new Error(msg, opts);
}
if (this.#base && !(this.#base instanceof Element)) {
msg = 'Supplied base is not an element';
opts.cause.reason = 'BaseNotAnElement';
throw new Error(msg, opts);
}
if (this.#base && !this.#selectors) {
msg = 'Base was supplied without selectors';
opts.cause.reason = 'BaseWithoutSelectors';
throw new Error(msg, opts);
}
if (this.#selectors && !this.#base) {
msg = 'Selectors were supplied without a base';
opts.cause.reason = 'SelectorsWithoutBase';
throw new Error(msg, opts);
}
}
/**
* @method
* @throws {Error} On many validation issues.
*/
#validateHow = () => { // eslint-disable-line max-statements
let msg = '';
const opts = {
cause: {
code: NH.base.Code.INVALID_ARGUMENT,
scroller: this.name,
},
};
if (!this.#uidCallback) {
msg = 'No uidCallback defined';
opts.cause.reason = 'UidCallbackMissing';
throw new Error(msg, opts);
}
if (!(this.#uidCallback instanceof Function)) {
msg = 'The uidCallback is not a function';
opts.cause.reason = 'UidCallbackNotFunction';
throw new Error(msg, opts);
}
if (this.#clickConfig.selectorArray && this.#clickConfig.finder) {
msg = 'Cannot have both a selectorArray AND a finder function';
opts.cause.reason = 'ClickConfigSelectorArrayAndFinderFunction';
throw new Error(msg, opts);
}
if (this.#clickConfig.selectorArray) {
if (!(this.#clickConfig.selectorArray instanceof Array)) {
msg = 'The selectorArray is not an Array';
opts.cause.reason = 'ClickConfigSelectorNotArray';
throw new Error(msg, opts);
}
}
if (this.#clickConfig.finder) {
if (!(this.#clickConfig.finder instanceof Function)) {
msg = 'The finder property should be a function';
opts.cause.reason = 'ClickConfigFinderNotFunction';
throw new Error(msg, opts);
}
if (this.#clickConfig.finder.length !== NH.base.ONE_ITEM) {
msg = 'The finder function should take exactly one argument,' +
` currently takes ${this.#clickConfig.finder.length}`;
opts.cause.reason = 'ClickConfigFinderWrongSignature';
throw new Error(msg, opts);
}
}
}
/**
* The page may still be loading, so wait for many things to settle.
*
* @method
* @returns {Promise<Element[]>} All the new base elements.
*/
#waitForContainers = () => {
const me = this.#waitForContainers.name;
this.logger.entered(me);
const results = [];
/**
* Simply eats any exception thrown by the Promise.
* @param {Promise} prom - Whatever Promise we are wrapping.
* @param {string} note - Put into log on error.
* @returns {Promise} Resolved promise.
*/
const wrapper = async (prom, note) => {
this.logger.log('wrapping', prom);
try {
return await prom;
} catch (e) {
this.logger.log(`wrapper ate error (${note}):`, e);
return Promise.resolve();
}
};
for (const {container} of this.#containerItems) {
this.logger.log('container', container);
results.push(wrapper(NH.web.waitForSelector(container,
this.#containerTimeout), container));
}
this.logger.leaving(me, results);
return Promise.all(results);
}
/**
* Watches for the current item, if there was one, to return.
*
* Used during activation to deal with items still being loaded.
*
* @method
* @returns {Promise<string>} Wait on this to finish with something
* useful to log.
*/
#currentItemWatcher = () => { // eslint-disable-line max-lines-per-function
const me = this.#currentItemWatcher.name;
this.logger.entered(me);
const uid = this.itemUid;
let prom = Promise.resolve('nothing to watch for');
if (uid) {
this.logger.log('reactivation with', uid);
let timeoutID = null;
prom = new Promise((resolve) => {
/** Dispatcher monitor. */
const moCallback = () => {
const monMe = moCallback.name;
this.logger.entered(monMe);
if (this.gotoUid(uid)) {
this.logger.log('item is present', this.item);
if (this.#isItemViewable(this.item)) {
this.logger.log('and viewable');
this.#mutationDispatcher.off('childList', moCallback);
clearTimeout(timeoutID);
resolve('looks good');
} else {
this.logger.log('but not yet viewable');
}
} else {
this.logger.log('not ready yet');
}
this.logger.leaving(monMe);
};
/** Standard setTimeout callback. */
const toCallback = () => {
this.#mutationDispatcher.off('childList', moCallback);
this.logger.log('one last try...');
moCallback();
resolve('we tried...');
if (litOptions.enableIssue289Monitoring &&
this.#pageMutationObserver.observing) {
NH.base.issues.post(
'Issues 289/372:', this.name, `${me} timed out`
);
}
};
this.#mutationDispatcher.on('childList', moCallback);
timeoutID = setTimeout(toCallback, this.#waitForItemTimeout);
moCallback();
});
}
this.logger.leaving(me, prom);
return prom;
}
#monitorConnectedness = () => {
const me = this.#monitorConnectedness.name;
this.logger.entered(me, this.#currentItem);
if (this.#currentItem && !this.#currentItem.isConnected) {
this.goto(this.#currentItem);
this.logger.log('current item reconnected');
}
this.logger.leaving(me);
}
/* eslint-disable require-jsdoc */
static ScrollerTestCase = class extends NH.xunit.TestCase {
testClassIsFrozen() {
this.assertRaisesRegExp(TypeError, /is not extensible/u, () => {
Scroller.#defaults.FIELD = 'field';
});
}
static { this.register(); }
}
/* eslint-enable */
}
/* eslint-disable max-lines-per-function */
/* eslint-disable no-empty-function */
/* eslint-disable no-new */
/* eslint-disable no-undefined */
/* eslint-disable no-unused-vars */
/* eslint-disable require-jsdoc */
class ScrollerTestCase extends NH.xunit.TestCase {
testNeedsName() {
const what = {
};
const how = {
};
this.assertRaisesCause(
Error,
{
code: NH.base.Code.INVALID_ARGUMENT,
reason: 'MissingName',
scroller: undefined,
},
() => {
new Scroller(what, how);
},
'undefined'
);
this.assertRaisesCause(
Error,
{
code: NH.base.Code.INVALID_ARGUMENT,
reason: 'MissingName',
scroller: '',
},
() => {
what.name = '';
new Scroller(what, how);
},
'empty string'
);
}
testNeedsBaseOrContainerItems() {
const what = {
name: this.id,
};
const how = {
};
this.assertRaisesCause(
Error,
{
code: NH.base.Code.INVALID_ARGUMENT,
reason: 'BaseOrContainerItems',
scroller: this.id,
},
() => {
new Scroller(what, how);
}
);
}
testNotBaseAndContainerItems() {
const what = {
name: this.id,
base: document.body,
containerItems: [{}],
};
const how = {
};
this.assertRaisesCause(
Error,
{
code: NH.base.Code.INVALID_ARGUMENT,
reason: 'BaseAndContainerItems',
scroller: this.id,
},
() => {
new Scroller(what, how);
}
);
}
testBaseIsElement() {
const what = {
name: this.id,
base: document,
};
const how = {
};
this.assertRaisesCause(
Error,
{
code: NH.base.Code.INVALID_ARGUMENT,
reason: 'BaseNotAnElement',
scroller: this.id,
},
() => {
new Scroller(what, how);
}
);
}
testBaseNeedsSelector() {
const what = {
name: this.id,
base: document.body,
};
const how = {
};
this.assertRaisesCause(
Error,
{
code: NH.base.Code.INVALID_ARGUMENT,
reason: 'BaseWithoutSelectors',
scroller: this.id,
},
() => {
new Scroller(what, how);
}
);
}
testSelectorNeedsBase() {
const what = {
name: this.id,
selectors: [],
containerItems: [{}],
};
const how = {
};
this.assertRaisesCause(
Error,
{
code: NH.base.Code.INVALID_ARGUMENT,
reason: 'SelectorsWithoutBase',
scroller: this.id,
},
() => {
new Scroller(what, how);
}
);
}
testBaseWithSelectorIsFine() {
const what = {
name: this.id,
base: document.body,
selectors: [],
};
const how = {
uidCallback: () => {},
};
this.assertNoRaises(() => {
new Scroller(what, how);
}, 'everything is in place');
}
testValidUidCallback() {
const what = {
name: this.id,
base: document.body,
selectors: [],
};
const how = {
};
this.assertRaisesCause(
Error,
{
code: NH.base.Code.INVALID_ARGUMENT,
reason: 'UidCallbackMissing',
scroller: this.id,
},
() => {
new Scroller(what, how);
},
'missing',
);
how.uidCallback = {};
this.assertRaisesCause(
Error,
{
code: NH.base.Code.INVALID_ARGUMENT,
reason: 'UidCallbackNotFunction',
scroller: this.id,
},
() => {
new Scroller(what, how);
},
'not function',
);
how.uidCallback = () => {};
this.assertNoRaises(() => {
new Scroller(what, how);
}, 'finally, good');
}
testValidClickConfig() {
const what = {
name: this.id,
containerItems: [{}],
};
const how = {
uidCallback: () => {},
};
this.assertNoRaises(() => {
new Scroller(what, how);
}, 'no clickConfig is fine');
how.clickConfig = {};
this.assertNoRaises(() => {
new Scroller(what, how);
}, 'empty clickConfig is fine');
// Existence is what matters for this check, not correctness
how.clickConfig = {
selectorArray: {},
finder: {},
};
this.assertRaisesCause(
Error,
{
code: NH.base.Code.INVALID_ARGUMENT,
reason: 'ClickConfigSelectorArrayAndFinderFunction',
scroller: this.id,
},
() => {
new Scroller(what, how);
},
'both selectorArray and finder'
);
how.clickConfig = {selectorArray: 'string'};
this.assertRaisesCause(
Error,
{
code: NH.base.Code.INVALID_ARGUMENT,
reason: 'ClickConfigSelectorNotArray',
scroller: this.id,
},
() => {
new Scroller(what, how);
},
'non-array'
);
how.clickConfig = {
finder: {},
};
this.assertRaisesCause(
Error,
{
code: NH.base.Code.INVALID_ARGUMENT,
reason: 'ClickConfigFinderNotFunction',
scroller: this.id,
},
() => {
new Scroller(what, how);
},
'non-function'
);
how.clickConfig = {
finder: (element) => {},
};
this.assertNoRaises(() => {
new Scroller(what, how);
}, 'single argument element finder is fine');
how.clickConfig.finder = () => {};
this.assertRaisesCause(
Error,
{
code: NH.base.Code.INVALID_ARGUMENT,
reason: 'ClickConfigFinderWrongSignature',
scroller: this.id,
},
() => {
new Scroller(what, how);
},
'zero argument element finder is not fine'
);
how.clickConfig.finder = (a, b, c) => {};
this.assertRaisesCause(
Error,
{
code: NH.base.Code.INVALID_ARGUMENT,
reason: 'ClickConfigFinderWrongSignature',
scroller: this.id,
},
() => {
new Scroller(what, how);
},
'too many arguments element finder is not fine'
);
}
static { this.register(); }
}
/* eslint-enable */
/**
* Manage a {Scroller} as a {@link NexusHoratio.base.Service}.
*
* @extends NexusHoratio.base.Service
*/
class ScrollerService extends NH.base.Service {
/**
* @param {string} instanceName - Custom portion of this instance.
*/
constructor(instanceName) {
super(instanceName);
this.on('activate', this.#onActivate)
.on('deactivate', this.#onDeactivate)
.allowReactivation(false)
.setScroller();
}
/**
* Sets the {@link Scroller} to manage with this service.
*
* If not value is passed, any existing instance will be removed.
*
* @param {Scroller} [scroller] - The instance to manage.
* @returns {ScrollerService} This instance, for chaining.
*/
setScroller(scroller = null) {
this.#scroller = scroller;
return this;
}
#scroller
#onActivate = () => {
this.#scroller?.activate();
}
#onDeactivate = () => {
this.#scroller?.deactivate();
}
}
/**
* A table with collapsible sections.
*
* @extends NexusHoratio.widget.Widget
*/
class AccordionTableWidget extends NH.widget.Widget {
/** @param {string} instanceName - Name for this instance. */
constructor(instanceName) {
super(instanceName, 'table');
this.logger.log(`${this.name} constructed`);
}
/**
* This becomes the current section.
* @param {string} section - Name of the new section.
* @returns {external:Element} The new section.
*/
addSection(section) {
this.#currentSection = document.createElement('tbody');
this.#currentSection.id = NH.base.safeId(`${this.id}-${section}`);
this.container.append(this.#currentSection);
return this.#currentSection;
}
/**
* Add a row of header cells to the current section.
* @param {...string} items - To make up the row cells.
*/
addHeader(...items) {
this.#addRow('th', ...items);
}
/**
* Add a row of data cells to the current section.
* @param {...string} items - To make up the row cells.
*/
addData(...items) {
this.#addRow('td', ...items);
}
#currentSection
/**
* Add a row to the current section.
*
* @method
* @param {string} type - Cell type, typically 'td' or 'th'.
* @param {...string} items - To make up the row cells.
*/
#addRow = (type, ...items) => {
const tr = document.createElement('tr');
for (const item of items) {
const cell = document.createElement(type);
cell.innerHTML = item;
tr.append(cell);
}
this.#currentSection.append(tr);
}
}
/**
* 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 ...
* @extends Function
*/
class Shortcut extends Function {
/**
* Wrap a function.
* @param {string} seq - Key sequence to activate this function.
* @param {string} desc - Human readable documentation about this
* function.
* @param {NexusHoratio.web~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.wrapper();');
const myself = this.bind(this);
myself.seq = seq;
myself.desc = desc;
this.func = func;
return myself;
}
wrapper = () => {
try {
this.func();
} catch (e) {
const output = [
`${this.constructor.name} caught error:`,
e.message,
];
if (e.cause) {
output.push(e.cause);
}
NH.base.issues.post(...output);
}
}
}
/**
* @external VMShortcuts
* @see {@link https://violentmonkey.github.io/guide/keyboard-shortcuts/}
*/
/**
* Integrates {@link external:VMShortcuts} with {@link
* module:linkedin-tool~Shortcut Shortcut}s.
*
* Instances of classes that have {@link module:linkedin-tool~Shortcut
* Shortcut} properties on them can be added and removed to each instance of
* this service. The shortcuts will be enabled/disabled as the service is
* activated/deactivated. This can allow each service to have different
* groups of shortcuts present.
*
* All Shortcuts tie into {@link external:VMShortcuts}'s conditions. These
* conditions are added once during instantiation and default to
* '!inputFocus'.
*
* The built in handler for browser `focus` events to update 'inputFocus'
* can be enabled by executing:
*
* @example
* VMKeyboardService.start();
*
* @extends NexusHoratio.base.Service
*/
class VMKeyboardService extends NH.base.Service {
/** @inheritdoc */
constructor(instanceName) {
super(instanceName);
VMKeyboardService.#services.add(this);
this.on('activate', this.#onActivate)
.on('deactivate', this.#onDeactivate);
}
static keyMap = new Map([
['LEFT', '←'],
['UP', '↑'],
['RIGHT', '→'],
['DOWN', '↓'],
]);
// eslint-disable-next-line require-jsdoc
static set condition(val) {
this.#shortcutOptions.condition = val;
}
/** @type {Set<module:linkedin-tool~VMKeyboardService>} */
static get services() {
return new Set(this.#services.values());
}
/** Add listener. */
static start() {
this.#listenForFocus(document);
}
/** Remove listener. */
static stop() {
for (const el of this.#listenForFocusElements.values()) {
el.removeEventListener('focus', this.#onFocus, this.#focusOption);
}
}
/**
* 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);
}
}
}
/**
* Parse a {@link module:linkedin-tool~Shortcut#seq Shortcut#seq} and wrap
* it in HTML.
*
* @example
* 'a c-b' ->
* '<kbd><kbd>a</kbd> then <kbd>Ctrl</kbd> + <kbd>b</kbd></kbd>'
* @param {module:linkedin-tool~Shortcut#seq} seq - Keystroke sequence.
* @returns {string} Appropriately wrapped HTML.
*/
static parseSeq(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 === NH.base.ONE_ITEM) {
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 = VMKeyboardService.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>`;
}
/** @type {boolean} */
get active() {
return this.#active;
}
/** @type {module:linkedin-tool~Shortcut[]} */
get shortcuts() {
return this.#shortcuts;
}
/**
* @param {object} instance - Object with {@link
* module:linkedin-tool~Shortcut Shortcut} properties.
* @returns {module:linkedin-tool~VMKeyboardService} This instance, for
* chaining.
*/
addInstance(instance) {
const me = this.addInstance.name;
this.logger.entered(me, instance);
if (this.#keyboards.has(instance)) {
this.logger.log('Already registered');
} else {
const keyboard = new VM.shortcut.KeyboardService();
for (const [key, value] of Object.entries(instance)) {
if (value instanceof Shortcut) {
// While we are here, give the function a name.
Object.defineProperty(value, 'name', {value: key});
keyboard.register(
value.seq, value, VMKeyboardService.#shortcutOptions
);
}
}
this.#keyboards.set(instance, keyboard);
this.#rebuildShortcuts();
}
this.logger.leaving(me);
return this;
}
/**
* @param {object} instance - Object with {@link
* module:linkedin-tool~Shortcut Shortcut} properties.
* @returns {module:linkedin-tool~VMKeyboardService} This instance, for
* chaining.
*/
removeInstance(instance) {
const me = this.removeInstance.name;
this.logger.entered(me, instance);
if (this.#keyboards.has(instance)) {
const keyboard = this.#keyboards.get(instance);
keyboard.disable();
this.#keyboards.delete(instance);
this.#rebuildShortcuts();
} else {
this.logger.log('Was not registered');
}
this.logger.leaving(me);
return this;
}
static #focusOption = {
capture: true,
};
static #lastFocusedElement = null;
static #listenForFocusElements = new Set();
static #services = new Set();
/**
* Initial options for all shortcuts.
*
* @type {VM.shortcut.IShortcutOptions}
*/
static #shortcutOptions = {
condition: '!inputFocus',
caseSensitive: true,
};
/**
* @method
* @param {external:Element} element - Element that gets a listener.
*/
static #listenForFocus = (element) => {
this.#listenForFocusElements.add(element);
element.addEventListener('focus', this.#onFocus, this.#focusOption);
}
/**
* Handle focus event to determine if shortcuts should be disabled.
*
* @method
* @param {Event} evt - Standard 'focus' event.
*/
static #onFocus = (evt) => {
let target = evt.target;
for (
let shadow = null;
(shadow = target.shadowRoot ?? null) !== null;
) {
this.#listenForFocus(shadow);
target = shadow.activeElement;
}
if (this.#lastFocusedElement &&
target !== this.#lastFocusedElement) {
this.#lastFocusedElement = null;
this.setKeyboardContext('inputFocus', false);
}
if (NH.web.isInput(target)) {
this.setKeyboardContext('inputFocus', true);
this.#lastFocusedElement = target;
}
}
#active = false;
#keyboards = new Map();
#shortcuts = [];
#onActivate = () => {
for (const keyboard of this.#keyboards.values()) {
keyboard.enable();
}
this.#active = true;
}
#onDeactivate = () => {
for (const keyboard of this.#keyboards.values()) {
keyboard.disable();
}
this.#active = false;
}
#rebuildShortcuts = () => {
this.#shortcuts = [];
for (const instance of this.#keyboards.keys()) {
for (const prop of Object.values(instance)) {
if (prop instanceof Shortcut) {
this.#shortcuts.push({seq: prop.seq, desc: prop.desc});
}
}
}
}
}
/* eslint-disable require-jsdoc */
class ParseSeqTestCase extends NH.xunit.TestCase {
testNormalInputs() {
const tests = [
{text: 'q', expected: '<kbd><kbd>q</kbd></kbd>'},
{text: 's-q', expected: '<kbd><kbd>Shift</kbd>+<kbd>q</kbd></kbd>'},
{text: 'Q', expected: '<kbd><kbd>Shift</kbd>+<kbd>q</kbd></kbd>'},
{text: 'a b', expected: '<kbd><kbd>a</kbd> then <kbd>b</kbd></kbd>'},
{text: '<', expected: '<kbd><kbd><</kbd></kbd>'},
{text: 'C-q', expected: '<kbd><kbd>Ctrl</kbd>+<kbd>q</kbd></kbd>'},
{text: 'c-q', expected: '<kbd><kbd>Ctrl</kbd>+<kbd>q</kbd></kbd>'},
{text: 'c-a-t',
expected: '<kbd><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+' +
'<kbd>t</kbd></kbd>'},
{text: 'a-c-T',
expected: '<kbd><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+' +
'<kbd>Shift</kbd>+<kbd>t</kbd></kbd>'},
{text: 'c-down esc',
expected: '<kbd><kbd>Ctrl</kbd>+<kbd>↓</kbd> ' +
'then <kbd>ESC</kbd></kbd>'},
{text: 'alt-up tab',
expected: '<kbd><kbd>Alt</kbd>+<kbd>↑</kbd> ' +
'then <kbd>TAB</kbd></kbd>'},
{text: 'shift-X control-alt-del',
expected: '<kbd><kbd>Shift</kbd>+<kbd>x</kbd> ' +
'then <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>DEL</kbd></kbd>'},
{text: 'c-x c-v',
expected: '<kbd><kbd>Ctrl</kbd>+<kbd>x</kbd> ' +
'then <kbd>Ctrl</kbd>+<kbd>v</kbd></kbd>'},
{text: 'a-x enter',
expected: '<kbd><kbd>Alt</kbd>+<kbd>x</kbd> ' +
'then <kbd>ENTER</kbd></kbd>'},
];
for (const {text, expected} of tests) {
this.assertEqual(VMKeyboardService.parseSeq(text), expected, text);
}
}
testKonamiCode() {
this.assertEqual(VMKeyboardService.parseSeq(
'up up down down left right left right b shift-a enter'
),
'<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>');
}
static { this.register(); }
}
/* eslint-enable */
/**
* LinkedIn specific information.
*
* @extends NexusHoratio.spa.Details
*/
class LinkedIn extends NH.spa.Details {
/** @hideconstructor */
constructor() {
super();
this.#navbarMutationObserver = new MutationObserver(
this.#navbarHandler
);
this.#navbarResizeObserver = new ResizeObserver(
this.#navbarHandler
);
this.ready = this.#waitUntilPageLoadedEnough();
this.#licenseElement = document.createElement('p');
this.#licenseElement.innerHTML = '<i>Loading license...</i>';
// The default reprString wraps in double-quotes.
this.#typeTool.addReprFunc('String', x => x);
this.dispatcher.on('initialize', this.#onInit);
if (litOptions.enableAlertUnsupportedPages) {
this.dispatcher.on('activated', this.#onActivateUnsupportedPageCheck);
}
}
static errorMarker = '---';
/**
* LinkedIn's common aside used in many layouts.
*
* @type {string}
*/
static get asideSelector() {
return this.#asideSelector;
}
/**
* LinkedIn's primary content for many layouts.
*
* @type {string}
*/
static get primaryContentSelector() {
return this.#primaryContentSelector;
}
/**
* LinkedIn's common navigation bar.
*
* @type {string}
*/
static get primaryNavSelector() {
return this.#primaryNavSelector;
}
/**
* CSS class name common for primary scrollers.
*
* @type {string}
*/
static get scrollerPrimaryClassName() {
return this.#scrollerPrimaryClassName;
}
/**
* CSS class name common for secondary scrollers.
*
* @type {string}
*/
static get scrollerSecondaryClassName() {
return this.#scrollerSecondaryClassName;
}
/**
* LinkedIn's common sidebar used in many layouts.
*
* @type {string}
*/
static get sidebarSelector() {
return this.#sidebarSelector;
}
/**
* @returns {TabbedUI~TabDefinition} Where to find documentation and
* file bugs.
*/
static aboutTab() {
const issuesLink = this.#ghUrl('labels/linkedin-tool');
const newIssueLink = this.#ghIssue('new/choose');
const newGfIssueLink = this.#gfUrl('feedback');
const releaseNotesLink = this.#gfUrl('versions');
const content = [
`<p>This is information about the <b>${APP_LONG}</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'),
};
return tab;
}
/**
* @param {string} variant - Migration text, one of `spa` or `lit`.
* @returns {TabbedUI~TabDefinition} Initial placeholder for error
* logging.
*/
static errorTab(variant) {
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 ' +
`"${this.errorMarker}".</p>`,
'<p><b>Please remove any identifying information before ' +
'including it in a bug report!</b></p>',
this.errorPlatformInfo(),
`<textarea data-${variant}-id="errors" spellcheck="false" ` +
'placeholder="No errors logged yet."></textarea>',
].join(''),
};
}
/**
* @implements {Scroller~uidCallback}
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
static ckeyIdentifier(element) {
const me = LinkedIn.ckeyIdentifier.name;
this.logger?.entered(me, element);
const content = element?.getAttribute(CKEY);
this.logger?.leaving(me, content);
return content;
}
/**
* Generate information about the current environment useful in bug
* reports.
* @returns {string} Text with some wrapped in a `pre` element.
*/
static errorPlatformInfo() {
const header = 'Please consider including some of the following ' +
'information in any bug report:';
const msgs = NH.userscript.environmentData();
msgs.push('Other libraries:', ` VM.shortcut: ${VM.shortcut.version}`);
return `${header}<pre>${msgs.join('\n')}</pre>`;
}
/**
* Combine text from child headers.
*
* @param {external:Element} element - Element to examine.
* @param {number} level - Header level to use.
* @returns {string} Combined header text.
*/
static hN(element, level) {
return element.querySelectorAll(`h${level}`)
.values()
.map(x => x.innerText.trim())
.toArray()
.join('; ');
}
/**
* Combine text from child headers.
*
* @param {external:Element} element - Element to examine.
* @returns {string} Combined header text.
*/
static h1(element) {
const level = 1;
return this.hN(element, level);
}
/**
* Combine text from child headers.
*
* @param {external:Element} element - Element to examine.
* @returns {string} Combined header text.
*/
static h2(element) {
const level = 2;
return this.hN(element, level);
}
urlChangeMonitorSelector = 'html';
/** @type {NexusHoratio.base.Dispatcher} */
get dispatcher2() {
return this.#dispatcher;
}
/**
* @typedef {object} LicenseData
* @memberof module:linkedin-tool~LinkedIn~
* @property {string} id - SPDX id for the license.
* @property {string} url - License URL.
* @property {string?} content - Fallback content.
*/
/** @type {module:linkedin-tool~LinkedIn~LicenseData} */
get licenseData() {
const me = 'get licenseData';
this.logger.entered(me);
if (!this.#licenseData) {
try {
this.#licenseData = NH.userscript.licenseData();
} catch (e) {
NH.base.issues.post(e.message);
this.#licenseData = {
id: e.case?.code.reason ?? 'Unknown',
url: '',
content: 'Unable to extract license data from the' +
' userscript: Please file a bug.',
};
}
}
this.logger.leaving(me, this.#licenseData);
return this.#licenseData;
}
/** @type {external:Element} */
get navbar() {
return this.#navbar;
}
/** @type {module:linkedin-tool~LinkedIn.Style} */
get pageStyle() {
return this.#pageStyle;
}
/** @type {boolean} */
get registrationComplete() {
return this.#registrationComplete;
}
// eslint-disable-next-line require-jsdoc
set registrationComplete(val) {
this.#registrationComplete = Boolean(val);
}
/**
* Scroll common sidebar into view and move focus to it.
*
* @method
*/
focusOnSidebar = () => {
const sidebar = document.querySelector(LinkedIn.sidebarSelector);
if (sidebar) {
NH.web.focusOnTree(sidebar);
}
}
/**
* Scroll common aside (right-hand sidebar) into view and move focus to
* it.
*
* @method
*/
focusOnAside = () => {
const aside = document.querySelector(LinkedIn.asideSelector);
if (aside) {
NH.web.focusOnTree(aside);
}
}
/** @returns {TabbedUI~TabDefinition} News information. */
newsTab() { // eslint-disable-line max-lines-per-function, max-statements
const me = this.newsTab.name;
this.logger.entered(me);
const {dates, knownIssues} = this.#preprocessKnownIssues();
const reader = new commonmark.Parser();
const writer = new commonmark.HtmlRenderer();
const content = [
'This is a manually curated list of changes over the last' +
' month or so that:',
'',
'* Added new features like support for new pages or more hotkeys',
'* Explicitly fixed a bug',
'* May cause a user noticeable change',
'',
'See the **About** tab for instructions on' +
' finding all changes by release.',
'<div>',
' <input type="checkbox" id="lit-news-read-toggle"/>',
' <label for="lit-news-read-toggle">Mark news as read.</label>',
'</div>',
'',
'---',
];
const dateHeader = '###';
const issueHeader = '####';
for (const [date, items] of dates) {
content.push(`${dateHeader} ${date}`);
for (const [issue, subjects] of items) {
const ki = knownIssues.get(issue);
let title = ki.title;
if (issue) {
const link = this.constructor.#ghIssue(issue);
title = `[${title}](${link})`;
}
content.push(
`${issueHeader} ${title}`
);
for (const subject of subjects) {
content.push(`* ${subject}`);
}
content.push('');
}
content.push('---');
}
const tab = {
name: 'News',
content: writer.render(reader.parse(content.join('\n'))),
};
this.#newsHash = NH.base.strHash(tab.content);
this.logger.leaving(me);
return tab;
}
/**
* @returns {TabbedUI~TabDefinition} License information.
*/
licenseTab() {
const me = this.licenseTab.name;
this.logger.entered(me);
const {id, url} = this.licenseData;
const tab = {
name: 'License',
content: `<p><a href="${url}">${id}</a></p>`,
};
this.logger.leaving(me, tab);
return tab;
}
static #FetchState = {
EMPTY: Symbol.for('Empty'),
FETCHING: Symbol.for('Fetching'),
FETCHED: Symbol.for('Fetched'),
}
static {
Object.freeze(LinkedIn.#FetchState);
}
static #asideSelector = [
// Style 1
'aside.scaffold-layout__aside',
// Style 2
'#workspace > div > div > section + aside',
].join(', ');
static #icon =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"' +
' fill="currentColor"' +
' viewBox="0 0 24 24" data-supported-dps="24x24">' +
'<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>';
static #primaryContentSelector = [
// Style 2
'#workspace > div > div > section',
].join(', ');
static #primaryNavSelector = [
// Style 1
'#global-nav .global-nav__primary-items',
// Style 2
`[${CKEY}="primaryNavLinksComponentRef"] nav > ul`,
].join(', ');
static #scrollerPrimaryClassName = 'lit-scroller-primary';
static #scrollerSecondaryClassName = 'lit-scroller-secondary';
static #sidebarSelector = [
// Style 1
'aside.scaffold-layout__sidebar',
// Style 2
'#workspace > div > div > aside:has(+ section)',
].join(', ');
/**
* Create a Greasy Fork project URL.
*
* @method
* @param {string} path - Portion of the URL.
* @returns {string} Full URL.
*/
static #gfUrl = (path) => {
const base = 'https://greasyfork.org/en/scripts/472097-linkedin-tool';
const url = `${base}/${path}`;
return url;
}
/**
* Create a GitHub project URL.
*
* @method
* @param {string} path - Portion of the URL.
* @returns {string} Full URL.
*/
static #ghUrl = (path) => {
const base = 'https://github.com/nexushoratio/userscripts';
const url = `${base}/${path}`;
return url;
}
/**
* Create a GitHub issue URL.
*
* @method
* @param {string} issue - Issue portion of the URL.
* @returns {string} Full URL.
*/
static #ghIssue = (issue) => {
const url = this.#ghUrl(`issues/${issue}`);
return url;
}
#badgeErrorResultsStyle2
#badgeErrorStyle1
#badgeErrorStyle2
#badgeNewsResultsStyle2
#badgeNewsStyle1
#badgeNewsStyle2
#dispatcher = new NH.base.Dispatcher('errors', 'news');
#errorText
#globals
#iframeDoc
#infoKeyboard
#infoTabs
#infoWidget
#licenseData
#licenseElement
#licenseState = LinkedIn.#FetchState.EMPTY;
#navbar
#navbarMutationObserver
#navbarResizeObserver
#newsHash
#newsQueue = new NH.base.MessageQueue();
#newsReadToggle
#ourMenuItemStyle1
#ourMenuItemStyle2
#pageStyle
#registrationComplete = false;
#shortcutsWidget
#typeTool = new NH.xunit.TypeTool();
#onInit = () => {
const me = this.#onInit.name;
this.logger.entered(me);
this.#checkForNews();
this.#infoTabs.tabs
.get('License').panel
.addEventListener('expose', this.#licenseHandler);
VMKeyboardService.condition = '!inputFocus && !inDialog';
VMKeyboardService.start();
this.logger.leaving(me);
}
/**
* @method
* @implements {NexusHoratio.base.Dispatcher~Handler}
* @param {string} type - Event type.
* @param {NexusHoratio.spa.Page~Pages} pages - Updated pages information.
*/
#onActivateUnsupportedPageCheck = (type, pages) => {
const me = this.#onActivateUnsupportedPageCheck.name;
this.logger.entered(me, type);
const ignoredPathname = (/.*/u).toString();
const filtered = pages.active.values()
.filter(x => x.pathname.toString() !== ignoredPathname)
.map(x => x.pathname)
.toArray();
if (!filtered.length && this.registrationComplete) {
NH.base.issues.post('Unsupported page:', window.location);
}
this.logger.leaving(me);
}
#checkForNews = () => {
const me = this.#checkForNews.name;
this.logger.entered(me);
if (this.#newsHash) {
const prev = litOptions.latestNewsRead;
const news = this.#newsHash !== prev;
this.#newsQueue.post(news);
this.#newsReadToggle.checked = !news;
}
this.logger.leaving(me);
}
/**
* @method
* @param {external:Element} element - Starting element to avoid another
* query.
* @returns {module:linkedin-tool~LinkedIn.Style} Guessed style.
*/
#guessPageStyle = (element) => {
const me = this.#guessPageStyle.name;
this.logger.entered(me, element);
const hint = element.closest('[id]').id;
let pageStyle = null;
switch (hint) {
case 'global-nav':
pageStyle = LinkedIn.Style.ONE;
break;
case 'primaryNavLinksComponentRef':
pageStyle = LinkedIn.Style.TWO;
break;
default:
pageStyle = LinkedIn.Style.UNKNOWN;
}
this.logger.leaving(me, pageStyle);
return pageStyle;
}
/**
* Hang out until the navigation bar has stabilized.
*
* @method
*/
#waitUntilPageLoadedEnough = async () => {
const me = this.#waitUntilPageLoadedEnough.name;
this.logger.entered(me);
// Wait for page to hopefully settle.
await NH.web.waitForSelector(
`${LinkedIn.primaryNavSelector} svg`
);
this.#finishConstruction();
this.logger.leaving(me);
}
/**
* Do the bits that were waiting on the page.
*
* @method
*/
#finishConstruction = () => {
const me = this.#finishConstruction.name;
this.logger.entered(me);
this.#createInfoWidget();
this.#addInfoTabs();
this.#addLitStyle();
this.#findNavbar();
this.logger.leaving(me);
}
#newsReadToggleHandler = () => {
const me = this.#newsReadToggleHandler.name;
this.logger.entered(me, this.#newsReadToggle.checked);
const newsRead = this.#newsReadToggle.checked;
this.#newsQueue.post(!newsRead);
if (newsRead) {
litOptions.latestNewsRead = this.#newsHash;
} else {
litOptions.latestNewsRead = 'marked-unread';
}
saveOptions(litOptions);
this.logger.leaving(me);
}
#licenseUpdateTabs = () => {
const me = this.#licenseUpdateTabs.name;
this.logger.entered(me);
const litPanel = this.#infoTabs.tabs.get('License').panel;
litPanel.replaceChildren(this.#licenseElement.cloneNode(true));
this.logger.leaving(me);
}
/**
* @typedef {object} FetchResult
* @memberof module:linkedin-tool~LinkedIn~
* @property {boolean} fetched - Indicates if a useful result was
* generated; all right to retry if false.
* @property {string} spdx - The SPDX id.
* @property {string} content - HTML content to be rendered.
*/
/**
* @method
* @returns {module:linkedin-tool~LinkedIn~FetchResult} Summary of the
* fetch.
*/
#licenseFetch = async () => {
const url = this.licenseData.url;
const result = {
fetched: false,
spdx: this.licenseData.id,
};
if (url) {
try {
const response = await fetch(url);
if (response.ok) {
result.content = await response.text();
result.fetched = true;
} else {
if (response.statusText) {
result.content = response.statusText;
} else {
result.content =
`License fetch failed with: ${response.status}`;
}
result.fetched = true;
}
} catch (e) {
result.content = e.message;
result.fetched = true;
}
} else {
result.fetched = true;
result.content = this.licenseData.content;
}
return result;
}
/**
* Lazily load license text when exposed.
*
* @method
*/
#licenseHandler = async () => {
const me = this.#licenseHandler.name;
this.logger.entered(me, this.#licenseState);
if (this.#licenseState === LinkedIn.#FetchState.EMPTY) {
this.#licenseUpdateTabs();
this.#licenseState = LinkedIn.#FetchState.FETCHING;
const result = await this.#licenseFetch();
if (result.fetched) {
const license = document.createElement('iframe');
license.style.flexGrow = 1;
license.title = result.spdx;
license.sandbox = '';
license.srcdoc = result.content;
this.#licenseElement = license;
this.#licenseUpdateTabs();
this.#licenseState = LinkedIn.#FetchState.FETCHED;
} else {
this.#licenseState = LinkedIn.#FetchState.EMPTY;
}
}
this.logger.leaving(me);
}
#createInfoWidget = () => {
this.#infoWidget = new NH.widget.Info(APP_LONG);
const widget = this.#infoWidget.container;
widget.classList.add('lit-info');
document.body.prepend(widget);
const dismissId = NH.base.safeId(`${widget.id}-dismiss`);
const infoName = this.#infoName(dismissId);
const instructions = this.#infoInstructions();
widget.append(infoName, instructions);
document.getElementById(dismissId)
.addEventListener('click', () => {
this.#infoWidget.close();
});
this.#infoKeyboard = new VM.shortcut.KeyboardService();
widget.addEventListener('open', this.#onOpenInfo);
widget.addEventListener('close', this.#onCloseInfo);
}
/**
* @method
* @param {string} dismissId - Element #id to give dismiss button.
* @returns {external:Element} For the info widget name header.
*/
#infoName = (dismissId) => {
const nameElement = document.createElement('div');
nameElement.classList.add('lit-justify');
const title = `<b>${APP_LONG}</b> - v${GM.info.script.version}`;
const dismiss = `<button id=${dismissId}>X</button>`;
nameElement.innerHTML = `<span>${title}</span><span>${dismiss}</span>`;
return nameElement;
}
/**
* @method
* @returns {external:Element} Instructions for navigating the info
* widget.
*/
#infoInstructions = () => {
const instructions = document.createElement('div');
instructions.classList.add('lit-justify');
instructions.classList.add('lit-instructions');
const left = VMKeyboardService.parseSeq('c-left');
const right = VMKeyboardService.parseSeq('c-right');
const esc = VMKeyboardService.parseSeq('esc');
instructions.innerHTML =
`<span>Use the ${left} and ${right} keys or click to select ` +
'tab</span>' +
`<span>Hit ${esc} to close</span>`;
return instructions;
}
#onOpenInfo = () => {
VMKeyboardService.setKeyboardContext('inDialog', true);
this.#infoKeyboard.enable();
this.#buildShortcutsInfo();
this.logger.log('info opened');
}
/**
* Force any 'focus' handlers to run.
*
* @method
*/
#forceFocusEvent = () => {
document.activeElement.dispatchEvent(new Event('focus'));
}
#onCloseInfo = () => {
this.#infoKeyboard.disable();
VMKeyboardService.setKeyboardContext('inDialog', false);
// Force this to run on the next event loop.
setTimeout(this.#forceFocusEvent, 0);
this.logger.log('info closed');
}
/**
* Create CSS styles for stuff specific to LinkedIn Tool.
*
* @method
*/
#addLitStyle = () => { // eslint-disable-line max-lines-per-function
const style = document.createElement('style');
style.id = `${this.id}-style`;
const styles = [
':root {' +
' --lit-color-positive: #01754f;' +
' --lit-color-negative: #cb112d;' +
'}',
'.lit-positive {' +
' color: white;' +
' background-color: var(--lit-color-positive);' +
'}',
'.lit-negative {' +
' background-color: var(--lit-color-negative);' +
'}',
`.${LinkedIn.scrollerPrimaryClassName} {` +
' border-color: orange !important;' +
' border-style: solid !important;' +
' border-width: medium !important;' +
' scroll-margin-bottom: 3em;' +
'}',
`.${LinkedIn.scrollerSecondaryClassName} {` +
' border-color: red !important;' +
' border-style: solid !important;' +
' border-width: thin !important;' +
' scroll-margin-bottom: 3em;' +
'}',
'.lit-info:modal {' +
' height: 100%;' +
' width: 65rem;' +
' font-size: 1.6rem;' +
' line-height: 1.5em;' +
' display: flex;' +
' flex-direction: column;' +
'}',
'.lit-justify {' +
' display: flex;' +
' flex-direction: row;' +
' justify-content: space-between;' +
'}',
'.lit-instructions {' +
' padding-bottom: 1ex;' +
' border-bottom: 1px solid black;' +
' margin-bottom: 5px;' +
'}',
'.lit-info button {' +
' border-width: 1px;' +
' border-style: solid;' +
' border-radius: 1em;' +
' padding: 3px;' +
'}',
'.lit-info code {' +
' background-color: ButtonFace;' +
' font-family: monospace;' +
'}',
'.lit-info input {' +
' opacity: unset !important;' +
'}',
'.lit-info kbd > kbd {' +
' font-size: 0.85em;' +
' padding: 0.07em;' +
' border-width: 1px;' +
' border-style: solid;' +
'}',
'.lit-info p {' +
' margin-bottom: 1em;' +
'}',
'.lit-info ul {' +
' list-style: unset;' +
' padding-inline: revert;' +
'}',
'.lit-info th {' +
' padding-top: 1em;' +
' text-align: left;' +
'}',
'.lit-info td:first-child {' +
' white-space: nowrap;' +
' text-align: right;' +
' padding-right: 0.5em;' +
'}',
'.lit-info textarea[data-lit-id="errors"] {' +
' flex-grow: 1;' +
' resize: none;' +
'}',
'.lit-kbd-service-active th {' +
' background-color: lightgray;' +
'}',
'.lit-menu-badge-news-style1 {' +
' top: 1.35rem !important;' +
' --color-alert: var(--lit-color-positive) !important;' +
'}',
'.lit-menu-badge-news-style2 {' +
' align-items: center;' +
' background-color: var(--lit-color-positive);' +
' border-radius: 1.2rem;' +
' color-scheme: light;' +
' color: white;' +
' display: flex;' +
' font-size: 1.2rem;' +
' font-weight: 600;' +
' height: 1.6rem;' +
' inset-block-start: -0.2rem;' +
' inset-inline-start: 100%;' +
' justify-content: center;' +
' margin-inline-start: -0.8rem;' +
' min-width: 1.6rem;' +
' padding-inline-end: 0.4rem;' +
' padding-inline-start: 0.4rem;' +
' position: absolute;' +
' top: 1.6rem;' +
' z-index: 100;' +
'}',
'.lit-menu-badge-news-style2::after {' +
' background: white;' +
' border-radius: 50%;' +
' content: "";' +
' height: 0.6rem;' +
' width: 0.6rem;' +
'}',
'.lit-menu-badge-error {' +
' align-items: center;' +
' background-color: var(--lit-color-negative);' +
' border-radius: 1.2rem;' +
' color-scheme: light;' +
' color: white;' +
' display: flex;' +
' font-size: 1.2rem;' +
' font-weight: 600;' +
' height: 1.6rem;' +
' inset-block-end: 1.8rem;' +
' inset-block-start: -0.2rem;' +
' inset-inline-start: 100%;' +
' justify-content: center;' +
' margin-inline-start: -0.8rem;' +
' min-width: 1.6rem;' +
' padding-inline-end: 0.4rem;' +
' padding-inline-start: 0.4rem;' +
' position: absolute;' +
' width: fit-content;' +
' z-index: 100;' +
'}',
// Get rid of the donut
'.lit-menu-badge-error::after {' +
' content: none !important;' +
'}',
'.lit-menu-badge-hide {' +
' opacity: 0;' +
'}',
];
style.textContent = styles.join('\n');
document.head.append(style);
}
/**
* Update Errors tab label based upon value.
*
* @method
* @param {number} count - Number of errors currently logged.
*/
#updateInfoErrorsLabel = (count) => {
const me = this.#updateInfoErrorsLabel.name;
this.logger.entered(me, count);
const label = this.#infoTabs.tabs.get('Errors').label;
if (count) {
this.#infoTabs.goto('Errors');
label.classList.add('lit-negative');
} else {
label.classList.remove('lit-negative');
}
this.logger.leaving(me);
}
/**
* @method
* @param {Event} evt - The 'change' event.
*/
#errorTextHandler = (evt) => {
const me = this.#errorTextHandler.name;
this.logger.entered(me, evt);
const count = evt.target.value
.split('\n')
.filter(x => x === LinkedIn.errorMarker).length;
this.dispatcher2.fire('errors', count);
this.#updateInfoErrorsLabel(count);
this.logger.leaving(me);
}
#addInfoTabsHandlers = () => {
const me = this.#addInfoTabsHandlers.name;
this.logger.entered(me);
this.#infoKeyboard.register('c-right', this.#nextTab);
this.#infoKeyboard.register('c-left', this.#prevTab);
this.#newsReadToggle = document.querySelector('#lit-news-read-toggle');
this.#newsReadToggle.addEventListener(
'change', this.#newsReadToggleHandler
);
this.#newsQueue.listen(this.#newsListener);
this.#errorText = document.querySelector('[data-lit-id="errors"]');
this.#errorText.addEventListener('change', this.#errorTextHandler);
issuesForLinkedIn.listen(this.#issueListener);
this.logger.leaving(me);
}
#addInfoTabs = () => {
const me = this.#addInfoTabs.name;
this.logger.entered(me);
const tabs = [
this.#shortcutsTab(),
LinkedIn.aboutTab(),
this.newsTab(),
LinkedIn.errorTab('lit'),
this.licenseTab(),
];
this.#infoTabs = new TabbedUI(APP_LONG);
for (const tab of tabs) {
this.#infoTabs.addTab(tab);
}
this.#infoTabs.goto(tabs[0].name);
this.#infoWidget.container.append(this.#infoTabs.container);
this.#addInfoTabsHandlers();
this.logger.leaving(me);
}
#nextTab = () => {
this.#infoTabs.next();
}
#prevTab = () => {
this.#infoTabs.prev();
}
#toolButtonHandler = () => {
this.#infoWidget.open();
}
/**
* Determine the style property differences between two elements.
*
* @method
* @param {external:Element} el1 - The first element.
* @param {external:Element} el2 - The second element.
* @param {Set<string>} ignore - A collection of style properties to
* ignore.
* @returns {string[]} Style properties present in the first, but not
* the second element, formatted to add to this source file.
*/
#findMissingStyleProperties = (el1, el2, ignore) => {
const me = this.#findMissingStyleProperties.name;
this.logger.entered(me, el1, el2, ignore);
const missing = new Map();
const styles1 = getComputedStyle(el1);
const styles2 = getComputedStyle(el2);
const set1 = new Set([...styles1]);
const set2 = new Set([...styles2]);
for (const prop of set1.union(set2)
.difference(ignore)) {
const val1 = styles1.getPropertyValue(prop);
const val2 = styles2.getPropertyValue(prop);
if (val1 !== val2) {
missing.set(prop, val1);
}
}
const results = [];
for (const [key, value] of missing.entries()) {
results.push(`' ${key}: ${value};' +`);
}
this.logger.leaving(me, results);
return results.sort();
}
/**
* Update news badge as appropriate.
*
* @method
* @implements {NexusHoratio.base.Dispatcher~Handler}
* @param {string} eventType - Event type.
* @param {boolean} show - Whether to show the badge or not.
*/
#newsHandlerBadgeStyle1 = (eventType, show) => {
const me = this.#newsHandlerBadgeStyle1.name;
this.logger.entered(me, eventType, show);
if (show) {
this.#badgeNewsStyle1.classList.add('notification-badge--show');
} else {
this.#badgeNewsStyle1.classList.remove('notification-badge--show');
}
this.logger.leaving(me);
}
/**
* Update error badge as appropriate.
*
* @method
* @implements {NexusHoratio.base.Dispatcher~Handler}
* @param {string} eventType - Event type.
* @param {number} count - Number of errors currently logged.
*/
#errorHandlerBadgeStyle1 = (eventType, count) => {
const me = this.#errorHandlerBadgeStyle1.name;
this.logger.entered(me, eventType, count);
this.#badgeErrorStyle1
.querySelector('.notification-badge__count').innerText = `${count}`;
if (count) {
this.#badgeErrorStyle1.classList.add('notification-badge--show');
} else {
this.#badgeErrorStyle1.classList.remove('notification-badge--show');
}
this.logger.leaving(me);
}
/**
* Tweak the internals of whatever random element we cloned.
*
* @method
* @param {external:Element} button - The newly created button.
*/
#finishButtonStyle1 = (button) => {
const title = button.querySelector('.global-nav__primary-link-text');
title.innerText = APP_SHORT;
title.setAttribute('title', APP_SHORT);
button.querySelector('li-icon')
.setAttribute('type', APP_SHORT.toLowerCase());
}
/**
* @method
* @param {external:Element} element - Element that will hold the badges.
*/
#assembleBadgesStyle1 = (element) => {
this.#badgeErrorStyle1 = element.querySelector(
'.notification-badge'
);
this.#badgeNewsStyle1 = this.#badgeErrorStyle1.cloneNode(true);
this.#badgeNewsStyle1
.classList.add('lit-menu-badge-news-style1');
this.#badgeErrorStyle1.after(this.#badgeNewsStyle1);
// Style-1 badges are easy to switch between counting or not. This
// makes sure we are in the correct mode for each badge.
let count = this.#badgeErrorStyle1
.querySelector('.notification-badge__no-count');
count?.classList.remove('notification-badge__no-count');
count?.classList.add('notification-badge__count');
count = this.#badgeNewsStyle1
.querySelector('.notification-badge__count');
count?.classList.remove('notification-badge__count');
count?.classList.add('notification-badge__no-count');
let a11y = this.#badgeErrorStyle1.querySelector('.a11y-text');
if (a11y) {
a11y.innerText = `${APP_LONG} error count`;
}
a11y = this.#badgeNewsStyle1.querySelector('.a11y-text');
if (a11y) {
a11y.innerText = `${APP_LONG} news notifications`;
}
}
#createMenuItemStyle1 = () => {
const me = this.#createMenuItemStyle1.name;
this.logger.entered(me, this.#navbar);
// Making the assumption that the there is at least one item with a
// badge and it is an anchor.
let item = this.#navbar
.querySelector('.global-nav__primary-item:has(.notification-badge)');
const subItem = item.querySelector('a');
item = item.cloneNode(false);
const button = document.createElement('button');
button.classList.add('global-nav__primary-link');
button.append(...Array.from(subItem.childNodes)
.map(x => x.cloneNode(true))
.map((x) => {
x.removeAttribute?.('id');
return x;
}));
const svg = button.querySelector('svg');
if (svg) {
svg.parentElement.innerHTML = LinkedIn.#icon;
item.append(button);
this.#finishButtonStyle1(button);
this.#assembleBadgesStyle1(button);
button.addEventListener('click', this.#toolButtonHandler);
this.#ourMenuItemStyle1 = item;
this.dispatcher2.on('errors', this.#errorHandlerBadgeStyle1);
this.dispatcher2.on('news', this.#newsHandlerBadgeStyle1);
}
this.logger.leaving(me, this.#ourMenuItemStyle1);
}
/**
* Update news badge as appropriate.
*
* @method
* @implements {NexusHoratio.base.Dispatcher~Handler}
* @param {string} eventType - Event type.
* @param {boolean} show - Whether to show the badge or not.
*/
#newsHandlerBadgeStyle2 = (eventType, show) => {
const me = this.#newsHandlerBadgeStyle2.name;
this.logger.entered(me, eventType, show, this.#badgeNewsStyle2);
if (show) {
this.#badgeNewsStyle2.classList.remove('lit-menu-badge-hide');
} else {
this.#badgeNewsStyle2.classList.add('lit-menu-badge-hide');
}
this.logger.leaving(me);
}
/**
* Updates error badge as appropriate.
*
* @method
* @implements {NexusHoratio.base.Dispatcher~Handler}
* @param {string} eventType - Event type.
* @param {number} count - Number of errors currently logged.
*/
#errorHandlerBadgeStyle2 = (eventType, count) => {
const me = this.#errorHandlerBadgeStyle2.name;
this.logger.entered(me, eventType, count);
this.#badgeErrorStyle2.innerText = `${count}`;
if (count) {
this.#badgeErrorStyle2
.classList.remove('lit-menu-badge-hide');
} else {
this.#badgeErrorStyle2.classList.add('lit-menu-badge-hide');
}
this.logger.leaving(me);
}
/**
* Tweak the internals of whatever random element we cloned.
*
* @method
* @param {external:Element} button - The newly created button.
*/
#finishButtonStyle2 = (button) => {
// Grab the common obfuscated class names
const buttons = this.#navbar.querySelectorAll('li > button');
const buttonClasses = new Set(buttons[0].classList)
.intersection(new Set(buttons[1].classList));
button.ariaLabel = APP_SHORT;
button.removeAttribute('aria-current');
button.className = [...buttonClasses].join(' ');
const textNodes = Array.from(button.querySelectorAll('*'))
.filter(el => el.childNodes[0]?.nodeType === Node.TEXT_NODE);
textNodes[0].innerText = APP_SHORT;
}
/**
* @method
* @param {external:Element} element - Element that will hold the badges.
*/
#assembleBadgesStyle2 = (element) => {
this.#badgeErrorStyle2 = document.createElement('span');
this.#badgeErrorStyle2.classList.add('lit-menu-badge-error');
this.#badgeNewsStyle2 = document.createElement('span');
this.#badgeNewsStyle2.classList.add(
'lit-menu-badge-news-style2', 'lit-menu-badge-hide'
);
element.append(this.#badgeErrorStyle2, this.#badgeNewsStyle2);
}
#createMenuItemStyle2 = () => {
const me = this.#createMenuItemStyle2.name;
this.logger.entered(me, this.#navbar);
const item = this.#navbar
.querySelector('li')
.cloneNode(true);
// The page may not have settled down yet, so check each bit carefully.
const button = item.querySelector('button');
if (button) {
const svg = button.querySelector('svg');
if (svg) {
const svgParent = svg.parentElement;
svg.outerHTML = LinkedIn.#icon;
button.querySelector('svg + span')
?.remove();
this.#finishButtonStyle2(button);
this.#assembleBadgesStyle2(svgParent);
button.addEventListener('click', this.#toolButtonHandler);
this.#ourMenuItemStyle2 = item;
this.dispatcher2.on('errors', this.#errorHandlerBadgeStyle2);
this.dispatcher2.on('news', this.#newsHandlerBadgeStyle2);
}
}
this.logger.leaving(me, this.#ourMenuItemStyle2);
}
/**
* Connect our menu item to the navbar if necessary.
*
* It will always go after "Me" menu item.
*
* This supports both Styles 1 and 2.
*
* @method
* @param {external:Element} menuItem - The menu item to connect.
* @param {string} selector - The CSS selector for "Me".
*/
#connectMenuItem = (menuItem, selector) => {
const me = this.#connectMenuItem.name;
this.logger.entered(me, menuItem, selector);
if (this.#navbar) {
if (!menuItem.isConnected) {
this.logger.log('Will connect menu item to', this.#navbar);
const navMe = this.#navbar.querySelector(selector)
?.closest('li');
this.logger.log('navMe', navMe);
if (navMe) {
navMe.after(menuItem);
} else {
// If the site changed and we cannot insert ourself after the Me
// menu item, then go first.
this.#navbar.prepend(menuItem);
NH.base.issues.post(
'Unable to find the Profile navbar item.',
'LIT menu installed in non-standard location.'
);
}
this.#checkForNews();
this.#refreshErrors();
}
}
this.logger.leaving(me);
}
#ensureMenuStyle1 = () => {
const me = this.#ensureMenuStyle1.name;
this.logger.entered(me, this.#ourMenuItemStyle1);
if (this.#pageStyle === LinkedIn.Style.ONE) {
if (!this.#ourMenuItemStyle1) {
this.#createMenuItemStyle1();
}
if (this.#ourMenuItemStyle1) {
this.#connectMenuItem(this.#ourMenuItemStyle1, '.global-nav__me');
}
}
this.logger.leaving(me);
}
#compareBadgeErrorStyle2 = () => {
const me = this.#compareBadgeErrorStyle2.name;
this.logger.entered(me);
// Only do this once.
if (!this.#badgeErrorResultsStyle2 &&
this.#badgeErrorStyle2?.isConnected) {
this.logger.log('checking error badge', this.#badgeErrorStyle2);
// Some badges are bad examples, so skip them using :not().
const badges = this.#navbar
.querySelectorAll('svg:not([id^="home"]) + span');
if (badges.length > NH.base.ONE_ITEM) {
const ignore = new Set([
'inline-size',
'inset-inline-end',
'opacity',
'perspective-origin',
'right',
'transform-origin',
'width',
]);
const results = this.#findMissingStyleProperties(
badges[0], this.#badgeErrorStyle2, ignore
);
if (results.length) {
NH.base.issues.post(
'Style-2 error badge needs updating:', results.join('\n')
);
}
this.#badgeErrorResultsStyle2 = results;
}
}
this.logger.leaving(me);
}
#compareBadgeNewsStyle2 = () => {
const me = this.#compareBadgeNewsStyle2.name;
this.logger.entered(me);
// Only do this once.
if (!this.#badgeNewsResultsStyle2 &&
this.#badgeNewsStyle2?.isConnected) {
this.logger.log('checking error badge', this.#badgeNewsStyle2);
const badge = this.#navbar
.querySelector('svg[id^="home"] + span');
if (badge) {
const ignore = new Set([
'background-color',
'bottom',
'inset-block-end',
'inset-block-start',
'opacity',
'top',
]);
const results = this.#findMissingStyleProperties(
badge, this.#badgeNewsStyle2, ignore
);
if (results.length) {
NH.base.issues.post(
'Style-2 news badge needs updating:', results.join('\n')
);
}
this.#badgeNewsResultsStyle2 = results;
}
}
this.logger.leaving(me);
}
#compareBadgesStyle2 = () => {
const me = this.#compareBadgesStyle2.name;
this.logger.entered(me);
this.#compareBadgeErrorStyle2();
this.#compareBadgeNewsStyle2();
this.logger.leaving(me);
}
/**
* Update News tab label as appropriate.
*
* @method
* @param {boolean} highlight - Whether to show the badge or not.
*/
#updateInfoNewsLabel = (highlight) => {
const me = this.#updateInfoNewsLabel.name;
this.logger.entered(me, highlight);
const litLabel = this.#infoTabs.tabs.get('News').label;
if (highlight) {
this.#infoTabs.goto('News');
litLabel.classList.add('lit-positive');
} else {
litLabel.classList.remove('lit-positive');
}
this.logger.leaving(me);
}
/**
* Decisions about news could be made before the UI is available.
*
* @method
*/
#newsListener = (...msgs) => {
const me = this.#newsListener.name;
this.logger.entered(me, msgs);
for (const msg of msgs) {
this.dispatcher2.fire('news', msg);
this.#updateInfoNewsLabel(msg);
}
this.logger.leaving(me);
}
#ensureMenuStyle2 = () => {
const me = this.#ensureMenuStyle2.name;
this.logger.entered(me, this.#ourMenuItemStyle2);
if (this.#pageStyle === LinkedIn.Style.TWO) {
if (!this.#ourMenuItemStyle2) {
this.#createMenuItemStyle2();
}
if (this.#ourMenuItemStyle2) {
this.#connectMenuItem(this.#ourMenuItemStyle2, 'li:last-child');
}
this.#compareBadgesStyle2();
}
this.logger.leaving(me);
}
/**
* Find the nav links and ensure observers.
*
* @method
*/
#findNavbar = () => {
const me = this.#findNavbar.name;
this.logger.entered(me, this.#navbar?.isConnected);
if (!this.#iframeDoc) {
const iframe = document
.querySelector('iframe[data-testid]')
?.contentDocument;
// Do not track the iframe until it has settled a bit.
if (iframe && iframe.URL !== 'about:blank') {
this.#iframeDoc = iframe;
}
}
let doObserve = !this.#navbar?.isConnected;
const navbar = document.querySelector(
LinkedIn.primaryNavSelector
) || this.#iframeDoc
?.querySelector(LinkedIn.primaryNavSelector);
if (navbar) {
const pageStyle = this.#guessPageStyle(navbar);
doObserve ||= pageStyle !== this.#pageStyle;
this.#pageStyle = pageStyle;
}
if (this.#navbar && navbar) {
doObserve ||= !this.#navbar.isSameNode(navbar);
}
if (doObserve) {
this.#navbar = navbar;
this.#observeNavbar();
}
this.logger.leaving(me, this.#navbar);
}
/**
* Reset observers for the navbar.
*
* @method
*/
#observeNavbar = () => {
const me = this.#observeNavbar.name;
this.logger.entered(me, this.#navbar);
this.#navbarMutationObserver.disconnect();
this.#navbarResizeObserver.disconnect();
if (this.#iframeDoc?.head) {
this.#navbarMutationObserver.observe(
this.#iframeDoc.head, {childList: true, subtree: true}
);
}
if (this.#navbar) {
this.#navbarMutationObserver.observe(
this.#navbar, {childList: true, subtree: true}
);
this.#navbarResizeObserver.observe(this.#navbar);
}
this.logger.leaving(me);
}
/**
* Recheck various items after a change to the navbar.
*
* @method
* @fires 'resize'
*/
#navbarHandler = () => {
const me = this.#navbarHandler.name;
this.logger.entered(me);
this.#findNavbar();
if (this.#navbar) {
this.#ensureMenuStyle1();
this.#ensureMenuStyle2();
}
this.logger.leaving(me);
}
/**
* @method
* @returns {TabbedUI~TabDefinition} Keyboard shortcuts listing.
*/
#shortcutsTab = () => {
this.#shortcutsWidget = new AccordionTableWidget('Shortcuts');
const tab = {
name: 'Keyboard Shortcuts',
content: this.#shortcutsWidget.container,
};
return tab;
}
#buildShortcutsInfo = () => {
const me = this.#buildShortcutsInfo.name;
this.logger.entered(me);
this.#shortcutsWidget.clear();
const activeFirst = [
...VMKeyboardService.services.values()
.filter(x => x.active),
...VMKeyboardService.services.values()
.filter(x => !x.active),
];
for (const service of activeFirst) {
this.logger.log('service:', service.shortName, service.active);
// Works in progress may not have any shortcuts yet.
if (service.shortcuts.length) {
const parsedName = NH.base.simpleParseWords(service.shortName)
.join(' ');
const section = this.#shortcutsWidget.addSection(service.shortName);
if (service.active) {
section.classList.add('lit-kbd-service-active');
}
this.#shortcutsWidget.addHeader('', parsedName);
for (const shortcut of service.shortcuts) {
this.logger.log('shortcut:', shortcut);
this.#shortcutsWidget.addData(
`${VMKeyboardService.parseSeq(shortcut.seq)}:`, shortcut.desc
);
}
}
}
this.logger.leaving(me);
}
/**
* Post problems about stale issues.
*
* @method
* @param {Set<string>} unknown - Issue ids referenced in news items but
* not in {@link globalKnownIssues}.
* @param {Set<string>} unused - Stale {@link globalKnownIssues} ids.
* @param {Set<string>} old - Stale {@link globalNewsContent} entries.
*/
#reportIssueProblems = (unknown, unused, old) => {
for (const item of unknown) {
NH.base.issues.post(
'Unknown issue detected:', item, this.constructor.#ghIssue(item)
);
}
for (const item of old) {
NH.base.issues.post('Old news item:', item);
}
for (const item of unused.values()) {
NH.base.issues.post(
'Unused issue detected:',
item,
this.constructor.#ghIssue(item.issueId)
);
}
}
/**
* @method
* @returns {obj} dates and known issues.
*/
#preprocessKnownIssues = () => {
const thirtyDays = 30 * 24 * 60 * 60 * 1000; // eslint-disable-line no-magic-numbers
const oldestAllowedDate = litOptions.enableAlertOldNews
? Date.now() - thirtyDays
: 0;
const knownIssues = new Map(globalIssues.map(x => [x.issueId, x]));
const unknownIssues = new Set();
const unusedIssues = new Map(
knownIssues
.entries()
.filter(x => x[1].date < oldestAllowedDate)
);
const oldItems = new Set();
const dates = new NH.base.DefaultMap(
() => new NH.base.DefaultMap(Array)
);
for (const item of globalNewsContent) {
if (new Date(item.date) < oldestAllowedDate) {
oldItems.add(item.subject);
}
for (const issue of item.issues) {
unusedIssues.delete(issue);
if (knownIssues.has(issue)) {
dates.get(item.date)
.get(issue)
.push(item.subject);
} else {
unknownIssues.add(issue);
}
}
}
this.#reportIssueProblems(unknownIssues, unusedIssues, oldItems);
return {
dates: dates,
knownIssues: knownIssues,
};
}
/**
* Send `change` event to the errors text area.
*
* @method
*/
#refreshErrors = () => {
const evt = new Event('change');
this.#errorText.dispatchEvent(evt);
}
/**
* Add content to the Errors tab so the user can use it to file feedback.
*
* @method
* @param {string} content - Information to add.
*/
#addError = (content) => {
const repr = this.#typeTool.repr(content);
this.#errorText.value += `${repr}\n`;
if (content === LinkedIn.errorMarker) {
this.#refreshErrors();
}
}
/**
* Add a marker to the Errors tab so the user can see where different
* issues happened.
*
* @method
*/
#addErrorMarker = () => {
this.#addError(LinkedIn.errorMarker);
}
#issueListener = (...issues) => {
for (const issue of issues) {
this.#addError(issue);
}
this.#addErrorMarker();
}
}
/**
* Different implementation styles across LinkedIn over time.
* @readonly
* @enum {Symbol}
*/
LinkedIn.Style = {
UNKNOWN: Symbol.for('Style-0'),
ONE: Symbol.for('Style-1'),
TWO: Symbol.for('Style-2'),
};
Object.freeze(LinkedIn.Style);
/**
* Verify a {@link NexusHoratio.spa.Page Page} implementation and current
* site style match.
*
* It will post a bug on mismatches.
*/
class LinkedInStyleService extends NH.spa.Page.Service {
/**
* @param {string} instanceName - Custom portion of this instance.
*/
constructor(instanceName) {
super(instanceName);
this.on('activate', this.#onActivate);
}
/**
* @param {...module:linkedin-tool~LinkedIn.Style} styles - Styles allowed
* for the page.
* @returns {module:linkedin-tool~LinkedInStyleService} This instance, for
* chaining.
*/
addStyles(...styles) {
for (const style of styles) {
this.#allowedStyles.add(style);
}
return this;
}
#allowedStyles = new Set();
#onActivate = () => {
if (!this.#allowedStyles.has(this.page.spa.details.pageStyle)) {
const style = this.page.spa.details.pageStyle.toString()
.replace('Symbol(', '')
.replace(')', '');
NH.base.issues.post([
`The page "${this.shortName}" was activated`,
`with unsupported style: ${style}`,
].join(' '));
}
}
}
/**
* LinkedIn Tool enhancements for {@link NexusHoratio.spa.Page spa.Page}.
*
* @extends NexusHoratio.spa.Page
*/
class Page extends NH.spa.Page {
/**
* @param {NexusHoratio.spa.Page~PageDetails} details - Details about the
* instance.
*/
constructor(details = {}) {
if (new.target === Page) {
throw new TypeError('Abstract class; do not instantiate directly.');
}
super(details);
this.logger.log('Adapter page constructed', this);
}
/**
* Alias for `this.constructor`.
*
* @type {module:linkedin-tool~Page}
*/
get ctor() {
return this.constructor;
}
/**
* Useful default for CSS class name.
*
* @type {string}
*/
get scrollerClassName() {
return this.cssClassName(['scroller']);
}
/**
* Derive a CSS className from the name of the subclass.
*
* @method
* @param {string[]} extras - Extract strings to add to the class name.
* @returns {string} A CSS className.
*/
cssClassName = (extras = []) => {
const split = NH.base.simpleParseWords(this.name)
.map(x => x.toLowerCase());
const className = ['lit'].concat(split)
.concat(extras)
.join('-');
return className;
}
/**
* A useful processor for many LIT pages.
*
* It just sums up height of the matched elements to set a top margin.
*
* @method
* @implements {NexusHoratio.web.StyleService~ElementsProcessor}
* @param {NexusHoratio.web.StyleService~ElementMap} elements - Elements
* to examine.
* @returns {NexusHoratio.web.StyleService~StyleProperties} Style
* properties for to contribute.
*/
elementsHeightProcessor = (elements) => {
const me = this.elementsHeightProcessor.name;
this.logger.entered(me, elements);
let height = 0;
const properties = new Map();
for (const value of elements.values()) {
if (value) {
const header = getComputedStyle(value);
if (header.visibility === 'visible') {
height += value.offsetHeight;
}
}
}
properties.set('scroll-margin-top', `${height}px`);
this.logger.leaving(me, properties);
return properties;
}
#klass
}
/* eslint-disable no-new */
/* eslint-disable require-jsdoc */
class LitPageTestCase extends NH.xunit.TestCase {
static TestPage = class extends Page {}
static CtorPage = class extends Page {
/**
* @alias CtorPage
* @ignore
*/
constructor() {
super();
this.ctor.#business = 'nunya';
}
static get businessClass() {
return [this.#business, 'class'].join('-');
}
get businessInst() {
return [this.ctor.#business, 'inst'].join('-');
}
static #business = 'uninit';
}
testAbstract() {
this.assertRaises(TypeError, () => {
new Page();
});
this.assertNoRaises(() => {
new LitPageTestCase.TestPage();
}, 'subclass passes');
}
testScrollerClassName() {
const page = new LitPageTestCase.TestPage();
this.assertEqual(
page.scrollerClassName, 'lit-test-page-scroller'
);
}
testCssClassName() {
const page = new LitPageTestCase.TestPage();
this.assertEqual(
page.cssClassName(),
'lit-test-page',
'no arguments'
);
this.assertEqual(
page.cssClassName([]),
'lit-test-page',
'empty array'
);
this.assertEqual(
page.cssClassName(['single']),
'lit-test-page-single',
'single item'
);
this.assertEqual(
page.cssClassName(
['multiple', 'items', 'here']
),
'lit-test-page-multiple-items-here',
'multiple items'
);
}
testCtor() {
this.assertEqual(
LitPageTestCase.CtorPage.businessClass, 'uninit-class', 'uninit'
);
const page = new LitPageTestCase.CtorPage();
this.assertEqual(page.ctor, LitPageTestCase.CtorPage, 'equivalence');
this.assertEqual(page.businessInst, 'nunya-inst', 'inst');
this.assertEqual(
LitPageTestCase.CtorPage.businessClass, 'nunya-class', 'static'
);
}
static { this.register(); }
}
/* eslint-enable */
/** Class for holding keystrokes that simplify debugging. */
class DebugKeys {
/** @hideconstructor */
constructor() {
this.#logger = new NH.base.Logger(`[${this.constructor.name}]`);
}
clearConsole = new Shortcut(
'c-c c-c',
'Clear the debug console',
() => {
NH.base.Logger.clear();
}
);
activeElement = new Shortcut(
'c-c c-a',
'Log the active element',
() => {
let shadow = '';
let target = document.activeElement;
while (target.shadowRoot) {
shadow = ' (shadow-dom)';
target = target.shadowRoot.activeElement;
}
this.#logger.log(`activeElement${shadow}`, target);
}
);
dumpWatched = new Shortcut(
'c-c c-d',
'Dump watched elements (help find a readySelector)',
() => {
const me = this.dumpWatched.name;
this.#logger.entered(me);
try {
const byId = new NH.base.DefaultMap(Array);
// Will get wrapped in a `:not(...)`
const ignoredExtras = [
'iframe',
'img',
'figure',
];
// Eliminate element ids used more than once.
document.querySelectorAll('[data-counter][id]')
.values()
.map(x => byId.get(x.id)
.push(1))
.toArray();
const nots = byId.entries()
.filter(x => x[1].length > 1)
.map(x => `#${x[0]}`)
.toArray()
.concat(ignoredExtras)
.map(x => `:not(${x})`)
.join('');
this.#logger.log(
'watched',
document.querySelectorAll(`[data-counter]${nots}`)
.values()
.map(x => [x.dataset.counter, x])
.toArray()
.sort((a, b) => b[0] - a[0])
);
} catch (e) {
this.#logger.log('caught', e);
}
this.#logger.leaving(me);
}
);
resetScrollers = new Shortcut(
'c-c c-s',
'Reset all Scroller IDs',
() => {
const me = this.resetScrollers.name;
this.#logger.entered(me);
let count = 0;
for (const item of document.querySelectorAll('[data-scroller-id]')) {
delete item.dataset.scrollerId;
count += 1;
}
this.#logger.log('items reset:', count);
this.#logger.leaving(me);
}
);
#logger
}
/**
* Class for handling aspects common across LinkedIn.
*
* This includes things like the global nav bar, information view, etc.
*
* @extends module:linkedin-tool~Page
*/
class Global extends Page {
/**
* @param {NexusHoratio.spa.SPA} spa - SPA instance that manages this
* {@link module:linkedin-tool~Page Page}.
*/
constructor(spa) {
super({spa: spa});
spa.details.dispatcher
.on('activate', this.#onHybridActivate);
this.addService(LinkedInStyleService)
.addStyles(LinkedIn.Style.ONE, LinkedIn.Style.TWO);
this.addService(VMKeyboardService)
.addInstance(this);
if (litOptions.enableDevMode) {
this.addService(VMKeyboardService)
.setShortName(DebugKeys.name)
.addInstance(new DebugKeys());
}
}
info = new Shortcut(
'?',
'Show this information view',
() => {
if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
this.#gotoNavButton(APP_SHORT);
} else {
this.#gotoNavLabel(APP_SHORT);
}
}
);
gotoSearch = new Shortcut(
'/',
'Go to Search box',
() => {
if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
NH.web.clickElement(document, ['#global-nav-search button']);
} else {
const element = document.querySelector(
`[${CKEY}="SearchResults_SearchTyahInputRef"]`
);
NH.web.focusOnElement(element);
}
}
);
goHome = new Shortcut(
'g h',
'Go Home (aka, Feed)',
() => {
if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
this.#gotoNavLink('feed');
} else {
this.#gotoNavLabel('Home');
}
}
);
gotoMyNetwork = new Shortcut(
'g w',
'Go to My Network',
() => {
if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
this.#gotoNavLink('mynetwork');
} else {
this.#gotoNavLabel('My Network');
}
}
);
gotoJobs = new Shortcut(
'g j',
'Go to Jobs',
() => {
if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
this.#gotoNavLink('jobs');
} else {
this.#gotoNavLabel('Jobs');
}
}
);
gotoMessaging = new Shortcut(
'g m',
'Go to Messaging',
() => {
if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
this.#gotoNavLink('messaging');
} else {
this.#gotoNavLabel('Messaging');
}
}
);
gotoNotifications = new Shortcut(
'g n',
'Go to Notifications',
() => {
if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
this.#gotoNavLink('notifications');
} else {
this.#gotoNavLabel('Notifications');
}
}
);
gotoProfile = new Shortcut(
'g p',
'Go to Profile (aka, Me)',
() => {
if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
this.#gotoNavButton('Me');
} else {
// Nothing easy to identify, so assume always after Notification
this.spa.details
.navbar
.querySelector('[aria-label^="Notifications"]')
.closest('li')
.nextSibling
.querySelector('button')
.click();
}
}
);
gotoBusiness = new Shortcut(
'g b',
'Go to Business',
() => {
if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
this.#gotoNavButton('Business');
} else {
this.#gotoNavLabel('For Business');
}
}
);
gotoLearning = new Shortcut(
'g l',
'Go to Learning',
() => {
this.#gotoNavLink('learning');
}
);
focusOnSidebar = new Shortcut(
',',
'Focus on the left/top sidebar (not always present)',
() => {
this.spa.details.focusOnSidebar();
}
);
focusOnAside = new Shortcut(
'.',
'Focus on the right/bottom sidebar (not always present)',
() => {
this.spa.details.focusOnAside();
}
);
/**
* Click on the requested link in the global nav bar.
*
* @method
* @param {string} item - Portion of the link to match.
*/
#gotoNavLink = (item) => {
const me = this.#gotoNavLink.name;
this.logger.entered(me, item);
// The navbar elements may be split across two containers. So we start
// at the navbar, move up to find a container that has the link.
const target = `a[href*="/${item}"]`;
this.spa.details.navbar
.closest(`:has(${target})`)
.querySelector(target)
.click();
this.logger.leaving(me);
}
/**
* Click on the requested button in the global nav bar.
*
* @method
* @param {string} item - Text on the button to look for.
*/
#gotoNavButton = (item) => {
const me = this.#gotoNavButton.name;
this.logger.entered(me, item);
Array.from(
this.spa.details.navbar.querySelectorAll('button')
)
.find(el => el.textContent.includes(item))
?.click();
this.logger.leaving(me);
}
/**
* Click on the requested element in the Style-2 global nav bar.
*
* This uses the `aria-label`, which has the potential to be translated.
*
* @method
* @param {string} item - The prefix for the target `aria-label`.
*/
#gotoNavLabel = (item) => {
const me = this.#gotoNavLabel.name;
this.logger.entered(me, item);
// The navbar elements may be split across two containers. So we start
// at the navbar, move up to find a container that has the label, then
// back down.
const target = `[aria-label^="${item}"]`;
this.spa.details.navbar
.closest(`:has(${target})`)
.querySelector(target)
.click();
this.logger.leaving(me);
}
/**
* @todo [(#295)](https://github.com/nexushoratio/userscripts/issues/295)
* This is a hack. Find a more principled solution.
*
* @method
*/
#onHybridActivate = () => {
const me = this.#onHybridActivate.name;
this.logger.entered(me, this.spa.details.pageStyle);
const main = document.querySelector('main')?.id;
if (this.spa.details.pageStyle === LinkedIn.Style.ONE &&
main === 'workspace') {
this.logger.log('hybrid mode, reloading');
document.location.reload();
}
this.logger.leaving(me);
}
}
/**
* Class for handling the Posts feed.
*
* @extends module:linkedin-tool~Page
*/
class Feed extends Page {
/**
* @param {NexusHoratio.spa.SPA} spa - SPA instance that manages this
* {@link module:linkedin-tool~Page Page}.
*/
constructor(spa) {
super({
spa: spa,
// eslint-disable-next-line prefer-regex-literals
pathname: RegExp('^/feed/?', 'u'),
// Sort by: X button (yes, the svg)
readySelector: '#linkedin-logo-xxsmall',
});
this.addService(LinkedInStyleService)
.addStyles(LinkedIn.Style.TWO);
this.addService(VMKeyboardService)
.addInstance(this);
this.#initScrollers();
}
/** @type {Scroller} */
get comments() {
if (!this.#commentScroller && this.posts.item) {
this.#initCommentScroller();
}
return this.#commentScroller;
}
/** @type {Scroller} */
get posts() {
return this.#postScroller;
}
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',
() => {
this.#lastScroller.focus();
}
);
showComments = new Shortcut(
'c',
'Show comments',
() => {
const el = this.posts.item;
// Check for the "Load more" button first, otherwise we just keep
// clicking on the first comment button which does nothing useful
// after the first batch of comments is loaded.
NH.web.clickElement(el, [
// Load more comments
`[${CKEY}*="LoadMoreComments"] button`,
// Inside post body
'[role="button"]',
]);
}
);
showMore = new Shortcut(
'm',
'Show more of current post or comment',
() => {
const el = this.#lastScroller.item;
NH.web.clickElement(el, ['[data-testid="expandable-text-button"]']);
}
);
loadMorePosts = new Shortcut(
'l',
'Load more posts (if the <button>New Posts</button> button ' +
'is available)', () => {
const me = this.loadMorePosts.name;
this.logger.entered(me);
const posts = this.posts;
/** Trigger function for {@link NexusHoratio.web.otrot2}. */
function trigger() {
// The topButton only shows up when the web app detects new posts.
const topButton = document.querySelector(
'button:has([id="arrow-up-small"])'
);
if (topButton?.checkVisibility()) {
topButton.click();
}
}
/** Action function for {@link NexusHoratio.web.otrot2}. */
function action() {
if (posts.item) {
posts.first();
}
}
const what = {
name: `${this.id} ${me}`,
base: document.querySelector('main [data-testid="mainFeed"]'),
};
const how = {
trigger: trigger,
action: action,
duration: 2000,
};
NH.web.otrot2(what, how);
this.logger.leaving(me);
}
);
viewReactions = new Shortcut(
'v r',
'View reactions on current post or comment',
() => {
const el = this.#getItemStatusBar();
NH.web.clickElement(el, ['a:has([role])']);
}
);
viewReposts = new Shortcut(
'v R',
'View reposts of current post',
() => {
const el = this.#getPostStatusBar();
NH.web.clickElement(el, ['a:not(:has([role]))']);
}
);
openMeatballMenu = new Shortcut(
'=',
'Open closest <button>⋯</button> menu',
() => {
const el = this.#getItemHeader();
NH.web.clickElement(el, [':has(> * > svg[id^="overflow"])']);
}
);
likeItem = new Shortcut(
'L',
'Like current post or comment',
() => {
const el = this.#getItemFooter();
NH.web.clickElement(el, [':has(> * > svg[id^="chevron-up"])']);
}
);
commentOnItem = new Shortcut(
'C',
'Comment on current post or comment',
() => {
const el = this.#getItemFooter();
NH.web.clickElement(el, [
// For post
':has(> * > svg[id^="comment"])',
// For comment
':scope > div > button',
]);
}
);
repost = new Shortcut(
'R',
'Repost current post',
() => {
const el = this.#getPostFooter();
NH.web.clickElement(el, [':has(> * > svg[id^="repost"])']);
}
);
sendPost = new Shortcut(
'S',
'Send current post privately',
() => {
const el = this.#getPostFooter();
NH.web.clickElement(el, [':has(> * > svg[id^="send"])']);
}
);
gotoShare = new Shortcut(
'P',
`Go to the share box to start a post or ${this.ctor.#tabSnippet} ` +
'to the other creator options',
() => {
document
.querySelector(`main [data-testid="mainFeed"] a[${CKEY}]`)
.focus();
}
);
toggleItem = new Shortcut(
'X',
'Toggle hiding current item',
async () => {
const me = this.toggleItem.name;
this.logger.entered(me);
const el = this.#lastScroller.item;
const target = await this.#getDismissElement();
/** Trigger function for {@link NexusHoratio.web.otrot}. */
function trigger() {
target.click();
}
if (target) {
const what = {
name: `${this.id} ${me}`,
base: el,
};
const how = {
trigger: trigger,
timeout: 3000,
};
await NH.web.otrot(what, how);
this.#lastScroller.item = el;
}
this.logger.leaving(me);
}
);
nextPostPlus = new Shortcut(
'J',
'Toggle hiding current post, then next post',
async () => {
this.#returnToPost();
await this.toggleItem();
this.nextPost();
}
);
prevPostPlus = new Shortcut(
'K',
'Toggle hiding current post, then previous post',
async () => {
this.#returnToPost();
await this.toggleItem();
this.prevPost();
}
);
static #tabSnippet = VMKeyboardService.parseSeq('tab');
#commentScroller
#lastScroller
#postScroller
#uidCommentRE =
/^(?:replaceableComment_urn:li:comment:\()?(?<body>.*)\)/u;
#uidPostRE = /^(?:expanded|collapsed)?(?<body>.*)FeedType/u;
#initScrollers = () => {
this.#initScrollerStyleService();
this.#initPostScroller();
}
#initScrollerStyleService = () => {
const styleConfig = {
className: this.scrollerClassName,
finder: this.#scrollerFinder,
elementsProcessor: this.elementsHeightProcessor,
};
this.addService(NH.web.StyleService, styleConfig);
}
#initPostScroller = () => {
const what = {
name: `${this.name} posts`,
containerItems: [
{
container: 'main [data-testid="mainFeed"]',
items: [
// Regular items
'[role="listitem"]',
// Dismissed item placeholders
`div[${CKEY}^="collapsed"]`,
].join(','),
},
],
};
const how = {
uidCallback: this.#uniquePostIdentifier,
classes: [
LinkedIn.scrollerPrimaryClassName,
this.scrollerClassName,
],
snapToTop: true,
};
this.#postScroller = new Scroller(what, how);
this.addService(ScrollerService)
.setScroller(this.#postScroller);
this.#postScroller.dispatcher
.on('activate', this.#onPostActivate)
.on('change', this.#onPostChange)
.on('out-of-range', this.spa.details.focusOnSidebar);
this.#lastScroller = this.#postScroller;
}
#initCommentScroller = () => {
const what = {
name: `${this.name} comments`,
base: this.posts.item,
selectors: [
[
// Regular
`[data-component-type] > div > div > [${CKEY}*=":comment:"]`,
].join(','),
],
};
const how = {
uidCallback: this.#uniqueCommentIdentifier,
classes: [
LinkedIn.scrollerSecondaryClassName,
this.scrollerClassName,
],
autoActivate: true,
snapToTop: false,
};
this.#commentScroller = new Scroller(what, how);
this.#commentScroller.dispatcher
.on('change', this.#onCommentChange)
.on('out-of-range', this.#returnToPost);
}
/**
* @method
* @returns {NexusHoratio.web.StyleService~ElementMap} Elements to
* monitor.
*/
#scrollerFinder = () => {
const me = this.#scrollerFinder.name;
this.logger.entered(me);
const elements = new Map();
// We want the element with the listener, which happens to be the one
// with the "aria-expanded".
elements.set('chevron', document.querySelector('#chevron-down-medium')
?.closest('[aria-expanded]'));
this.logger.leaving(me, elements);
return elements;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniquePostIdentifier = (scroller, element) => {
const me = this.#uniquePostIdentifier.name;
this.logger.entered(me, element);
let content = '';
const key = LinkedIn.ckeyIdentifier(element);
const groups = this.#uidPostRE.exec(key)?.groups;
if (key) {
content = key;
}
if (groups) {
content = groups.body;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueCommentIdentifier = (scroller, element) => {
const me = this.#uniqueCommentIdentifier.name;
this.logger.entered(me, element);
let content = '';
const key = LinkedIn.ckeyIdentifier(element);
const groups = this.#uidCommentRE.exec(key)?.groups;
if (key) {
content = key;
}
if (groups) {
content = groups.body;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
/**
* @method
* @returns {external:Element} Header container for current post.
*/
#getPostHeader = () => {
const me = this.#getPostHeader.name;
this.logger.entered(me);
const el = this.posts.item?.querySelector([
// Regular
'h2 + div',
// Dismissed
'div:has(+ hr)',
].join(','));
this.logger.leaving(me, el);
return el;
}
/**
* @method
* @returns {external:Element} Header container for current comment.
*/
#getCommentHeader = () => {
const me = this.#getCommentHeader.name;
this.logger.entered(me);
const el = this.comments?.item
?.querySelector('div:has(> div ~ button)');
this.logger.leaving(me, el);
return el;
}
/**
* @method
* @returns {external:Element} Header container for current item.
*/
#getItemHeader = () => {
const me = this.#getItemHeader.name;
this.logger.entered(me);
const el = this.#getCommentHeader() || this.#getPostHeader();
this.logger.leaving(me, el);
return el;
}
/**
* @method
* @returns {external:Element} Footer container for current post.
*/
#getPostFooter = () => {
const me = this.#getPostFooter.name;
this.logger.entered(me);
const el = this.posts.item
?.querySelector('div:has(> h2) > div:last-of-type');
this.logger.leaving(me, el);
return el;
}
/**
* @method
* @returns {external:Element} Footer container for current comment.
*/
#getCommentFooter = () => {
const me = this.#getCommentFooter.name;
this.logger.entered(me);
// Comment Footer and StatusBar use the same query.
const el = this.comments?.item
?.querySelector('div:has(> hr)');
this.logger.leaving(me, el);
return el;
}
/**
* @method
* @returns {external:Element} Footer container for current item.
*/
#getItemFooter = () => {
const me = this.#getItemFooter.name;
this.logger.entered(me);
const el = this.#getCommentFooter() || this.#getPostFooter();
this.logger.leaving(me, el);
return el;
}
/**
* @method
* @returns {external:Element} StatusBar container for current post.
*/
#getPostStatusBar = () => {
const me = this.#getPostStatusBar.name;
this.logger.entered(me);
const el = this.posts.item
?.querySelector('div:has(> a [role="presentation"])');
this.logger.leaving(me, el);
return el;
}
/**
* @method
* @returns {external:Element} StatusBar container for current comment.
*/
#getCommentStatusBar = () => {
const me = this.#getCommentStatusBar.name;
this.logger.entered(me);
// Comment Footer and StatusBar use the same query.
const el = this.comments?.item
?.querySelector('div:has(> hr)');
this.logger.leaving(me, el);
return el;
}
/**
* @method
* @returns {external:Element} StatusBar container for current item.
*/
#getItemStatusBar = () => {
const me = this.#getItemStatusBar.name;
this.logger.entered(me);
const el = this.#getCommentStatusBar() || this.#getPostStatusBar();
this.logger.leaving(me, el);
return el;
}
/**
* Find the correct element to dismiss the current item.
*
* Comments and ads require invoking a popup menu (portal).
*
* @method
* @returns {external:Element} The element to click.
*/
#getDismissElement = async () => { // eslint-disable-line max-lines-per-function, max-statements
const me = this.#getDismissElement.name;
this.logger.entered(me, this.#lastScroller.item);
let el = null;
if (this.#lastScroller.item) {
const portalSelector = '[data-floating-ui-portal]';
const timeout = 2000;
if (document.querySelector(portalSelector)) {
document.dispatchEvent(
new KeyboardEvent('keydown', {key: 'Escape'})
);
await this.#waitForSelectorToBeGone(portalSelector, timeout);
}
// If the current item is a regular post, this will match.
let selector = [
// Visible
':has(> * > svg[id^="close"])',
// Dismissed
'button:has(> span > span)',
].join(',');
let header = this.#getItemHeader();
el = header?.querySelector(selector);
if (!el) {
// Some items need to trigger a popup menu.
this.openMeatballMenu();
try {
// The menus take a while to populate. Even though known types of
// menus look to have the same "cancelled eye" icon, they are
// currently brought in differently. One menu type uses one named
// "visibility-off-*" while another menu has no id. Interestingly
// enough, the one without an id is the only svg icon without a
// name so, :not([id]) is used for finding it proper, while a
// sibling named "signal" is used waiting for the menu to settle.
header = await NH.web.waitForSelector([
portalSelector,
':has(svg[id^="visibility-off"], svg[id^="signal"])',
].join(''), timeout);
selector = [
':has(> * > svg[id^="visibility-off"])',
':has(> * > svg:not([id])',
].join(',');
} catch (e) {
// If the menu was slow, use a simpler selector.
selector = 'svg:not([id])';
}
el = header?.querySelector(selector);
}
}
this.logger.leaving(me, el);
return el;
}
/**
* Wait for matching selector to disappear.
*
* This could probably be rolled into {@link
* NexusHoratio.web.waitForSelector}.
*
* @method
* @param {string} selector - CSS selector.
* @param {number} [timeout=0] - Time to wait in milliseconds, 0 disables.
* @returns {Promise<NexusHoratio.web~Results>} Basically, something to
* await on.
*/
#waitForSelectorToBeGone = (selector, timeout = 0) => {
/**
* @implements {Monitor}
* @returns {Continuation} Indicate whether done monitoring.
*/
const monitor = () => {
const element = document.querySelector(selector);
if (element) {
this.logger.log(`match for ${selector}`, element);
return {done: false};
}
this.logger.log('And gone');
return {done: true};
};
const what = {
name: this.#waitForSelectorToBeGone.name,
base: document,
};
const how = {
observeOptions: {childList: true, subtree: true},
monitor: monitor,
timeout: timeout,
};
return NH.web.otmot(what, how);
}
#onPostActivate = () => {
const me = this.#onPostActivate.name;
this.logger.entered(me);
/**
* Wait for the post to be reloaded.
*
* @implements {NexusHoratio.web.Monitor}
* @returns {NexusHoratio.web.Continuation} Indicate whether done
* monitoring.
*/
const monitor = () => {
this.logger.log('monitor item classes:', this.posts.item.classList);
return {
done: !this.posts.item.classList.contains('has-occluded-height'),
};
};
if (this.posts.item) {
const what = {
name: `${this.id} ${me}`,
base: this.posts.item,
};
const how = {
observeOptions: {
attributeFilter: ['class'],
attributes: true,
},
monitor: monitor,
timeout: 5000,
};
NH.web.otmot(what, how)
.finally(() => {
this.posts.shine();
this.posts.show();
});
}
this.logger.leaving(me);
}
/**
* Reset the comment scroller.
*
* @method
*/
#resetComments = () => {
if (this.#commentScroller) {
this.#commentScroller.destroy();
this.#commentScroller = null;
}
this.comments;
}
#onCommentChange = () => {
this.#lastScroller = this.comments;
}
/**
* Reselects current post, triggering same actions as initial selection.
*
* @method
*/
#returnToPost = () => {
this.posts.item = this.posts.item;
}
/**
* Resets the comments {@link Scroller}.
*
* @method
*/
#onPostChange = () => {
const me = this.#onPostChange.name;
this.logger.entered(me, this.posts.item);
this.#resetComments();
this.#lastScroller = this.posts;
this.logger.leaving(me);
}
}
/**
* Class for handling the MyNetwork page.
*
* This page takes 3-4 seconds to load every time. Revisits are
* likely to take a while.
*
* @extends module:linkedin-tool~Page
*/
class MyNetwork extends Page {
/**
* @param {NexusHoratio.spa.SPA} spa - SPA instance that manages this
* {@link module:linkedin-tool~Page Page}.
*/
constructor(spa) {
super({
spa: spa,
name: 'My Network (Grow, Catch up)',
// eslint-disable-next-line prefer-regex-literals
pathname: RegExp('^/mynetwork(?:/(?:grow/|catch-up/.*)|$)', 'u'),
readySelector: '#linkedin-logo-xxsmall',
});
this.addService(LinkedInStyleService)
.addStyles(LinkedIn.Style.TWO);
this.addService(VMKeyboardService)
.setShortName(this.name)
.addInstance(this);
this.#initScrollers();
}
/** @type {Scroller} */
get collections() {
return this.#collectionScroller;
}
/** @type {Scroller} */
get individuals() {
if (!this.#individualScroller && this.collections.item) {
this.#initIndividualScroller();
}
return this.#individualScroller;
}
nextCollection = new Shortcut(
'j',
'Next collection card',
() => {
this.collections.next();
}
);
prevCollection = new Shortcut(
'k',
'Previous collection card',
() => {
this.collections.prev();
}
);
nextIndividual = new Shortcut(
'n',
'Next individual item in collection',
() => {
this.individuals?.next();
}
);
prevIndividual = new Shortcut(
'p',
'Previous individual item in collection',
() => {
this.individuals?.prev();
}
);
firstItem = new Shortcut(
'<',
'Go to the first collection card or individual item',
() => {
this.#lastScroller.first();
}
);
lastItem = new Shortcut(
'>',
'Go to the last collection card or individual item',
() => {
this.#lastScroller.last();
}
);
focusBrowser = new Shortcut(
'f',
'Change browser focus to current card/item',
() => {
this.#lastScroller.focus();
}
);
openMeatballMenu = new Shortcut(
'=',
'Open closest <button>⋯</button> menu',
() => {
const el = this.#lastScroller?.item;
NH.web.clickElement(el, [
// Catch up items
':has(> * > svg[id^="overflow"])',
]);
}
);
tabList = new Shortcut(
'l',
'Focus on Manage invitations tab list',
() => {
const el = document.querySelector(
`${MyNetwork.#tablistSelector} [aria-current]`
);
el.scrollIntoView(false);
NH.web.focusOnElement(el);
}
);
engageIndividual = new Shortcut(
'E',
'Engage the individual (Connect, Follow, Join, Message, etc)',
() => {
const el = this.individuals?.item;
NH.web.clickElement(el, [
// Connect
':has(> * > svg[id^="connect"])',
// Withdraw pending
':has(> * > svg[id^="clock"])',
// Follow
':has(> * > svg[id^="add-"])',
// Unfollow
':has(> * > svg[id^="check"])',
// Catch up Message
':has(> * > svg[id^="send"])',
]);
}
);
likeItem = new Shortcut(
'L',
'Like current item',
() => {
const el = this.#lastScroller.item;
NH.web.clickElement(el, [':has(> * > svg[id^="chevron-up"])']);
}
);
commentOnItem = new Shortcut(
'C',
'Comment on current item',
() => {
const el = this.#lastScroller.item;
NH.web.clickElement(el, [':has(> * > svg[id^="comment"])']);
}
);
dismissIndividual = new Shortcut(
'X',
'Dismiss current item',
() => {
const el = this.individuals?.item;
NH.web.clickElement(el, [
// Most items
':has(> * > svg[id^="close"]',
]);
}
);
static #tablistSelector = `${LinkedIn.primaryContentSelector} nav`;
#collectionScroller
#individualScroller
#lastScroller
#initScrollers = () => {
this.#initScrollerStyleService();
this.#initCollectionScroller();
}
#initScrollerStyleService = () => {
const styleConfig = {
className: this.scrollerClassName,
finder: this.#scrollerFinder,
elementsProcessor: this.elementsHeightProcessor,
};
this.addService(NH.web.StyleService, styleConfig);
}
#initCollectionScroller = () => {
const what = {
name: `${this.name} cards`,
containerItems: [
{
container: LinkedIn.primaryContentSelector,
items: [
// Most "Grow" cards
':scope > div > div > div > div > section',
':scope > div > div > div > div > div > section',
// "Catch up"
':scope > div > div > section',
].join(','),
},
],
};
const how = {
uidCallback: this.#uniqueCollectionIdentifier,
classes: [
LinkedIn.scrollerPrimaryClassName,
this.scrollerClassName,
],
snapToTop: true,
};
this.#collectionScroller = new Scroller(what, how);
this.addService(ScrollerService)
.setScroller(this.#collectionScroller);
this.#collectionScroller.dispatcher
.on('change', this.#onCollectionChange)
.on('out-of-range', this.spa.details.focusOnSidebar);
this.#lastScroller = this.#collectionScroller;
}
#initIndividualScroller = () => {
const what = {
name: `${this.name} individual`,
base: this.collections.item,
selectors: [
[
// Carousel cards (different variations)
'[data-testid="carousel-child-container"] > div > a',
'[data-testid="carousel-child-container"] > div:has(> div)',
// Most cards with followable entities in them
'[role="listitem"]',
].join(','),
],
};
const how = {
uidCallback: this.#uniqueIndividualsIdentifier,
classes: [
LinkedIn.scrollerSecondaryClassName,
this.scrollerClassName,
],
autoActivate: true,
snapToTop: false,
clickConfig: {
selectorArray: ['a', 'button'],
matchSelf: true,
},
};
this.#individualScroller = new Scroller(what, how);
this.#individualScroller.dispatcher
.on('change', this.#onIndividualChange)
.on('focus', this.#onIndividualFocus)
.on('out-of-range', this.#returnToCollection);
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueCollectionIdentifier = (scroller, element) => {
const me = this.#uniqueCollectionIdentifier.name;
this.logger.entered(me, element);
let content = '';
const key = LinkedIn.ckeyIdentifier(element);
const childKey = LinkedIn.ckeyIdentifier(
element.querySelector(`[${CKEY}]`)
);
if (childKey) {
content = childKey;
}
if (key) {
content = key;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueIndividualsIdentifier = (scroller, element) => {
const me = this.#uniqueIndividualsIdentifier.name;
this.logger.entered(me, element);
let content = '';
const key = LinkedIn.ckeyIdentifier(element);
const childKey = LinkedIn.ckeyIdentifier(
element.querySelector(`[${CKEY}]`)
);
if (childKey) {
content = childKey;
}
if (key) {
content = key;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
/**
* @method
* @returns {NexusHoratio.web.StyleService~ElementMap} Elements to
* monitor.
*/
#scrollerFinder = () => {
const me = this.#scrollerFinder.name;
this.logger.entered(me);
const elements = new Map();
elements.set('tablist',
document.querySelector(this.ctor.#tablistSelector));
this.logger.leaving(me, elements);
return elements;
}
#resetIndividuals = () => {
if (this.#individualScroller) {
this.#individualScroller.destroy();
this.#individualScroller = null;
}
this.individuals;
}
#onIndividualChange = () => {
this.#lastScroller = this.individuals;
}
#onIndividualFocus = () => {
this.collections.show();
}
#onCollectionChange = () => {
const me = this.#onCollectionChange.name;
this.logger.entered(me);
this.#resetIndividuals();
this.#lastScroller = this.collections;
this.logger.leaving(me, this.collections.item);
}
#returnToCollection = () => {
this.collections.item = this.collections.item;
}
}
/**
* Class for handling Invitation Manager.
*
* While this page does have multiple sections (Manage Invitations and
* Suggestions for you), the latter is enclosed by the former. There is no
* way to highlight the former without including the latter. So just treat
* the page as one big long list.
*
* @extends module:linkedin-tool~Page
*/
class InvitationManager extends Page {
/**
* @param {NexusHoratio.spa.SPA} spa - SPA instance that manages this
* {@link module:linkedin-tool~Page Page}.
*/
constructor(spa) {
super({
spa: spa,
// eslint-disable-next-line prefer-regex-literals
pathname: RegExp('^/mynetwork/invitation-manager/.*', 'u'),
readySelector: '#linkedin-logo-xxsmall',
});
this.addService(LinkedInStyleService)
.addStyles(LinkedIn.Style.TWO);
this.addService(VMKeyboardService)
.addInstance(this);
this.#initScrollers();
}
/** @type {Scroller} */
get invites() {
return this.#inviteScroller;
}
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',
() => {
this.invites.focus();
}
);
showMore = new Shortcut(
'm',
'Show more of current invite',
() => {
const el = this.invites.item;
NH.web.clickElement(el, ['button[data-testid]']);
}
);
viewInviter = new Shortcut(
'i',
'View invite principal',
() => {
const el = this.invites.item;
NH.web.clickElement(el, [
// Most invites
':scope [role="listitem"] a:has(+ span)',
// Suggestions for you
'a',
]);
}
);
viewTarget = new Shortcut(
't',
'View invitation target ' +
'(may not be the same as inviter, e.g., Newsletter)',
() => {
const el = this.invites.item;
if (el.tagName === 'A') {
el.click();
} else {
NH.web.clickElement(el, [':scope [role="listitem"] > * > a']);
}
}
);
tabList = new Shortcut(
'l',
'Focus on Manage invitations tab list',
() => {
const el = document.querySelector('main nav [aria-current]');
el.scrollIntoView(false);
NH.web.focusOnElement(el);
}
);
acceptInvite = new Shortcut(
'A',
'Accept invite',
() => {
const el = this.invites.item;
NH.web.clickElement(el, ['[aria-label^="Accept"]']);
}
);
ignoreInvite = new Shortcut(
'I',
'Ignore invite',
() => {
const el = this.invites.item;
NH.web.clickElement(el, ['[aria-label^="Ignore"]']);
}
);
connectSuggestion = new Shortcut(
'C',
'Connect with suggestion',
() => {
const el = this.invites.item;
this.logger.log('el', el);
NH.web.clickElement(el, ['[aria-label^="Invite"]']);
}
);
messageInviter = new Shortcut(
'M',
'Message inviter',
() => {
const el = this.invites.item;
NH.web.clickElement(el, ['a[href*="/compose/"]']);
}
);
withDraw = new Shortcut(
'W',
'Withdraw invitation',
() => {
const el = this.invites.item;
const rc = NH.web.clickElement(el, [
// Suggestions for you
'[aria-label^="Pending"]',
]);
if (!rc) {
// Sent tab
const elements = Array.from(el?.querySelectorAll('a') || [])
.filter(x => x.innerText === 'Withdraw');
if (elements.length === NH.base.ONE_ITEM) {
elements[0].click();
}
}
}
);
dismissInvite = new Shortcut(
'X',
'Dismiss invitation (after accepting or ignoring)',
() => {
const el = this.invites.item;
NH.web.clickElement(el, [':has(> * > svg[id^="close"]']);
}
);
#inviteScroller
#initScrollers = () => {
this.#initScrollerStyleService();
this.#initInviteScroller();
}
#initScrollerStyleService = () => {
const styleConfig = {
className: this.scrollerClassName,
finder: this.#scrollerFinder,
elementsProcessor: this.elementsHeightProcessor,
};
this.addService(NH.web.StyleService, styleConfig);
}
#initInviteScroller = () => {
const what = {
name: `${this.name} cards`,
containerItems: [
{
container: `${LinkedIn.primaryContentSelector}` +
' [data-testid="lazy-column"]',
items: [
// Standard invites
`:scope > div[${CKEY}]`,
// Suggestions for you
'h3 ~ div > a',
].join(','),
},
],
};
const how = {
uidCallback: this.#uniqueInvitationIdentifier,
classes: [
LinkedIn.scrollerPrimaryClassName,
this.scrollerClassName,
],
snapToTop: true,
};
this.#inviteScroller = new Scroller(what, how);
this.addService(ScrollerService)
.setScroller(this.#inviteScroller);
this.#inviteScroller.dispatcher
.on('out-of-range', this.spa.details.focusOnAside);
}
/**
* @method
* @returns {NexusHoratio.web.StyleService~ElementMap} Elements to
* monitor.
*/
#scrollerFinder = () => {
const me = this.#scrollerFinder.name;
this.logger.entered(me);
const elements = new Map();
elements.set('nav',
document.querySelector('#workspace [data-sdui-screen] nav'));
this.logger.leaving(me, elements);
return elements;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueInvitationIdentifier = (scroller, element) => {
const me = this.#uniqueInvitationIdentifier.name;
this.logger.entered(me, element);
let content = '';
const key = LinkedIn.ckeyIdentifier(element);
if (key) {
content = key;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
}
/**
* 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.
*
* @extends module:linkedin-tool~Page
*/
class Jobs extends Page {
/**
* @param {NexusHoratio.spa.SPA} spa - SPA instance that manages this
* {@link module:linkedin-tool~Page Page}.
*/
constructor(spa) {
super({
spa: spa,
pathname: '/jobs/',
readySelector: '#linkedin-logo-xxsmall',
});
this.addService(LinkedInStyleService)
.addStyles(LinkedIn.Style.TWO);
this.addService(VMKeyboardService)
.addInstance(this);
this.#initScrollers();
}
/** @type {Scroller} */
get jobs() {
if (!this.#jobScroller && this.sections.item) {
this.#initJobScroller();
}
return this.#jobScroller;
}
/** @type {Scroller} */
get sections() {
return this.#sectionScroller;
}
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();
}
);
firstItem = new Shortcut(
'<',
'Go to to first section or job',
() => {
this.#lastScroller.first();
}
);
lastItem = new Shortcut(
'>',
'Go to last section or job currently loaded',
() => {
this.#lastScroller.last();
}
);
focusBrowser = new Shortcut(
'f',
'Change browser focus to current section or job',
() => {
this.#lastScroller.focus();
}
);
activateItem = new Shortcut(
'Enter',
'Activate the current item (click on it)',
() => {
this.jobs?.click();
}
);
openMeatballMenu = new Shortcut(
'=',
'Open closest <button>⋯</button> menu',
() => {
const el = this.jobs?.item;
NH.web.clickElement(el, [':has(> * > svg[id^="overflow"])']);
}
);
loadMoreSections = new Shortcut(
'l',
'Load more sections',
() => {
const base = document.querySelector(this.#sectionsContainer);
NH.web.clickElement(base,
[':scope > div:last-of-type > button']);
}
);
toggleDismissJob = new Shortcut(
'X',
'Toggle dismissing job',
() => {
const el = this.jobs?.item;
NH.web.clickElement(el, [
':has(> * > svg[id^="close"]',
':has(> * > svg[id^="undo-"]',
]);
}
);
#jobScroller
#lastScroller
#sectionScroller
#sectionsContainer =
'[data-testid="JobsHomeFeedModuleListCollection"]';
#initScrollers = () => {
this.#initScrollerStyleService();
this.#initSectionScroller();
}
#initScrollerStyleService = () => {
const styleConfig = {
className: this.scrollerClassName,
finder: this.#scrollerFinder,
elementsProcessor: this.#scrollerElementsProcessor,
};
this.addService(NH.web.StyleService, styleConfig);
}
#initSectionScroller = () => {
const what = {
name: `${this.name} sections`,
containerItems: [
{
container: this.#sectionsContainer,
items: [
// Premium "top applicant"
`:scope > [${CKEY}^="Jobs"] > * > [${CKEY}^="Jobs"]`,
// Everything else
`:scope > div > div[${CKEY}]`,
].join(','),
},
],
};
const how = {
uidCallback: this.#uniqueSectionIdentifier,
classes: [
LinkedIn.scrollerPrimaryClassName,
this.scrollerClassName,
],
snapToTop: true,
};
this.#sectionScroller = new Scroller(what, how);
this.addService(ScrollerService)
.setScroller(this.#sectionScroller);
this.#sectionScroller.dispatcher
.on('change', this.#onSectionChange)
.on('out-of-range', this.spa.details.focusOnSidebar);
this.#lastScroller = this.#sectionScroller;
}
#initJobScroller = () => {
const what = {
name: `${this.name} entries`,
base: this.sections.item,
selectors: [
[
// Match your profile - Show all button
':scope > * > a',
// Most job entries
':scope > * > * > a',
// Carousels
'[data-testid="carousel-child-container"] a',
// Job collections tabs
'[role="button"]',
// Job collections entries
`[${CKEY}^="JobsHomeModuleTabbed"] > * > a`,
`[${CKEY}^="JobsHomeModuleTabbed"] > * > * > a`,
].join(','),
],
};
const how = {
uidCallback: this.#uniqueJobIdentifier,
classes: [
LinkedIn.scrollerSecondaryClassName,
this.scrollerClassName,
],
autoActivate: true,
snapToTop: false,
clickConfig: {
selectorArray: ['[role="button"]', 'a', 'button'],
matchSelf: true,
},
};
this.#jobScroller = new Scroller(what, how);
this.#jobScroller.dispatcher
.on('change', this.#onJobChange)
.on('out-of-range', this.#returnToSection);
}
/**
* @method
* @returns {NexusHoratio.web.StyleService~ElementMap} Elements to
* monitor.
*/
#scrollerFinder = () => {
const me = this.#scrollerFinder.name;
this.logger.entered(me);
const elements = new Map();
elements.set('div', document.querySelector('main > div'));
this.logger.leaving(me, elements);
return elements;
}
/**
* @method
* @implements {NexusHoratio.web.StyleService~ElementsProcessor}
* @param {NexusHoratio.web.StyleService~ElementMap} elements - Elements
* to examine.
* @returns {NexusHoratio.web.StyleService~StyleProperties} Style
* properties for to contribute.
*/
#scrollerElementsProcessor = (elements) => {
const me = this.#scrollerElementsProcessor.name;
this.logger.entered(me, elements);
const properties = new Map();
for (const [key, value] of elements.entries()) {
switch (key) {
case 'div':
if (value) {
const style = getComputedStyle(value);
properties.set('scroll-margin-top', style.marginTop);
}
break;
default:
NH.base.issues.post(
this.name, me, 'Unsupported element key:', key
);
}
}
this.logger.leaving(me, properties);
return properties;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueJobIdentifier = (scroller, element) => {
const me = this.#uniqueJobIdentifier.name;
this.logger.entered(me, element);
let content = '';
const key = LinkedIn.ckeyIdentifier(element);
if (key) {
content = key;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueSectionIdentifier = (scroller, element) => {
const me = this.#uniqueSectionIdentifier.name;
this.logger.entered(me, element);
let content = '';
const key = LinkedIn.ckeyIdentifier(element);
if (key) {
content = key;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
#resetJobs = () => {
const me = this.#resetJobs.name;
this.logger.entered(me, this.#jobScroller);
if (this.#jobScroller) {
this.#jobScroller.destroy();
this.#jobScroller = null;
}
this.jobs;
this.logger.leaving(me);
}
/**
* Reselects current section, triggering some actions as initial
* selection.
*
* @method
*/
#returnToSection = () => {
this.sections.item = this.sections.item;
}
#onJobChange = () => {
this.#lastScroller = this.jobs;
}
/**
* Updates {@link Jobs} specific watcher data and removes the jobs
* {@link Scroller}.
*
* @method
*/
#onSectionChange = () => {
const me = this.#onSectionChange.name;
this.logger.entered(me);
this.#resetJobs();
this.#lastScroller = this.sections;
this.logger.leaving(me);
}
/**
* Recover scroll position after elements were recreated.
*
* @method
* @param {number} topScroll - Where to scroll to.
*/
#resetScroll = (topScroll) => {
const me = this.#resetScroll.name;
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);
}
}
/**
* Class for handling Jobs collections.
*
* @extends module:linkedin-tool~Page
*/
class JobsCollections extends Page {
/**
* @param {NexusHoratio.spa.SPA} spa - SPA instance that manages this
* {@link module:linkedin-tool~Page Page}.
*/
constructor(spa) {
super({
spa: spa,
name: 'Jobs Collections (various listings)',
// eslint-disable-next-line prefer-regex-literals
pathname: RegExp('^/jobs/(?:collections|search)/.*', 'u'),
readySelector: 'footer.global-footer-compact',
});
this.addService(LinkedInStyleService)
.addStyles(LinkedIn.Style.ONE);
this.addService(VMKeyboardService)
.setShortName(this.name)
.addInstance(this);
this.#initScrollers();
}
/** @type {Scroller} */
get cards() {
return this.#cardsScroller;
}
/** @type {Scroller} */
get details() {
return this.#detailsScroller;
}
/** @type {Scroller} */
get paginator() {
return this.#paginationScroller;
}
nextJob = new Shortcut(
'j',
'Next job card',
() => {
this.cards.next();
}
);
prevJob = new Shortcut(
'k',
'Previous job card',
() => {
this.cards.prev();
}
);
nextDetail = new Shortcut(
'n',
'Next job detail',
() => {
this.details.next();
}
);
prevDetail = new Shortcut(
'p',
'Previous job detail',
() => {
this.details.prev();
}
);
nextResultsPage = new Shortcut(
'N',
'Next results page',
() => {
this.paginator.next();
}
);
prevResultsPage = new Shortcut(
'P',
'Previous results page',
() => {
this.paginator.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',
() => {
this.#lastScroller.focus();
}
);
detailsPane = new Shortcut(
'd',
'Move browser focus to the details pane',
() => {
NH.web.focusOnTree(document.querySelector(
'div.jobs-details__main-content'
));
}
);
tabList = new Shortcut(
'l',
'Focus on discovery tab list (has native scrolling using arrows)',
() => {
const el = document.querySelector(
'.jobs-search-discovery-tabs nav [aria-current="true"]'
);
el.focus();
}
);
openShareMenu = new Shortcut(
's',
'Open share menu',
() => {
NH.web.clickElement(document, ['.social-share button']);
}
);
openMeatballMenu = new Shortcut(
'=',
'Open the <button>⋯</button> menu',
() => {
// XXX: There are TWO buttons. The *second* one is hidden until the
// user scrolls down. This always triggers the first one.
NH.web.clickElement(document, ['.jobs-options button']);
}
);
applyToJob = new Shortcut(
'A',
'Apply to job (or previous application)',
() => {
NH.web.clickElement(document, [
// Apply and Easy Apply buttons
'#jobs-apply-button-id',
// See application link
'a[href^="/jobs/tracker"]',
]);
}
);
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.
NH.web.clickElement(document, ['button.jobs-save-button']);
}
);
toggleDismissJob = new Shortcut(
'X',
'Toggle dismissing job, if available',
() => {
NH.web.clickElement(this.cards.item, ['button']);
}
);
nextJobPlus = new Shortcut(
'J',
'Toggle dismissing then next job card',
() => {
this.toggleDismissJob();
this.nextJob();
}
);
prevJobPlus = new Shortcut(
'K',
'Toggle dismissing then previous job card',
() => {
this.toggleDismissJob();
this.prevJob();
}
);
toggleFollowCompany = new Shortcut(
'F', 'Toggle following company', () => {
NH.web.clickElement(document, ['button.follow']);
}
);
toggleAlert = new Shortcut(
'L', 'Toggle the job search aLert, if available', () => {
NH.web.clickElement(document,
['main .jobs-search-create-alert__artdeco-toggle']);
}
);
#cardsScroller
#detailsContainerClassName
#detailsScroller
#lastScroller
#paginationScroller
#uidDetailsClassRE = /^(?:job-details|jobs)-(?<class>[^_]*)__/u;
#initScrollers = () => {
this.#initScrollerStyleService();
this.#initCardsScroller();
this.#initPaginationScroller();
this.#initDetailsScroller();
}
#initScrollerStyleService = () => {
this.#detailsContainerClassName = this.cssClassName(
['details', 'container']
);
const styleConfig = {
className: this.#detailsContainerClassName,
finder: this.#detailsFinder,
elementsProcessor: this.#detailsElementsProcessor,
events: ['transitionend'],
};
this.addService(NH.web.StyleService, styleConfig);
}
#initCardsScroller = () => {
const what = {
name: `${this.name} cards`,
containerItems: [
{
container: 'div.scaffold-layout__list > div > ul',
// This selector is also used in #onCardActivate.
items: ':scope > li',
},
],
};
const how = {
uidCallback: this.#uniqueJobIdentifier,
classes: [LinkedIn.scrollerPrimaryClassName],
snapToTop: true,
};
this.#cardsScroller = new Scroller(what, how);
this.addService(ScrollerService)
.setScroller(this.#cardsScroller);
this.#cardsScroller.dispatcher
.on('activate', this.#onCardActivate)
.on('change', this.#onCardChange);
this.#lastScroller = this.#cardsScroller;
}
#initPaginationScroller = () => {
const what = {
name: `${this.name} pagination`,
containerItems: [
{
container: 'div.jobs-search-results-list__pagination > ul',
// This selector is also used in #onPaginationActivate.
items: ':scope > li > button',
},
],
};
const how = {
uidCallback: this.#uniquePaginationIdentifier,
classes: [LinkedIn.scrollerSecondaryClassName],
snapToTop: false,
containerTimeout: 1000,
observeAttributes: true,
};
this.#paginationScroller = new Scroller(what, how);
this.addService(ScrollerService)
.setScroller(this.#paginationScroller);
this.#paginationScroller.dispatcher
.on('activate', this.#onPaginationActivate)
.on('change', this.#onPaginationChange);
}
#initDetailsScroller = () => {
const what = {
name: `${this.name} details`,
containerItems: [
{
container: 'div.jobs-details__main-content',
items: ':scope > div, :scope > section',
},
],
};
const how = {
uidCallback: this.#uniqueDetailsIdentifier,
classes: [LinkedIn.scrollerSecondaryClassName],
snapToTop: true,
};
this.#detailsScroller = new Scroller(what, how);
this.addService(ScrollerService)
.setScroller(this.#detailsScroller);
this.#detailsScroller.dispatcher
.on('change', this.#onDetailsChange);
}
/**
* @method
* @implements {NexusHoratio.web.StyleService~ElementsProcessor}
* @param {NexusHoratio.web.StyleService~ElementMap} elements - Elements
* to examine.
* @returns {NexusHoratio.web.StyleService~StyleProperties} Style
* properties for to contribute.
*/
#detailsElementsProcessor = (elements) => {
const me = this.#detailsElementsProcessor.name;
this.logger.entered(me, elements);
let height = 0;
let padding = '0px';
const properties = new Map();
for (const [key, value] of elements.entries()) {
switch (key) {
case 'container':
if (value) {
value.classList.add(this.#detailsContainerClassName);
padding = getComputedStyle(value).paddingTop;
}
break;
case 'header':
if (value) {
const header = getComputedStyle(value);
if (header.visibility === 'visible') {
height = value.offsetHeight;
}
}
break;
default:
NH.base.issues.post(
this.name, me, 'Unsupported element key:', key
);
}
}
properties.set('scroll-padding-top', `calc(${padding} + ${height}px)`);
this.logger.leaving(me, properties);
return properties;
}
/**
* @method
* @returns {NexusHoratio.web.StyleService~ElementMap} Elements to
* monitor.
*/
#detailsFinder = () => {
const me = this.#detailsFinder.name;
this.logger.entered(me);
const elements = new Map();
elements.set('container', document.querySelector(
'.jobs-search__job-details--wrapper'
));
elements.set('header', document.querySelector(
'.job-details-jobs-unified-top-card__sticky-header'
));
this.logger.leaving(me, elements);
return elements;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueDetailsIdentifier = (scroller, element) => {
const me = this.#uniqueDetailsIdentifier.name;
this.logger.entered(me, element);
let content = '';
const id = element.id;
const nestedId = element.querySelector(
'[id]:not([id^="ember"]):not([id^="artdeco"])'
)?.id;
const h2 = LinkedIn.h2(element);
const classes = new Set(
element.querySelectorAll('*:not(h2,svg)')
.values()
.map(x => [...x.classList])
.toArray()
.flat()
.sort()
);
const klass = new Set(
classes
.values()
.map(x => this.#uidDetailsClassRE.exec(x)?.groups.class)
.filter(x => x)
)
.values()
.toArray()
.sort()
.join('-_-');
if (h2) {
content = h2;
}
if (klass) {
content = klass;
}
if (nestedId) {
content = nestedId;
}
if (id) {
content = id;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueJobIdentifier = (scroller, element) => {
const me = this.#uniqueJobIdentifier.name;
this.logger.entered(me, element);
let content = '';
const jobId = element.dataset.occludableJobId;
if (jobId) {
content = jobId;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniquePaginationIdentifier = (scroller, element) => {
const me = this.#uniquePaginationIdentifier.name;
this.logger.entered(me, element);
let content = '';
const label = element.getAttribute('aria-label');
if (label) {
content = label;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
#onCardActivate = async () => {
const me = this.#onCardActivate.name;
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.
try {
const timeout = 2000;
const item = await NH.web.waitForSelector(
`li[data-occludable-job-id="${jobId}"]`,
timeout
);
this.logger.log('Found', item);
this.cards.gotoUid(this.#uniqueJobIdentifier(item));
this.logger.log('and went to it');
} catch (e) {
this.logger.log('Job card matching URL not found, staying put');
}
this.logger.leaving(me);
}
#onCardChange = () => {
const me = this.#onCardChange.name;
this.logger.entered(me, this.cards.item);
NH.web.clickElement(this.cards.item, ['div[data-job-id]']);
this.details.first();
this.#lastScroller = this.cards;
this.logger.leaving(me);
}
#onPaginationActivate = async () => {
const me = this.#onPaginationActivate.name;
this.logger.entered(me);
try {
const timeout = 2000;
const item = await NH.web.waitForSelector(
'div.jobs-search-results-list__pagination > ul [aria-current]',
timeout
);
this.paginator.goto(item);
} catch (e) {
this.logger.log('Results paginator not found, staying put');
}
this.logger.leaving(me);
}
#onPaginationChange = () => {
this.#lastScroller = this.paginator;
}
#onDetailsChange = () => {
this.#lastScroller = this.details;
}
}
/**
* Class for handling the direct Jobs view.
*
* @extends module:linkedin-tool~Page
*/
class JobsView extends Page {
/**
* @param {NexusHoratio.spa.SPA} spa - SPA instance that manages this
* {@link module:linkedin-tool~Page Page}.
*/
constructor(spa) {
super({
spa: spa,
// eslint-disable-next-line prefer-regex-literals
pathname: RegExp('^/jobs/view/\\d+.*', 'u'),
readySelector: '[data-sdui-component]',
});
this.addService(LinkedInStyleService)
.addStyles(LinkedIn.Style.TWO);
this.addService(VMKeyboardService)
.addInstance(this);
this.#initScrollers();
}
/** @type {Scroller} */
get cards() {
return this.#cardScroller;
}
/** @type {Scroller} */
get entries() {
if (!this.#entryScroller && this.cards.item) {
this.#initEntryScroller();
}
return this.#entryScroller;
}
nextCard = new Shortcut(
'j',
'Next card',
() => {
this.cards.next();
}
);
prevCard = new Shortcut(
'k',
'Previous card',
() => {
this.cards.prev();
}
);
nextEntry = new Shortcut(
'n',
'Next entry in a section',
() => {
this.entries?.next();
}
);
prevEntry = new Shortcut(
'p',
'Previous entry in a section',
() => {
this.entries?.prev();
}
);
firstItem = new Shortcut(
'<',
'Go to the first item',
() => {
this.#lastScroller.first();
}
);
lastItem = new Shortcut(
'>',
'Go to the last item',
() => {
this.#lastScroller.last();
}
);
focusBrowser = new Shortcut(
'f',
'Change browser focus to current item',
() => {
this.#lastScroller.focus();
}
);
showMore = new Shortcut(
'm',
'Show more of current item',
() => {
const el = this.#lastScroller.item;
if (el) {
NH.web.clickElement(el, ['[data-testid="expandable-text-button"]']);
}
}
);
applyToJob = new Shortcut(
'A',
'Apply to job',
() => {
const el = document.querySelector(this.#jobCardSelector);
NH.web.clickElement(el, [
// Matches both "link-external" and "linkedin-bug" icons
'[aria-label]:has(> * > svg[id^="link"])',
]);
}
);
toggleFollowCompany = new Shortcut(
'F', 'Toggle following company', () => {
// The anchor below is the link to the company in "About the company"
NH.web.clickElement(document, [
// Follow
'a + :has(> * > svg[id^="add-"])',
// Unfollow
'a + :has(> * > svg[id^="check-"])',
]);
}
);
toggleAlert = new Shortcut(
'L',
'Toggle the similar job search aLert',
() => {
NH.web.clickElement(document, ['[role="switch"]']);
}
);
toggleSaveJob = new Shortcut(
'S',
'Toggle saving job',
() => {
const el = document.querySelector(this.#jobCardSelector);
NH.web.clickElement(el, [
// Fragile, as currently only button in the card without an icon.
'button:not(:has(svg))',
]);
}
);
#cardScroller
#cardsContainer = '[data-testid="lazy-column"]';
#entryScroller
#jobCardSelector = `${this.#cardsContainer} > div:first-child`;
#lastScroller
#initScrollers = () => {
this.#initScrollerStyleService();
this.#initCardsScroller();
}
#initScrollerStyleService = () => {
const styleConfig = {
className: this.scrollerClassName,
finder: this.#scrollerFinder,
elementsProcessor: this.elementsHeightProcessor,
};
this.addService(NH.web.StyleService, styleConfig);
}
#initCardsScroller = () => {
const what = {
name: `${this.name} cards`,
containerItems: [
{
container: this.#cardsContainer,
items: [
// Main content
':scope > :first-child',
// Rest
':scope > :not(:first-child) > *',
].join(','),
},
],
};
const how = {
uidCallback: this.#uniqueCardIdentifier,
classes: [
LinkedIn.scrollerPrimaryClassName,
this.scrollerClassName,
],
snapToTop: true,
};
this.#cardScroller = new Scroller(what, how);
this.addService(ScrollerService)
.setScroller(this.#cardScroller);
this.#cardScroller.dispatcher
.on('change', this.#onCardChange);
this.#lastScroller = this.#cardScroller;
}
#initEntryScroller = () => {
const what = {
name: `${this.name} entries`,
base: this.cards.item,
selectors: [
// More jobs - Matches grid and footer
`:scope[${CKEY}^="JobDetailsSimilarJobsSlot"] a`,
],
};
const how = {
uidCallback: this.#uniqueEntryIdentifier,
classes: [
LinkedIn.scrollerSecondaryClassName,
this.scrollerClassName,
],
autoActivate: true,
snapToTop: false,
};
this.#entryScroller = new Scroller(what, how);
this.#entryScroller.dispatcher
.on('change', this.#onEntryChange)
.on('out-of-range', this.#returnToCard);
}
/**
* @method
* @returns {NexusHoratio.web.StyleService~ElementMap} Elements to
* monitor.
*/
#scrollerFinder = () => {
const me = this.#scrollerFinder.name;
this.logger.entered(me);
const elements = new Map();
elements.set('toolbar', document.querySelector('[role="toolbar"]'));
this.logger.leaving(me, elements);
return elements;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueCardIdentifier = (scroller, element) => {
const me = this.#uniqueCardIdentifier.name;
this.logger.entered(me, element);
let content = '';
const key = LinkedIn.ckeyIdentifier(element);
const label = element
.querySelector('[aria-label]')
?.getAttribute('aria-label');
const h2 = LinkedIn.h2(element);
if (h2) {
content = h2;
}
if (label) {
content = label;
}
if (key) {
content = key;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueEntryIdentifier = (scroller, element) => {
const me = this.#uniqueEntryIdentifier.name;
this.logger.entered(me, element);
let content = '';
const href = element.href;
if (href) {
content = new URL(href).searchParams.get('currentJobId');
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
#onCardChange = () => {
this.#resetEntries();
this.#lastScroller = this.cards;
}
#resetEntries = () => {
if (this.#entryScroller) {
this.#entryScroller.destroy();
this.#entryScroller = null;
}
this.entries;
}
#onEntryChange = () => {
this.#lastScroller = this.entries;
}
#returnToCard = () => {
this.cards.item = this.cards.item;
}
}
/**
* Class for handling the Messaging page.
*
* @extends module:linkedin-tool~Page
*/
class Messaging extends Page {
/**
* @param {NexusHoratio.spa.SPA} spa - SPA instance that manages this
* {@link module:linkedin-tool~Page Page}.
*/
constructor(spa) {
super({
spa: spa,
// eslint-disable-next-line prefer-regex-literals
pathname: RegExp('^/messaging/.*', 'u'),
readySelector: LinkedIn.asideSelector,
});
this.addService(LinkedInStyleService)
.addStyles(LinkedIn.Style.ONE);
this.addService(VMKeyboardService)
.addInstance(this);
this.#initScrollers();
}
/** @type {Scroller} */
get convoCards() {
return this.#convoCardScroller;
}
/** @type {Scroller} */
get messages() {
if (!this.#messageScroller && this.convoCards.item) {
this.#initMessageScroller();
}
return this.#messageScroller;
}
nextConvo = new Shortcut(
'j',
'Next conversation card',
() => {
this.convoCards.next();
}
);
prevConvo = new Shortcut(
'k',
'Previous conversation card',
() => {
this.convoCards.prev();
}
);
nextMessage = new Shortcut(
'n',
'Next message in conversation',
() => {
this.messages.next();
}
);
prevMessage = new Shortcut(
'p',
'Previous message in conversation',
() => {
this.messages.prev();
}
);
firstItem = new Shortcut(
'<',
'First conversation card or message',
() => {
this.#lastScroller.first();
}
);
lastItem = new Shortcut(
'>',
'Last conversation card or message',
() => {
this.#lastScroller.last();
}
);
focusBrowser = new Shortcut(
'f',
'Move browser focus to most recently selected item',
() => {
this.#lastScroller.focus();
}
);
loadMoreConversations = new Shortcut(
'l',
'Load more conversations',
() => {
const me = this.loadMoreConversations.name;
this.logger.entered(me);
// This button has no distinguishing features, but seems to be the
// last item in this list, and only one immediately a list item.
NH.web.clickElement(document,
[`${this.#convoCardsList} > li > button`]);
this.logger.leaving(me);
}
);
messageFilters = new Shortcut(
'm',
'Go to messaging filters',
() => {
const me = this.messageFilters.name;
this.logger.entered(me);
NH.web.focusOnElement(document.querySelector(
`${Messaging.#messagingFilterSelector} button`
));
this.logger.leaving(me);
}
);
searchMessages = new Shortcut(
's',
'Go to Search messages',
() => {
const me = this.searchMessages.name;
this.logger.entered(me);
NH.web.focusOnElement(
document.querySelector('#search-conversations')
);
this.logger.leaving(me);
}
);
openMeatballMenu = new Shortcut(
'=',
'Open closest <button>⋯</button> menu (tricky, ' +
'as there are many buttons to choose from)',
() => {
const me = this.openMeatballMenu.name;
this.logger.entered(me, this.#lastScroller);
if (this.convoCards.item.contains(document.activeElement) ||
this.messages.item?.contains(document.activeElement)) {
let buttons = null;
if (this.#lastScroller === this.convoCards) {
buttons = this.convoCards.item.querySelectorAll('button');
if (buttons.length === NH.base.ONE_ITEM) {
buttons[0].click();
} else {
NH.base.issues.post(
'Current conversation card does not have only one button',
this.convoCards.item.outerHTML
);
}
} else {
this.logger.log('Using messages', this.messages.item);
buttons = document.querySelectorAll(
'div.msg-title-bar button.msg-thread-actions__control'
);
if (buttons.length === NH.base.ONE_ITEM) {
buttons[0].click();
} else {
const msgs = Array.from(buttons)
.map(x => x.outerHTML);
NH.base.issues.post(
'The message title bar did not have exactly one button ' +
'matching the search criteria',
...msgs
);
}
}
} else {
this.#clickClosestMenuButton();
}
this.logger.leaving(me);
}
);
messageBox = new Shortcut(
'M',
'Go to the <i>Write a message</i> box',
() => {
NH.web.clickElement(document, [Messaging.#messageBoxSelector]);
}
);
newMessage = new Shortcut(
'N',
'Compose a new message',
() => {
const me = this.newMessage.name;
this.logger.entered(me);
// Composing a new message changes the URL, triggering page
// activation, which immediately refocuses on the current
// conversation. Setting it to `null` does lose are spot in the
// Scroller, but then at least the feature works.
this.convoCards.item = null;
NH.web.clickElement(document,
['#messaging :has(> svg[data-test-icon^="compose"])']);
this.logger.leaving(me);
}
);
toggleStar = new Shortcut(
'S',
'Toggle star on the current conversation',
() => {
NH.web.clickElement(document, ['button.msg-thread__star-icon']);
}
);
static #messageBoxSelector = 'main div.msg-form__contenteditable';
static #messagingFilterSelector =
'.msg-cross-pillar-inbox-filters-v3__container';
static #messagingOptionsSelector =
'button[aria-label="See more messaging options"]';
static #sendToggleSelector = 'button.msg-form__send-toggle';
#activator
#convoCardScroller
#convoCardsList = 'main' +
' ul.msg-conversations-container__conversations-list';
#lastScroller
#messageScroller
#initScrollers = () => {
this.#initCardsScroller();
}
#initCardsScroller = () => {
const what = {
name: `${this.name} conversations`,
containerItems: [
{
container: this.#convoCardsList,
items: ':scope > li.msg-conversations-container__pillar',
},
],
};
const how = {
uidCallback: this.#uniqueConvoCardsIdentifier,
classes: [LinkedIn.scrollerPrimaryClassName],
snapToTop: true,
};
this.#convoCardScroller = new Scroller(what, how);
this.addService(ScrollerService)
.setScroller(this.#convoCardScroller);
this.#convoCardScroller.dispatcher
.on('activate', this.#onConvoCardActivate)
.on('change', this.#onConvoCardChange);
}
#initMessageScroller = () => {
const what = {
name: `${this.name} messages`,
containerItems: [
{
container: 'ul.msg-s-message-list-content',
items: ':scope' +
' > li.msg-s-message-list__event > div[data-event-urn]',
},
],
};
const how = {
uidCallback: this.#uniqueMessageIdentifier,
classes: [LinkedIn.scrollerSecondaryClassName],
autoActivate: true,
snapToTop: false,
};
this.#messageScroller = new Scroller(what, how);
this.#messageScroller.dispatcher
.on('change', this.#onMessageChange);
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueConvoCardsIdentifier = (scroller, element) => {
const me = this.#uniqueConvoCardsIdentifier.name;
this.logger.entered(me, element);
// XXX: As of 2026-04-14, there are no distinguishing features in the
// cards. Unlike the similar UI for JobsCollections, there is no easy
// mapping between the URL and the card. The img.src looks interesting,
// but not really. It is possible to have multiple cards for the
// person, making using the URL unsuitable. And some folks do not have
// photos, so get the same placeholder data: scheme.
const content = scroller.defaultUid(element);
this.logger.leaving(me);
return content;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueMessageIdentifier = (scroller, element) => {
const me = this.#uniqueMessageIdentifier.name;
this.logger.entered(me, element);
let content = '';
const urn = element.dataset.eventUrn;
if (urn) {
content = urn;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
/**
* @typedef {object} Point
* @memberof module:linkedin-tool~Messaging~
* @property {number} x - Horizontal location in pixels.
* @property {number} y - Vertical location in pixels.
* @property {external:Element} element - Associated element.
*/
/**
* @method
* @param {external:Element} element - Element to examine.
* @returns {module:linkedin-tool~Messaging~Point} Center of the element.
*/
#centerOfElement = (element) => {
const TWO = 2;
const center = {
x: 0,
y: 0,
element: element,
};
if (element) {
const bbox = element.getBoundingClientRect();
this.logger.log('bbox:', bbox);
center.x = (bbox.left + bbox.right) / TWO;
center.y = (bbox.top + bbox.bottom) / TWO;
}
return center;
}
#clickClosestMenuButton = () => {
// Two more buttons to choose from. There are two ways of calculating
// the distance from the activeElement to the buttons: Path in the DOM
// tree or geometry. Considering the buttons are fixed, I suspect
// geometry is probably easier than trying to find the common ancestors.
const messagingOptions = document.querySelector(
Messaging.#messagingOptionsSelector
);
if (!messagingOptions) {
NH.base.issues.post(
'Unable to find the messaging options button.',
'Selector used:',
Messaging.#messagingOptionsSelector
);
}
const sendToggle = document.querySelector(
Messaging.#sendToggleSelector
);
if (!sendToggle) {
NH.base.issues.post(
'Unable to find the messaging send toggle button',
'Selector used:',
Messaging.#sendToggleSelector
);
}
const activeCenter = this.#centerOfElement(document.activeElement);
const optionsCenter = this.#centerOfElement(messagingOptions);
const toggleCenter = this.#centerOfElement(sendToggle);
optionsCenter.distance = this.#distanceBetweenPoints(
activeCenter, optionsCenter
);
toggleCenter.distance = this.#distanceBetweenPoints(
activeCenter, toggleCenter
);
const centers = [optionsCenter, toggleCenter];
centers.sort((a, b) => a.distance - b.distance);
centers[0].element.click();
}
/**
* @method
* @param {module:linkedin-tool~Messaging~Point} one - First point.
* @param {module:linkedin-tool~Messaging~Point} two - Second point.
* @returns {number} Distance between the points in pixels.
*/
#distanceBetweenPoints = (one, two) => {
const me = this.#distanceBetweenPoints.name;
this.logger.entered(me, one, two);
const xd = one.x - two.x;
const yd = one.y - two.y;
const distance = Math.sqrt((xd * xd) + (yd * yd));
this.logger.leaving(me, distance);
return distance;
}
#onConvoCardActivate = async () => {
const me = this.#onConvoCardActivate.name;
this.logger.entered(me);
await this.#findActiveConvo();
this.logger.leaving(me);
}
#onConvoCardChange = () => {
const me = this.#onConvoCardChange.name;
this.logger.entered(me);
const el = this.convoCards?.item;
NH.web.clickElement(el, ['.msg-conversation-listitem__link']);
this.#resetMessages();
this.#lastScroller = this.convoCards;
this.logger.leaving(me);
}
#resetMessages = () => {
if (this.#messageScroller) {
this.#messageScroller.destroy();
this.#messageScroller = null;
}
this.messages;
}
#onMessageChange = () => {
this.#lastScroller = this.messages;
}
#findActiveConvo = async () => {
const me = this.#findActiveConvo.name;
this.logger.entered(me);
try {
const timeout = 2000;
const item = await NH.web.waitForSelector(
'.msg-conversations-container__convo-item-link--active', timeout
);
this.convoCards.goto(item.closest('li'));
// Page loading could still be happening, preventing the previous
// goto() from doing anything useful. Try again on the next loop.
setTimeout(() => {
this.convoCards.focus();
}, 0);
} catch (e) {
this.logger.log('Active conversation card not found, staying put');
}
this.logger.leaving(me);
}
}
/**
* Class for handling the Notifications page.
*
* @extends module:linkedin-tool~Page
*/
class Notifications extends Page {
/**
* @param {NexusHoratio.spa.SPA} spa - SPA instance that manages this
* {@link module:linkedin-tool~Page Page}.
*/
constructor(spa) {
super({
spa: spa,
pathname: '/notifications/',
readySelector: 'footer.global-footer-compact',
});
this.addService(LinkedInStyleService)
.addStyles(LinkedIn.Style.ONE);
this.addService(VMKeyboardService)
.addInstance(this);
this.#initScrollers();
}
/** @type {Scroller} */
get notifications() {
return this.#notificationScroller;
}
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.focus();
}
);
activateNotification = new Shortcut(
'Enter',
'Activate the current notification (click on it)',
() => {
this.notifications.click();
}
);
loadMoreNotifications = new Shortcut(
'l',
'Load more notifications',
() => {
const me = this.loadMoreNotifications.name;
this.logger.entered(me);
const savedScrollTop = document.documentElement.scrollTop;
let first = false;
const notifications = this.notifications;
/** Trigger function for {@link NexusHoratio.web.otrot2}. */
function trigger() {
if (NH.web.clickElement(document,
['main button:has(> svg[data-test-icon^="arrow-up"]'])) {
first = true;
} else {
NH.web.clickElement(document,
['main button.scaffold-finite-scroll__load-button']);
}
}
/** Action function for {@link NexusHoratio.web.otrot2}. */
const action = () => {
if (first) {
if (notifications.item) {
notifications.first();
}
} else {
document.documentElement.scrollTop = savedScrollTop;
this.notifications.shine();
}
};
const what = {
name: `${this.id} ${me}`,
base: document.querySelector('div.scaffold-finite-scroll__content'),
};
const how = {
trigger: trigger,
action: action,
duration: 2000,
};
NH.web.otrot2(what, how);
this.logger.leaving(me);
}
);
openMeatballMenu = new Shortcut(
'=',
'Open the <button>⋯</button> menu',
() => {
NH.web.clickElement(this.notifications.item,
['button:has(> svg[data-test-icon^="overflow"]']);
}
);
gotoFilter = new Shortcut(
'F',
'Move focus to the notification filters',
() => {
this.notifications.item = null;
NH.web.focusOnElement(
document.querySelector('#notification-nt-pill .nt-pill--selected')
);
}
);
deleteNotification = new Shortcut(
'X',
'Toggle current notification deletion',
async () => {
const me = this.deleteNotification.name;
this.logger.entered(me);
const el = this.notifications.item;
/** Trigger function for {@link NexusHoratio.web.otrot}. */
function trigger() {
NH.web.clickElement(el, [
'button:has(svg[data-test-icon^="trash"])',
'button:has(svg[data-test-icon^="undo"])',
]);
}
if (el) {
const what = {
name: `${this.id} ${me}`,
base: el,
};
const how = {
trigger: trigger,
timeout: 3000,
};
await NH.web.otrot(what, how);
}
this.logger.leaving(me);
}
);
#notificationScroller
#initScrollers = () => {
this.#initScrollerStyleService();
this.#initNotificationScroller();
}
#initScrollerStyleService = () => {
const styleConfig = {
className: this.scrollerClassName,
finder: this.#scrollerFinder,
elementsProcessor: this.#scrollerElementsProcessor,
};
this.addService(NH.web.StyleService, styleConfig);
}
#initNotificationScroller = () => {
const what = {
name: `${this.name} cards`,
containerItems: [
{
container: 'main section div.nt-card-list',
items: 'article',
},
],
};
const how = {
uidCallback: this.#uniqueNotificationIdentifier,
classes: [
LinkedIn.scrollerPrimaryClassName,
this.scrollerClassName,
],
snapToTop: true,
clickConfig: {
finder: this.#cardItemToClick,
},
};
this.#notificationScroller = new Scroller(what, how);
this.addService(ScrollerService)
.setScroller(this.#notificationScroller);
this.#notificationScroller.dispatcher
.on('out-of-range', this.spa.details.focusOnSidebar);
}
/**
* @method
* @returns {NexusHoratio.web.StyleService~ElementMap} Elements to
* monitor.
*/
#scrollerFinder = () => {
const me = this.#scrollerFinder.name;
this.logger.entered(me);
const elements = new Map();
elements.set('aside', document.querySelector('aside > div'));
elements.set('pill', document.querySelector('#notification-nt-pill'));
this.logger.leaving(me, elements);
return elements;
}
/**
* @method
* @implements {NexusHoratio.web.StyleService~ElementsProcessor}
* @param {NexusHoratio.web.StyleService~ElementMap} elements - Elements
* to examine.
* @returns {NexusHoratio.web.StyleService~StyleProperties} Style
* properties for to contribute.
*/
#scrollerElementsProcessor = (elements) => {
const me = this.#scrollerElementsProcessor.name;
this.logger.entered(me, elements);
let marginTop = 0;
const properties = new Map();
for (const [key, value] of elements.entries()) {
switch (key) {
case 'aside':
if (value) {
const rect = value.getBoundingClientRect();
marginTop += rect.top;
}
break;
case 'pill':
if (value) {
marginTop += value.offsetHeight;
}
break;
default:
NH.base.issues.post(
this.name, me, 'Unsupported element key:', key
);
}
}
properties.set('scroll-margin-top', `${marginTop}px`);
this.logger.leaving(me, properties);
return properties;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueNotificationIdentifier = (scroller, element) => {
const me = this.#uniqueNotificationIdentifier.name;
this.logger.entered(me, element);
let content = '';
const hotKey = element.parentElement.dataset.finiteScrollHotkeyItem;
const cardIndex = element.dataset.ntCardIndex;
if (hotKey) {
content = hotKey;
}
if (cardIndex) {
content = cardIndex;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
/**
* Given a notification card, find the correct item inside of it to click.
*
* @method
* @implements {Scroller~ElementFinder}
* @param {external:Element} element - Element to examine.
* @returns {external:Element} Found element.
*/
#cardItemToClick = (element) => {
let found = null;
if (element) {
const elements = element.querySelectorAll(
'.nt-card__headline'
);
if (elements.length === NH.base.ONE_ITEM) {
found = elements[0];
} else {
const ba = element.querySelectorAll('button,a');
if (ba.length === NH.base.ONE_ITEM) {
found = ba[0];
}
}
}
return found;
}
}
/**
* Class for handling the Profile page.
*
* @extends module:linkedin-tool~Page
*/
class Profile extends Page {
/**
* @param {NexusHoratio.spa.SPA} spa - SPA instance that manages this
* {@link module:linkedin-tool~Page Page}.
*/
constructor(spa) {
super({
spa: spa,
// eslint-disable-next-line prefer-regex-literals
pathname: RegExp('^/in/.*', 'u'),
readySelector: '[data-sdui-component]',
readySelectorTimeout: 5000,
});
this.addService(LinkedInStyleService)
.addStyles(LinkedIn.Style.TWO);
this.addService(VMKeyboardService)
.addInstance(this);
spa.details.dispatcher
.on('activated', this.#onSpaActivated);
this.#initScrollers();
}
/**
* Different ways of computing UIDs for entry elements.
* @readonly
* @enum {Symbol}
*/
UidMode = {
ANCHOR: Symbol.for('anchor'),
ANCHOR_FEED: Symbol.for('anchorFeed'),
ANCHOR_GROUPS: Symbol.for('anchorGroups'),
ANCHOR_LEARNING: Symbol.for('anchorLearning'),
ANCHOR_NEWSLETTERS: Symbol.for('anchorNewsletters'),
ANCHOR_OVERLAY: Symbol.for('anchorOverlay'),
ANCHOR_PROFILE: Symbol.for('anchorProfile'),
ANCHOR_PULSE: Symbol.for('anchorPulse'),
ANCHOR_SHOWCASE: Symbol.for('anchorShowcase'),
ARIA_LABEL: Symbol.for('ariaLabel'),
CKEY: Symbol.for('ckey'),
COMMENT_URN: Symbol.for('commentUrn'),
COMPANY: Symbol.for('company'),
DEFAULT: Symbol.for('default'),
FALLBACK: Symbol.for('fallback'),
FOOTER: Symbol.for('footer'),
HREF: Symbol.for('href'),
MULTI_IMG: Symbol.for('multiImg'),
SAFETY: Symbol.for('safety'),
SCHOOL: Symbol.for('school'),
TEST_ID: Symbol.for('testId'),
};
/** @type {Scroller} */
get entries() {
if (!this.#entryScroller && this.sections.item) {
this.#initEntryScroller();
}
return this.#entryScroller;
}
/** @type {Scroller} */
get sections() {
return this.#sectionScroller;
}
nextSection = new Shortcut(
'j',
'Next section',
() => {
this.sections.next();
}
);
prevSection = new Shortcut(
'k',
'Previous section',
() => {
this.sections.prev();
}
);
nextEntry = new Shortcut(
'n',
'Next entry in a section',
() => {
this.entries?.next();
}
);
prevEntry = new Shortcut(
'p',
'Previous entry in a section',
() => {
this.entries?.prev();
}
);
firstItem = new Shortcut(
'<',
'Go to the first section or entry',
() => {
this.#lastScroller.first();
}
);
lastItem = new Shortcut(
'>',
'Go to the last section or entry',
() => {
this.#lastScroller.last();
}
);
focusBrowser = new Shortcut(
'f',
'Change browser focus to current item',
() => {
this.#lastScroller.focus();
}
);
showMore = new Shortcut(
'm',
'Show more of the current item',
() => {
const el = this.#lastScroller.item;
NH.web.clickElement(el, ['[data-testid="expandable-text-button"]']);
}
);
editItem = new Shortcut(
'E',
'Edit the current item (if possible)',
() => {
// Some sections have multiple edit buttons, so walk amongst them.
const el = this.#lastScroller.item;
this.logger.log('el', el);
}
);
/**
* Create a CSS child combinator selector of DIVs.
*
* @method
* @param {number} n - The number of DIVs in the selector.
* @returns {string} N divs like "div > div > ... > div".
*/
div = (n) => {
const a = Array(n)
.fill('div');
return a.join(' > ');
}
#arrowRight = ':has(svg[id^="arrow-right"])';
#arrowRightNot = `:not(${this.#arrowRight})`;
#checkingPartialOrder = false
/* eslint-disable no-magic-numbers */
#div3 = this.div(3);
#div4 = this.div(4);
#div5 = this.div(5);
#div6 = this.div(6);
#div7 = this.div(7);
/* eslint-enable */
#divAnchorNoArrowRight = `div > a${this.#arrowRightNot}`;
#divSectionDiv3 = `div > section > ${this.#div3}`;
#entriesCurrentModes
#entriesCurrentUid
#entriesScrollerConfigDefault = Symbol('default');
#entriesScrollerConfigs = new Map();
#entriesSelectorAbout = [
// Fairly simple layout
`:scope > ${this.#div3}:has(> p) > *`,
].join(',');
#entriesSelectorActivity = [
// ANCHOR_FEED
`div[${CKEY}*="posts"]` +
' [data-testid="carousel-child-container"] > * > *',
// HREF
`div[${CKEY}*="comments"] ${this.#divAnchorNoArrowRight}`,
// HREF
`div[${CKEY}*="videos"] ${this.#divAnchorNoArrowRight}`,
// IMG
`div[${CKEY}*="images"] ${this.#divAnchorNoArrowRight}`,
// HREF
`div[${CKEY}*="articles"] ${this.#divAnchorNoArrowRight}`,
// Newsletters have both subscribe and posts subsections
// ANCHOR
`div[${CKEY}*="newsletters"] div:has(> a[href*="/newsletters/"])`,
// HREF
`div[${CKEY}*="newsletters"] div > a[href*="/pulse/"]`,
// HREF
`div[${CKEY}*="events"] ${this.#divAnchorNoArrowRight}`,
// ANCHOR_FEED
`div[${CKEY}*="documents"] > div > div > div:not(:has(> a > span))`,
].join(',');
#entriesSelectorAnalytics = [
`:scope:has(> ${this.#div3} > a[href$="/dashboard/"])` +
` a${this.#arrowRightNot}`,
].join(',');
#entriesSelectorCauses = [
// Skip the header.
':scope > div > div:not(:has(> h2))',
].join(',')
#entriesSelectorCertification = [
// Simple layout
`:scope > ${this.#div6}`,
].join(',');
#entriesSelectorConnectedAccounts = [
// Two layouts discovered so far:
// * End users
// * Self promotion
`:scope > ${this.#div3} > a`,
`:scope > ${this.#div7}`,
`:scope > ${this.#div5} > a`,
].join(',');
#entriesSelectorCourses = [
// Simple layout
`:scope > ${this.#div5}`,
].join(',')
#entriesSelectorDefault = [
// Default catches the edit button on own page.
`:scope > ${this.#div4}`,
].join(',')
#entriesSelectorEducation = [
// Sections with footers are one div deeper.
`:scope > ${this.#div4}:not(:has(> svg)) > div[${CKEY}]`,
`:scope > ${this.#div5} > div[${CKEY}]`,
].join(',');
#entriesSelectorExperience = [
// Simple layout
`:scope > ${this.#div4}`,
].join(',');
#entriesSelectorFeatured = [
// Simple carousel
'[data-testid="carousel-child-container"] > * > *',
].join(',');
#entriesSelectorFooter = [
// "Show all" buttons
`hr ~ div > a${this.#arrowRight}`,
].join(',');
#entriesSelectorHighlights = [
// Simple layout
`:scope > ${this.#div7}`,
].join(',');
#entriesSelectorHonors = [
// Simple layout
`:scope > ${this.#div6}`,
].join(',')
#entriesSelectorInterests = [
// Simple layout, but deep due to multiple tab panels.
`:scope > ${this.#div6} > ${this.#div3}`,
].join(',')
#entriesSelectorLanguages = [
// Simple layout
`:scope > ${this.#div6}`,
].join(',')
#entriesSelectorOrganizations = [
// Simple layout
`:scope > ${this.#div6}`,
].join(',')
#entriesSelectorPatents = [
// Users with more than two patents have a different depth. This likely
// does not yet capture everything.
`:scope > ${this.#div5}:has(> div > p)`,
`:scope > ${this.#div6}:has(> div > p)`,
].join(',')
#entriesSelectorProjects = [
// Simple layout
`:scope > ${this.#div5}`,
].join(',')
#entriesSelectorPublications = [
// Users with more than two publications have a different depth. And
// publications with more than one author also have tricky depths. This
// likely does not yet capture everything.
`:scope > ${this.#div5}:has(> div > p)`,
`:scope > ${this.#div5}:has(> hr) > div:has(> div > p)`,
].join(',')
#entriesSelectorRecommendations = [
// Skip the selection filter
`:scope > ${this.#div6}:not(:has(> div > input))`,
].join(',')
#entriesSelectorServices = [
`:scope > ${this.#div5}` +
':not([data-testid="carousel-viewport-container"])' +
' > *',
':scope [data-testid="carousel-child-container"] > *',
].join(',');
#entriesSelectorSkills = [
// We want div5 because not all div6 have a ckey
`:scope > ${this.#div5}:not(:has(> hr))`,
].join(',')
#entriesSelectorSuggestedForYou = [
// May or may not be a list/carousel
`:scope > ${this.#divSectionDiv3}` +
':not(:has(> h2)) > div',
].join(',');
#entriesSelectorTestScores = [
// Simple layout
`:scope > ${this.#div5}`,
].join(',')
#entriesSelectorTopcard = [
// Most items
`:scope > ${this.#divSectionDiv3} > * > :is(div, a)` +
// Skip premium badge
':not(:has(> a > svg))' +
// Skip premium footer
':not(:has(> svg[id^="premium"]))' +
// Skip carousels
':not([data-testid="carousel-viewport-container"])',
// Buttons for Premium background carousel
':scope [data-testid="pagination-controls-list"]',
// Links to external websites
`:scope > ${this.#divSectionDiv3} > p`,
// Carousels (private edit footer, DIVs at different levels)
':scope' +
' [data-testid="carousel-child-container"]' +
' div:has(> a[href*="/in/"])',
].join(',');
#entriesSelectorVolunteering = [
// Simple layout
':scope [role="listitem"]',
].join(',')
#entryScroller
#lastScroller
#modeUidSelectorId
#modeUidSelectorTestId
#sectionScroller
#sectionUidPrefixes = new Map();
// Known sections in "curr next" pairs, suitable for tsort.
#sectionsPartialOrder = new Set([
'About, Activity',
'About, ExperienceTopLevelSection',
'About, Featured',
'About, Services',
'Activity, ExperienceTopLevelSection',
'Activity, Skills',
'Analytics, About',
'CertificationTopLevel, CourseTopLevelSection',
'CertificationTopLevel, HonorsTopLevel',
'CertificationTopLevel, Patents',
'CertificationTopLevel, Projects',
'CertificationTopLevel, PublicationTopLevelSection',
'CertificationTopLevel, RecommendationsTopLevel',
'CertificationTopLevel, Skills',
'CertificationTopLevel, VolunteerExperienceTopLevel',
'ConnectedAccountsTopLevel, CertificationTopLevel',
'ConnectedAccountsTopLevel, Skills',
'CourseTopLevelSection, Causes',
'CourseTopLevelSection, HonorsTopLevel',
'CourseTopLevelSection, Interests',
'CourseTopLevelSection, LanguageTopLevel',
'CourseTopLevelSection, Organizations',
'EducationTopLevelSection, CertificationTopLevel',
'EducationTopLevelSection, ConnectedAccountsTopLevel',
'EducationTopLevelSection, CourseTopLevelSection',
'EducationTopLevelSection, HonorsTopLevel',
'EducationTopLevelSection, Interests',
'EducationTopLevelSection, LanguageTopLevel',
'EducationTopLevelSection, Patents',
'EducationTopLevelSection, Projects',
'EducationTopLevelSection, PublicationTopLevelSection',
'EducationTopLevelSection, RecommendationsTopLevel',
'EducationTopLevelSection, Skills',
'EducationTopLevelSection, VolunteerExperienceTopLevel',
'ExperienceTopLevelSection, Causes',
'ExperienceTopLevelSection, CertificationTopLevel',
'ExperienceTopLevelSection, EducationTopLevelSection',
'ExperienceTopLevelSection, Interests',
'ExperienceTopLevelSection, Skills',
'ExperienceTopLevelSection, VolunteerExperienceTopLevel',
'Featured, Activity',
'HonorsTopLevel, Interests',
'HonorsTopLevel, LanguageTopLevel',
'HonorsTopLevel, Organizations',
'HonorsTopLevel, TestScoresTopLevel',
'Interests, Causes',
'LanguageTopLevel, Causes',
'LanguageTopLevel, Interests',
'LanguageTopLevel, Organizations',
'Organizations, Causes',
'Organizations, Interests',
'Patents, CourseTopLevelSection',
'Patents, HonorsTopLevel',
'Patents, Interests',
'Projects, CourseTopLevelSection',
'Projects, Interests',
'Projects, LanguageTopLevel',
'Projects, PublicationTopLevelSection',
'Projects, RecommendationsTopLevel',
'Projects, Skills',
'Projects, VolunteerExperienceTopLevel',
'PublicationTopLevelSection, Causes',
'PublicationTopLevelSection, CourseTopLevelSection',
'PublicationTopLevelSection, HonorsTopLevel',
'PublicationTopLevelSection, Interests',
'PublicationTopLevelSection, LanguageTopLevel',
'PublicationTopLevelSection, Organizations',
'PublicationTopLevelSection, Patents',
'RecommendationsTopLevel, Causes',
'RecommendationsTopLevel, CourseTopLevelSection',
'RecommendationsTopLevel, HonorsTopLevel',
'RecommendationsTopLevel, Interests',
'RecommendationsTopLevel, LanguageTopLevel',
'RecommendationsTopLevel, Organizations',
'RecommendationsTopLevel, Patents',
'RecommendationsTopLevel, PublicationTopLevelSection',
'SalesInsightsOrHighlights, About',
'SalesInsightsOrHighlights, Activity',
'Services, Activity',
'Services, Featured',
'Skills, CourseTopLevelSection',
'Skills, HonorsTopLevel',
'Skills, Interests',
'Skills, LanguageTopLevel',
'Skills, Patents',
'Skills, PublicationTopLevelSection',
'Skills, RecommendationsTopLevel',
'SuggestedForYou, Analytics',
'TestScoresTopLevel, Causes',
'TestScoresTopLevel, LanguageTopLevel',
'Topcard, About',
'Topcard, Activity',
'Topcard, Analytics',
'Topcard, Featured',
'Topcard, SalesInsightsOrHighlights',
'Topcard, SimilarTo',
'Topcard, SuggestedForYou',
'VolunteerExperienceTopLevel, CourseTopLevelSection',
'VolunteerExperienceTopLevel, HonorsTopLevel',
'VolunteerExperienceTopLevel, Interests',
'VolunteerExperienceTopLevel, LanguageTopLevel',
'VolunteerExperienceTopLevel, PublicationTopLevelSection',
'VolunteerExperienceTopLevel, RecommendationsTopLevel',
'VolunteerExperienceTopLevel, Skills',
]);
#initScrollers = () => {
this.#initModeToUidHelpers();
this.#initEntryScrollerConfigs();
this.#initScrollerStyleService();
this.#initSectionScroller();
}
/**
* Create a CSS attribute selector with an ignore list.
*
* @example
* [x]:not([[x]="foo"]):not([x]="bar")
*
* @method
* @param {string} attr - Attribute.
* @param {string[]} ignore - Values to ignore.
* @returns {string} CSS selector.
*/
#modeAttrHelperSelector = (attr, ignore) => {
const positive = `[${attr}]`;
const negative = ignore.map(x => `:not([${attr}="${x}"])`)
.join('');
const ret = positive + negative;
return ret;
}
#initModeToUidHelpers = () => {
this.#modeUidSelectorId = this.#modeAttrHelperSelector('id', [
'calendar-small',
'company-accent-4',
'link-external-small',
'school-accent-4',
]);
this.#modeUidSelectorTestId = this.#modeAttrHelperSelector(
'data-testid', [
'expandable-text-box',
'expandable-text-button',
]
);
}
#initScrollerStyleService = () => {
const styleConfig = {
className: this.scrollerClassName,
finder: this.#scrollerFinder,
elementsProcessor: this.elementsHeightProcessor,
events: ['transitionend'],
};
this.addService(NH.web.StyleService, styleConfig);
}
#initSectionScroller = () => {
const what = {
name: `${this.name} sections`,
containerItems: [
{
container: '[data-testid="lazy-column"]',
items: [
// Most sections
`:scope div[${CKEY}^="com.linkedin.sdui.profile.card."]`,
// Analytics
':scope > div > div' +
` > div:not([${CKEY}^="com.linkedin.sdui.profile.card."])` +
' > div > section',
// Interests
':scope' +
` > div:not([${CKEY}^="com.linkedin.sdui.profile.card."])` +
' > div > div > section',
].join(','),
},
],
};
const how = {
uidCallback: this.#uniqueSectionIdentifier,
classes: [
LinkedIn.scrollerPrimaryClassName,
this.scrollerClassName,
],
snapToTop: true,
};
this.#sectionScroller = new Scroller(what, how);
this.addService(ScrollerService)
.setScroller(this.#sectionScroller);
this.#sectionScroller.dispatcher
.on('change', this.#onSectionChange);
this.#lastScroller = this.#sectionScroller;
}
#initEntryScrollerConfigs = () => { // eslint-disable-line max-lines-per-function, max-statements
this.#entriesScrollerConfigs.set(
this.#entriesScrollerConfigDefault, {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorDefault,
this.#entriesSelectorFooter,
],
modes: [this.UidMode.FOOTER],
}
);
this.#entriesScrollerConfigs.set('Topcard', {
uidCallback: this.#entriesUidFromModes,
selectors: [this.#entriesSelectorTopcard],
modes: [
this.UidMode.ANCHOR_OVERLAY,
this.UidMode.ARIA_LABEL,
this.UidMode.ANCHOR,
this.UidMode.MULTI_IMG,
this.UidMode.SAFETY,
this.UidMode.HREF,
],
});
this.#entriesScrollerConfigs.set('SuggestedForYou', {
uidCallback: this.#entriesUidFromModes,
selectors: [this.#entriesSelectorSuggestedForYou],
modes: [this.UidMode.ANCHOR_OVERLAY],
});
this.#entriesScrollerConfigs.set('SalesInsightsOrHighlights', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorHighlights,
this.#entriesSelectorFooter,
],
modes: [
this.UidMode.COMPANY,
this.UidMode.ANCHOR_PROFILE,
this.UidMode.ARIA_LABEL,
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('Analytics', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorAnalytics,
this.#entriesSelectorFooter,
],
modes: [
this.UidMode.HREF,
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('About', {
uidCallback: this.#entriesUidFromModes,
selectors: [this.#entriesSelectorAbout],
modes: [
this.UidMode.ANCHOR_OVERLAY,
this.UidMode.DEFAULT,
],
});
this.#entriesScrollerConfigs.set('Services', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorServices,
this.#entriesSelectorFooter,
],
modes: [
this.UidMode.MULTI_IMG,
this.UidMode.HREF,
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('Featured', {
uidCallback: this.#entriesUidFromModes,
selectors: [this.#entriesSelectorFeatured],
modes: [
this.UidMode.SAFETY,
this.UidMode.ANCHOR_FEED,
this.UidMode.ANCHOR_LEARNING,
this.UidMode.ANCHOR_NEWSLETTERS,
this.UidMode.ANCHOR_OVERLAY,
this.UidMode.ANCHOR_PULSE,
this.UidMode.ANCHOR_PROFILE,
],
});
this.#entriesScrollerConfigs.set('Activity', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorActivity,
this.#entriesSelectorFooter,
],
modes: [
this.UidMode.ANCHOR_FEED,
this.UidMode.COMMENT_URN,
this.UidMode.MULTI_IMG,
this.UidMode.ANCHOR_PULSE,
this.UidMode.ANCHOR_NEWSLETTERS,
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('ExperienceTopLevelSection', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorExperience,
this.#entriesSelectorFooter,
],
modes: [
this.UidMode.ANCHOR_OVERLAY,
this.UidMode.COMPANY,
this.UidMode.SCHOOL,
this.UidMode.FOOTER,
// CKEY actually looks stable here.
this.UidMode.CKEY,
this.UidMode.ANCHOR_PROFILE,
],
});
this.#entriesScrollerConfigs.set('EducationTopLevelSection', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorEducation,
this.#entriesSelectorFooter,
],
modes: [
this.UidMode.SCHOOL,
this.UidMode.ANCHOR_OVERLAY,
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('ConnectedAccountsTopLevel', {
uidCallback: this.#entriesUidFromModes,
selectors: [this.#entriesSelectorConnectedAccounts],
modes: [
this.UidMode.MULTI_IMG,
this.UidMode.HREF,
],
});
this.#entriesScrollerConfigs.set('CertificationTopLevel', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorCertification,
this.#entriesSelectorFooter,
],
modes: [
// /safety/go links often go to external certification sites.
this.UidMode.SAFETY,
this.UidMode.ANCHOR_OVERLAY,
this.UidMode.COMPANY,
this.UidMode.SCHOOL,
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('Projects', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorProjects,
this.#entriesSelectorFooter,
],
modes: [
this.UidMode.SAFETY,
this.UidMode.ANCHOR_OVERLAY,
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('VolunteerExperienceTopLevel', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorVolunteering,
this.#entriesSelectorFooter,
],
modes: [
this.UidMode.COMPANY,
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('Skills', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorSkills,
this.#entriesSelectorFooter,
],
modes: [
// CKEY actually looks stable here.
this.UidMode.CKEY,
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('RecommendationsTopLevel', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorRecommendations,
this.#entriesSelectorFooter,
],
modes: [
this.UidMode.ANCHOR_PROFILE,
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('PublicationTopLevelSection', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorPublications,
this.#entriesSelectorFooter,
],
modes: [
this.UidMode.SAFETY,
this.UidMode.ANCHOR_OVERLAY,
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('Patents', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorPatents,
this.#entriesSelectorFooter,
],
modes: [
this.UidMode.SAFETY,
this.UidMode.ANCHOR_OVERLAY,
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('CourseTopLevelSection', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorCourses,
this.#entriesSelectorFooter,
],
modes: [
this.UidMode.MULTI_IMG,
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('HonorsTopLevel', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorHonors,
this.#entriesSelectorFooter,
],
modes: [
// MULTI_IMG has caused duplicates
this.UidMode.ANCHOR_OVERLAY,
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('TestScoresTopLevel', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorTestScores,
this.#entriesSelectorFooter,
],
modes: [
// MULTI_IMG has caused duplicates
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('LanguageTopLevel', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorLanguages,
this.#entriesSelectorFooter,
],
modes: [this.UidMode.FOOTER],
});
this.#entriesScrollerConfigs.set('Organizations', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorOrganizations,
this.#entriesSelectorFooter,
],
modes: [this.UidMode.FOOTER],
});
this.#entriesScrollerConfigs.set('Interests', {
uidCallback: this.#entriesUidFromModes,
selectors: [
this.#entriesSelectorInterests,
this.#entriesSelectorFooter,
],
modes: [
this.UidMode.ANCHOR_NEWSLETTERS,
this.UidMode.ANCHOR_PROFILE,
this.UidMode.COMPANY,
this.UidMode.ANCHOR_SHOWCASE,
this.UidMode.ANCHOR_GROUPS,
this.UidMode.SCHOOL,
this.UidMode.FOOTER,
],
});
this.#entriesScrollerConfigs.set('Causes', {
uidCallback: this.#entriesUidFromModes,
selectors: [
// Small section
this.#entriesSelectorCauses,
],
modes: [
// Nothing better to use.
this.UidMode.DEFAULT,
],
});
}
#initEntryScroller = () => {
const me = this.#initEntryScroller.name;
this.logger.entered(me, 'current section', this.sections.itemUid);
const config = this.#entriesScrollerConfigs.get(
this.sections.itemUid
) ?? this.#entriesScrollerConfigs.get(
this.#entriesScrollerConfigDefault
);
const what = {
name: `${this.name} entries`,
base: this.sections.item,
selectors: config.selectors,
};
const how = {
uidCallback: this.#entriesUidShim,
classes: [
LinkedIn.scrollerSecondaryClassName,
this.scrollerClassName,
],
autoActivate: true,
snapToTop: false,
};
this.#entriesCurrentUid = config.uidCallback;
this.#entriesCurrentModes = config.modes;
this.#entryScroller = new Scroller(what, how);
this.#entryScroller.dispatcher
.on('change', this.#onEntryChange)
.on('out-of-range', this.#returnToSection);
this.logger.leaving(me);
}
/**
* Compute all UIDs for the requested modes.
*
* @method
* @param {Scroller} scroller - Scroller instance.
* @param {external:Element} element - Element to examine.
* @param {UidMode[]} modes - Computation modes to consider.
* @returns {Map<UidMode, string>} All computed values.
*/
#entriesModeToUid = (scroller, element, modes) => { // eslint-disable-line max-lines-per-function, max-statements, complexity
const me = this.#entriesModeToUid.name;
this.logger.entered(me, element, modes);
const results = new Map();
// Different types may get additional post-processing.
let content = null;
let href = null;
for (const mode of modes) {
let scratch = null;
content = null;
href = null;
switch (mode) {
case this.UidMode.ANCHOR:
href = element.querySelector(
'a' +
':not([href*="/company/"])' +
':not([href*="/feed/"])' +
':not([href*="/groups/"])' +
':not([href*="/in/"])' +
':not([href$="#"])' +
':not([href*="/learning/"])' +
':not([href*="/newsletters/"])' +
':not([href*="/pulse/"])' +
':not([href*="/safety/"])' +
':not([href*="/school/"])' +
':not([href*="/showcase/"])'
)?.href;
break;
case this.UidMode.ANCHOR_FEED:
href = element.querySelector('a[href*="/feed/"]')?.href;
break;
case this.UidMode.ANCHOR_GROUPS:
scratch = element.matches('a[href *= "/groups/"]')
? element
: element.querySelector('a[href *= "/groups/"]');
href = scratch?.href;
break;
case this.UidMode.ANCHOR_LEARNING:
href = element.querySelector('a[href*="/learning/"]')?.href;
break;
case this.UidMode.ANCHOR_NEWSLETTERS:
href = element.querySelector('a[href*="/newsletters/"]')?.href;
break;
case this.UidMode.ANCHOR_OVERLAY:
scratch = element.querySelector('a[href*="/in/"]')?.href;
if (scratch) {
// eslint-disable-next-line prefer-regex-literals
const re = RegExp(
'^/in/[^/]*/(?:overlay|edit|opportunities)/', 'u'
);
const overlayUrl = new URL(scratch);
const suffix = '/#';
if (re.test(overlayUrl.pathname)) {
href = scratch;
} else if (overlayUrl.href.endsWith(suffix)) {
// Using content because we know this will match /in/ later.
content = overlayUrl.pathname + suffix;
}
}
break;
case this.UidMode.ANCHOR_PROFILE:
href = element.querySelector('a[href*="/in/"]')?.href;
break;
case this.UidMode.ANCHOR_PULSE:
scratch = element.matches('a[href *= "/pulse/"]')
? element
: element.querySelector('a[href *= "/pulse/"]');
href = scratch?.href;
break;
case this.UidMode.ANCHOR_SHOWCASE:
scratch = element.matches('a[href *= "/showcase/"]')
? element
: element.querySelector('a[href *= "/showcase/"]');
href = scratch?.href;
break;
case this.UidMode.ARIA_LABEL:
content = element.ariaLabel ||
element.querySelector('[aria-label]')
?.getAttribute('aria-label');
break;
case this.UidMode.CKEY:
content = LinkedIn.ckeyIdentifier(element)
?.replace('com.linkedin.sdui.profile.', '');
break;
case this.UidMode.COMMENT_URN:
// The ?? is so there is always a valid URL
scratch = new URL(element.href ?? document.location)
.searchParams;
content = scratch.get('dashReplyUrn') ??
scratch.get('dashCommentUrn');
break;
case this.UidMode.COMPANY:
scratch = element.querySelector('a[href*="/company/"]')
?.href;
if (scratch) {
// The same company may be referenced more than once.
content = new URL(scratch).pathname +
scroller.defaultUid(element);
}
break;
case this.UidMode.DEFAULT:
content = scroller.defaultUid(element);
break;
case this.UidMode.FALLBACK:
// No-op
break;
case this.UidMode.FOOTER:
scratch = element.matches(this.#entriesSelectorFooter);
if (scratch) {
href = element.href;
}
break;
case this.UidMode.HREF:
scratch = element.matches(this.#entriesSelectorFooter);
if (!scratch) {
href = element.href;
}
break;
case this.UidMode.MULTI_IMG:
scratch = [];
for (const img of element.querySelectorAll('img')) {
scratch.push(new URL(img.src).pathname);
}
content = scratch.join('|');
break;
case this.UidMode.SAFETY:
scratch = element.querySelector('a[href*="/safety/"]')
?.href;
if (scratch) {
content = new URL(scratch).searchParams.get('urlhash');
this.logger.log('safety details',
new URL(scratch).searchParams);
}
break;
case this.UidMode.SCHOOL:
scratch = element.querySelector('a[href*="/school/"]')
?.href;
if (scratch) {
// The same school may be referenced more than once.
content = new URL(scratch).pathname +
scroller.defaultUid(element);
}
break;
case this.UidMode.TEST_ID:
scratch = element.matches(this.#modeUidSelectorTestId)
? element
: element.querySelector(this.#modeUidSelectorTestId);
content = scratch?.dataset.testid;
break;
default:
NH.base.issues.post(
'Unsupported profile entry mode:', mode.description
);
}
if (content) {
results.set(mode, content);
} else if (href) {
try {
const url = new URL(href);
const pathname = url.pathname;
const extra = element.parentElement.matches(':has(hr)')
? '-hr'
: '';
results.set(mode, pathname + extra);
if (document.location.pathname === pathname) {
if (mode === this.UidMode.HREF) {
this.logger.log('ignoring self href');
results.delete(mode);
} else {
this.logger.log('points to self', mode.description);
}
}
} catch (e) {
this.logger.log('caught while examining href:', e);
}
}
}
this.logger.leaving(me, results);
return results;
}
/**
* Suggest UID sources.
*
* @method
* @param {Scroller} scroller - Scroller instance.
* @param {external:Element} element - Element to examine.
*/
#entriesSuggestUids = (scroller, element) => {
const me = this.#entriesSuggestUids.name;
this.logger.entered(me, element);
const suggestions = [];
const results = this.#entriesModeToUid(
scroller, element, Object.values(this.UidMode)
);
for (const [key, value] of results.entries()) {
suggestions.push(key);
this.logger.log(key.description, value);
}
const page = new URL(document.location);
const anchors = new Set(element.querySelectorAll('a')
.values()
.map(x => x.href)
.filter(x => !['/', page.pathname].includes(new URL(x).pathname)));
const ids = element.querySelectorAll(this.entriesUidSelectorId);
if (anchors.size > 1) {
suggestions.push('anchors');
this.logger.log('Anchors to consider:', anchors);
}
if (ids.length > 1) {
suggestions.push('ids');
this.logger.log('IDs to consider:', ids);
}
this.logger.leaving(me, 'Suggested:', suggestions);
}
/**
* Return the first UID computed from supported modes.
*
* @method
* @param {Scroller} scroller - Scroller instance.
* @param {external:Element} element - Element to examine.
* @returns {{0: UidMode, 1: string}} How the UID was computed, and
* value.
*/
#entriesUidFromModes = (scroller, element) => {
const me = this.#entriesUidFromModes.name;
this.logger.entered(me, element);
const results = this.#entriesModeToUid(
scroller, element, this.#entriesCurrentModes
);
if (results.size === 0) {
this.#entriesSuggestUids(scroller, element);
results.set(this.UidMode.FALLBACK, scroller.defaultUid(element));
}
const [mode, uid] = results
.entries()
.next().value;
this.logger.leaving(me, mode, uid);
return [mode, uid];
}
/**
* @method
* @returns {NexusHoratio.web.StyleService~ElementMap} Elements to
* monitor.
*/
#scrollerFinder = async () => {
const me = this.#scrollerFinder.name;
this.logger.entered(me);
const timeout = 4000;
const elements = new Map();
try {
elements.set(
'toolbar', await NH.web.waitForSelector('[role="toolbar"]', timeout)
);
} catch (e) {
NH.base.issues.post(`${this.name}.${me}: toolbar`, e);
}
this.logger.leaving(me, elements);
return elements;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueSectionIdentifier = (scroller, element) => { // eslint-disable-line max-statements, max-lines-per-function
const me = this.#uniqueSectionIdentifier.name;
this.logger.entered(me, element);
// There are two types of prefixes: One with a magic string, another
// with url. We need both.
const prefixMagic = this.#sectionUidPrefixes.get(
window.location.pathname
);
const twoBack = -2;
const prefixUrl = [
prefixMagic.split('.ref')[0],
'.ref',
window.location.pathname.split('/')
.at(twoBack),
].join('');
let content = '';
let cardId = '';
const key = LinkedIn.ckeyIdentifier(element);
const similarTo = element.closest(
`[${CKEY}^="ProfilePostConnectDrawer"]`
);
const analytics = element.querySelector('a[href$="/dashboard/"]');
const interests = element.querySelector(`h2[${CKEY}$="_Interests`);
if (key) {
content = key;
if (key.startsWith(prefixMagic)) {
cardId = key.slice(prefixMagic.length);
} else if (key.startsWith(prefixUrl)) {
cardId = key.slice(prefixUrl.length);
}
}
if (similarTo) {
content = 'SimilarTo';
}
if (analytics) {
content = 'Analytics';
}
if (interests) {
content = 'Interests';
}
if (cardId) {
content = cardId;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#entriesUidShim = (scroller, element) => {
const [mode, content] = this.#entriesCurrentUid(scroller, element);
return [mode.description, content].join('-');
}
#resetEntries = () => {
if (this.#entryScroller) {
this.#entryScroller.destroy();
this.#entryScroller = null;
}
this.entries;
}
/**
* @method
* @implements {NexusHoratio.base.Dispatcher~Handler}
* @param {string} type - Event type.
* @param {NexusHoratio.spa.Page~Pages} pages - Updated pages information.
*/
#onSpaActivated = async (type, pages) => {
const me = this.#onSpaActivated.name;
this.logger.entered(me);
let prefix = `Skipping as not a ${this.name} page`;
if (pages.active.has(this)) {
// When returning to the previous page, the URL change is detected
// first (triggering this method) before the page is updated. In that
// situation, this can see the old Topcard. By mapping pathname to
// prefix (which seems stable), this will use the previous value
// rather than getting the wrong one.
const pathname = window.location.pathname;
prefix = this.#sectionUidPrefixes.get(pathname);
if (!prefix) {
const TOP_CARD = 'Topcard';
const selector = `[${CKEY}$="${TOP_CARD}"]`;
const timeout = 8000;
// Grab the per-user prefix for the current profile that is used for
// many `section` identifiers.
try {
const topCard = await NH.web.waitForSelector(selector, timeout);
prefix = topCard?.getAttribute(CKEY)
?.slice(0, -TOP_CARD.length);
this.#sectionUidPrefixes.set(pathname, prefix);
} catch (e) {
NH.base.issues.post(
`${TOP_CARD} timed out`,
'See https://github.com/nexushoratio/userscripts/issues/302#issuecomment-5269123801'
);
}
}
}
this.logger.leaving(me, prefix);
}
#checkPartialOrder = () => {
const me = this.#checkPartialOrder.name;
this.logger.entered(me, this.#checkingPartialOrder);
if (!this.#checkingPartialOrder) {
this.#checkingPartialOrder = true;
const startItem = this.sections.item;
this.sections.last();
const lastItem = this.sections.item;
this.sections.first();
while (this.sections.item !== lastItem) {
const left = this.sections.itemUid;
this.sections.next();
const right = this.sections.itemUid;
const pair = `${left}, ${right}`;
if (left && right && !this.#sectionsPartialOrder.has(pair)) {
this.#sectionsPartialOrder.add(pair);
NH.base.issues.post('Missing Profile pairing', `'${pair}',`);
}
}
this.sections.goto(startItem);
this.#checkingPartialOrder = false;
}
this.logger.leaving(me);
}
#onEntryChange = () => {
this.#lastScroller = this.entries;
}
#onSectionChange = () => {
this.#resetEntries();
this.#lastScroller = this.sections;
if (litOptions.enableAlertUnknownProfileSections) {
this.#checkPartialOrder();
}
}
#returnToSection = () => {
this.sections.item = this.sections.item;
}
}
/**
* Class for handling the Events page.
*
* @extends module:linkedin-tool~Page
*/
class Events extends Page {
/**
* @param {NexusHoratio.spa.SPA} spa - SPA instance that manages this
* {@link module:linkedin-tool~Page Page}.
*/
constructor(spa) {
super({
spa: spa,
pathname: '/events/',
readySelector: '#share-linkedin-small',
readySelectorTimeout: 4000,
});
this.addService(LinkedInStyleService)
.addStyles(LinkedIn.Style.ONE);
this.addService(VMKeyboardService)
.addInstance(this);
this.#initScrollers();
}
/** @type {Scroller} */
get collections() {
return this.#collectionScroller;
}
/** @type {Scroller} */
get events() {
if (!this.#eventScroller && this.collections.item) {
this.#initEventScroller();
}
return this.#eventScroller;
}
nextEventsCollection = new Shortcut(
'j',
'Next event collection',
() => {
this.collections.next();
}
);
prevEventsCollection = new Shortcut(
'k',
'Previous event collection',
() => {
this.collections.prev();
}
);
nextEvent = new Shortcut(
'n',
'Next event in collection',
() => {
this.events?.next();
}
);
prevEvent = new Shortcut(
'p',
'Previous event in collection',
() => {
this.events?.prev();
}
);
firstItem = new Shortcut(
'<',
'Go to first item',
() => {
this.#lastScroller.first();
}
);
lastItem = new Shortcut(
'>',
'Go to last item',
() => {
this.#lastScroller.last();
}
);
focusBrowser = new Shortcut(
'f',
'Change browser focus to current item',
() => {
this.#lastScroller.focus();
}
);
shareItem = new Shortcut(
'S',
'Share the current item, if available',
() => {
const me = this.shareItem.name;
const item = this.events?.item;
this.logger.entered(me, item);
if (item) {
NH.web.clickElement(item,
['button.events-components-shared-support-share__share-button']);
}
this.logger.leaving(me);
}
);
#collectionScroller
#collectionsContainer = 'main:has(> section)'
#eventScroller
#lastScroller
#initScrollers = () => {
this.#initScrollerStyleService();
this.#initCollectionScroller();
}
#initScrollerStyleService = () => {
const styleConfig = {
className: this.scrollerClassName,
finder: this.#scrollerFinder,
elementsProcessor: this.#scrollerElementsProcessor,
};
this.addService(NH.web.StyleService, styleConfig);
}
#initCollectionScroller = () => {
const what = {
name: `${this.name} collections`,
containerItems: [
{
container: this.#collectionsContainer,
items: [
// Major collections
':scope > section',
].join(','),
},
],
};
const how = {
uidCallback: this.#uniqueCollectionIdentifier,
classes: [
LinkedIn.scrollerPrimaryClassName,
this.scrollerClassName,
],
snapToTop: true,
};
this.#collectionScroller = new Scroller(what, how);
this.addService(ScrollerService)
.setScroller(this.#collectionScroller);
this.#collectionScroller.dispatcher
.on('change', this.#onCollectionChange);
this.#lastScroller = this.#collectionScroller;
}
#initEventScroller = () => {
const what = {
name: `${this.name} events`,
base: this.collections.item,
selectors: [
// Your events collection
':scope > section > div > a',
// Your events footer
':scope > div > a',
// Most event collections
':scope > main > div > section',
// Exclusive for Premium
':scope > main > div > div > section',
// Show more (most of them)
':scope > footer',
],
};
const how = {
uidCallback: this.#uniqueEventIdentifier,
classes: [
LinkedIn.scrollerSecondaryClassName,
this.scrollerClassName,
],
snapToTop: false,
};
this.#eventScroller = new Scroller(what, how);
this.#eventScroller.dispatcher
.on('change', this.#onEventChange)
.on('out-of-range', this.#returnToCollection);
}
/**
* @method
* @returns {NexusHoratio.web.StyleService~ElementMap} Elements to
* monitor.
*/
#scrollerFinder = () => {
const me = this.#scrollerFinder.name;
this.logger.entered(me);
const elements = new Map();
elements.set('navbar', document.querySelector('#global-nav'));
elements.set(
'main',
document.querySelector(this.#collectionsContainer)?.parentElement
);
this.logger.leaving(me, elements);
return elements;
}
/**
* @method
* @implements {NexusHoratio.web.StyleService~ElementsProcessor}
* @param {NexusHoratio.web.StyleService~ElementMap} elements - Elements
* to examine.
* @returns {NexusHoratio.web.StyleService~StyleProperties} Style
* properties for to contribute.
*/
#scrollerElementsProcessor = (elements) => {
const me = this.#scrollerElementsProcessor.name;
this.logger.entered(me, elements);
const tops = [];
const properties = new Map();
for (const [key, value] of elements.entries()) {
switch (key) {
case 'navbar':
if (value) {
tops.push(`${value.offsetHeight}px`);
}
break;
case 'main':
if (value) {
tops.push(getComputedStyle(value).marginTop);
}
break;
default:
NH.base.issues.post(
this.name, me, 'Unsupported element key:', key
);
}
}
if (tops.length) {
const plus = tops.join(' + ');
properties.set('scroll-margin-top', `calc(${plus})`);
}
this.logger.leaving(me, properties);
return properties;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueCollectionIdentifier = (scroller, element) => {
const me = this.#uniqueCollectionIdentifier.name;
this.logger.entered(me, element);
let content = '';
const h1 = LinkedIn.h1(element);
const h2 = LinkedIn.h2(element);
if (h2) {
content = h2;
}
if (h1) {
content = h1;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueEventIdentifier = (scroller, element) => {
const me = this.#uniqueEventIdentifier.name;
this.logger.entered(me, element);
let content = '';
const anchor = element.querySelector('a');
if (anchor?.href) {
content = new URL(anchor.href).pathname;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
#resetEvents = () => {
this.#eventScroller?.destroy();
this.#eventScroller = null;
this.events;
}
#onEventChange = () => {
this.events?.item?.classList.remove('artdeco-card');
this.#lastScroller = this.events;
}
#returnToCollection = () => {
this.collections.item = this.collections.item;
}
#onCollectionChange = () => {
this.#resetEvents();
this.#lastScroller = this.collections;
}
}
/**
* Class for handling the Specific Event pages.
*
* @todo [(#237)](https://github.com/nexushoratio/userscripts/issues/237)
* WIP
*
* @extends module:linkedin-tool~Page
*/
class EventsSpecific extends Page {
/**
* @param {NexusHoratio.spa.SPA} spa - SPA instance that manages this
* {@link module:linkedin-tool~Page Page}.
*/
constructor(spa) {
super({
spa: spa,
name: 'Specific Event (WIP)',
// eslint-disable-next-line prefer-regex-literals
pathname: RegExp('^/events/[^/]*/', 'u'),
readySelector: 'footer.global-footer-compact',
});
this.addService(LinkedInStyleService)
.addStyles(LinkedIn.Style.ONE);
this.addService(VMKeyboardService)
.setShortName(this.name)
.addInstance(this);
this.#initScrollers();
}
/** @type {Scroller} */
get entries() {
if (!this.#entriesScroller && this.sections.item) {
this.#initEntriesScroller();
}
return this.#entriesScroller;
}
/** @type {Scroller} */
get sections() {
return this.#sectionsScroller;
}
nextSection = new Shortcut(
'j',
'Next section',
() => {
this.sections.next();
}
);
prevSection = new Shortcut(
'k',
'Previous section',
() => {
this.sections.prev();
}
);
nextEntry = new Shortcut(
'n',
'Next entry in a section',
() => {
this.entries?.next();
}
);
prevEntry = new Shortcut(
'p',
'Previous entry in a section',
() => {
this.entries?.prev();
}
);
firstItem = new Shortcut(
'<',
'Go to the first section or entry',
() => {
this.#lastScroller.first();
}
);
lastItem = new Shortcut(
'>',
'Go to the last section or entry',
() => {
this.#lastScroller.last();
}
);
focusBrowser = new Shortcut(
'f',
'Change browser focus to current item',
() => {
this.#lastScroller.focus();
}
);
showMore = new Shortcut(
'm',
'Toggle showing more of current item',
() => {
const el = this.#lastScroller.item;
NH.web.clickElement(el, ['a[class*="lt-line-clamp"]']);
}
);
attend = new Shortcut(
'A',
'Attend the event',
() => {
NH.web.clickElement(document, ['button.artdeco-button--primary']);
}
);
openShareMenu = new Shortcut(
'S',
'Open share menu',
() => {
NH.web.clickElement(document, ['.social-share button']);
}
);
openMeatballMenu = new Shortcut(
'=',
'Open the <button>⋯</button> menu',
() => {
// Will need work as there are also menus in the post section.
const topCard = document.querySelector('main > section');
NH.web.clickElement(topCard,
['button:has(> svg[data-test-icon^="overflow"]']);
}
);
#entriesScroller
#entriesScrollerConfigs = new Map();
#lastScroller
#sectionsContainer = 'main:has(> section)'
#sectionsScroller
#initScrollers = () => {
this.#initEntriesScrollerConfigs();
this.#initScrollerStyleService();
this.#initSectionsScroller();
}
#initEntriesScrollerConfigs = () => {
this.#entriesScrollerConfigs.set(
'top-card', {
uidCallback: this.#uniqueEntriesIdTopcard,
selectors: [':scope > div > div'],
}
);
this.#entriesScrollerConfigs.set(
'description', {
// This actually works well here.
uidCallback: this.#uniqueSectionIdentifier,
selectors: [':scope > div > div'],
}
);
this.#entriesScrollerConfigs.set(
'live-speaker-list', {
// This actually works well here.
uidCallback: this.#uniqueEntriesIdSpeaker,
selectors: [':scope > div > div > div'],
}
);
this.#entriesScrollerConfigs.set(
'urn', {
uidCallback: this.#uniqueEntriesIdTbd,
selectors: [
/**
* @todo [(#237)](https://github.com/nexushoratio/userscripts/issues/237)
* Placeholder during development.
*/
':scope > *',
],
}
);
}
#initScrollerStyleService = () => {
const styleConfig = {
className: this.scrollerClassName,
finder: this.#scrollerFinder,
elementsProcessor: this.#scrollerElementsProcessor,
};
this.addService(NH.web.StyleService, styleConfig);
}
#initEntriesScroller = () => {
const me = this.#initEntriesScroller.name;
this.logger.entered(me, 'current section', this.sections.itemUid);
const key = this.sections.itemUid;
const config = this.#entriesScrollerConfigs.get(key) ??
this.#entriesScrollerConfigs.get('urn');
this.logger.log('config', config);
const what = {
name: `${this.name} entries`,
base: this.sections.item,
selectors: config.selectors,
};
const how = {
uidCallback: config.uidCallback,
classes: [
LinkedIn.scrollerSecondaryClassName,
this.scrollerClassName,
],
autoActivate: true,
snapToTop: false,
};
this.#entriesScroller = new Scroller(what, how);
this.#entriesScroller.dispatcher
.on('change', this.#onEntryChange)
.on('out-of-range', this.#returnToSections);
this.logger.leaving(me);
}
/**
* @method
* @returns {NexusHoratio.web.StyleService~ElementMap} Elements to
* monitor.
*/
#scrollerFinder = () => {
const me = this.#scrollerFinder.name;
this.logger.entered(me);
const elements = new Map();
elements.set('navbar', document.querySelector('#global-nav'));
elements.set(
'main',
document.querySelector(this.#sectionsContainer)?.parentElement
);
this.logger.leaving(me, elements);
return elements;
}
/**
* @method
* @implements {NexusHoratio.web.StyleService~ElementsProcessor}
* @param {NexusHoratio.web.StyleService~ElementMap} elements - Elements
* to examine.
* @returns {NexusHoratio.web.StyleService~StyleProperties} Style
* properties for to contribute.
*/
#scrollerElementsProcessor = (elements) => {
const me = this.#scrollerElementsProcessor.name;
this.logger.entered(me, elements);
const tops = [];
const properties = new Map();
for (const [key, value] of elements.entries()) {
switch (key) {
case 'navbar':
if (value) {
tops.push(`${value.offsetHeight}px`);
}
break;
case 'main':
if (value) {
tops.push(getComputedStyle(value).marginTop);
}
break;
default:
NH.base.issues.post(
this.name, me, 'Unsupported element key:', key
);
}
}
if (tops.length) {
const plus = tops.join(' + ');
properties.set('scroll-margin-top', `calc(${plus})`);
}
this.logger.leaving(me, properties);
return properties;
}
#initSectionsScroller = () => {
const what = {
name: `${this.name} sections`,
containerItems: [
{
container: this.#sectionsContainer,
items: [
// Topcard
':scope > section',
// Details
':scope > div > div > section',
// Comments
':scope > div > div [role="article"]',
// Networking
':scope > div > section',
].join(','),
},
],
};
const how = {
uidCallback: this.#uniqueSectionIdentifier,
classes: [
LinkedIn.scrollerPrimaryClassName,
this.scrollerClassName,
],
snapToTop: true,
};
this.#sectionsScroller = new Scroller(what, how);
this.addService(ScrollerService)
.setScroller(this.#sectionsScroller);
this.#sectionsScroller.dispatcher
.on('change', this.#onSectionChange);
this.#lastScroller = this.sections;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueSectionIdentifier = (scroller, element) => { // eslint-disable-line max-statements
const me = this.#uniqueSectionIdentifier.name;
this.logger.entered(me, element);
const idSelector = '[id]:not([id^="ember"])';
const prefix = 'events-';
let content = '';
const id = element.matches(idSelector)
? element
: element.querySelector(idSelector);
const header = element.querySelector('header');
const cohorts = element.querySelector('.events-cohort-item');
const urn = element.dataset.urn;
if (id) {
content = id.id;
}
if (header) {
content = header.classList.values()
.find(x => x.startsWith(prefix));
}
if (cohorts) {
const scratch = ['cohorts'];
if (header) {
scratch.push(header.innerText);
} else {
scratch.push(scroller.defaultUid(element));
}
content = scratch.join('-');
}
if (urn) {
content = urn;
}
if (!content) {
content = scroller.defaultUid(element);
} else if (content.startsWith(prefix)) {
content = content
.slice(prefix.length)
.split('__')[0];
}
this.logger.leaving(me, content);
return content;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueEntriesIdTbd = (scroller, element) => {
const me = this.#uniqueEntriesIdTbd.name;
this.logger.entered(me, element);
let content = '';
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueEntriesIdTopcard = (scroller, element) => {
const me = this.#uniqueEntriesIdTopcard.name;
this.logger.entered(me, element);
const prefix = 'events-';
let content = '';
const cssClass = element.classList.values()
.find(x => x.startsWith(prefix));
if (cssClass) {
content = cssClass;
}
if (!content) {
content = scroller.defaultUid(element);
} else if (content.startsWith(prefix)) {
content = content
.slice(prefix.length)
.split('__')[0];
}
this.logger.leaving(me, content);
return content;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueEntriesIdSpeaker = (scroller, element) => {
const me = this.#uniqueEntriesIdSpeaker.name;
this.logger.entered(me, element);
let content = '';
const anchor = element.querySelector('a')?.href;
if (anchor) {
content = new URL(anchor).pathname;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
#onSectionChange = () => {
this.#resetEntries();
this.#lastScroller = this.sections;
}
#resetEntries = () => {
this.#entriesScroller?.destroy();
this.#entriesScroller = null;
this.entries;
}
#onEntryChange = () => {
this.#lastScroller = this.entries;
if (this.entries?.itemUid === 'navigation-container') {
this.entries.item.querySelector('[aria-selected="true"]')
?.focus();
}
}
#returnToSections = () => {
this.sections.item = this.sections.item;
}
}
/**
* Class for handling the SearchResultsPeople page.
*
* @todo [(#209)](https://github.com/nexushoratio/userscripts/issues/209)
* WIP
*
* @extends module:linkedin-tool~Page
*/
class SearchResultsPeople extends Page {
/**
* @param {NexusHoratio.spa.SPA} spa - SPA instance that manages this
* {@link module:linkedin-tool~Page Page}.
*/
constructor(spa) {
super({
spa: spa,
name: 'Search Results People (WIP)',
pathname: '/search/results/people/',
readySelector: '#linkedin-logo-xxsmall',
});
this.addService(LinkedInStyleService)
.addStyles(LinkedIn.Style.TWO);
this.addService(VMKeyboardService)
.addInstance(this);
this.#initScrollers();
}
/** @type {Scroller} */
get paginator() {
return this.#paginationScroller;
}
/** @type {Scroller} */
get results() {
return this.#resultScroller;
}
nextResult = new Shortcut(
'j',
'Next result',
() => {
this.results.next();
}
);
prevResult = new Shortcut(
'k',
'Previous result',
() => {
this.results.prev();
}
);
nextResultsPage = new Shortcut(
'N',
'Next results page',
() => {
this.paginator.next();
}
);
prevResultsPage = new Shortcut(
'P',
'Previous results page',
() => {
this.paginator.prev();
}
);
firstItem = new Shortcut(
'<',
'Go to the first item',
() => {
this.#lastScroller.first();
}
);
lastItem = new Shortcut(
'>',
'Go to the last item',
() => {
this.#lastScroller.last();
}
);
focusBrowser = new Shortcut(
'f',
'Change browser focus to current item',
() => {
this.#lastScroller.focus();
}
);
gotoFilter = new Shortcut(
'F',
'Move focus to the search filters',
() => {
const element = document.querySelector(
`[${CKEY}="SearchResults_SearchResultsFilterBar"] [role="button"]`
);
NH.web.focusOnElement(element);
}
);
#lastScroller
#paginationScroller
#resultScroller
#initScrollers = () => {
this.#initScrollerStyleService();
this.#initPaginationScroller();
this.#initResultScroller();
}
#initScrollerStyleService = () => {
const styleConfig = {
className: this.scrollerClassName,
finder: this.#scrollerFinder,
elementsProcessor: this.#scrollerElementsProcessor,
};
this.addService(NH.web.StyleService, styleConfig);
}
#initPaginationScroller = () => {
const what = {
name: `${this.name} pagination`,
containerItems: [
{
// This selector is also used in #onPaginationActivate.
container: 'main ul[data-testid="pagination-controls-list"]',
items: ':scope > li',
},
],
};
const how = {
uidCallback: this.#uniquePaginationIdentifier,
classes: [LinkedIn.scrollerSecondaryClassName],
snapToTop: false,
containerTimeout: 1000,
};
this.#paginationScroller = new Scroller(what, how);
this.addService(ScrollerService)
.setScroller(this.#paginationScroller);
this.#paginationScroller.dispatcher
.on('activate', this.#onPaginationActivate)
.on('change', this.#onPaginationChange);
}
#initResultScroller = () => {
const what = {
name: `${this.name} cards`,
containerItems: [
{
container: '[data-testid="lazy-column"]',
items: `a[${CKEY}]:not([aria-label])`,
},
],
};
const how = {
uidCallback: this.#uniqueResultIdentifier,
classes: [
LinkedIn.scrollerPrimaryClassName,
this.scrollerClassName,
],
snapToTop: true,
};
this.#resultScroller = new Scroller(what, how);
this.addService(ScrollerService)
.setScroller(this.#resultScroller);
this.#resultScroller.dispatcher
.on('change', this.#onResultChange);
this.#lastScroller = this.#resultScroller;
}
/**
* @method
* @returns {NexusHoratio.web.StyleService~ElementMap} Elements to
* monitor.
*/
#scrollerFinder = () => {
const me = this.#scrollerFinder.name;
this.logger.entered(me);
const elements = new Map();
elements.set(
'primary',
document.querySelector(LinkedIn.primaryContentSelector)
?.parentElement
?.parentElement
);
this.logger.leaving(me, elements);
return elements;
}
/**
* @method
* @implements {NexusHoratio.web.StyleService~ElementsProcessor}
* @param {NexusHoratio.web.StyleService~ElementMap} elements - Elements
* to examine.
* @returns {NexusHoratio.web.StyleService~StyleProperties} Style
* properties for to contribute.
*/
#scrollerElementsProcessor = (elements) => {
const me = this.#scrollerElementsProcessor.name;
this.logger.entered(me, elements);
const properties = new Map();
for (const [key, value] of elements.entries()) {
switch (key) {
case 'primary':
if (value) {
const style = getComputedStyle(value);
properties.set('scroll-margin-top', style.marginTop);
}
break;
default:
NH.base.issues.post(
this.name, me, 'Unsupported element key:', key
);
}
}
this.logger.leaving(me, properties);
return properties;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniquePaginationIdentifier = (scroller, element) => {
const me = this.#uniquePaginationIdentifier.name;
this.logger.entered(me, element);
const content = scroller.defaultUid(element);
this.logger.leaving(me, content);
return content;
}
/**
* @method
* @implements {Scroller~uidCallback}
* @param {Scroller} scroller - The calling {@link Scroller} instance.
* @param {external:Element} element - Element to examine.
* @returns {string} A value unique to this element.
*/
#uniqueResultIdentifier = (scroller, element) => {
const me = this.#uniqueResultIdentifier.name;
this.logger.entered(me, element);
let content = '';
const href = element.href;
if (href) {
content = new URL(href).pathname;
}
if (!content) {
content = scroller.defaultUid(element);
}
this.logger.leaving(me, content);
return content;
}
#onPaginationActivate = async () => {
const me = this.#onPaginationActivate.name;
this.logger.entered(me);
try {
const timeout = 2000;
const item = await NH.web.waitForSelector(
'[data-testid="pagination-controls-list"] > li' +
' > [aria-current="true"]',
timeout
);
this.paginator.goto(item);
// The previous line popped the page to the bottom, so go to someplace
// reasonable. On a similar page, JobsCollections, the URL changes to
// match the current card, so it watches that to avoid this same
// problem.
const result = this.results.item;
if (result) {
this.results.goto(result);
} else {
this.results.first();
}
} catch (e) {
this.logger.log('Results paginator not found, staying put');
}
this.logger.leaving(me);
}
#onPaginationChange = () => {
this.#lastScroller = this.paginator;
}
#onResultChange = () => {
this.#lastScroller = this.results;
}
}
/**
* Class for tracking pages to support.
*
* @extends module:linkedin-tool~Page
*/
class PagesToDo extends Page {
/**
* @param {NexusHoratio.spa.SPA} spa - SPA instance that manages this
* {@link module:linkedin-tool~Page Page}.
*/
constructor(spa) { // eslint-disable-line max-lines-per-function
const URLs = [
/**
* @todo [(#253)](https://github.com/nexushoratio/userscripts/issues/253)
* Support **Manage Events** page
*/
'/mynetwork/network-manager/events/',
/**
* @todo [(#255)](https://github.com/nexushoratio/userscripts/issues/255)
* Support **Search appearances** page
*/
'/analytics/search-appearances/',
/**
* @todo [(#256)](https://github.com/nexushoratio/userscripts/issues/256)
* Support **Verify** page
*/
'/verify/',
/**
* @todo [(#257)](https://github.com/nexushoratio/userscripts/issues/257)
* Support **Analytics & tools** page
*/
'/dashboard/',
/**
* @todo [(#260)](https://github.com/nexushoratio/userscripts/issues/260)
* Support **Job tracker** page
*/
'/jobs-tracker/',
/**
* @todo [(#261)](https://github.com/nexushoratio/userscripts/issues/261)
* Support **Follow Page** Page
*/
'/suggested-for-you/follow-page/',
/**
* @todo [(#262)](https://github.com/nexushoratio/userscripts/issues/262)
* Support **Analytics Posts** Page
*/
'/analytics/creator/content/',
/**
* @todo [(#263)](https://github.com/nexushoratio/userscripts/issues/263)
* Support **Feed update** Page
*/
'/feed/update/',
/**
* @todo [(#264)](https://github.com/nexushoratio/userscripts/issues/264)
* Support **Saved Posts** Page
*/
'/my-items/saved-posts/',
/**
* @todo [(#265)](https://github.com/nexushoratio/userscripts/issues/265)
* Support **Post analytics** Page
*/
'/analytics/post-summary/[^/]*/',
/**
* @todo [(#266)](https://github.com/nexushoratio/userscripts/issues/266)
* Support **Company** Page
*/
'/company/[^/]*/',
/**
* @todo [(#360)](https://github.com/nexushoratio/userscripts/issues/360)
* Support **SearchResultsAll** page
*/
'/search/results/all/?',
'/search/results/',
/**
* @todo [(#386)](https://github.com/nexushoratio/userscripts/issues/386)
* Support **Games** pages
*/
'/games/.*/?',
/**
* @todo [(#408)](https://github.com/nexushoratio/userscripts/issues/408)
* Support **Connections** page
*/
'/mynetwork/invite-connect/connections/?',
].map(x => `(${x})`)
.join('|');
super({
spa: spa,
pathname: RegExp(`^(${URLs})$`, 'u'),
});
}
}
NH.xunit.testing.run();
const linkedIn = new LinkedIn();
await linkedIn.ready;
log.log('proceeding...');
const spa = new NH.spa.SPA(linkedIn);
if (litOptions.enableWatchPage) {
spa.register(NH.spa.WatchPage);
}
spa.register(Feed);
spa.register(MyNetwork);
spa.register(InvitationManager);
spa.register(Jobs);
spa.register(JobsCollections);
spa.register(JobsView);
spa.register(Messaging);
spa.register(Notifications);
spa.register(Profile);
spa.register(Events);
spa.register(EventsSpecific);
spa.register(SearchResultsPeople);
spa.register(PagesToDo);
// Registering Global last ensures we check for an unsupported page at least
// once.
linkedIn.registrationComplete = true;
spa.register(Global);
log.log('Initialization successful.');
})();