/** * Mastodon embed timeline * @author idotj * @version 4.3.10 * @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, 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 = '
' + 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) ); } } 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 + "
    "; } // Add all to main post container const post = '
    ' + '
    ' + avatar + user + timestamp + "
    " + content + media + 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 Sensitive/spoiler status * @returns {string} Media in HTML format */ #createMedia(m, s = false) { const spoiler = s; 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; } /** * Open a dialog/modal with the styles of Mastodon timeline * @param {string} i Dialog Id name * @param {string} c Dialog HTML content */ #openDialog(i, c) { let dialog = document.createElement("dialog"); dialog.id = i; dialog.classList.add("mt-dialog"); dialog.dataset.theme = this.mtContainerNode.getAttribute("data-theme"); dialog.innerHTML = c; document.body.prepend(dialog); dialog.showModal(); dialog.addEventListener("close", () => { document.body.removeChild(dialog); }); } /** * Build a carousel/lightbox with the media content in the post clicked * @param {event} e User interaction trigger */ #showCarousel(e) { // List all medias in the post and remove sensitive/spoiler medias const mediaSiblings = Array.from( e.target.parentNode.parentNode.children ).filter((element) => !element.classList.contains("mt-post-media-spoiler")); const mediaClickedIndex = mediaSiblings.indexOf(e.target.parentNode) + 1; // Build media element and wrapper let mediaItems = []; mediaSiblings.forEach((sibling, i) => { let mediaElement = ""; if ( sibling.getAttribute("data-media-type") === "gifv" || sibling.getAttribute("data-media-type") === "video" ) { mediaElement = ` `; } else { mediaElement = ` ${sibling.getAttribute(
          `; } const mediaWrapper = ` `; mediaItems.push(mediaWrapper); }); // Build carousel const carouselHTML = ` `; // Call dialog/modal with carousel "id" and HTML content this.#openDialog("mt-carousel", carouselHTML); // Set carousel interactions for horizontal scroll and buttons if (mediaItems.length >= 2) { this.#setCarouselInteractions(mediaSiblings.length, mediaClickedIndex); } } /** * Add interactions for the carousel * @param {number} t Total number of medias loaded * @param {number} m Index position of media clicked by user */ #setCarouselInteractions(t, m) { let currentMediaIndex = m; const carousel = document.getElementById("mt-carousel-scroll"); let scrollTimeout = 0; let userScrolling = false; const prevBtn = document.getElementById("mt-carousel-prev"); const nextBtn = document.getElementById("mt-carousel-next"); // Scroll the carusel to the media element const scrollCarouselTo = (i, behavior = "smooth") => { document .getElementById("mt-carousel-" + i) .scrollIntoView({ behavior: behavior }); }; // First run, place the scroll on clicked media scrollCarouselTo(currentMediaIndex, "instant"); // Get current index of the media shown on screen const updateMediaIndex = () => { const scrolledMedia = (carousel.scrollLeft + carousel.clientWidth) / carousel.clientWidth; return Math.round(scrolledMedia + Number.EPSILON); }; // Scroll interactions const isScrolling = () => { clearTimeout(scrollTimeout); scrollTimeout = setTimeout(() => { if (userScrolling) { currentMediaIndex = updateMediaIndex(); checkBtnsVisibility(); } userScrolling = true; }, 60); }; carousel.addEventListener("scroll", isScrolling); // Click interactions const checkBtnsVisibility = () => { prevBtn.hidden = currentMediaIndex === 1; nextBtn.hidden = currentMediaIndex === t; }; const userClick = (e) => { const idTarget = e.target.closest("button")?.id; // Prev/next buttons if (idTarget === "mt-carousel-next") { userScrolling = false; ++currentMediaIndex; if (currentMediaIndex > t) currentMediaIndex = t; scrollCarouselTo(currentMediaIndex); checkBtnsVisibility(); } else if (idTarget === "mt-carousel-prev") { userScrolling = false; --currentMediaIndex; if (currentMediaIndex < 1) currentMediaIndex = 1; scrollCarouselTo(currentMediaIndex); checkBtnsVisibility(); } // Close button if (idTarget === "mt-carousel-close") { killEventListeners(); } }; document.addEventListener("click", userClick); // Keyboard interactions const userKeyDown = (e) => { if (e.key === "Escape" || e.keyCode === 27) { killEventListeners(); } }; document.addEventListener("keydown", userKeyDown); // Kill carousel listeners const killEventListeners = () => { carousel.removeEventListener("scroll", isScrolling); document.removeEventListener("click", userClick); document.removeEventListener("keydown", userKeyDown); }; } /** * Replace the video preview image by the video player * @param {event} e User interaction trigger */ #loadPostVideo(e) { const parentNode = e.target.closest("[data-media-type]"); const urlVideo = parentNode.dataset.mediaUrlHd; parentNode.replaceChildren(); parentNode.innerHTML = ``; } /** * Spoiler button * @param {event} e User interaction trigger */ #toogleSpoiler(e) { const target = e.target; const nextSibling = target.nextSibling; if ( nextSibling.localName === "img" || nextSibling.localName === "audio" || nextSibling.localName === "video" ) { target.parentNode.classList.remove("mt-post-media-spoiler"); target.style.display = "none"; } else if ( nextSibling.classList.contains("spoiler-txt-hidden") || nextSibling.classList.contains("spoiler-txt-visible") ) { if (target.textContent == this.mtSettings.btnShowMore) { nextSibling.classList.remove("spoiler-txt-hidden"); nextSibling.classList.add("spoiler-txt-visible"); target.setAttribute("aria-expanded", "true"); target.textContent = this.mtSettings.btnShowLess; } else { nextSibling.classList.remove("spoiler-txt-visible"); nextSibling.classList.add("spoiler-txt-hidden"); target.setAttribute("aria-expanded", "false"); target.textContent = this.mtSettings.btnShowMore; } } } /** * Create preview link * @param {object} c Preview link content * @returns {string} Preview link in HTML format */ #createPreviewLink(c) { let previewDescription = ""; if (this.mtSettings.previewMaxLines !== "0" && c.description) { const txtCss = this.mtSettings.previewMaxLines.length !== 0 ? " truncate" : ""; previewDescription = '' + this.#parseHTMLstring(c.description) + ""; } const card = '' + (c.image ? '
    ' +
          this.#escapeHTML(c.image_description) +
          '
    ' : '
    📄
    ') + "
    " + '
    ' + (c.provider_name ? '' + this.#parseHTMLstring(c.provider_name) + "" : "") + '' + c.title + "" + previewDescription + (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() { let btnSeeMoreHTML = ""; let btnReloadHTML = ""; // 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"; } btnSeeMoreHTML = ` ${this.mtSettings.btnSeeMore} `; } // Create button to refresh the timeline if (this.mtSettings.btnReload) { btnReloadHTML = ` `; // Add footer container this.mtBodyNode.parentNode.insertAdjacentHTML( "beforeend", '" ); // Add event listener to the button "Refresh" const reloadBtn = this.mtContainerNode.getElementsByClassName("btn-refresh")[0]; reloadBtn.addEventListener("click", () => { this.mtUpdate(); }); } } /** * Add EventListeners for timeline interactions and trigger functions */ #addPostListener() { this.mtBodyNode.addEventListener("click", (e) => { const target = e.target; const localName = target.localName; const parentNode = target.parentNode; // Check if post cointainer was clicked if ( localName == "article" || target.offsetParent?.localName == "article" || (this.mtSettings.disableCarousel && parentNode.getAttribute("data-media-type") === "image") ) { this.#openPostUrl(e); } // Check if Show More/Less button was clicked if ( localName == "button" && target.classList.contains("mt-btn-spoiler") ) { this.#toogleSpoiler(e); } // Check if image in post was clicked if ( !this.mtSettings.disableCarousel && localName == "img" && (parentNode.getAttribute("data-media-type") === "image" || parentNode.getAttribute("data-media-type") === "audio") ) { this.#showCarousel(e); } // Check if video preview image or play icon/button was clicked if ( target.className == "mt-btn-play" || (localName == "svg" && parentNode.className == "mt-btn-play") || (localName == "path" && parentNode.parentNode.className == "mt-btn-play") || (localName == "img" && (parentNode.getAttribute("data-media-type") === "video" || parentNode.getAttribute("data-media-type") === "gifv")) ) { this.#loadPostVideo(e); } }); this.mtBodyNode.addEventListener("keydown", (e) => { const localName = e.target.localName; // Check if Enter key was pressed with focus in an article if (e.key === "Enter" && 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 !== "bdi" && e.target.localName !== "time" && !e.target.classList.contains("mt-post-media-spoiler") && 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-header-user-name" && 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." ); } }