Skip to content

Instantly share code, notes, and snippets.

@weiland
Created October 13, 2024 21:15
Show Gist options
  • Save weiland/7c39b14be03859372269fb366e9d192a to your computer and use it in GitHub Desktop.
Save weiland/7c39b14be03859372269fb366e9d192a to your computer and use it in GitHub Desktop.
Mastodon custom emojis support for Träwelling Web
(async () => {
// types
/**
* @typedef {Object} MastodonEmoji
* @property {string} shortcode
* @property {string} url
* @property {string} static_url
*/
/**
* @typedef {Object} Emoji
* @property {string} shortcode
* @property {string} url
*/
// debugging
const DEBUG = true;
/** @param {...any} args */
const debug = (...args) => DEBUG && console.debug("[custom-emojis]:", ...args);
// localStorage (around 10 MB limit)
const MAX_AGE = 60 * 60 * 24 * 1; // one day for now
const CACHE_KEY = "traewelling_customEmojis";
// Unix timestamp in seconds
const NOW = Math.floor(Date.now() / 1000);
/** @type {Map<string, {emojis: Emoji[], expiration: number}>} */
let cache = new Map();
/**
* Replaces all contents in `localStorage` with new contents from cache object.
*/
function updateLocalStorage() {
if (!cache || cache.size === 0) return;
try {
localStorage.setItem(CACHE_KEY, JSON.stringify([...cache]));
debug("wrote new cache items to `localStorage`");
} catch (error) {
console.error("could not save emojis cache to `localStorage`", error);
}
}
function locadCacheFromLocalStorage() {
try {
if (!navigator.cookieEnabled) console.info("Cookies are disabled.");
const item = localStorage.getItem(CACHE_KEY);
if (item) {
const content = new Map(JSON.parse(item));
cache = content;
debug("loaded cache items from localStorage");
}
} catch (error) {
console.error("could not load localStorage", error);
}
}
// try to load data from localStorage
if (cache.size === 0) {
locadCacheFromLocalStorage();
}
/**
* Replaces all text emojis (`:emoji_name:`) with matching images from mastodon.
*
*/
async function renderEmojis() {
// selector to find all statuses on the page
const selector = ".card.status";
const elements = document.querySelectorAll(selector);
if (!elements) return;
// Array of all statuses containing emojis
const statuses = await Promise.all(
[...elements]
.map((e) => toPossibleEmojiStatus(/** @type {HTMLDivElement} */ (e)))
.filter((s) => s !== undefined)
// fetch all emojis and cache them.
.map(async ({ id, body, shortcodes }) => ({
body,
emojis: await updateEmojis(id, shortcodes),
})),
);
debug("statuses", statuses);
// write cache to `localStorage` (after fetching)
updateLocalStorage();
// replace all HTML occurances
for (const { body, emojis } of statuses) {
replaceAllEmojis(body, emojis);
}
}
/**
* Extracts the status id and body element from a status HTMLElement
* if the body contains an emoji, otherwise return `undefined`.
*
* @param {HTMLDivElement} status
*
* @returns {{id: string, body: HTMLDivElement, shortcodes: string[]}|undefined}
*/
function toPossibleEmojiStatus(status) {
// obtain status Id
const id = status.dataset.trwlId;
if (!id) return;
// obtain status text
/** @type {HTMLDivElement|null} */
const statusElement = document.querySelector(`#status-${id}`);
if (!statusElement) {
console.error("no `statusElement` found");
return;
}
/** @type {HTMLDivElement|null} */
const body = statusElement.querySelector(".status-body");
if (!body) return;
const text = body.textContent?.trim() ?? "";
// matches all `:words:` and `:custom_emojis:`
const shortcodes = [...text.matchAll(/:(\w+):/g)].map(([_, shortcode]) => shortcode);
if (!shortcodes) return;
return { id, body, shortcodes };
}
/**
* Updates local emojis
* including localStorage cache.
* Returns an updated list with
*
* @param {string} statusId
* @param {string[]} emojis - Array with shortcode-url emoji pairs
*
* @returns {Promise<Emoji[]>}
*/
async function updateEmojis(statusId, emojis) {
// fetch mastodon url of a user for a status.
const hostname = await fetchMastodonHostname(statusId);
if (!hostname) return [];
// match emojis with list from server
const serverEmojis = await fetchCachedEmojis(hostname);
const updatedEmojis = emojis
.map((e) => serverEmojis.find(({ shortcode }) => shortcode === e))
.filter((e) => e !== undefined);
return updatedEmojis;
}
/**
* Fetches emojis from mastodon hostname or use data from cache.
*
* @param {string} hostname - mastodon instance hostname
*
* @returns {Promise<Emoji[]>} - Array with emoji pairs
*/
async function fetchCachedEmojis(hostname) {
// return cached emojis for hostname if cached and not outdated
const entry = cache.get(hostname);
if (cache.has(hostname) && entry) {
const { emojis, expiration } = entry;
// if data is not yet expired, return it
if (expiration && expiration > NOW) {
debug("using cached emojis");
return emojis;
}
}
// otherwise, fetch fresh emojis
const emojis = await fetchEmojis(hostname);
const newEntry = { emojis, expiration: NOW + MAX_AGE };
// cache the new result
cache.set(hostname, newEntry);
debug("saved new cache items to localStorage");
return emojis;
}
/**
* Fetches a user's mastodon instance hostname for a status.
*
* @param {String} id - status id
* @returns {Promise<String|undefined>}
*/
async function fetchMastodonHostname(id) {
const response = await fetch(`/api/v1/status/${id}`);
const { data } = await response.json();
const mastoHost = data?.userDetails?.mastodonUrl;
if (!mastoHost) return;
const url = new URL(mastoHost);
return url.hostname;
}
/**
* Fetch all mastodon custom emojis.
*
* @param {String} hostname - Mastodon instance hostname.
* @returns {Promise<Emoji[]>}
*/
async function fetchEmojis(hostname) {
const response = await fetch(`https://${hostname}/api/v1/custom_emojis`);
/** @type {MastodonEmoji[]} */
const json = await response.json();
return json.map(({ shortcode, static_url: url }) => ({ shortcode, url }));
}
/**
* Replaces all custom emojis with a matching image element in the HTMLElement.
*
* @param {HTMLDivElement} body - status body HTMLElement
* @param {Emoji[]} emojis - shortcode-url emoji pairs
*/
function replaceAllEmojis(body, emojis) {
if (!body || !emojis) return;
const old = body.innerHTML;
let newHTML = old;
for (const { shortcode, url } of emojis) {
const img = `<img src="${url}" alt="${shortcode} emoji" style="max-height: calc(var(--mdb-body-font-size) * var(--mdb-body-line-height));" title="${shortcode}" />`;
newHTML = newHTML.replace(`:${shortcode}:`, img);
}
body.innerHTML = newHTML;
}
// window.renderEmojis = renderEmojis;
renderEmojis();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment