/** * Mastodon embed timeline * @author idotj * @version 4.5.0 * @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", dateFormatLocale: "en-GB", dateFormatOptions: { day: "2-digit", month: "short", year: "numeric", }, hideUnlisted: false, hideReblog: false, hideReplies: false, hidePinnedPosts: false, hideUserAccount: false, txtMaxLines: "", filterByLanguage: "", btnShowMore: "SHOW MORE", btnShowLess: "SHOW LESS", markdownBlockquote: false, hideEmojos: false, btnShowContent: "SHOW CONTENT", hideVideoPreview: false, btnPlayVideoTxt: "Load and play video", hidePreviewLink: false, previewMaxLines: "", hideCounterBar: false, disableCarousel: false, carouselCloseTxt: "Close carousel", carouselPrevTxt: "Previous media item", carouselNextTxt: "Next media item", btnSeeMore: "See more posts at Mastodon", btnReload: "Refresh", insistSearchContainer: false, insistSearchContainerTime: "3000", }; this.mtSettings = { ...this.defaultSettings, ...customSettings }; this.#checkMaxNbPost(); this.linkHeader = {}; this.mtContainerNode = ""; this.mtBodyNode = ""; this.fetchedData = {}; this.#onDOMContentLoaded(() => { this.#getContainerNode(); }); } /** * Verify that the values of posts fetched and showed are consistent */ #checkMaxNbPost() { if ( Number(this.mtSettings.maxNbPostShow) > Number(this.mtSettings.maxNbPostFetch) ) { console.error( `Please check your settings! The maximum number of posts to show is bigger than the maximum number of posts to fetch. Changing the value of "maxNbPostFetch" to: ${this.mtSettings.maxNbPostShow}` ); this.mtSettings.maxNbPostFetch = this.mtSettings.maxNbPostShow; } } /** * 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 ('light' or 'dark') */ 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 */ #getTimelineData() { return new Promise((resolve, reject) => { const instanceApiUrl = this.mtSettings.instanceUrl ? `${this.mtSettings.instanceUrl}/api/v1/` : this.#showError( "Please check your instanceUrl value", "⚠️" ); const urls = this.#setUrls(instanceApiUrl); const urlsPromises = Object.entries(urls).map(([key, url]) => { const headers = key === "timeline"; return this.#fetchData(url, headers) .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(async (dataObjects) => { this.fetchedData = dataObjects.reduce((result, dataItem) => { return { ...result, ...dataItem }; }, {}); // Merge pinned posts with timeline 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, })); this.fetchedData.timeline = [ ...pinnedPosts, ...this.fetchedData.timeline, ]; } // Fetch more posts if maxNbPostFetch is not reached if (this.#isNbPostsFulfilled()) { resolve(); } else { do { await this.#fetchMorePosts(); } while (!this.#isNbPostsFulfilled() && this.linkHeader.next); resolve(); } }); }); } /** * Set all urls before fetching the data * @param {String} Instance url api * @returns {Object} */ #setUrls(i) { const { timelineType, userId, hashtagName, maxNbPostFetch, hidePinnedPosts, hideEmojos, } = this.mtSettings; const urls = {}; switch (timelineType) { case "profile": if (!userId) { this.#showError( "Please check your userId value", "⚠️" ); break; } urls.timeline = `${i}accounts/${userId}/statuses?limit=${maxNbPostFetch}`; if (!hidePinnedPosts) { urls.pinned = `${i}accounts/${userId}/statuses?pinned=true`; } break; case "hashtag": if (!hashtagName) { this.#showError( "Please check your hashtagName value", "⚠️" ); break; } urls.timeline = `${i}timelines/tag/${hashtagName}?limit=${maxNbPostFetch}`; break; case "local": urls.timeline = `${i}timelines/public?local=true&limit=${maxNbPostFetch}`; break; default: this.#showError( "Please check your timelineType value", "⚠️" ); } if (!hideEmojos) { urls.emojos = `${i}custom_emojis`; } return urls; } /** * Fetch data from server * @param {String} u Url address to fetch * @param {Boolean} h gets the link header * @returns {Array} List of objects */ async #fetchData(u, h = false) { const response = await fetch(u); if (!response.ok) { throw new Error(` Failed to fetch the following Url:
${u}
Error status: ${response.status}
Error message: ${response.statusText} `); } const data = await response.json(); // Get Link headers for pagination if (h && response.headers.get("Link")) { this.linkHeader = this.#parseLinkHeader(response.headers.get("Link")); } return data; } /** * Check if there are enough posts to reach the value of maxNbPostFetch * @returns {Boolean} */ #isNbPostsFulfilled() { return ( this.fetchedData.timeline.length >= Number(this.mtSettings.maxNbPostFetch) ); } /** * Fetch extra posts */ #fetchMorePosts() { return new Promise((resolve) => { if (this.linkHeader.next) { this.#fetchData(this.linkHeader.next, true).then((data) => { this.fetchedData.timeline = [...this.fetchedData.timeline, ...data]; resolve(); }); } else { resolve(); } }); } /** * Parse link header into an object * @param {String} l Link header * @returns {Object} */ #parseLinkHeader(l) { const linkArray = l.split(", ").map((header) => header.split("; ")); const linkMap = linkArray.map((header) => { const linkRel = header[1].replace(/"/g, "").replace("rel=", ""); const linkURL = header[0].slice(1, -1); return [linkRel, linkURL]; }); return Object.fromEntries(linkMap); } /** * Filter all fetched posts and append them on the timeline * @param {String} t Type of build (new or reload) */ async #buildTimeline(t) { await this.#getTimelineData(); // console.log("Mastodon timeline data fetched: ", this.fetchedData); const { hideUnlisted, hideReblog, hideReplies, maxNbPostShow, filterByLanguage, } = this.mtSettings; const posts = this.fetchedData.timeline; let nbPostToShow = 0; this.mtBodyNode.replaceChildren(); const filteredPosts = posts.filter((post) => { const isPublicOrUnlisted = post.visibility === "public" || (!hideUnlisted && post.visibility === "unlisted"); const shouldHideReblog = hideReblog && post.reblog; const shouldHideReplies = hideReplies && post.in_reply_to_id; const postLanguage = post.language || (post.reblog ? post.reblog.language : null); const matchesLanguage = filterByLanguage === "" || postLanguage === filterByLanguage; return ( isPublicOrUnlisted && !shouldHideReblog && !shouldHideReplies && matchesLanguage ); }); filteredPosts.forEach((post, index) => { if (index < maxNbPostShow) { this.#appendPost(post, index); } }); // Check if there are posts to display or not if (this.mtBodyNode.innerHTML !== "") { if (t === "newTimeline") { this.#manageSpinner(); this.#setCSSvariables(); this.#addAriaSetsize(nbPostToShow); this.#addPostListener(); if (this.mtSettings.btnSeeMore || this.mtSettings.btnReload) this.#buildFooter(); } else if (t === "updateTimeline") { this.#manageSpinner(); } else { this.#showError("The function buildTimeline() was expecting a param"); } } else { const errorMessage = `No posts to show
${ posts?.length || 0 } posts have been fetched from the server
This may be due to an incorrect configuration with the parameters or with the filters applied (to hide certains type of posts)`; this.#showError(errorMessage, "📭"); } } /** * Establishes the defined CSS variables */ #setCSSvariables() { if ( this.mtSettings.txtMaxLines !== "0" && this.mtSettings.txtMaxLines.length !== 0 ) { this.mtBodyNode.parentNode.style.setProperty( "--mt-txt-max-lines", this.mtSettings.txtMaxLines ); } if ( this.mtSettings.previewMaxLines !== "0" && this.mtSettings.previewMaxLines.length !== 0 ) { this.mtBodyNode.parentNode.style.setProperty( "--mt-preview-max-lines", this.mtSettings.previewMaxLines ); } } /** * Add the attribute Aria-setsize to all posts * @param {Number} n The total number of posts showed in the timeline */ #addAriaSetsize(n) { const articles = this.mtBodyNode.getElementsByTagName("article"); for (let i = 0; i < n; i++) { articles[i].setAttribute("aria-setsize", n); } } /** * 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) { const isReblog = Boolean(c.reblog); const post = isReblog ? c.reblog : c; const { url, created_at: date, replies_count, reblogs_count, favourites_count, } = post; const { avatar, url: accountUrl, username, display_name, emojis, } = post.account; // Avatar const avatarHTML = '' + '
' + '
' + '' +
      this.#escapeHTML(username) +
      ' avatar' + "
" + (isReblog ? '
' + '' +
          this.#escapeHTML(c.account.username) +
          ' avatar' + "
" : "") + "
" + "
"; // User const userNameFull = !this.mtSettings.hideEmojos && display_name ? this.#shortcode2Emojos(display_name, emojis) : display_name || username; const accountName = this.mtSettings.hideUserAccount ? "" : '
"; const userHTML = '
' + '' + '' + userNameFull + "" + accountName + "" + "
"; // Date const formattedDate = this.#formatDate(date); const dateHTML = '
' + (c.pinned ? "..." : "") + '' + '" + (c.edited_at ? " *" : "") + "" + "
"; // Post text const txtTruncateCss = this.mtSettings.txtMaxLines !== "0" ? " truncate" : ""; let postTxt = ""; const textSource = post.spoiler_text ? post.spoiler_text : post.content; if (textSource) { postTxt = '
' + '
' + this.#formatPostText(textSource) + "
" + "
"; } // Media const media = [ ...c.media_attachments, ...(c.reblog?.media_attachments || []), ] .map((attachment) => this.#createMedia(attachment, post.sensitive)) .join(""); const mediaHTML = media ? `
${media}
` : ""; // Preview link const previewLinkHTML = !this.mtSettings.hidePreviewLink && c.card ? this.#createPreviewLink(c.card) : ""; // Poll const pollHTML = c.poll ? '
' + "
    " + c.poll.options .map(function (opt) { return "
  • " + opt.title + "
  • "; }) .join("") + "
" + "
" : ""; // Counter bar const counterBarHTML = !this.mtSettings.hideCounterBar ? '
' + this.#counteBarItem("replies", replies_count) + this.#counteBarItem("reblog", reblogs_count) + this.#counteBarItem("favorites", favourites_count) + "
" : ""; return ( '
' + '
' + avatarHTML + userHTML + dateHTML + "
" + postTxt + mediaHTML + previewLinkHTML + pollHTML + counterBarHTML + "
" ); } /** * Build counter bar items * @param {string} t Type of icon * @param {Number} i Counter */ #counteBarItem(t, c) { const icons = { replies: '', reblog: '', favorites: '', }; return `
${icons[t]}${c}
`; } /** * Sanitize an HTML string * (c) Chris Ferdinandi, MIT License, https://gomakethings.com * @param {String} s The HTML string to sanitize * @param {Boolean} n If true, returns HTML nodes instead of a string * @return {String|NodeList} The sanitized string or nodes */ #cleanHTML(s, n) { /** * Convert the string to an HTML document * @return {Node} An HTML document */ function stringToHTML() { let parser = new DOMParser(); let doc = parser.parseFromString(s, "text/html"); return doc.body || document.createElement("body"); } /** * Remove