Skip to content
4 changes: 4 additions & 0 deletions src/utils/css_map.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { inject } from './inject.js';

/**
* @see https://github.com/tumblr/docs/blob/master/web-platform.md#getcssmap
* @type {Record<string, string[]>}
*/
export const cssMap = await inject('/main_world/css_map.js');

/**
Expand Down
16 changes: 16 additions & 0 deletions src/utils/interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export const getPopoverWrapper = element => {
*/

/**
* Filter an array of post elements (or descendants) to determine which to process.
* Usually used inside an onNewPosts listener callback function.
* @param {Element[]} postElements Post elements (or descendants) to filter
* @param {PostFilterOptions} [postFilterOptions] Post filter options
* @returns {HTMLDivElement[]} Matching post elements
Expand Down Expand Up @@ -181,6 +183,13 @@ const isVerticallyOverflowing = element => {
return elementRect.bottom > document.documentElement.clientHeight || elementRect.top < 0;
};

/**
* Append a DOM element to a target container, attempting to position it so it
* is fully visible rather than overflowing into a hidden area.
* @param {Element} element Element to append
* @param {Element} target Target container
* @param {'below'|'above'} defaultPosition Above/below position to use if both are valid
*/
export const appendWithoutOverflow = (element, target, defaultPosition = 'below') => {
element.dataset.position = defaultPosition;
element.style.removeProperty('--horizontal-offset');
Expand All @@ -205,5 +214,12 @@ export const appendWithoutOverflow = (element, target, defaultPosition = 'below'
}
};

/**
* Navigate up the React component tree to find an element's closest rendered
* parent matching a given selector. Follows React "portals".
* @param {Element} element A target element, such as a portalled menu
* @param {string} selector CSS selector
* @returns {element?} An element matching the selector and "containing" the target element
*/
export const getClosestRenderedElement = (element, selector) =>
inject('/main_world/closest_rendered_element.js', [selector], element);
8 changes: 8 additions & 0 deletions src/utils/language_data.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { inject } from './inject.js';

/**
* @see https://github.com/tumblr/docs/blob/master/web-platform.md#languagedata
* @typedef {object} TumblrLanguageData
* @property {string} code Current Tumblr locale code
* @property {Record<string, string>} translations Per docs, "a map of English root strings to the equivalents in the current language"
*/

/** @type {TumblrLanguageData} */
export const languageData = await inject('/main_world/language_data.js');

