/** * Mastodon embed timeline * @author idotj * @version 4.4.1 * @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, txtMaxLines: "", 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) { let urls = {}; if (this.mtSettings.timelineType === "profile") { if (this.mtSettings.userId) { urls.timeline = `${i}accounts/${this.mtSettings.userId}/statuses?limit=${this.mtSettings.maxNbPostFetch}`; if (!this.mtSettings.hidePinnedPosts) { urls.pinned = `${i}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 = `${i}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 = `${i}timelines/public?local=true&limit=${this.mtSettings.maxNbPostFetch}`; } else { this.#showError( "Please check your timelineType value", "⚠️" ); } if (!this.mtSettings.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 posts = this.fetchedData.timeline; let nbPostToShow = 0; this.mtBodyNode.replaceChildren(); posts.forEach((post) => { const isPublicOrUnlisted = post.visibility === "public" || (!this.mtSettings.hideUnlisted && post.visibility === "unlisted"); const shouldHideReblog = this.mtSettings.hideReblog && post.reblog; const shouldHideReplies = this.mtSettings.hideReplies && post.in_reply_to_id; // Filter by (Public / Unlisted) if (isPublicOrUnlisted && !shouldHideReblog && !shouldHideReplies) { if (nbPostToShow < this.mtSettings.maxNbPostShow) { this.#appendPost(post, nbPostToShow); nbPostToShow++; } else { // Reached the limit of maximum number of posts to show } } }); // 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) { 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 content = ""; if (this.mtSettings.txtMaxLines !== "0") { const txtCss = this.mtSettings.txtMaxLines.length !== 0 ? " truncate" : ""; if (c.spoiler_text !== "") { content = '
' + this.#formatPostText(c.spoiler_text) + ' " + '
' + this.#formatPostText(c.content) + "
" + "
"; } else if ( c.reblog && c.reblog.content !== "" && c.reblog.spoiler_text !== "" ) { content = '
' + this.#formatPostText(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) ); } } media = `
${media.join("")}
`; // 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 + "
    "; } // Put all elements together in the post container const post = '
    ' + '
    ' + avatar + user + timestamp + "
    " + content + media + previewLink + poll + counterBar + "
    "; return post; } /** * 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