diff --git a/css/mastodon-timeline-4.0.3-1.css b/css/mastodon-timeline-4.0.3-1.css new file mode 100644 index 0000000..199de49 --- /dev/null +++ b/css/mastodon-timeline-4.0.3-1.css @@ -0,0 +1,473 @@ +/* Mastodon embed feed timeline v4.0.3-1*/ +/* More info at: */ +/* https://gitlab.com/clvgt12/mastodon-embed-feed-timeline */ + +/* Variables */ +.mt-container, +.mt-container[data-theme="light"] { + --mt-txt-max-lines: none; + --mt-color-bg: #fff; + --mt-color-bg-hover: #d9e1e8; + --mt-color-line-gray: #c0cdd9; + --mt-color-contrast-gray: #606984; + --mt-color-content-txt: #000; + --mt-color-link: #3a3bff; + --mt-color-error-txt: #8b0000; + --mt-color-btn-bg: #6364ff; + --mt-color-btn-bg-hover: #563acc; + --mt-color-btn-txt: #fff; +} +.mt-container[data-theme="dark"] { + --mt-color-bg: #fff; + --mt-color-bg-hover: #d9e1e8; + --mt-color-line-gray: #c0cdd9; + --mt-color-contrast-gray: #606984; + --mt-color-content-txt: #000; + --mt-color-link: #3a3bff; + --mt-color-error-txt: #8b0000; +} + +/* Reset CSS */ +.mt-container button { + font: inherit; +} +.mt-container a, +.mt-container button { + cursor: pointer; +} + +/* Main container */ +.mt-container { + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; + position: relative; + background-color: var(--mt-color-bg); + scrollbar-color: var(--mt-color-contrast-gray) var(--mt-color-bg); + scrollbar-width: auto; +} +.mt-container::-webkit-scrollbar { + width: 0.25rem; + height: 0.25rem; +} +.mt-container::-webkit-scrollbar-thumb { + background-color: var(--mt-color-contrast-gray); + border: none; + border-radius: 3rem; +} +.mt-container::-webkit-scrollbar-thumb:hover, +.mt-container::-webkit-scrollbar-thumb:active { + background-color: var(--mt-color-contrast-gray); +} +.mt-container::-webkit-scrollbar-track { + background-color: var(--mt-color-bg); + border: none; + border-radius: 0; +} +.mt-container::-webkit-scrollbar-track:hover, +.mt-container::-webkit-scrollbar-track:active, +.mt-container::-webkit-scrollbar-corner { + background-color: var(--mt-color-bg); +} +.mt-container a:link, +.mt-container a:active, +.mt-container a { + text-decoration: none; + color: var(--mt-color-link); +} +.mt-container a:not(.mt-post-preview):hover { + text-decoration: underline; +} +.mt-body { + padding: 1rem clamp(0.25rem, 4vw, 1rem); + white-space: pre-wrap; + word-wrap: break-word; + margin-bottom: 1rem; +} +.mt-body .invisible { + font-size: 0; + line-height: 0; + display: inline-block; + width: 0; + height: 0; + position: absolute; +} +.mt-body a { + color: var(--mt-color-link); + text-decoration: none; +} +.mt-body a:hover { +color: var(--mt-color-link); +text-decoration: underline; +} + +/* Post container */ +.mt-post { + margin: 0.25rem; + padding: 1rem 0.5rem; + position: relative; + min-height: 3.75rem; + background-color: transparent; + border-bottom: 1px solid var(--mt-color-line-gray); +} +.mt-post:hover, +.mt-post:focus { + cursor: pointer; + background-color: var(--mt-color-bg-hover); +} +.mt-post p:last-child { + margin-bottom: 0; +} + +/* User avatar */ +.mt-post-avatar { + margin-right: 0.75rem; +} +.mt-post-avatar-standard { + width: 2.25rem; + height: 2.25rem; +} +.mt-post-avatar-boosted { + width: 3rem; + height: 3rem; + position: relative; +} +.mt-post-avatar-image-big img { + aspect-ratio: 1/1; + width: 2.25rem; + height: 2.25rem; + border-radius: 0.25rem; + overflow: hidden; +} +.mt-post-avatar-image-small img { + aspect-ratio: 1/1; + width: 1.5rem; + height: 1.5rem; + top: 1.5rem; + left: 1.5rem; + position: absolute; + border-radius: 0.25rem; + overflow: hidden; +} + +/* User name and date */ +.mt-post-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} +.mt-post-header-user { + font-weight: 600; + padding-right: 1rem; +} +.mt-post-header-user > a { + display: flex; + align-items: flex-start; + color: var(--mt-color-content-txt) !important; + overflow-wrap: anywhere; + text-decoration: none; +} +.mt-post-header-date { + font-size: 0.75rem; + text-align: right; + margin: 0 0 0 auto; +} +.mt-post-header-date > a { + color: var(--mt-color-contrast-gray) !important; +} + +/* Text */ +.mt-post-txt { + margin-bottom: 1rem; + color: var(--mt-color-content-txt); +} +.mt-post-txt .spoiler-txt-hidden { + display: none; +} +.mt-post-txt.truncate { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: var(--mt-txt-max-lines); + -webkit-box-orient: vertical; +} +.mt-post-txt:not(.truncate) .ellipsis::after { + content: "..."; +} +.mt-post-txt blockquote { + border-left: 0.25rem solid var(--mt-color-line-gray); + margin-left: 0; + padding-left: 0.5rem; +} +.mt-post-header-user .mt-custom-emoji, +.mt-post-txt .mt-custom-emoji { + height: 1.5rem; + min-width: 1.5rem; + margin-bottom: -0.25rem; + width: auto; +} + +/* Poll */ +.mt-post-poll { + margin-bottom: 1rem; + color: var(--mt-color-content-txt); +} +.mt-post-poll ul { + list-style: none; + padding: 0; + margin: 0; +} +.mt-post-poll ul li { + font-size: 0.9rem; + margin-bottom: 0.5rem; +} +.mt-post-poll.mt-post-poll-expired ul li { + color: var(--mt-color-contrast-gray); +} +.mt-post-poll ul li:not(:last-child) { + margin-bottom: 0.25rem; +} +.mt-post-poll ul li:before { + content: "◯"; + padding-right: 0.5rem; +} +.mt-post-poll.mt-post-poll-expired ul li:before { + content: ""; + padding-right: 0; +} + +/* Medias */ +.mt-post-media { + position: relative; + overflow: hidden; + margin-bottom: 1rem; + border-radius: 0.75rem; +} +.mt-post-media-spoiler > img, +.mt-post-media-spoiler > audio, +.mt-post-media-spoiler > video, +.mt-post-media-spoiler > .mt-post-media-play-icon { + filter: blur(2rem); + pointer-events: none; +} +.mt-post-media > audio { + width: 100%; + position: relative; + z-index: 1; +} +.mt-post-media > img, +.mt-post-media > video { + width: 100%; + height: 100%; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + color: var(--mt-color-content-txt); +} +.mt-post-media.mt-loading-spinner .mt-post-media-play-icon { + display: none; +} +.mt-post-media-play-icon { + display: flex; + position: absolute; + width: 3rem; + height: 3rem; + top: calc(50% - 1.5rem); + left: calc(50% - 1.5rem); + justify-content: center; + align-items: center; + background-color: transparent; + border: none; + cursor: pointer; +} +.mt-post-media-play-icon > svg { + width: 2.5rem; + height: 2.5rem; + fill: var(--mt-color-bg); + stroke: var(--mt-color-content-txt); + stroke-width: 1px; +} + +/* Preview link */ +.mt-post-preview { + min-height: 4rem; + display: flex; + flex-direction: column; + border: 1px solid var(--mt-color-line-gray); + border-radius: 0.5rem; + color: var(--mt-color-link); + font-size: 0.8rem; + margin: 1rem 0; + overflow: hidden; +} +.mt-post-preview-image { + width: 100%; + align-self: stretch; +} +.mt-post-preview-image img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + color: var(--mt-color-content-txt); + border-bottom: 1px solid var(--mt-color-line-gray); +} +.mt-post-preview-noImage { + width: 100%; + font-size: 1.5rem; + align-self: inherit; + text-align: left; + display: none; +} +.mt-post-preview-content { + width: 100%; + display: flex; + align-self: center; + flex-direction: column; + padding: 0.5rem 1rem; + gap: 0.5rem; +} +.mt-post-preview-title { + font-weight: 600; + font-size: 1.0rem; + font-family: serif; +} + +/* Counter bar */ +.mt-post-counter-bar { + display: flex; + min-width: 6rem; + max-width: 40rem; + justify-content: space-between; + color: var(--mt-color-contrast-gray); +} +.mt-post-counter-bar-replies, +.mt-post-counter-bar-reblog, +.mt-post-counter-bar-favorites { + display: flex; + font-size: 0.75rem; + gap: 0.25rem; + align-items: center; + opacity: 0.5; +} +.mt-post-counter-bar-replies > svg, +.mt-post-counter-bar-reblog > svg, +.mt-post-counter-bar-favorites > svg { + width: 1rem; + fill: var(--mt-color-contrast-gray); +} + +/* Buttons */ +.mt-container .mt-btn-dark { + display: flex; + border-radius: 0.25rem; + background-color: var(--mt-color-line-gray); + border: 0; + color: var(--mt-color-content-txt); + font-weight: 600; + font-size: 0.75rem; + text-align: center; + padding: 0 0.5rem; + line-height: 1.25rem; + + vertical-align: top; +} +.mt-container .mt-btn-violet, +.mt-container a.mt-btn-violet { + display: flex; + gap: 0.5rem; + border-radius: 0.25rem; + border: 0.5rem; + padding: 0.5rem 0.75rem; + font-size: 1rem; + font-weight: 600; + text-align: center; + background-color: var(--mt-color-btn-bg); + color: var(--mt-color-btn-txt); +} +.mt-container .mt-btn-violet:hover, +.mt-container a.mt-btn-violet:hover { + background-color: var(--mt-color-btn-bg-hover); + text-decoration: none; +} +.mt-post-txt .mt-btn-spoiler { + display: inline-block; +} +.mt-post-media.mt-loading-spinner > .mt-btn-spoiler { + display: none; +} +.mt-post-media > .mt-btn-spoiler { + position: absolute; + top: 50%; + left: 50%; + z-index: 2; + transform: translate(-50%, -50%); +} + +/* Error */ +.mt-error { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + color: var(--mt-color-error-txt); + padding: 0.75rem; + text-align: center; +} +.mt-error-icon { + font-size: 2rem; +} +.mt-error-message { + width: 100%; + padding: 1rem 0; +} +.mt-error-message hr { + color: var(--mt-color-line-gray); +} + +/* Loading spinner */ +.mt-body > .mt-loading-spinner { + position: absolute; + width: 3rem; + height: 3rem; + margin: auto; + top: calc(50% - 1.5rem); + right: calc(50% - 1.5rem); +} +.mt-loading-spinner { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Cg%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 64 64' to='360 64 64' dur='1000ms' repeatCount='indefinite'/%3E%3Cpath d='M64 6.69a57.3 57.3 0 1 1 0 114.61A57.3 57.3 0 0 1 6.69 64' fill='none' stroke='%23404040' stroke-width='12'/%3E%3C/g%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center center; + background-color: transparent; + background-size: min(2.5rem, calc(100% - 0.5rem)); +} + +/* Footer */ +.mt-footer { + display: flex; + flex-flow: wrap; + margin: auto auto 2rem auto; + padding: 0 1.5rem; + gap: 1.5rem; + align-items: center; + justify-content: center; +} + +/* Hidden elements */ +.visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} \ No newline at end of file diff --git a/js/mastodon-timeline-4.0.3-1.js b/js/mastodon-timeline-4.0.3-1.js new file mode 100644 index 0000000..758ef92 --- /dev/null +++ b/js/mastodon-timeline-4.0.3-1.js @@ -0,0 +1,1111 @@ +/** + * Mastodon embed feed timeline + * @author clvgt12 + * @version 4.0.3-1 + * @url https://gitlab.com/clvgt12/mastodon-embed-feed-timeline + * @license GNU AGPLv3 + */ + +/** + * Mastodon embed feed timeline + * @author idotj + * @version 4.0.3 + * @url https://gitlab.com/idotj/mastodon-embed-feed-timeline + * @license GNU AGPLv3 + */ +"use strict"; + +class MastodonTimeline { + 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", + 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", + }; + + this.mtSettings = { ...this.defaultSettings, ...customSettings }; + + this.mtContainerNode = ""; + this.mtBodyNode = ""; + this.fetchedData = {}; + + this.mtInit(); + } + + /** + * Trigger callback when DOM loaded or complete + * @param {function} c Callback executed + */ + onDOMContentLoaded(c) { + if ( + typeof document !== "undefined" && + (document.readyState === "complete" || + document.readyState === "interactive") + ) { + c(); + } else if ( + typeof document !== "undefined" && + (document.readyState !== "complete" || + document.readyState !== "interactive") + ) { + document.addEventListener("DOMContentLoaded", c); + } + } + + /** + * Initialize and build the timeline + */ + mtInit() { + // console.log("Creating Mastodon timeline with settings: ", this.mtSettings); + + this.onDOMContentLoaded(() => { + // Register container node + this.mtContainerNode = document.getElementById( + this.mtSettings.mtContainerId + ); + + // Register body node + this.mtBodyNode = + this.mtContainerNode.getElementsByClassName("mt-body")[0]; + + this.#loadColorTheme(); + this.#buildTimeline("newTimeline"); + }); + } + + /** + * 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}`; + } 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.mtBodyNode.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.mtBodyNode.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.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, + 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 = c.reblog.account.display_name + ? c.reblog.account.display_name + : c.reblog.account.username; + if (!this.mtSettings.hideEmojos) { + userName = this.#createEmoji( + 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 = c.account.display_name + ? c.account.display_name + : c.account.username; + if (!this.mtSettings.hideEmojos) { + userName = this.#createEmoji( + 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.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 = + '
    ' + + "" + + "
    "; + } + + // 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(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 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." + ); + } +}