/**
Expand Down
7 changes: 7 additions & 0 deletions src/utils/memoize.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* Create a version of a function that returns results for repeated calls with
* the same argument from a cache, improving performance.
* @template T, R
* @param {function(T): R} func A function with one argument
* @returns {function(T): R} A memoized version of the function
*/
export const memoize = (func) => {
const cache = new Map();
return (arg) => {
Expand Down
13 changes: 13 additions & 0 deletions src/utils/modals.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export const showModal = ({ title, message = [], buttons = [] }) => {
modalElement.focus();
};

/**
* Hide the current takeover prompt.
*/
export const hideModal = () => {
document.getElementById('xkit-modal')?.remove();
lastFocusedElement?.focus();
Expand All @@ -44,6 +47,10 @@ export const hideModal = () => {
export const modalCancelButton = dom('button', null, { click: hideModal }, ['Cancel']);
export const modalCompleteButton = dom('button', { class: 'blue' }, { click: hideModal }, ['OK']);

/**
* Show a takeover prompt to the user with details about a thrown exception.
* @param {Error} exception Thrown exception to describe
*/
export const showErrorModal = exception => {
console.error('XKit Rewritten error:', exception);

Expand All @@ -63,6 +70,12 @@ export const showErrorModal = exception => {
});
};

/**
* Create a version of a function that shows a takeover prompt to the user if it throws.
* @template T, R
* @param {function(...T): R} func A function
* @returns {function(...T): R} A version of the function with explicit error handling
*/
export const withModalOnError = func =>
async (...args) => {
try {
Expand Down
16 changes: 13 additions & 3 deletions src/utils/mutations.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ const isolateErrors = callback => {
}
};

/**
* Utilities to run specified code when a modification to the page results in
* the addition of elements matching a specified CSS selector.
*/
export const pageModifications = Object.freeze({
/** @type {Map<(elements: Element[]) => void, string>} */ listeners: new Map(),

/**
* Register a page modification
* Register a page modification callback
* @param {string} selector CSS selector for elements to target
* @param {(elements: Element[]) => void} modifierFunction Function to handle matching elements
*/
Expand All @@ -32,15 +36,15 @@ export const pageModifications = Object.freeze({
},

/**
* Unregister a page modification
* Unregister a page modification callback
* @param {(elements: Element[]) => void} modifierFunction Previously-registered function to remove
*/
unregister (modifierFunction) {
this.listeners.delete(modifierFunction);
},

/**
* Run a page modification on all existing matching elements
* Run a page modification callback on all existing matching elements
* @param {(elements: Element[]) => void} modifierFunction Previously-registered function to run
*/
trigger (modifierFunction) {
Expand All @@ -64,11 +68,17 @@ export const pageModifications = Object.freeze({
},
});

/**
* Utilities to run specified code when new posts are added to the page.
*/
export const onNewPosts = Object.freeze({
addListener: callback => pageModifications.register(`${postSelector}:not(.sortable-fallback) article`, callback),
removeListener: callback => pageModifications.unregister(callback),
});

/**
* Utilities to run specified code when new notification elements are added to the page.
*/
export const onNewNotifications = Object.freeze({
addListener: callback => pageModifications.register(notificationSelector, callback),
removeListener: callback => pageModifications.unregister(callback),
Expand Down
2 changes: 2 additions & 0 deletions src/utils/preferences.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/**
* Get all of a feature's preference values that are automatically registered by
* the "preferences" field in its feature.json metadata file.
* @param {string} featureName Internal name of feature
* @returns {Promise<object>} The feature's preference values
*/
Expand Down
4 changes: 4 additions & 0 deletions src/utils/react_props.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
*/
export const blogData = async (meatballMenu) => inject('/main_world/unbury_blog.js', [], meatballMenu);

/**
* @param {Element} postElement An on-screen post element
* @returns {Promise<boolean>}

Check warning on line 41 in src/utils/react_props.js

View workflow job for this annotation

GitHub Actions / Test

Missing JSDoc @returns description
*/
export const isMyPost = async (postElement) => {
const { blog, isSubmission, postAuthor, community } = await timelineObject(postElement);
const userIsMember = userBlogNames.includes(blog.name);
Expand Down
5 changes: 5 additions & 0 deletions src/utils/remixicon.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ if (document.querySelector(`svg[data-src="${symbolsUrl}"]`) === null) {
});
}

/**
* @see https://remixicon.com/
* @param {string} symbolId RemixIcon symbol id to use
* @returns {SVGElement} an SVG element that renders the specified icon
*/
export const buildSvg = symbolId => dom('svg', { xmlns: 'http://www.w3.org/2000/svg' }, null, [
dom('use', { xmlns: 'http://www.w3.org/2000/svg', href: `#${symbolId}` }),
]);
3 changes: 3 additions & 0 deletions src/utils/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ const buildSidebarRow = ({ label, onclick, href, count, carrot }) =>
]);

/**
* Add an interactable sidebar item control that will be visible in the page's
* right sidebar in the desktop Tumblr layout, or in the left mobile drawer in
* the tablet/mobile Tumblr layouts.
* @param {object} options Sidebar item options
* @param {string} options.id Unique ID for the sidebar item
* @param {string} options.title Human-readable sidebar item heading
Expand Down
7 changes: 7 additions & 0 deletions src/utils/text_format.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const thresholds = [
];

const relativeTimeFormat = new Intl.RelativeTimeFormat(document.documentElement.lang, { style: 'long' });
/**
* @param {number} unixTime Second-resolution unix time value (as used in Tumblr post timestamps)
* @returns {string} e.g. "13 minutes ago"
*/
export const constructRelativeTimeString = function (unixTime) {
const now = Math.trunc(Date.now() / 1000);
const unixDiff = unixTime - now;
Expand All @@ -24,6 +28,9 @@ export const constructRelativeTimeString = function (unixTime) {
return relativeTimeFormat.format(-0, 'second');
};

/**
* Produces e.g. "July 11, 2020 at 9:58 PM EDT"
*/
export const dateTimeFormat = new Intl.DateTimeFormat(document.documentElement.lang, {
year: 'numeric',
month: 'long',
Expand Down
6 changes: 6 additions & 0 deletions src/utils/timeline_id.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
/**
* TimelineFilter functions exported from this module can be run on a timeline DOM
* element to identify its type from its data attributes. These attributes are not
* part of the official Tumblr API, which provides no clean way to do this.
*/

const createSelector = (...components) => `:is(${components.filter(Boolean).join(', ')})`;

export const timelineSelector = ':is([data-timeline], [data-timeline-id])';
Expand Down
10 changes: 10 additions & 0 deletions src/utils/tumblr_helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,18 @@ export const isNpfCompatible = postData => {
return isBlocksPostFormat || shouldOpenInLegacy === false;
};

/**
* Performs a "soft" navigation within Tumblr's single-page-application.
* @see https://github.com/tumblr/docs/blob/master/web-platform.md#navigate
* @param {string} location Path to navigate to
* @returns {void}
*/
export const navigate = location => inject('/main_world/navigate.js', [location]);

/**
* A click event handler that can be applied to anchor elements to automate soft navigation.
* @param {PointerEvent} event Click event object
*/
export const onClickNavigate = event => {
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) {
event.stopImmediatePropagation();
Expand Down