Created
October 13, 2024 21:15
-
-
Save weiland/7c39b14be03859372269fb366e9d192a to your computer and use it in GitHub Desktop.
Mastodon custom emojis support for Träwelling Web
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(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