/** * Mastodon embed feed timeline * @author idotj * @version 4.0.0 * @url https://gitlab.com/idotj/mastodon-embed-feed-timeline * @license GNU AGPLv3 */ "use strict"; class MastodonTimeline { constructor(customSettings = {}) { this.defaultSettings = { mtContainerId: "mt-container", mtBody: "", instanceUrl: "https://mastodon.social", timelineType: "local", userId: "", profileName: "", hashtagName: "", spinnerClass: "mt-loading-spinner", defaultTheme: "auto", maxNbPostFetch: "20", maxNbPostShow: "20", hideUnlisted: false, hideReblog: false, hideReplies: false, hideVideoPreview: false, hidePreviewLink: false, hideEmojos: false, markdownBlockquote: false, hideCounterBar: false, txtMaxLines: "0", btnShowMore: "SHOW MORE", btnShowLess: "SHOW LESS", btnShowContent: "SHOW CONTENT", btnSeeMore: "See more posts at Mastodon", btnReload: "Refresh", fetchedData: {}, }; this.mtSettings = { ...this.defaultSettings, ...customSettings }; // Set node of body container this.mtSettings.mtBody = document .getElementById(this.mtSettings.mtContainerId) .getElementsByClassName("mt-body")[0]; this.mtInit(); } /** * Initialize and build the timeline */ mtInit() { console.log("Init Mastodon timeline. Settings: ", this.mtSettings); this.#loadColorTheme(); this.#buildTimeline("newTimeline"); } /** * Reload the timeline by fetching the lastest posts */ mtUpdate() { this.mtSettings.mtBody.replaceChildren(); this.mtSettings.mtBody.insertAdjacentHTML( "afterbegin", '
' ); this.#buildTimeline("updateTimeline"); } /** * Apply the color theme in the timeline * @param {string} themeType Type of color theme */ mtColorTheme(themeType) { document .getElementById(this.mtSettings.mtContainerId) .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}`; } 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.mtSettings.fetchedData = dataObjects.reduce((result, dataItem) => { return { ...result, ...dataItem }; }, {}); console.log("Timeline data fetched: ", this.mtSettings.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(); // Empty container body this.mtSettings.mtBody.replaceChildren(); // Set posts counter to 0 let nbPostShow = 0; for (let i in this.mtSettings.fetchedData.timeline) { // First filter (Public / Unlisted) if ( this.mtSettings.fetchedData.timeline[i].visibility == "public" || (!this.mtSettings.hideUnlisted && this.mtSettings.fetchedData.timeline[i].visibility == "unlisted") ) { // Second filter (Reblog / Replies) if ( (this.mtSettings.hideReblog && this.mtSettings.fetchedData.timeline[i].reblog) || (this.mtSettings.hideReplies && this.mtSettings.fetchedData.timeline[i].in_reply_to_id) ) { // Nothing here (Don't append posts) } else { if (nbPostShow < this.mtSettings.maxNbPostShow) { this.#appendPost( this.mtSettings.fetchedData.timeline[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.mtSettings.mtBody.innerHTML === "") { const errorMessage = "No posts to show
" + (this.mtSettings.fetchedData.timeline?.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.mtSettings.mtBody.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, 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 userName = this.#createEmoji( c.reblog.account.display_name ? c.reblog.account.display_name : c.reblog.account.username, this.mtSettings.fetchedData.emojos ); user = '
' + '' + userName + ' account' + "" + "
"; // 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 userName = this.#createEmoji( c.account.display_name ? c.account.display_name : c.account.username, this.mtSettings.fetchedData.emojos ); user = '
' + '' + userName + ' account' + "" + "
"; // 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 = '
' + '' + '" + "" + "
"; // Main text let txtCss = ""; if (this.mtSettings.txtMaxLines !== "0") { txtCss = " truncate"; this.mtSettings.mtBody.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 = '
    ' + "" + "
    "; } // 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.#createEmoji(content, this.mtSettings.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 */ #createEmoji(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 (MM DD, YYYY) */ #formatDate(d) { const monthNames = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; const date = new Date(d); const displayDate = monthNames[date.getMonth()] + " " + date.getDate() + ", " + date.getFullYear(); return displayDate; } /** * 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 = function (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.mtSettings.mtBody.parentNode.insertAdjacentHTML( "beforeend", '' ); const containerFooter = document .getElementById(this.mtSettings.mtContainerId) .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 = document .getElementById(this.mtSettings.mtContainerId) .getElementsByClassName("btn-refresh")[0]; reloadBtn.addEventListener("click", () => { this.mtUpdate(); }); } } } /** * Add EventListeners for timeline interactions and trigger functions */ #setPostsInteracion() { this.mtSettings.mtBody.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.mtSettings.mtBody.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 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.mtSettings.mtBody .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.mtSettings.mtBody.innerHTML = '
    ' + icon + '
    Oops, something\'s happened:
    ' + t + "
    "; this.mtSettings.mtBody.setAttribute("role", "none"); throw new Error( "Stopping the script due to an error building the timeline." ); } }