/** * Mastodon embed timeline * @author idotj * @version 4.3.2 * @url https://gitlab.com/idotj/mastodon-embed-timeline * @license GNU AGPLv3 */ "use strict"; export class Init { constructor(customSettings = {}) { this.defaultSettings = { mtContainerId: "mt-container", instanceUrl: "https://mastodon.social", timelineType: "local", userId: "", profileName: "", hashtagName: "", spinnerClass: "mt-loading-spinner", defaultTheme: "auto", maxNbPostFetch: "20", maxNbPostShow: "20", dateLocale: "en-GB", dateOptions: { day: "2-digit", month: "short", year: "numeric", }, hideUnlisted: false, hideReblog: false, hideReplies: false, hidePinnedPosts: false, hideUserAccount: false, hideEmojos: false, hideVideoPreview: false, hidePreviewLink: false, hideCounterBar: false, markdownBlockquote: false, txtMaxLines: "0", btnShowMore: "SHOW MORE", btnShowLess: "SHOW LESS", btnShowContent: "SHOW CONTENT", btnSeeMore: "See more posts at Mastodon", btnReload: "Refresh", insistSearchContainer: false, insistSearchContainerTime: "3000", }; this.mtSettings = { ...this.defaultSettings, ...customSettings }; this.mtContainerNode = ""; this.mtBodyNode = ""; this.fetchedData = {}; this.#onDOMContentLoaded(() => { this.#getContainerNode(); }); } /** * Trigger callback when DOM loaded or completed * @param {function} c Callback executed */ #onDOMContentLoaded(c) { if (typeof document !== "undefined" && document.readyState === "complete") { c(); } else if ( typeof document !== "undefined" && document.readyState !== "complete" ) { document.addEventListener("DOMContentLoaded", c()); } } /** * Find main container in DOM before building the timeline */ #getContainerNode() { // console.log("Initializing Mastodon timeline with settings: ", this.mtSettings); const assignContainerNode = () => { this.mtContainerNode = document.getElementById( this.mtSettings.mtContainerId ); this.mtBodyNode = this.mtContainerNode.getElementsByClassName("mt-body")[0]; this.#loadColorTheme(); this.#buildTimeline("newTimeline"); }; // Some frameworks render the DOM in deferred way and need some extra time if (this.mtSettings.insistSearchContainer) { const startCheck = performance.now(); const findContainerNode = () => { // Check if the container is ready in DOM if (document.getElementById(this.mtSettings.mtContainerId)) { assignContainerNode(); } else { // If the container is not found, check again const currentCheck = performance.now(); if ( currentCheck - startCheck < this.mtSettings.insistSearchContainerTime ) { requestAnimationFrame(findContainerNode); } else { console.error( `Impossible to find the
container with id: "${ this.mtSettings.mtContainerId }" after several attempts for ${ this.mtSettings.insistSearchContainerTime / 1000 } seconds` ); } } }; findContainerNode(); } else { if (document.getElementById(this.mtSettings.mtContainerId)) { assignContainerNode(); } else { console.error( `Impossible to find the
container with id: "${this.mtSettings.mtContainerId}". Please try to add the option 'insistSearchContainer: true' when initializing the script` ); } } } /** * Reload the timeline by fetching the lastest posts */ mtUpdate() { this.#onDOMContentLoaded(() => { this.mtBodyNode.replaceChildren(); this.mtBodyNode.insertAdjacentHTML( "afterbegin", '
' ); this.#buildTimeline("updateTimeline"); }); } /** * Apply the color theme in the timeline * @param {string} themeType Type of color theme */ mtColorTheme(themeType) { this.#onDOMContentLoaded(() => { this.mtContainerNode.setAttribute("data-theme", themeType); }); } /** * Get the theme style chosen by the user or by the browser/OS */ #loadColorTheme() { if (this.mtSettings.defaultTheme === "auto") { let systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); systemTheme.matches ? this.mtColorTheme("dark") : this.mtColorTheme("light"); // Update the theme if user change browser/OS preference systemTheme.addEventListener("change", (e) => { e.matches ? this.mtColorTheme("dark") : this.mtColorTheme("light"); }); } else { this.mtColorTheme(this.mtSettings.defaultTheme); } } /** * Requests to the server to collect all the data * @returns {object} Data container */ #fetchTimelineData() { return new Promise((resolve, reject) => { /** * Fetch data from server * @param {string} url address to fetch * @returns {array} List of objects */ async function fetchData(url) { const response = await fetch(url); if (!response.ok) { throw new Error( "Failed to fetch the following Url:
" + url + "
" + "Error status: " + response.status + "
" + "Error message: " + response.statusText ); } const data = await response.json(); return data; } // Urls to fetch let urls = {}; if (this.mtSettings.instanceUrl) { if (this.mtSettings.timelineType === "profile") { if (this.mtSettings.userId) { urls.timeline = `${this.mtSettings.instanceUrl}/api/v1/accounts/${this.mtSettings.userId}/statuses?limit=${this.mtSettings.maxNbPostFetch}`; if (!this.mtSettings.hidePinnedPosts) { urls.pinned = `${this.mtSettings.instanceUrl}/api/v1/accounts/${this.mtSettings.userId}/statuses?pinned=true`; } } else { this.#showError( "Please check your userId value", "⚠️" ); } } else if (this.mtSettings.timelineType === "hashtag") { if (this.mtSettings.hashtagName) { urls.timeline = `${this.mtSettings.instanceUrl}/api/v1/timelines/tag/${this.mtSettings.hashtagName}?limit=${this.mtSettings.maxNbPostFetch}`; } else { this.#showError( "Please check your hashtagName value", "⚠️" ); } } else if (this.mtSettings.timelineType === "local") { urls.timeline = `${this.mtSettings.instanceUrl}/api/v1/timelines/public?local=true&limit=${this.mtSettings.maxNbPostFetch}`; } else { this.#showError( "Please check your timelineType value", "⚠️" ); } } else { this.#showError( "Please check your instanceUrl value", "⚠️" ); } if (!this.mtSettings.hideEmojos) { urls.emojos = this.mtSettings.instanceUrl + "/api/v1/custom_emojis"; } const urlsPromises = Object.entries(urls).map(([key, url]) => { return fetchData(url) .then((data) => ({ [key]: data })) .catch((error) => { reject( new Error("Something went wrong fetching data from: " + url) ); this.#showError(error.message); return { [key]: [] }; }); }); // Fetch all urls simultaneously Promise.all(urlsPromises).then((dataObjects) => { this.fetchedData = dataObjects.reduce((result, dataItem) => { return { ...result, ...dataItem }; }, {}); // console.log("Mastodon timeline data fetched: ", this.fetchedData); resolve(); }); }); } /** * Filter all fetched posts and append them on the timeline * @param {string} t Type of build (new or reload) */ async #buildTimeline(t) { await this.#fetchTimelineData(); // Merge pinned posts with timeline posts let posts; if ( !this.mtSettings.hidePinnedPosts && this.fetchedData.pinned?.length !== undefined && this.fetchedData.pinned.length !== 0 ) { const pinnedPosts = this.fetchedData.pinned.map((obj) => ({ ...obj, pinned: true, })); posts = [...pinnedPosts, ...this.fetchedData.timeline]; } else { posts = this.fetchedData.timeline; } // Empty container body this.mtBodyNode.replaceChildren(); // Set posts counter to 0 let nbPostShow = 0; for (let i in posts) { // First filter (Public / Unlisted) if ( posts[i].visibility == "public" || (!this.mtSettings.hideUnlisted && posts[i].visibility == "unlisted") ) { // Second filter (Reblog / Replies) if ( (this.mtSettings.hideReblog && posts[i].reblog) || (this.mtSettings.hideReplies && posts[i].in_reply_to_id) ) { // Nothing here (Don't append posts) } else { if (nbPostShow < this.mtSettings.maxNbPostShow) { this.#appendPost(posts[i], Number(i)); nbPostShow++; } else { // Nothing here (Reached the limit of maximum number of posts to show) } } } } // If there are no posts to display, show an error message if (this.mtBodyNode.innerHTML === "") { const errorMessage = "No posts to show
" + (posts?.length || 0) + " posts have been fetched from the server
This may be due to an incorrect configuration in the parameters or to filters applied (to hide certains type of posts)"; this.#showError(errorMessage, "📭"); } else { if (t === "newTimeline") { this.#manageSpinner(); this.#setPostsInteracion(); this.#buildFooter(); } else if (t === "updateTimeline") { this.#manageSpinner(); } else { this.#showError("The function buildTimeline() was expecting a param"); } } } /** * Add each post in the timeline container * @param {object} c Post content * @param {number} i Index of post */ #appendPost(c, i) { this.mtBodyNode.insertAdjacentHTML("beforeend", this.#assamblePost(c, i)); } /** * Build post structure * @param {object} c Post content * @param {number} i Index of post */ #assamblePost(c, i) { let avatar, user, userName, accountName, url, date, formattedDate, favoritesCount, reblogCount, repliesCount; if (c.reblog) { // BOOSTED post // Post url url = c.reblog.url; // Boosted avatar avatar = '' + '
' + '
' + '' +
        this.#escapeHtml(c.reblog.account.username) +
        ' avatar' + "
" + '
' + '' +
        this.#escapeHtml(c.account.username) +
        ' avatar' + "
" + "
" + "
"; // User name and url if (!this.mtSettings.hideEmojos && c.reblog.account.display_name) { userName = this.#shortcode2Emojos( c.reblog.account.display_name, c.reblog.account.emojis ); } else { userName = c.reblog.account.display_name ? c.reblog.account.display_name : c.reblog.account.username; } if (!this.mtSettings.hideUserAccount) { accountName = '
"; } else { accountName = ""; } user = '
' + '' + userName + "" + accountName + "" + "
"; // Date date = c.reblog.created_at; // Counter bar repliesCount = c.reblog.replies_count; reblogCount = c.reblog.reblogs_count; favoritesCount = c.reblog.favourites_count; } else { // STANDARD post // Post url url = c.url; // Avatar avatar = '' + '
' + '
' + '' +
        this.#escapeHtml(c.account.username) +
        ' avatar' + "
" + "
" + "
"; // User name and url if (!this.mtSettings.hideEmojos && c.account.display_name) { userName = this.#shortcode2Emojos( c.account.display_name, c.account.emojis ); } else { userName = c.account.display_name ? c.account.display_name : c.account.username; } if (!this.mtSettings.hideUserAccount) { accountName = '
"; } else { accountName = ""; } user = '
' + '' + userName + "" + accountName + "" + "
"; // Date date = c.created_at; // Counter bar repliesCount = c.replies_count; reblogCount = c.reblogs_count; favoritesCount = c.favourites_count; } // Date formattedDate = this.#formatDate(date); const timestamp = '
' + (c.pinned ? '' : "") + '' + '" + (c.edited_at ? " *" : "") + "" + "
"; // Main text let txtCss = ""; if (this.mtSettings.txtMaxLines !== "0") { txtCss = " truncate"; this.mtBodyNode.parentNode.style.setProperty( "--mt-txt-max-lines", this.mtSettings.txtMaxLines ); } let content = ""; if (c.spoiler_text !== "") { content = '
' + c.spoiler_text + ' " + '
' + this.#formatPostText(c.content) + "
" + "
"; } else if ( c.reblog && c.reblog.content !== "" && c.reblog.spoiler_text !== "" ) { content = '
' + c.reblog.spoiler_text + ' " + '
' + this.#formatPostText(c.reblog.content) + "
" + "
"; } else if ( c.reblog && c.reblog.content !== "" && c.reblog.spoiler_text === "" ) { content = '
' + '
' + this.#formatPostText(c.reblog.content) + "
" + "
"; } else { content = '
' + '
' + this.#formatPostText(c.content) + "
" + "
"; } // Media attachments let media = []; if (c.media_attachments.length > 0) { for (let i in c.media_attachments) { media.push(this.#createMedia(c.media_attachments[i], c.sensitive)); } } if (c.reblog && c.reblog.media_attachments.length > 0) { for (let i in c.reblog.media_attachments) { media.push( this.#createMedia(c.reblog.media_attachments[i], c.reblog.sensitive) ); } } // Preview link let previewLink = ""; if (!this.mtSettings.hidePreviewLink && c.card) { previewLink = this.#createPreviewLink(c.card); } // Poll let poll = ""; if (c.poll) { let pollOption = ""; for (let i in c.poll.options) { pollOption += "
  • " + c.poll.options[i].title + "
  • "; } poll = '
    ' + "
      " + pollOption + "
    " + "
    "; } // Counter bar let counterBar = ""; if (!this.mtSettings.hideCounterBar) { const repliesTag = '
    ' + '' + repliesCount + "
    "; const reblogTag = '
    ' + '' + reblogCount + "
    "; const favoritesTag = '
    ' + '' + favoritesCount + "
    "; counterBar = '
    ' + repliesTag + reblogTag + favoritesTag + "
    "; } // Add all to main post container const post = '
    ' + '
    ' + avatar + user + timestamp + "
    " + content + media.join("") + previewLink + poll + counterBar + "
    "; return post; } /** * Handle text changes made to posts * @param {string} c Text content * @returns {string} Text content modified */ #formatPostText(c) { let content = c; // Format hashtags and mentions content = this.#addTarget2hashtagMention(content); // Convert emojos shortcode into images if (!this.mtSettings.hideEmojos) { content = this.#shortcode2Emojos(content, this.fetchedData.emojos); } // Convert markdown styles into HTML if (this.mtSettings.markdownBlockquote) { content = this.#replaceHTMLtag( content, "

    >", "

    ", "

    ", "

    " ); } return content; } /** * Add target="_blank" to all #hashtags and @mentions in the post * @param {string} c Text content * @returns {string} Text content modified */ #addTarget2hashtagMention(c) { let content = c.replaceAll('rel="tag"', 'rel="tag" target="_blank"'); content = content.replaceAll( 'class="u-url mention"', 'class="u-url mention" target="_blank"' ); return content; } /** * Find all start/end and replace them by another start/end * @param {string} c Text content * @param {string} initialTagOpen Start HTML tag to replace * @param {string} initialTagClose End HTML tag to replace * @param {string} replacedTagOpen New start HTML tag * @param {string} replacedTagClose New end HTML tag * @returns {string} Text in HTML format */ #replaceHTMLtag( c, initialTagOpen, initialTagClose, replacedTagOpen, replacedTagClose ) { if (c.includes(initialTagOpen)) { const regex = new RegExp( initialTagOpen + "(.*?)" + initialTagClose, "gi" ); return c.replace(regex, replacedTagOpen + "$1" + replacedTagClose); } else { return c; } } /** * Escape quotes and other special characters, to make them safe to add * to HTML content and attributes as plain text * @param {string} s String * @returns {string} String */ #escapeHtml(s) { return (s ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } /** * Find all custom emojis shortcode and replace by image * @param {string} c Text content * @param {array} e List with all custom emojis * @returns {string} Text content modified */ #shortcode2Emojos(c, e) { if (c.includes(":")) { for (const emojo of e) { const regex = new RegExp(`\\:${emojo.shortcode}\\:`, "g"); c = c.replace( regex, `Emoji ${emojo.shortcode}` ); } return c; } else { return c; } } /** * Format date * @param {string} d Date in ISO format (YYYY-MM-DDTHH:mm:ss.sssZ) * @returns {string} Date formated */ #formatDate(d) { const originalDate = new Date(d); const formattedDate = new Intl.DateTimeFormat( this.mtSettings.dateLocale, this.mtSettings.dateOptions ).format(originalDate); return formattedDate; } /** * Create media element * @param {object} m Media content * @param {boolean} s Spoiler/Sensitive status * @returns {string} Media in HTML format */ #createMedia(m, s) { const spoiler = s || false; const type = m.type; let media = ""; if (type === "image") { media = '
    ' + (spoiler ? '" : "") + '' +
        (m.description ? this.#escapeHtml(m.description) : ' + "
    "; } if (type === "audio") { if (m.preview_url) { media = '
    ' + (spoiler ? '" : "") + '' + '' +
          (m.description ? this.#escapeHtml(m.description) : ' + "
    "; } else { media = '
    ' + (spoiler ? '" : "") + '' + "
    "; } } if (type === "video" || type === "gifv") { if (!this.mtSettings.hideVideoPreview) { media = '
    ' + (spoiler ? '" : "") + '' +
          (m.description ? this.#escapeHtml(m.description) : ' + '' + "
    "; } else { media = '
    ' + (spoiler ? '" : "") + '' + "
    "; } } return media; } /** * Replace the video preview image by the video player * @param {event} e User interaction trigger */ #loadPostVideo(e) { const parentNode = e.target.closest("[data-video-url]"); const videoUrl = parentNode.dataset.videoUrl; parentNode.replaceChildren(); parentNode.innerHTML = ''; } /** * Spoiler button * @param {event} e User interaction trigger */ #toogleSpoiler(e) { const nextSibling = e.target.nextSibling; if ( nextSibling.localName === "img" || nextSibling.localName === "audio" || nextSibling.localName === "video" ) { e.target.parentNode.classList.remove("mt-post-media-spoiler"); e.target.style.display = "none"; } else if ( nextSibling.classList.contains("spoiler-txt-hidden") || nextSibling.classList.contains("spoiler-txt-visible") ) { if (e.target.textContent == this.mtSettings.btnShowMore) { nextSibling.classList.remove("spoiler-txt-hidden"); nextSibling.classList.add("spoiler-txt-visible"); e.target.setAttribute("aria-expanded", "true"); e.target.textContent = this.mtSettings.btnShowLess; } else { nextSibling.classList.remove("spoiler-txt-visible"); nextSibling.classList.add("spoiler-txt-hidden"); e.target.setAttribute("aria-expanded", "false"); e.target.textContent = this.mtSettings.btnShowMore; } } } /** * Create preview link * @param {object} c Preview link content * @returns {string} Preview link in HTML format */ #createPreviewLink(c) { const card = '' + (c.image ? '
    ' +
          this.#escapeHtml(c.image_description) +
          '
    ' : '
    📄
    ') + "
    " + '
    ' + (c.provider_name ? '' + this.#parseHTMLstring(c.provider_name) + "" : "") + '' + c.title + "" + (c.author_name ? '' + this.#parseHTMLstring(c.author_name) + "" : "") + "
    " + "
    "; return card; } /** * Parse HTML string * @param {string} s HTML string * @returns {string} Plain text */ #parseHTMLstring(s) { const parser = new DOMParser(); const txt = parser.parseFromString(s, "text/html"); return txt.body.textContent; } /** * Build footer after last post */ #buildFooter() { if (this.mtSettings.btnSeeMore || this.mtSettings.btnReload) { // Add footer container this.mtBodyNode.parentNode.insertAdjacentHTML( "beforeend", '' ); const containerFooter = this.mtContainerNode.getElementsByClassName("mt-footer")[0]; // Create button to open Mastodon page if (this.mtSettings.btnSeeMore) { let btnSeeMorePath = ""; if (this.mtSettings.timelineType === "profile") { if (this.mtSettings.profileName) { btnSeeMorePath = this.mtSettings.profileName; } else { this.#showError( "Please check your profileName value", "⚠️" ); } } else if (this.mtSettings.timelineType === "hashtag") { btnSeeMorePath = "tags/" + this.mtSettings.hashtagName; } else if (this.mtSettings.timelineType === "local") { btnSeeMorePath = "public/local"; } const btnSeeMoreHTML = '' + this.mtSettings.btnSeeMore + ""; containerFooter.insertAdjacentHTML("beforeend", btnSeeMoreHTML); } // Create button to refresh the timeline if (this.mtSettings.btnReload) { const btnReloadHTML = '"; containerFooter.insertAdjacentHTML("beforeend", btnReloadHTML); const reloadBtn = this.mtContainerNode.getElementsByClassName("btn-refresh")[0]; reloadBtn.addEventListener("click", () => { this.mtUpdate(); }); } } } /** * Add EventListeners for timeline interactions and trigger functions */ #setPostsInteracion() { this.mtBodyNode.addEventListener("click", (e) => { // Check if post cointainer was clicked if ( e.target.localName == "article" || e.target.offsetParent?.localName == "article" || (e.target.localName == "img" && !e.target.parentNode.getAttribute("data-video-url")) ) { this.#openPostUrl(e); } // Check if Show More/Less button was clicked if ( e.target.localName == "button" && e.target.classList.contains("mt-btn-spoiler") ) { this.#toogleSpoiler(e); } // Check if video preview image or play icon/button was clicked if ( e.target.className == "mt-post-media-play-icon" || (e.target.localName == "svg" && e.target.parentNode.className == "mt-post-media-play-icon") || (e.target.localName == "path" && e.target.parentNode.parentNode.className == "mt-post-media-play-icon") || (e.target.localName == "img" && e.target.parentNode.getAttribute("data-video-url")) ) { this.#loadPostVideo(e); } }); this.mtBodyNode.addEventListener("keydown", (e) => { // Check if Enter key was pressed with focus in an article if (e.key === "Enter" && e.target.localName == "article") { this.#openPostUrl(e); } }); } /** * Open post in a new tab/page avoiding any other natural link * @param {event} e User interaction trigger */ #openPostUrl(e) { const urlPost = e.target.closest(".mt-post").dataset.location; if ( e.target.localName !== "a" && e.target.localName !== "span" && e.target.localName !== "button" && e.target.localName !== "time" && e.target.className !== "mt-post-preview-noImage" && e.target.parentNode.className !== "mt-post-avatar-image-big" && e.target.parentNode.className !== "mt-post-avatar-image-small" && e.target.parentNode.className !== "mt-post-preview-image" && e.target.parentNode.className !== "mt-post-preview" && urlPost ) { window.open(urlPost, "_blank", "noopener"); } } /** * Add/Remove EventListeners for loading spinner */ #manageSpinner() { // Remove EventListener and CSS class to container const removeSpinner = (e) => { e.target.parentNode.classList.remove(this.mtSettings.spinnerClass); e.target.removeEventListener("load", removeSpinner); e.target.removeEventListener("error", removeSpinner); }; // Add EventListener to images this.mtBodyNode .querySelectorAll(`.${this.mtSettings.spinnerClass} > img`) .forEach((e) => { e.addEventListener("load", removeSpinner); e.addEventListener("error", removeSpinner); }); } /** * Show an error on the timeline * @param {string} e Error message * @param {string} i Icon */ #showError(t, i) { const icon = i || "❌"; this.mtBodyNode.innerHTML = '
    ' + icon + '
    Oops, something\'s happened:
    ' + t + "
    "; this.mtBodyNode.setAttribute("role", "none"); throw new Error( "Stopping the script due to an error building the timeline." ); } }