diff --git a/.editorconfig b/.editorconfig index 25e0bf1..3ed1a3a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,4 @@ +# Editor configuration, see https://editorconfig.org root = true [*] @@ -9,6 +10,7 @@ indent_size = 2 trim_trailing_whitespace = true [*.md] +max_line_length = off trim_trailing_whitespace = false [**.min.js] diff --git a/.gitignore b/.gitignore index e4c1108..e93664d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,143 @@ -# Created by https://www.toptal.com/developers/gitignore/api/linux +# Created by https://www.toptal.com/developers/gitignore/api/ + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit ### Intellij ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider diff --git a/CHANGELOG b/CHANGELOG index 6a423fb..5f5c677 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,16 @@ +v4.0.0 - xx/02/2024 +- Revamped JS (ES6) +- Renamed CSS classes +- Improved performance +- Removed the use of the word "toot" in code and comments +- Dynamic text for content-sensitive "Show/Hide" buttons +- Easier way to customize timeline settings +- Button to refresh timeline +- Improved theme color management +- Improve error messages +- Possibility to have several timelines on the same page +- Fixed inconsistency in video aspect ratio + v3.13.3 - 16/01/2024 - Add Docker compose @@ -49,7 +62,7 @@ v3.9.0 - 02/09/2023 v3.8.2 - 26/08/2023 - Add support to customized emojis -- Javascript refactoring to allow multiple requests +- JavaScript refactoring to allow multiple requests v3.8.1 - 14/08/2023 - Show preview card from link, photo or video URL diff --git a/README.md b/README.md index cd09718..d4b4247 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 🐘 Mastodon embed timeline +# 🐘 Mastodon embed timeline (new v4) ![Mastodon timeline widget screenshot](screenshot-light-dark.jpg "Mastodon timeline widget screenshot") @@ -7,108 +7,233 @@ Embed a mastodon feed timeline in your page, only with a CSS and JS file. Demo running: -## đŸ—‚ī¸ User guide +## 📋 Table of contents -### Install +- [**Installation**](#installation) +- [**Usage**](#usage) + - [Initialize](#initialize) + - [Local timeline](#local-timeline) + - [Profile timeline](#profile-timeline) + - [Hashtag timeline](#hashtag-timeline) + - [Customize](#customize) + - [API](#api) + - [Examples](#examples) + - [Browser support](#browser-support) -Just copy both files (_mastodon-timeline.css_ and _mastodon-timeline.js_ from /src folder) in your project folder. +## Installation -Now call each one in your page using the `` and ` + + ``` -```html - -``` +## Usage -Then copy the following html structure: +### Initialize + +The first step to get your timeline up is to add the following HTML structure in your page: ```html -
-
-
+
+
+
``` -Great, you have a Mastodon timeline running in your page. +Then after that you can initialize the script by running: -### Setup - -Edit the the JS file _mastodon-timeline.js_ and at the beginning find these two lines: - -```javascript - instance_url: 'Your Mastodon instance (not including the last `/` symbol)', - timeline_type: 'local', +```js +const myTimeline = new MastodonTimeline(); ``` -Enter your Mastodon instance URL and reload the page. You should see toots from your local instance in your timeline. +By default it will show a timeline with 20 posts from the instance [mastodon.social](https://mastodon.social/public/local) -If you want to show a profile timeline then change the `timeline_type` to `profile` and set the following values: +â„šī¸ If you are trying to use your timeline in a CMS such as Drupal, Wordpress, Joomla, etc... you will need to initialize your script when the whole page has loaded. In that case initialize the script by running: -```javascript - user_id: 'Your user ID number on Mastodon instance', - profile_name: 'Your user name on Mastodon instance (including the `@` symbol at the beginning)', +```js +window.addEventListener("load", () => { + const myTimeline = new MastodonTimeline(); +}); ``` -If you prefer to show a timeline with a specific hashtag then change the `timeline_type` to `hashtag` and enter the name of the hashtag: +#### Local timeline -```javascript - hashtag_name: 'YourHashtag (not including the `#` symbol)', +Add the following option/value when initializing the timeline: + +```js +const myTimeline = new MastodonTimeline({ + instanceUrl: "https://mastodon.online", +}); ``` -Also you have some parameters to customize your embed timeline: +It will show a timeline with posts from the instance [mastodon.online](https://mastodon.online/public/local) -```javascript -// Preferred color theme: 'light', 'dark' or 'auto'. Default: auto -default_theme: "auto", +#### Profile timeline -// Maximum amount of toots to get. Default: 20 -toots_limit: "20", +Add the following options/values when initializing the timeline: -// Hide unlisted toots. Default: don't hide -hide_unlisted: false, - -// Hide boosted toots. Default: don't hide -hide_reblog: false, - -// Hide replies toots. Default: don't hide -hide_replies: false, - -// Hide video image preview and load video player instead. Default: don't hide -hide_video_preview: false, - -// Hide preview for links. Default: don't hide -hide_preview_link: false, - -// Hide custom emojis available on the server. Default: don't hide -hide_emojos: false, - -// Converts Markdown symbol ">" at the beginning of a paragraph into a blockquote HTML tag. Default: don't apply -markdown_blockquote: false, - -// Limit the text content to a maximum number of lines. Default: 0 (unlimited) -text_max_lines: "0", - -// Hide replies, boosts and favourites toots counter. Default: don't hide -hide_counter_bar: false, - -// Customize the text of the link pointing to the Mastodon page (appears after the last toot) -link_see_more: "See more posts at Mastodon", +```js +const myTimeline = new MastodonTimeline({ + instanceUrl: "https://mastodon.online", + timelineType: "profile", + userId: "000180745", + profileName: "@idotj", +}); ``` -### Tip +It will show a timeline with posts from my Mastodon profile [@idotj](https://mastodon.online/@idotj) -To setup a **profile timeline** you will need your `user_id` number. If you don't know it you have two ways to get it: +â„šī¸ If you don't know your `userId` you have two ways to get it: - Copy the url below and paste it in a new tab. Remember to replace the words `INSTANCE` and `USERNAME` with your current values in the url: The first value you see in the list is your `id` number. - Click on the link below and put your `@USERNAME` and `@INSTANCE` in the input field: - https://prouser123.me/mastodon-userid-lookup/ + [https://prouser123.me/mastodon-userid-lookup/](https://prouser123.me/mastodon-userid-lookup/) + +#### Hashtag timeline + +Add the following options/values when initializing the timeline: + +```js +const myTimeline = new MastodonTimeline({ + instanceUrl: "https://mastodon.online", + timelineType: "hashtag", + hashtagName: "fediverse", +}); +``` + +It will show a timeline with posts containing the hashtag [#fediverse](https://mastodon.online/tags/fediverse) + +### Customize + +You can pass more options/values to personalize your timeline: + +```js + // Id of the
containing the timeline + mtContainerId: "mt-container", + + // Mastodon instance Url (including https://) + instanceUrl: "https://mastodon.social", + + // Choose type of posts to show in the timeline: 'local', 'profile', 'hashtag'. Default: local + timelineType: "local", + + // Your user ID number on Mastodon instance. Leave it empty if you didn't choose 'profile' as type of timeline + userId: "", + + // Your user name on Mastodon instance (including the @ symbol at the beginning). Leave it empty if you didn't choose 'profile' as type of timeline + profileName: "", + + // The name of the hashtag (not including the # symbol). Leave it empty if you didn't choose 'hashtag' as type of timeline + hashtagName: "", + + // Class name for the loading spinner (also used in CSS file) + spinnerClass: "mt-loading-spinner", + + // Preferred color theme: 'light', 'dark' or 'auto'. Default: auto + defaultTheme: "auto", + + // Maximum number of posts to request to the server. Default: 20 + maxNbPostFetch: "20", + + // Maximum number of posts to show in the timeline. Default: 20 + maxNbPostShow: "20", + + // Hide unlisted posts. Default: don't hide + hideUnlisted: false, + + // Hide boosted posts. Default: don't hide + hideReblog: false, + + // Hide replies posts. Default: don't hide + hideReplies: false, + + // Hide video image preview and load video player instead. Default: don't hide + hideVideoPreview: false, + + // Hide preview card if post contains a link, photo or video from a Url. Default: don't hide + hidePreviewLink: false, + + // Hide custom emojis available on the server. Default: don't hide + hideEmojos: false, + + // Converts Markdown symbol ">" at the beginning of a paragraph into a blockquote HTML tag. Default: don't apply + markdownBlockquote: false, + + // Hide replies, boosts and favourites posts counter. Default: don't hide + hideCounterBar: false, + + // Limit the text content to a maximum number of lines. Default: 0 (unlimited) + txtMaxLines: "0", + + // Customize the text of the button used for showing/hiding sensitive/spolier text + btnShowMore: "SHOW MORE", + btnShowLess: "SHOW LESS", + + // Customize the text of the button used for showing sensitive/spolier media content + btnShowContent: "SHOW CONTENT", + + // Customize the text of the button pointing to the Mastodon page placed at the end of the timeline. Leave the value empty to hide it + btnSeeMore: "See more posts at Mastodon", + + // Customize the text of the button reloading the list of posts placed at the end of the timeline. Leave the value empty to hide it + btnReload: "Refresh", + +``` + +### API + +| Function | Description | +| --- | --- | +| `mtColorTheme(themeType)` | Apply a theme color. `themeType` accepts only two values: `light` or `dark` | +| `mtUpdate()` | Reload the timeline by fetching the lastest posts | + +### Examples + +The folder `/examples` contains several demos in HTML to play with. Just download the folder and open each HTML file in your favorite browser. + +Also, you have a Docker file to perform your tests if needed. Simply inside the `/examples` folder run: + +```terminal +docker compose up +``` + +### Browser support + +Mastodon embed timeline is supported on the latest versions of the following browsers: + +- Chrome +- Firefox +- Edge +- Safari +- Brave +- Opera ## 🚀 Improve me @@ -120,5 +245,8 @@ GNU Affero General Public License v3.0 ## đŸ’Ŧ FAQ -Check the [closed issues](https://gitlab.com/idotj/mastodon-embed-feed-timeline/-/issues/?sort=created_date&state=closed&first_page_size=20), you might find your question there. +Check the [closed issues](https://gitlab.com/idotj/mastodon-embed-feed-timeline/-/issues/?sort=created_date&state=closed&first_page_size=20), you might find your question there. + If nothing matches with your problem, check the [open issues](https://gitlab.com/idotj/mastodon-embed-feed-timeline/-/issues/?sort=created_date&state=opened&first_page_size=20) or feel free to create a new one. + +Looking for a previous version of Mastodon embed timeline? Check on the tags list to see all the released versions: [Tags version history](https://gitlab.com/idotj/mastodon-embed-feed-timeline/-/tags) diff --git a/dist/mastodon-timeline.min.css b/dist/mastodon-timeline.min.css new file mode 100644 index 0000000..f331c69 --- /dev/null +++ b/dist/mastodon-timeline.min.css @@ -0,0 +1 @@ +.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:#282c37;--mt-color-bg-hover:#313543;--mt-color-line-gray:#393f4f;--mt-color-contrast-gray:#606984;--mt-color-content-txt:#fff;--mt-color-link:#8c8dff;--mt-color-error-txt:#fe6c6c}.mt-container button{font:inherit}.mt-container a,.mt-container button{cursor:pointer}.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:.25rem;height:.25rem}.mt-container::-webkit-scrollbar-thumb{background-color:var(--mt-color-contrast-gray);border:none;border-radius:3rem}.mt-container::-webkit-scrollbar-thumb:active,.mt-container::-webkit-scrollbar-thumb:hover{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-corner,.mt-container::-webkit-scrollbar-track:active,.mt-container::-webkit-scrollbar-track:hover{background-color:var(--mt-color-bg)}.mt-container a,.mt-container a:active,.mt-container a:link{text-decoration:none;color:var(--mt-color-link)}.mt-container a:not(.mt-post-preview):hover{text-decoration:underline}.mt-body{padding:1rem clamp(.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-post{margin:.25rem;padding:1rem .5rem;position:relative;min-height:3.75rem;background-color:transparent;border-bottom:1px solid var(--mt-color-line-gray)}.mt-post:focus,.mt-post:hover{cursor:pointer;background-color:var(--mt-color-bg-hover)}.mt-post p:last-child{margin-bottom:0}.mt-post-avatar{margin-right:.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:.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:.25rem;overflow:hidden}.mt-post-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:1rem}.mt-post-header-user{font-weight:600;margin-top:.5rem;padding-right:1rem}.mt-post-header-user>a{display:flex;align-items:flex-start;color:var(--mt-color-content-txt)!important;overflow-wrap:anywhere}.mt-post-header-date{font-size:.75rem;text-align:right;margin:.5rem 0 0 auto}.mt-post-header-date>a{color:var(--mt-color-contrast-gray)!important}.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:.25rem solid var(--mt-color-line-gray);margin-left:0;padding-left:.5rem}.mt-post-header-user .mt-custom-emoji,.mt-post-txt .mt-custom-emoji{height:1.5rem;min-width:1.5rem;margin-bottom:-.25rem;width:auto}.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:.9rem;margin-bottom:.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:.25rem}.mt-post-poll ul li:before{content:"◯";padding-right:.5rem}.mt-post-poll.mt-post-poll-expired ul li:before{content:"";padding-right:0}.mt-post-media{position:relative;overflow:hidden;margin-bottom:1rem}.mt-post-media-spoiler>.mt-post-media-play-icon,.mt-post-media-spoiler>audio,.mt-post-media-spoiler>img,.mt-post-media-spoiler>video{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}.mt-post-preview{min-height:4rem;display:flex;flex-direction:row;border:1px solid var(--mt-color-line-gray);border-radius:.5rem;color:var(--mt-color-link);font-size:.8rem;margin:1rem 0;overflow:hidden}.mt-post-preview-image{width:40%;align-self:stretch}.mt-post-preview-image img{display:block;width:100%;height:100%;object-fit:cover;color:var(--mt-color-content-txt)}.mt-post-preview-noImage{width:40%;font-size:1.5rem;align-self:center;text-align:center}.mt-post-preview-content{width:60%;display:flex;align-self:center;flex-direction:column;padding:.5rem 1rem;gap:.5rem}.mt-post-preview-title{font-weight:600}.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-favorites,.mt-post-counter-bar-reblog,.mt-post-counter-bar-replies{display:flex;font-size:.75rem;gap:.25rem;align-items:center;opacity:.5}.mt-post-counter-bar-favorites>svg,.mt-post-counter-bar-reblog>svg,.mt-post-counter-bar-replies>svg{width:1rem;fill:var(--mt-color-contrast-gray)}.mt-container .mt-btn-dark{display:flex;border-radius:.25rem;background-color:var(--mt-color-line-gray);border:0;color:var(--mt-color-content-txt);font-weight:600;font-size:.75rem;text-align:center;padding:0 .5rem;line-height:1.25rem;vertical-align:top}.mt-container .mt-btn-violet,.mt-container a.mt-btn-violet{display:flex;gap:.5rem;border-radius:.25rem;border:.5rem;padding:.5rem .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%)}.mt-error{position:absolute;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;height:calc(100% - 3.5rem);width:calc(100% - 4.5rem);justify-content:center;align-items:center;color:var(--mt-color-error-txt);padding:.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)}.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% - .5rem))}.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}.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/dist/mastodon-timeline.min.js b/dist/mastodon-timeline.min.js new file mode 100644 index 0000000..f8eb015 --- /dev/null +++ b/dist/mastodon-timeline.min.js @@ -0,0 +1,7 @@ +/** + * Mastodon embed feed timeline + * @author idotj + * @version 4.0.0 + * @url https://gitlab.com/idotj/mastodon-embed-feed-timeline + * @license GNU AGPLv3 +*/ "use strict";class MastodonTimeline{constructor(t={}){this.defaultSettings={mtContainerId:"mt-container",mtBody:"",instanceUrl:"https://mastodon.social",timelineType:"local",userId:"",profileName:"",hashtagName:"",spinnerClass:"mt-loading-spinner",defaultTheme:"auto",maxNbPostFetch:"20",maxNbPostShow:"20",hideUnlisted:!1,hideReblog:!1,hideReplies:!1,hideVideoPreview:!1,hidePreviewLink:!1,hideEmojos:!1,markdownBlockquote:!1,hideCounterBar:!1,txtMaxLines:"0",btnShowMore:"SHOW MORE",btnShowLess:"SHOW LESS",btnShowContent:"SHOW CONTENT",btnSeeMore:"See more posts at Mastodon",btnReload:"Refresh",fetchedData:{}},this.mtSettings={...this.defaultSettings,...t},this.mtSettings.mtBody=document.getElementById(this.mtSettings.mtContainerId).getElementsByClassName("mt-body")[0],this.mtInit()}mtInit(){this.#a(),this.#b("newTimeline")}mtUpdate(){this.mtSettings.mtBody.replaceChildren(),this.mtSettings.mtBody.insertAdjacentHTML("afterbegin",'
'),this.#b("updateTimeline")}mtColorTheme(t){document.getElementById(this.mtSettings.mtContainerId).setAttribute("data-theme",t)}#a(){if("auto"===this.mtSettings.defaultTheme){let t=window.matchMedia("(prefers-color-scheme: dark)");t.matches?this.mtColorTheme("dark"):this.mtColorTheme("light"),t.addEventListener("change",t=>{t.matches?this.mtColorTheme("dark"):this.mtColorTheme("light")})}else this.mtColorTheme(this.mtSettings.defaultTheme)}#c(){return new Promise((t,e)=>{async function s(t){let e=await fetch(t);if(!e.ok)throw Error("Failed to fetch the following Url:
"+t+"
Error status: "+e.status+"
Error message: "+e.statusText);let s=await e.json();return s}let i={};this.mtSettings.instanceUrl?"profile"===this.mtSettings.timelineType?this.mtSettings.userId?i.timeline=`${this.mtSettings.instanceUrl}/api/v1/accounts/${this.mtSettings.userId}/statuses?limit=${this.mtSettings.maxNbPostFetch}`:this.#d("Please check your userId value","âš ī¸"):"hashtag"===this.mtSettings.timelineType?this.mtSettings.hashtagName?i.timeline=`${this.mtSettings.instanceUrl}/api/v1/timelines/tag/${this.mtSettings.hashtagName}?limit=${this.mtSettings.maxNbPostFetch}`:this.#d("Please check your hashtagName value","âš ī¸"):"local"===this.mtSettings.timelineType?i.timeline=`${this.mtSettings.instanceUrl}/api/v1/timelines/public?local=true&limit=${this.mtSettings.maxNbPostFetch}`:this.#d("Please check your timelineType value","âš ī¸"):this.#d("Please check your instanceUrl value","âš ī¸"),this.mtSettings.hideEmojos||(i.emojos=this.mtSettings.instanceUrl+"/api/v1/custom_emojis");let a=Object.entries(i).map(([t,i])=>s(i).then(e=>({[t]:e})).catch(s=>(e(Error("Something went wrong fetching data from: "+i)),this.#d(s.message),{[t]:[]})));Promise.all(a).then(e=>{this.mtSettings.fetchedData=e.reduce((t,e)=>({...t,...e}),{}),t()})})}async #b(e){await this.#c(),this.mtSettings.mtBody.replaceChildren();let s=0;for(let i in this.mtSettings.fetchedData.timeline)("public"==this.mtSettings.fetchedData.timeline[i].visibility||!this.mtSettings.hideUnlisted&&"unlisted"==this.mtSettings.fetchedData.timeline[i].visibility)&&(this.mtSettings.hideReblog&&this.mtSettings.fetchedData.timeline[i].reblog||this.mtSettings.hideReplies&&this.mtSettings.fetchedData.timeline[i].in_reply_to_id||sThis may be due to an incorrect configuration in the parameters or to filters applied (to hide certains type of posts)";this.#d(a,"\uD83D\uDCED")}else"newTimeline"===e?(this.#f(),this.#g(),this.#h()):"updateTimeline"===e?this.#f():this.#d("The function buildTimeline() was expecting a param")}#e(o,n){this.mtSettings.mtBody.insertAdjacentHTML("beforeend",this.#i(o,n))}#i(r,l){let m,d,h,c,g,p,u,v,b;r.reblog?(c=r.reblog.url,m='
'+this.#j(r.reblog.account.username)+' avatar
'+this.#j(r.account.username)+' avatar
',h=this.#k(r.reblog.account.display_name?r.reblog.account.display_name:r.reblog.account.username,this.mtSettings.fetchedData.emojos),d='
'+h+' account
',g=r.reblog.created_at,b=r.reblog.replies_count,v=r.reblog.reblogs_count,u=r.reblog.favourites_count):(c=r.url,m='
'+this.#j(r.account.username)+' avatar
',h=this.#k(r.account.display_name?r.account.display_name:r.account.username,this.mtSettings.fetchedData.emojos),d='
'+h+' account
',g=r.created_at,b=r.replies_count,v=r.reblogs_count,u=r.favourites_count),p=this.#l(g);let S='
",$="";"0"!==this.mtSettings.txtMaxLines&&($=" truncate",this.mtSettings.mtBody.parentNode.style.setProperty("--mt-txt-max-lines",this.mtSettings.txtMaxLines));let f="";f=""!==r.spoiler_text?'
'+r.spoiler_text+'
'+this.#m(r.content)+"
":r.reblog&&""!==r.reblog.content&&""!==r.reblog.spoiler_text?'
'+r.reblog.spoiler_text+'
'+this.#m(r.reblog.content)+"
":r.reblog&&""!==r.reblog.content&&""===r.reblog.spoiler_text?'
'+this.#m(r.reblog.content)+"
":'
'+this.#m(r.content)+"
";let _=[];if(r.media_attachments.length>0)for(let w in r.media_attachments)_.push(this.#n(r.media_attachments[w],r.sensitive));if(r.reblog&&r.reblog.media_attachments.length>0)for(let y in r.reblog.media_attachments)_.push(this.#n(r.reblog.media_attachments[y],r.reblog.sensitive));let x="";!this.mtSettings.hidePreviewLink&&r.card&&(x=this.#o(r.card));let N="";if(r.poll){let T="";for(let L in r.poll.options)T+="
  • "+r.poll.options[L].title+"
  • ";N='
      '+T+"
    "}let k="";if(!this.mtSettings.hideCounterBar){let C='
    '+b+"
    ",M='
    '+v+"
    ",E='
    '+u+"
    ";k='
    '+C+M+E+"
    "}let B='
    '+m+d+S+"
    "+f+_.join("")+x+N+k+"
    ";return B}#m(P){let H=P;return H=this.#p(H),this.mtSettings.hideEmojos||(H=this.#k(H,this.mtSettings.fetchedData.emojos)),this.mtSettings.markdownBlockquote&&(H=this.#q(H,"

    >","

    ","

    ","

    ")),H}#p(j){let A=j.replaceAll('rel="tag"','rel="tag" target="_blank"');return A.replaceAll('class="u-url mention"','class="u-url mention" target="_blank"')}#q(I,D,U,F,R){if(!I.includes(D))return I;{let z=RegExp(D+"(.*?)"+U,"gi");return I.replace(z,F+"$1"+R)}}#j(q){return(q??"").replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}#k(O,Z){if(!O.includes(":"))return O;for(let V of Z){let J=RegExp(`\\:${V.shortcode}\\:`,"g");O=O.replace(J,`Emoji ${V.shortcode}`)}return O}#l(W){let Y=new Date(W),G=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec",][Y.getMonth()]+" "+Y.getDate()+", "+Y.getFullYear();return G}#n(K,Q){let X=Q||!1,tt=K.type,te="";return"image"===tt&&(te='
    '+(X?'":"")+''+(K.description?this.#j(K.description):
    '),"audio"===tt&&(te=K.preview_url?'
    '+(X?'":"")+''+(K.description?this.#j(K.description):
    ':'
    '+(X?'":"")+'
    '),("video"===tt||"gifv"===tt)&&(te=this.mtSettings.hideVideoPreview?'
    '+(X?'":"")+'
    ':'
    '+(X?'":"")+''+(K.description?this.#j(K.description):
    '),te}#r(ts){let ti=ts.target.closest("[data-video-url]"),ta=ti.dataset.videoUrl;ti.replaceChildren(),ti.innerHTML=''}#s(to){let tn=to.target.nextSibling;"img"===tn.localName||"audio"===tn.localName||"video"===tn.localName?(to.target.parentNode.classList.remove("mt-post-media-spoiler"),to.target.style.display="none"):(tn.classList.contains("spoiler-txt-hidden")||tn.classList.contains("spoiler-txt-visible"))&&(to.target.textContent==this.mtSettings.btnShowMore?(tn.classList.remove("spoiler-txt-hidden"),tn.classList.add("spoiler-txt-visible"),to.target.setAttribute("aria-expanded","true"),to.target.textContent=this.mtSettings.btnShowLess):(tn.classList.remove("spoiler-txt-visible"),tn.classList.add("spoiler-txt-hidden"),to.target.setAttribute("aria-expanded","false"),to.target.textContent=this.mtSettings.btnShowMore))}#o=function(t){let e=''+(t.image?'
    '+this.#j(t.image_description)+'
    ':'
    \uD83D\uDCC4
    ')+'
    '+(t.provider_name?''+this.#t(t.provider_name)+"":"")+''+t.title+""+(t.author_name?''+this.#t(t.author_name)+"":"")+"
    ";return e};#t(tr){let tl=new DOMParser,tm=tl.parseFromString(tr,"text/html");return tm.body.textContent}#h(){if(this.mtSettings.btnSeeMore||this.mtSettings.btnReload){this.mtSettings.mtBody.parentNode.insertAdjacentHTML("beforeend",'');let td=document.getElementById(this.mtSettings.mtContainerId).getElementsByClassName("mt-footer")[0];if(this.mtSettings.btnSeeMore){let th="";"profile"===this.mtSettings.timelineType?this.mtSettings.profileName?th=this.mtSettings.profileName:this.#d("Please check your profileName value","âš ī¸"):"hashtag"===this.mtSettings.timelineType?th="tags/"+this.mtSettings.hashtagName:"local"===this.mtSettings.timelineType&&(th="public/local");let tc=''+this.mtSettings.btnSeeMore+"";td.insertAdjacentHTML("beforeend",tc)}if(this.mtSettings.btnReload){let tg='";td.insertAdjacentHTML("beforeend",tg);let tp=document.getElementById(this.mtSettings.mtContainerId).getElementsByClassName("btn-refresh")[0];tp.addEventListener("click",()=>{this.mtUpdate()})}}}#g(){this.mtSettings.mtBody.addEventListener("click",t=>{"article"!=t.target.localName&&t.target.offsetParent?.localName!="article"&&("img"!=t.target.localName||t.target.parentNode.getAttribute("data-video-url"))||this.#u(t),"button"==t.target.localName&&t.target.classList.contains("mt-btn-spoiler")&&this.#s(t),("mt-post-media-play-icon"==t.target.className||"svg"==t.target.localName&&"mt-post-media-play-icon"==t.target.parentNode.className||"path"==t.target.localName&&"mt-post-media-play-icon"==t.target.parentNode.parentNode.className||"img"==t.target.localName&&t.target.parentNode.getAttribute("data-video-url"))&&this.#r(t)}),this.mtSettings.mtBody.addEventListener("keydown",t=>{"Enter"===t.key&&"article"==t.target.localName&&this.#u(t)})}#u(tu){let tv=tu.target.closest(".mt-post").dataset.location;"a"!==tu.target.localName&&"span"!==tu.target.localName&&"button"!==tu.target.localName&&"time"!==tu.target.localName&&"mt-post-preview-noImage"!==tu.target.className&&"mt-post-avatar-image-big"!==tu.target.parentNode.className&&"mt-post-avatar-image-small"!==tu.target.parentNode.className&&"mt-post-preview-image"!==tu.target.parentNode.className&&"mt-post-preview"!==tu.target.parentNode.className&&tv&&window.open(tv,"_blank","noopener")}#f(){let tb=t=>{t.target.parentNode.classList.remove(this.mtSettings.spinnerClass),t.target.removeEventListener("load",tb),t.target.removeEventListener("error",tb)};this.mtSettings.mtBody.querySelectorAll(`.${this.mtSettings.spinnerClass} > img`).forEach(t=>{t.addEventListener("load",tb),t.addEventListener("error",tb)})}#d(tS,t$){throw this.mtSettings.mtBody.innerHTML='
    '+(t$||"❌")+'
    Oops, something\'s happened:
    '+tS+"
    ",this.mtSettings.mtBody.setAttribute("role","none"),Error("Stopping the script due to an error building the timeline.")}} diff --git a/examples/css/mastodon-timeline.css b/examples/css/mastodon-timeline.css new file mode 100644 index 0000000..a9f37df --- /dev/null +++ b/examples/css/mastodon-timeline.css @@ -0,0 +1,462 @@ +/* Mastodon embed feed timeline v4.0.0 */ +/* More info at: */ +/* https://gitlab.com/idotj/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: #282c37; + --mt-color-bg-hover: #313543; + --mt-color-line-gray: #393f4f; + --mt-color-contrast-gray: #606984; + --mt-color-content-txt: #fff; + --mt-color-link: #8c8dff; + --mt-color-error-txt: #fe6c6c; +} + +/* 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; +} + +/* 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: flex-start; + margin-bottom: 1rem; +} +.mt-post-header-user { + font-weight: 600; + margin-top: 0.5rem; + padding-right: 1rem; +} +.mt-post-header-user > a { + display: flex; + align-items: flex-start; + color: var(--mt-color-content-txt) !important; + overflow-wrap: anywhere; +} +.mt-post-header-date { + font-size: 0.75rem; + text-align: right; + margin: 0.5rem 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; +} +.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: row; + 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: 40%; + align-self: stretch; +} +.mt-post-preview-image img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + color: var(--mt-color-content-txt); +} +.mt-post-preview-noImage { + width: 40%; + font-size: 1.5rem; + align-self: center; + text-align: center; +} +.mt-post-preview-content { + width: 60%; + display: flex; + align-self: center; + flex-direction: column; + padding: 0.5rem 1rem; + gap: 0.5rem; +} +.mt-post-preview-title { + font-weight: 600; +} + +/* 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; + height: calc(100% - 3.5rem); + width: calc(100% - 4.5rem); + 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; +} diff --git a/docker-compose.yml b/examples/docker-compose.yml similarity index 51% rename from docker-compose.yml rename to examples/docker-compose.yml index 197c3ee..ca24ec8 100644 --- a/docker-compose.yml +++ b/examples/docker-compose.yml @@ -3,7 +3,7 @@ # # $ docker compose up -version: '3.3' +version: "3.3" services: lighttpd: @@ -12,9 +12,13 @@ services: - "8080:80" - "8443:443" volumes: - - ./src/index.html:/var/www/index.html - - ./src/mastodon-timeline.js:/var/www/mastodon-timeline.js - - ./src/mastodon-timeline.css:/var/www/mastodon-timeline.css + - ./css/mastodon-timeline.css:/var/www/css/mastodon-timeline.css + - ./js/mastodon-timeline.js:/var/www/js/mastodon-timeline.js + - ./local-timeline.html:/var/www/index.html + - ./profile-timeline.html:/var/www/profile-timeline.html + - ./hashtag-timeline.html:/var/www/hashtag-timeline.html + - ./theme-timeline.html:/var/www/theme-timeline.html + - ./multiple-timelines.html:/var/www/multiple-timelines.html environment: - PORT=80 - SERVER_NAME=mastodon-timeline diff --git a/examples/hashtag-timeline.html b/examples/hashtag-timeline.html new file mode 100644 index 0000000..57f651a --- /dev/null +++ b/examples/hashtag-timeline.html @@ -0,0 +1,115 @@ + + + + + Mastodon embed timeline + + + + + + + + + + +
    + +
    +

    🐘 Mastodon embed timeline

    +

    Profile timeline

    +

    + This example shows posts containing the hashtag +
    + #fediverse +

    +

    It has been initialized with the following script:

    +
    +        
    +  <script>
    +    const myTimeline = new MastodonTimeline({
    +      instanceUrl: "https://mastodon.online",
    +      timelineType: "hashtag",
    +      hashtagName: "fediverse",
    +    });
    +  </script>
    +          
    +        
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    + + + + + + diff --git a/examples/js/mastodon-timeline.js b/examples/js/mastodon-timeline.js new file mode 100644 index 0000000..fb6f135 --- /dev/null +++ b/examples/js/mastodon-timeline.js @@ -0,0 +1,1069 @@ +/** + * Mastodon embed feed timeline + * @author idotj + * @version 4.0.0 + * @url https://gitlab.com/idotj/mastodon-embed-feed-timeline + * @license GNU AGPLv3 + */ +"use strict"; + +class MastodonTimeline { + constructor(customSettings = {}) { + this.defaultSettings = { + mtContainerId: "mt-container", + mtBody: "", + instanceUrl: "https://mastodon.social", + timelineType: "local", + userId: "", + profileName: "", + hashtagName: "", + spinnerClass: "mt-loading-spinner", + defaultTheme: "auto", + maxNbPostFetch: "20", + maxNbPostShow: "20", + hideUnlisted: false, + hideReblog: false, + hideReplies: false, + hideVideoPreview: false, + hidePreviewLink: false, + hideEmojos: false, + markdownBlockquote: false, + hideCounterBar: false, + txtMaxLines: "0", + btnShowMore: "SHOW MORE", + btnShowLess: "SHOW LESS", + btnShowContent: "SHOW CONTENT", + btnSeeMore: "See more posts at Mastodon", + btnReload: "Refresh", + fetchedData: {}, + }; + + this.mtSettings = { ...this.defaultSettings, ...customSettings }; + + // Set node of body container + this.mtSettings.mtBody = document + .getElementById(this.mtSettings.mtContainerId) + .getElementsByClassName("mt-body")[0]; + + this.mtInit(); + } + + /** + * Initialize and build the timeline + */ + mtInit() { + console.log("Init Mastodon timeline. Settings: ", this.mtSettings); + this.#loadColorTheme(); + this.#buildTimeline("newTimeline"); + } + + /** + * Reload the timeline by fetching the lastest posts + */ + mtUpdate() { + this.mtSettings.mtBody.replaceChildren(); + this.mtSettings.mtBody.insertAdjacentHTML( + "afterbegin", + '
    ' + ); + this.#buildTimeline("updateTimeline"); + } + + /** + * Apply the color theme in the timeline + * @param {string} themeType Type of color theme + */ + mtColorTheme(themeType) { + document + .getElementById(this.mtSettings.mtContainerId) + .setAttribute("data-theme", themeType); + } + + /** + * Get the theme style chosen by the user or by the browser/OS + */ + #loadColorTheme() { + if (this.mtSettings.defaultTheme === "auto") { + let systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); + systemTheme.matches + ? this.mtColorTheme("dark") + : this.mtColorTheme("light"); + // Update the theme if user change browser/OS preference + systemTheme.addEventListener("change", (e) => { + e.matches ? this.mtColorTheme("dark") : this.mtColorTheme("light"); + }); + } else { + this.mtColorTheme(this.mtSettings.defaultTheme); + } + } + + /** + * Requests to the server to collect all the data + * @returns {object} Data container + */ + #fetchTimelineData() { + return new Promise((resolve, reject) => { + /** + * Fetch data from server + * @param {string} url address to fetch + * @returns {array} List of objects + */ + async function fetchData(url) { + const response = await fetch(url); + + if (!response.ok) { + throw new Error( + "Failed to fetch the following Url:
    " + + url + + "
    " + + "Error status: " + + response.status + + "
    " + + "Error message: " + + response.statusText + ); + } + + const data = await response.json(); + return data; + } + + // Urls to fetch + let urls = {}; + + if (this.mtSettings.instanceUrl) { + if (this.mtSettings.timelineType === "profile") { + if (this.mtSettings.userId) { + urls.timeline = `${this.mtSettings.instanceUrl}/api/v1/accounts/${this.mtSettings.userId}/statuses?limit=${this.mtSettings.maxNbPostFetch}`; + } else { + this.#showError( + "Please check your userId value", + "âš ī¸" + ); + } + } else if (this.mtSettings.timelineType === "hashtag") { + if (this.mtSettings.hashtagName) { + urls.timeline = `${this.mtSettings.instanceUrl}/api/v1/timelines/tag/${this.mtSettings.hashtagName}?limit=${this.mtSettings.maxNbPostFetch}`; + } else { + this.#showError( + "Please check your hashtagName value", + "âš ī¸" + ); + } + } else if (this.mtSettings.timelineType === "local") { + urls.timeline = `${this.mtSettings.instanceUrl}/api/v1/timelines/public?local=true&limit=${this.mtSettings.maxNbPostFetch}`; + } else { + this.#showError( + "Please check your timelineType value", + "âš ī¸" + ); + } + } else { + this.#showError( + "Please check your instanceUrl value", + "âš ī¸" + ); + } + if (!this.mtSettings.hideEmojos) { + urls.emojos = this.mtSettings.instanceUrl + "/api/v1/custom_emojis"; + } + + const urlsPromises = Object.entries(urls).map(([key, url]) => { + return fetchData(url) + .then((data) => ({ [key]: data })) + .catch((error) => { + reject( + new Error("Something went wrong fetching data from: " + url) + ); + this.#showError(error.message); + return { [key]: [] }; + }); + }); + + // Fetch all urls simultaneously + Promise.all(urlsPromises).then((dataObjects) => { + this.mtSettings.fetchedData = dataObjects.reduce((result, dataItem) => { + return { ...result, ...dataItem }; + }, {}); + + console.log("Timeline data fetched: ", this.mtSettings.fetchedData); + resolve(); + }); + }); + } + + /** + * Filter all fetched posts and append them on the timeline + * @param {string} t Type of build (new or reload) + */ + async #buildTimeline(t) { + await this.#fetchTimelineData(); + + // Empty container body + this.mtSettings.mtBody.replaceChildren(); + + // Set posts counter to 0 + let nbPostShow = 0; + + for (let i in this.mtSettings.fetchedData.timeline) { + // First filter (Public / Unlisted) + if ( + this.mtSettings.fetchedData.timeline[i].visibility == "public" || + (!this.mtSettings.hideUnlisted && + this.mtSettings.fetchedData.timeline[i].visibility == "unlisted") + ) { + // Second filter (Reblog / Replies) + if ( + (this.mtSettings.hideReblog && + this.mtSettings.fetchedData.timeline[i].reblog) || + (this.mtSettings.hideReplies && + this.mtSettings.fetchedData.timeline[i].in_reply_to_id) + ) { + // Nothing here (Don't append posts) + } else { + if (nbPostShow < this.mtSettings.maxNbPostShow) { + this.#appendPost( + this.mtSettings.fetchedData.timeline[i], + Number(i) + ); + nbPostShow++; + } else { + // Nothing here (Reached the limit of maximum number of posts to show) + } + } + } + } + + // If there are no posts to display, show an error message + if (this.mtSettings.mtBody.innerHTML === "") { + const errorMessage = + "No posts to show
    " + + (this.mtSettings.fetchedData.timeline?.length || 0) + + " posts have been fetched from the server
    This may be due to an incorrect configuration in the parameters or to filters applied (to hide certains type of posts)"; + this.#showError(errorMessage, "📭"); + } else { + if (t === "newTimeline") { + this.#manageSpinner(); + this.#setPostsInteracion(); + this.#buildFooter(); + } else if (t === "updateTimeline") { + this.#manageSpinner(); + } else { + this.#showError("The function buildTimeline() was expecting a param"); + } + } + } + + /** + * Add each post in the timeline container + * @param {object} c Post content + * @param {number} i Index of post + */ + #appendPost(c, i) { + this.mtSettings.mtBody.insertAdjacentHTML( + "beforeend", + this.#assamblePost(c, i) + ); + } + + /** + * Build post structure + * @param {object} c Post content + * @param {number} i Index of post + */ + #assamblePost(c, i) { + let avatar, + user, + userName, + url, + date, + formattedDate, + favoritesCount, + reblogCount, + repliesCount; + + if (c.reblog) { + // BOOSTED post + // Post url + url = c.reblog.url; + + // Boosted avatar + avatar = + '' + + '
    ' + + '
    ' + + '' +
+        this.#escapeHtml(c.reblog.account.username) +
+        ' avatar' + + "
    " + + '
    ' + + '' +
+        this.#escapeHtml(c.account.username) +
+        ' avatar' + + "
    " + + "
    " + + "
    "; + + // User name and url + userName = this.#createEmoji( + c.reblog.account.display_name + ? c.reblog.account.display_name + : c.reblog.account.username, + this.mtSettings.fetchedData.emojos + ); + user = + '
    ' + + '' + + userName + + ' account' + + "" + + "
    "; + + // Date + date = c.reblog.created_at; + + // Counter bar + repliesCount = c.reblog.replies_count; + reblogCount = c.reblog.reblogs_count; + favoritesCount = c.reblog.favourites_count; + } else { + // STANDARD post + // Post url + url = c.url; + + // Avatar + avatar = + '' + + '
    ' + + '
    ' + + '' +
+        this.#escapeHtml(c.account.username) +
+        ' avatar' + + "
    " + + "
    " + + "
    "; + + // User name and url + userName = this.#createEmoji( + c.account.display_name ? c.account.display_name : c.account.username, + this.mtSettings.fetchedData.emojos + ); + user = + '
    ' + + '' + + userName + + ' account' + + "" + + "
    "; + + // Date + date = c.created_at; + + // Counter bar + repliesCount = c.replies_count; + reblogCount = c.reblogs_count; + favoritesCount = c.favourites_count; + } + + // Date + formattedDate = this.#formatDate(date); + const timestamp = + '
    ' + + '' + + '" + + "" + + "
    "; + + // Main text + let txtCss = ""; + if (this.mtSettings.txtMaxLines !== "0") { + txtCss = " truncate"; + this.mtSettings.mtBody.parentNode.style.setProperty( + "--mt-txt-max-lines", + this.mtSettings.txtMaxLines + ); + } + + let content = ""; + if (c.spoiler_text !== "") { + content = + '
    ' + + c.spoiler_text + + ' " + + '
    ' + + this.#formatPostText(c.content) + + "
    " + + "
    "; + } else if ( + c.reblog && + c.reblog.content !== "" && + c.reblog.spoiler_text !== "" + ) { + content = + '
    ' + + c.reblog.spoiler_text + + ' " + + '
    ' + + this.#formatPostText(c.reblog.content) + + "
    " + + "
    "; + } else if ( + c.reblog && + c.reblog.content !== "" && + c.reblog.spoiler_text === "" + ) { + content = + '
    ' + + '
    ' + + this.#formatPostText(c.reblog.content) + + "
    " + + "
    "; + } else { + content = + '
    ' + + '
    ' + + this.#formatPostText(c.content) + + "
    " + + "
    "; + } + + // Media attachments + let media = []; + if (c.media_attachments.length > 0) { + for (let i in c.media_attachments) { + media.push(this.#createMedia(c.media_attachments[i], c.sensitive)); + } + } + if (c.reblog && c.reblog.media_attachments.length > 0) { + for (let i in c.reblog.media_attachments) { + media.push( + this.#createMedia(c.reblog.media_attachments[i], c.reblog.sensitive) + ); + } + } + + // Preview link + let previewLink = ""; + if (!this.mtSettings.hidePreviewLink && c.card) { + previewLink = this.#createPreviewLink(c.card); + } + + // Poll + let poll = ""; + if (c.poll) { + let pollOption = ""; + for (let i in c.poll.options) { + pollOption += "
  • " + c.poll.options[i].title + "
  • "; + } + poll = + '
    ' + + "
      " + + 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.join("") + + previewLink + + poll + + counterBar + + "
    "; + + return post; + } + + /** + * Handle text changes made to posts + * @param {string} c Text content + * @returns {string} Text content modified + */ + #formatPostText(c) { + let content = c; + + // Format hashtags and mentions + content = this.#addTarget2hashtagMention(content); + + // Convert emojos shortcode into images + if (!this.mtSettings.hideEmojos) { + content = this.#createEmoji(content, this.mtSettings.fetchedData.emojos); + } + + // Convert markdown styles into HTML + if (this.mtSettings.markdownBlockquote) { + content = this.#replaceHTMLtag( + content, + "

    >", + "

    ", + "

    ", + "

    " + ); + } + + return content; + } + + /** + * Add target="_blank" to all #hashtags and @mentions in the post + * @param {string} c Text content + * @returns {string} Text content modified + */ + #addTarget2hashtagMention(c) { + let content = c.replaceAll('rel="tag"', 'rel="tag" target="_blank"'); + content = content.replaceAll( + 'class="u-url mention"', + 'class="u-url mention" target="_blank"' + ); + + return content; + } + + /** + * Find all start/end and replace them by another start/end + * @param {string} c Text content + * @param {string} initialTagOpen Start HTML tag to replace + * @param {string} initialTagClose End HTML tag to replace + * @param {string} replacedTagOpen New start HTML tag + * @param {string} replacedTagClose New end HTML tag + * @returns {string} Text in HTML format + */ + #replaceHTMLtag( + c, + initialTagOpen, + initialTagClose, + replacedTagOpen, + replacedTagClose + ) { + if (c.includes(initialTagOpen)) { + const regex = new RegExp( + initialTagOpen + "(.*?)" + initialTagClose, + "gi" + ); + + return c.replace(regex, replacedTagOpen + "$1" + replacedTagClose); + } else { + return c; + } + } + + /** + * Escape quotes and other special characters, to make them safe to add + * to HTML content and attributes as plain text + * @param {string} s String + * @returns {string} String + */ + #escapeHtml(s) { + return (s ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + /** + * Find all custom emojis shortcode and replace by image + * @param {string} c Text content + * @param {array} e List with all custom emojis + * @returns {string} Text content modified + */ + #createEmoji(c, e) { + if (c.includes(":")) { + for (const emojo of e) { + const regex = new RegExp(`\\:${emojo.shortcode}\\:`, "g"); + c = c.replace( + regex, + `Emoji ${emojo.shortcode}` + ); + } + + return c; + } else { + return c; + } + } + + /** + * Format date + * @param {string} d Date in ISO format (YYYY-MM-DDTHH:mm:ss.sssZ) + * @returns {string} Date formated (MM DD, YYYY) + */ + #formatDate(d) { + const monthNames = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + + const date = new Date(d); + + const displayDate = + monthNames[date.getMonth()] + + " " + + date.getDate() + + ", " + + date.getFullYear(); + + return displayDate; + } + + /** + * Create media element + * @param {object} m Media content + * @param {boolean} s Spoiler/Sensitive status + * @returns {string} Media in HTML format + */ + #createMedia(m, s) { + const spoiler = s || false; + const type = m.type; + let media = ""; + + if (type === "image") { + media = + '
    ' + + (spoiler + ? '" + : "") + + '' +
+        (m.description ? this.#escapeHtml(m.description) : ' + + "
    "; + } + + if (type === "audio") { + if (m.preview_url) { + media = + '
    ' + + (spoiler + ? '" + : "") + + '' + + '' +
+          (m.description ? this.#escapeHtml(m.description) : ' + + "
    "; + } else { + media = + '
    ' + + (spoiler + ? '" + : "") + + '' + + "
    "; + } + } + + if (type === "video" || type === "gifv") { + if (!this.mtSettings.hideVideoPreview) { + media = + '
    ' + + (spoiler + ? '" + : "") + + '' +
+          (m.description ? this.#escapeHtml(m.description) : ' + + '' + + "
    "; + } else { + media = + '
    ' + + (spoiler + ? '" + : "") + + '' + + "
    "; + } + } + + return media; + } + + /** + * Replace the video preview image by the video player + * @param {event} e User interaction trigger + */ + #loadPostVideo(e) { + const parentNode = e.target.closest("[data-video-url]"); + const videoUrl = parentNode.dataset.videoUrl; + parentNode.replaceChildren(); + parentNode.innerHTML = + ''; + } + + /** + * Spoiler button + * @param {event} e User interaction trigger + */ + #toogleSpoiler(e) { + const nextSibling = e.target.nextSibling; + if ( + nextSibling.localName === "img" || + nextSibling.localName === "audio" || + nextSibling.localName === "video" + ) { + e.target.parentNode.classList.remove("mt-post-media-spoiler"); + e.target.style.display = "none"; + } else if ( + nextSibling.classList.contains("spoiler-txt-hidden") || + nextSibling.classList.contains("spoiler-txt-visible") + ) { + if (e.target.textContent == this.mtSettings.btnShowMore) { + nextSibling.classList.remove("spoiler-txt-hidden"); + nextSibling.classList.add("spoiler-txt-visible"); + e.target.setAttribute("aria-expanded", "true"); + e.target.textContent = this.mtSettings.btnShowLess; + } else { + nextSibling.classList.remove("spoiler-txt-visible"); + nextSibling.classList.add("spoiler-txt-hidden"); + e.target.setAttribute("aria-expanded", "false"); + e.target.textContent = this.mtSettings.btnShowMore; + } + } + } + + /** + * Create preview link + * @param {object} c Preview link content + * @returns {string} Preview link in HTML format + */ + #createPreviewLink = function (c) { + const card = + '' + + (c.image + ? '
    ' +
+          this.#escapeHtml(c.image_description) +
+          '
    ' + : '
    📄
    ') + + "
    " + + '
    ' + + (c.provider_name + ? '' + + this.#parseHTMLstring(c.provider_name) + + "" + : "") + + '' + + c.title + + "" + + (c.author_name + ? '' + + this.#parseHTMLstring(c.author_name) + + "" + : "") + + "
    " + + "
    "; + + return card; + }; + + /** + * Parse HTML string + * @param {string} s HTML string + * @returns {string} Plain text + */ + #parseHTMLstring(s) { + const parser = new DOMParser(); + const txt = parser.parseFromString(s, "text/html"); + return txt.body.textContent; + } + + /** + * Build footer after last post + */ + #buildFooter() { + if (this.mtSettings.btnSeeMore || this.mtSettings.btnReload) { + // Add footer container + this.mtSettings.mtBody.parentNode.insertAdjacentHTML( + "beforeend", + '' + ); + + const containerFooter = document + .getElementById(this.mtSettings.mtContainerId) + .getElementsByClassName("mt-footer")[0]; + + // Create button to open Mastodon page + if (this.mtSettings.btnSeeMore) { + let btnSeeMorePath = ""; + if (this.mtSettings.timelineType === "profile") { + if (this.mtSettings.profileName) { + btnSeeMorePath = this.mtSettings.profileName; + } else { + this.#showError( + "Please check your profileName value", + "âš ī¸" + ); + } + } else if (this.mtSettings.timelineType === "hashtag") { + btnSeeMorePath = "tags/" + this.mtSettings.hashtagName; + } else if (this.mtSettings.timelineType === "local") { + btnSeeMorePath = "public/local"; + } + const btnSeeMoreHTML = + '' + + this.mtSettings.btnSeeMore + + ""; + + containerFooter.insertAdjacentHTML("beforeend", btnSeeMoreHTML); + } + + // Create button to refresh the timeline + if (this.mtSettings.btnReload) { + const btnReloadHTML = + '"; + + containerFooter.insertAdjacentHTML("beforeend", btnReloadHTML); + + const reloadBtn = document + .getElementById(this.mtSettings.mtContainerId) + .getElementsByClassName("btn-refresh")[0]; + reloadBtn.addEventListener("click", () => { + this.mtUpdate(); + }); + } + } + } + + /** + * Add EventListeners for timeline interactions and trigger functions + */ + #setPostsInteracion() { + this.mtSettings.mtBody.addEventListener("click", (e) => { + // Check if post cointainer was clicked + if ( + e.target.localName == "article" || + e.target.offsetParent?.localName == "article" || + (e.target.localName == "img" && + !e.target.parentNode.getAttribute("data-video-url")) + ) { + this.#openPostUrl(e); + } + // Check if Show More/Less button was clicked + if ( + e.target.localName == "button" && + e.target.classList.contains("mt-btn-spoiler") + ) { + this.#toogleSpoiler(e); + } + // Check if video preview image or play icon/button was clicked + if ( + e.target.className == "mt-post-media-play-icon" || + (e.target.localName == "svg" && + e.target.parentNode.className == "mt-post-media-play-icon") || + (e.target.localName == "path" && + e.target.parentNode.parentNode.className == + "mt-post-media-play-icon") || + (e.target.localName == "img" && + e.target.parentNode.getAttribute("data-video-url")) + ) { + this.#loadPostVideo(e); + } + }); + this.mtSettings.mtBody.addEventListener("keydown", (e) => { + // Check if Enter key was pressed with focus in an article + if (e.key === "Enter" && e.target.localName == "article") { + this.#openPostUrl(e); + } + }); + } + + /** + * Open post in a new page avoiding any other natural link + * @param {event} e User interaction trigger + */ + #openPostUrl(e) { + const urlPost = e.target.closest(".mt-post").dataset.location; + if ( + e.target.localName !== "a" && + e.target.localName !== "span" && + e.target.localName !== "button" && + e.target.localName !== "time" && + e.target.className !== "mt-post-preview-noImage" && + e.target.parentNode.className !== "mt-post-avatar-image-big" && + e.target.parentNode.className !== "mt-post-avatar-image-small" && + e.target.parentNode.className !== "mt-post-preview-image" && + e.target.parentNode.className !== "mt-post-preview" && + urlPost + ) { + window.open(urlPost, "_blank", "noopener"); + } + } + + /** + * Add/Remove EventListeners for loading spinner + */ + #manageSpinner() { + // Remove EventListener and CSS class to container + const removeSpinner = (e) => { + e.target.parentNode.classList.remove(this.mtSettings.spinnerClass); + e.target.removeEventListener("load", removeSpinner); + e.target.removeEventListener("error", removeSpinner); + }; + // Add EventListener to images + this.mtSettings.mtBody + .querySelectorAll(`.${this.mtSettings.spinnerClass} > img`) + .forEach((e) => { + e.addEventListener("load", removeSpinner); + e.addEventListener("error", removeSpinner); + }); + } + + /** + * Show an error on the timeline + * @param {string} e Error message + * @param {string} i Icon + */ + #showError(t, i) { + const icon = i || "❌"; + this.mtSettings.mtBody.innerHTML = + '
    ' + + icon + + '
    Oops, something\'s happened:
    ' + + t + + "
    "; + this.mtSettings.mtBody.setAttribute("role", "none"); + throw new Error( + "Stopping the script due to an error building the timeline." + ); + } +} diff --git a/examples/local-timeline.html b/examples/local-timeline.html new file mode 100644 index 0000000..97f63ff --- /dev/null +++ b/examples/local-timeline.html @@ -0,0 +1,138 @@ + + + + + Mastodon embed timeline + + + + + + + + + + +
    + +
    +

    🐘 Mastodon embed timeline

    +

    Local timeline

    +

    + This example shows 20 posts from the following instance: +
    + mastodon.social +

    +

    + It has the default parameters and has been initialized with the + following script: +

    +
    +        
    +  <script>
    +    const myTimeline = new MastodonTimeline();
    +  </script>
    +          
    +        
    + +
    + +

    + To change the current instance and show a different local timeline, + for example: +
    + mastodon.online +

    +

    + You just need to initialize the script by passing the custom option + instanceUrl with the value + "https://mastodon.online" as follows in the example: +

    +
    +        
    +  <script>
    +    const myTimeline = new MastodonTimeline({
    +      instanceUrl: "https://mastodon.online"
    +    });
    +  </script>
    +          
    +        
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    + + + + + + diff --git a/examples/multiple-timelines.html b/examples/multiple-timelines.html new file mode 100644 index 0000000..246b87f --- /dev/null +++ b/examples/multiple-timelines.html @@ -0,0 +1,165 @@ + + + + + Mastodon embed timeline + + + + + + + + + + +
    + +
    +

    🐘 Mastodon embed timeline

    +

    Multiple timeline

    +

    + This example shows 3 local timelines running in the same page: +
    + mastodon.social +
    + mastodon.online +
    + mstdn.social +

    +

    It has been initialized with the following script:

    +
    +        
    +  <script>
    +    const myTimeline1 = new MastodonTimeline({
    +      mtContainerId: "mt-container-01",
    +      instanceUrl: "https://mastodon.social",
    +    });
    +
    +    const myTimeline2 = new MastodonTimeline({
    +      mtContainerId: "mt-container-02",
    +      instanceUrl: "https://mastodon.online",
    +    });
    +
    +    const myTimeline3 = new MastodonTimeline({
    +      mtContainerId: "mt-container-03",
    +      instanceUrl: "https://mstdn.social",
    +    });
    +  </script>
    +          
    +        
    +
    + +
    + +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    + + + + + + diff --git a/examples/profile-timeline.html b/examples/profile-timeline.html new file mode 100644 index 0000000..de46a46 --- /dev/null +++ b/examples/profile-timeline.html @@ -0,0 +1,117 @@ + + + + + Mastodon embed timeline + + + + + + + + + + +
    + +
    +

    🐘 Mastodon embed timeline

    +

    Profile timeline

    +

    + This example shows posts from my Mastodon profile +
    + @idotj +

    +

    It has been initialized with the following script:

    +
    +        
    +  <script>
    +    const myTimeline = new MastodonTimeline({
    +      instanceUrl: "https://mastodon.online",
    +      timelineType: "profile",
    +      userId: "000180745",
    +      profileName: "@idotj",
    +    });
    +  </script>
    +          
    +        
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    + + + + + + diff --git a/examples/theme-timeline.html b/examples/theme-timeline.html new file mode 100644 index 0000000..e8c4f22 --- /dev/null +++ b/examples/theme-timeline.html @@ -0,0 +1,182 @@ + + + + + Mastodon embed timeline + + + + + + + + + + +
    + +
    +

    🐘 Mastodon embed timeline

    +

    Light timeline

    +

    This example shows a timeline in light color:

    +

    It has been initialized with the following script:

    +
    +        
    +  <script>
    +    const myTimeline1 = new MastodonTimeline({
    +      mtContainerId: "mt-container-01",
    +      defaultTheme: "light",
    +    });
    +  </script>
    +          
    +        
    + +
    + +

    Dark timeline

    +

    This example shows a timeline in dark color:

    +

    It has been initialized with the following script:

    +
    +        
    +  <script>
    +    const myTimeline2 = new MastodonTimeline({
    +      mtContainerId: "mt-container-02",
    +      defaultTheme: "dark",
    +    });
    +  </script>
    +          
    +        
    + +
    + +

    Theme API

    +

    + You can change your timeline color calling the function mtColorTheme() +

    +
    + + +
    +
    +          
    +  <button onclick="myTimeline1.mtColorTheme('dark')">
    +      Switch 1st timeline to dark theme
    +  </button>
    +
    +  <button onclick="myTimeline2.mtColorTheme('light')">
    +      Switch 2nd timeline to light theme
    +  </button>
    +            
    +          
    +
    + +
    + +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    + + + + + + diff --git a/screenshot-light-dark.jpg b/screenshot-light-dark.jpg index 33a19db..62ef876 100644 Binary files a/screenshot-light-dark.jpg and b/screenshot-light-dark.jpg differ diff --git a/src/index.html b/src/index.html deleted file mode 100644 index 67d86d0..0000000 --- a/src/index.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - Mastodon embed timeline - - - - - - - - - - -
    -
    -
    -
    -
    -
    -
    - - - - diff --git a/src/mastodon-timeline.css b/src/mastodon-timeline.css index e870efc..a9f37df 100644 --- a/src/mastodon-timeline.css +++ b/src/mastodon-timeline.css @@ -1,78 +1,89 @@ -/* Mastodon embed feed timeline v3.13.2 */ +/* Mastodon embed feed timeline v4.0.0 */ /* More info at: */ /* https://gitlab.com/idotj/mastodon-embed-feed-timeline */ /* Variables */ -:root { - --text-max-lines: none; +.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: #282c37; + --mt-color-bg-hover: #313543; + --mt-color-line-gray: #393f4f; + --mt-color-contrast-gray: #606984; + --mt-color-content-txt: #fff; + --mt-color-link: #8c8dff; + --mt-color-error-txt: #fe6c6c; } -/* Theme colors */ -:root, -html[data-theme="light"] { - --bg-color: #fff; - --bg-hover-color: #d9e1e8; - --line-gray-color: #c0cdd9; - --contrast-gray-color: #606984; - --content-text: #000; - --link-color: #3a3bff; - --error-text-color: #8b0000; +/* Reset CSS */ +.mt-container button { + font: inherit; } -html[data-theme="dark"] { - --bg-color: #282c37; - --bg-hover-color: #313543; - --line-gray-color: #393f4f; - --contrast-gray-color: #606984; - --content-text: #fff; - --link-color: #8c8dff; - --error-text-color: #fe6c6c; +.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(--bg-color); - scrollbar-color: var(--line-gray-color) var(--bg-color); - scrollbar-width: thin; + 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(--line-gray-color); + 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(--line-gray-color); + background-color: var(--mt-color-contrast-gray); } .mt-container::-webkit-scrollbar-track { - background-color: var(--bg-color); + 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(--bg-color); + background-color: var(--mt-color-bg); } .mt-container a:link, .mt-container a:active, .mt-container a { text-decoration: none; - color: var(--link-color); + color: var(--mt-color-link); } -.mt-container a:not(.mt-toot-preview):hover { +.mt-container a:not(.mt-post-preview):hover { text-decoration: underline; } .mt-body { - padding: 1rem clamp(0.25rem, 4vw, 1.5rem); + padding: 1rem clamp(0.25rem, 4vw, 1rem); white-space: pre-wrap; word-wrap: break-word; + margin-bottom: 1rem; } .mt-body .invisible { font-size: 0; @@ -83,45 +94,45 @@ html[data-theme="dark"] { position: absolute; } -/* Toot container */ -.mt-toot { +/* 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(--line-gray-color); + border-bottom: 1px solid var(--mt-color-line-gray); } -.mt-toot:hover, -.mt-toot:focus { +.mt-post:hover, +.mt-post:focus { cursor: pointer; - background-color: var(--bg-hover-color); + background-color: var(--mt-color-bg-hover); } -.mt-toot p:last-child { +.mt-post p:last-child { margin-bottom: 0; } /* User avatar */ -.mt-toot-avatar { +.mt-post-avatar { margin-right: 0.75rem; } -.mt-toot-avatar-standard { +.mt-post-avatar-standard { width: 2.25rem; height: 2.25rem; } -.mt-toot-avatar-boosted { +.mt-post-avatar-boosted { width: 3rem; height: 3rem; position: relative; } -.mt-toot-avatar-image-big img { +.mt-post-avatar-image-big img { aspect-ratio: 1/1; width: 2.25rem; height: 2.25rem; border-radius: 0.25rem; overflow: hidden; } -.mt-toot-avatar-image-small img { +.mt-post-avatar-image-small img { aspect-ratio: 1/1; width: 1.5rem; height: 1.5rem; @@ -133,59 +144,56 @@ html[data-theme="dark"] { } /* User name and date */ -.mt-toot-header { +.mt-post-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; } -.mt-toot-header-user { +.mt-post-header-user { font-weight: 600; margin-top: 0.5rem; padding-right: 1rem; } -.mt-toot-header-user > a { +.mt-post-header-user > a { display: flex; align-items: flex-start; - color: var(--content-text) !important; + color: var(--mt-color-content-txt) !important; overflow-wrap: anywhere; } -.mt-toot-header-date { +.mt-post-header-date { font-size: 0.75rem; text-align: right; margin: 0.5rem 0 0 auto; } -.mt-toot-header-date > a { - color: var(--contrast-gray-color) !important; +.mt-post-header-date > a { + color: var(--mt-color-contrast-gray) !important; } /* Text */ -.mt-toot-text { +.mt-post-txt { margin-bottom: 1rem; - color: var(--content-text); + color: var(--mt-color-content-txt); } -.mt-toot-text .spoiler-btn { - display: inline-block; -} -.mt-toot-text .spoiler-text-hidden { +.mt-post-txt .spoiler-txt-hidden { display: none; } -.mt-toot-text.truncate { +.mt-post-txt.truncate { display: -webkit-box; overflow: hidden; - -webkit-line-clamp: var(--text-max-lines); + -webkit-line-clamp: var(--mt-txt-max-lines); -webkit-box-orient: vertical; } -.mt-toot-text:not(.truncate) .ellipsis::after { +.mt-post-txt:not(.truncate) .ellipsis::after { content: "..."; } -.mt-toot-text blockquote { - border-left: 0.25rem solid var(--line-gray-color); +.mt-post-txt blockquote { + border-left: 0.25rem solid var(--mt-color-line-gray); margin-left: 0; padding-left: 0.5rem; } -.mt-toot-header-user .custom-emoji, -.mt-toot-text .custom-emoji { +.mt-post-header-user .mt-custom-emoji, +.mt-post-txt .mt-custom-emoji { height: 1.5rem; min-width: 1.5rem; margin-bottom: -0.25rem; @@ -193,80 +201,67 @@ html[data-theme="dark"] { } /* Poll */ -.mt-toot-poll { +.mt-post-poll { margin-bottom: 1rem; - color: var(--content-text); + color: var(--mt-color-content-txt); } -.mt-toot-poll ul { +.mt-post-poll ul { list-style: none; padding: 0; margin: 0; } -.mt-toot-poll ul li { +.mt-post-poll ul li { font-size: 0.9rem; margin-bottom: 0.5rem; } -.mt-toot-poll.mt-toot-poll-expired ul li { - color: var(--contrast-gray-color); +.mt-post-poll.mt-post-poll-expired ul li { + color: var(--mt-color-contrast-gray); } -.mt-toot-poll ul li:not(:last-child) { +.mt-post-poll ul li:not(:last-child) { margin-bottom: 0.25rem; } -.mt-toot-poll ul li:before { +.mt-post-poll ul li:before { content: "◯"; padding-right: 0.5rem; } -.mt-toot-poll.mt-toot-poll-expired ul li:before { +.mt-post-poll.mt-post-poll-expired ul li:before { content: ""; padding-right: 0; } /* Medias */ -.mt-toot-media { +.mt-post-media { position: relative; overflow: hidden; margin-bottom: 1rem; } -.mt-toot-media > .spoiler-btn { - position: absolute; - top: 50%; - left: 50%; - z-index: 1; - transform: translate(-50%, -50%); -} -.mt-toot-media-spoiler > img, -.mt-toot-media-spoiler > audio, -.mt-toot-media-spoiler > video, -.mt-toot-media-spoiler > .mt-toot-media-play-icon { +.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-toot-media.img-ratio14_7, -.mt-toot-media.video-ratio14_7 { - padding-top: 56.95%; - width: 100%; -} -.mt-toot-media > audio { +.mt-post-media > audio { width: 100%; position: relative; z-index: 1; } -.img-ratio14_7 > img, -.video-ratio14_7 > img, -.video-ratio14_7 > video { +.mt-post-media > img, +.mt-post-media > video { width: 100%; - height: auto; + height: 100%; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; - color: var(--content-text); + color: var(--mt-color-content-txt); } -.mt-toot-media.loading-spinner .mt-toot-media-play-icon { +.mt-post-media.mt-loading-spinner .mt-post-media-play-icon { display: none; } -.mt-toot-media-play-icon { +.mt-post-media-play-icon { display: flex; position: absolute; width: 3rem; @@ -279,44 +274,44 @@ html[data-theme="dark"] { border: none; cursor: pointer; } -.mt-toot-media-play-icon > svg { +.mt-post-media-play-icon > svg { width: 2.5rem; height: 2.5rem; - fill: var(--bg-color); - stroke:var(--content-text); + fill: var(--mt-color-bg); + stroke: var(--mt-color-content-txt); stroke-width: 1px; } /* Preview link */ -.mt-toot-preview { +.mt-post-preview { min-height: 4rem; display: flex; flex-direction: row; - border: 1px solid var(--line-gray-color); + border: 1px solid var(--mt-color-line-gray); border-radius: 0.5rem; - color: var(--link-color); + color: var(--mt-color-link); font-size: 0.8rem; margin: 1rem 0; overflow: hidden; } -.mt-toot-preview-image { +.mt-post-preview-image { width: 40%; align-self: stretch; } -.mt-toot-preview-image img { +.mt-post-preview-image img { display: block; width: 100%; height: 100%; object-fit: cover; - color: var(--content-text); + color: var(--mt-color-content-txt); } -.mt-toot-preview-noImage { +.mt-post-preview-noImage { width: 40%; font-size: 1.5rem; align-self: center; text-align: center; } -.mt-toot-preview-content { +.mt-post-preview-content { width: 60%; display: flex; align-self: center; @@ -324,59 +319,93 @@ html[data-theme="dark"] { padding: 0.5rem 1rem; gap: 0.5rem; } -.mt-toot-preview-title { +.mt-post-preview-title { font-weight: 600; } -/* Spoiler button */ -.spoiler-btn { - border-radius: 2px; - background-color: var(--line-gray-color); - border: 0; - color: var(--content-text); - font-weight: 700; - font-size: 0.7rem; - padding: 0 0.35rem; - text-transform: uppercase; - line-height: 1.25rem; - cursor: pointer; - vertical-align: top; -} - /* Counter bar */ -.mt-toot-counter-bar { +.mt-post-counter-bar { display: flex; min-width: 6rem; max-width: 40rem; justify-content: space-between; - color: var(--contrast-gray-color); + color: var(--mt-color-contrast-gray); } -.mt-toot-counter-bar-replies, -.mt-toot-counter-bar-reblog, -.mt-toot-counter-bar-favorites { +.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-toot-counter-bar-replies > svg, -.mt-toot-counter-bar-reblog > svg, -.mt-toot-counter-bar-favorites > svg { +.mt-post-counter-bar-replies > svg, +.mt-post-counter-bar-reblog > svg, +.mt-post-counter-bar-favorites > svg { width: 1rem; - fill: var(--contrast-gray-color); + 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; height: calc(100% - 3.5rem); width: calc(100% - 4.5rem); justify-content: center; align-items: center; - color: var(--error-text-color); + color: var(--mt-color-error-txt); padding: 0.75rem; text-align: center; } @@ -384,14 +413,15 @@ html[data-theme="dark"] { font-size: 2rem; } .mt-error-message { + width: 100%; padding: 1rem 0; } .mt-error-message hr { - color: var(--line-gray-color); + color: var(--mt-color-line-gray); } /* Loading spinner */ -.mt-body > .loading-spinner { +.mt-body > .mt-loading-spinner { position: absolute; width: 3rem; height: 3rem; @@ -399,7 +429,7 @@ html[data-theme="dark"] { top: calc(50% - 1.5rem); right: calc(50% - 1.5rem); } -.loading-spinner { +.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; @@ -407,11 +437,15 @@ html[data-theme="dark"] { background-size: min(2.5rem, calc(100% - 0.5rem)); } -/* Footer (See more link) */ +/* Footer */ .mt-footer { - margin: 1rem auto 2rem auto; - padding: 0 2rem; - text-align: center; + 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 */ diff --git a/src/mastodon-timeline.js b/src/mastodon-timeline.js index c7e096d..adf3df3 100644 --- a/src/mastodon-timeline.js +++ b/src/mastodon-timeline.js @@ -1,971 +1,1069 @@ /** - * Mastodon embed feed timeline v3.13.2 - * More info at: - * https://gitlab.com/idotj/mastodon-embed-feed-timeline + * Mastodon embed feed timeline + * @author idotj + * @version 4.0.0 + * @url https://gitlab.com/idotj/mastodon-embed-feed-timeline + * @license GNU AGPLv3 */ +"use strict"; -/** - * Timeline settings - * Adjust these parameters to customize your timeline - */ -window.addEventListener("load", () => { - const mastodonTimeline = new MastodonApi({ - // Id of the
    containing the timeline - container_body_id: "mt-body", +class MastodonTimeline { + constructor(customSettings = {}) { + this.defaultSettings = { + mtContainerId: "mt-container", + mtBody: "", + instanceUrl: "https://mastodon.social", + timelineType: "local", + userId: "", + profileName: "", + hashtagName: "", + spinnerClass: "mt-loading-spinner", + defaultTheme: "auto", + maxNbPostFetch: "20", + maxNbPostShow: "20", + hideUnlisted: false, + hideReblog: false, + hideReplies: false, + hideVideoPreview: false, + hidePreviewLink: false, + hideEmojos: false, + markdownBlockquote: false, + hideCounterBar: false, + txtMaxLines: "0", + btnShowMore: "SHOW MORE", + btnShowLess: "SHOW LESS", + btnShowContent: "SHOW CONTENT", + btnSeeMore: "See more posts at Mastodon", + btnReload: "Refresh", + fetchedData: {}, + }; - // Class name for the loading spinner (also used in CSS file) - spinner_class: "loading-spinner", + this.mtSettings = { ...this.defaultSettings, ...customSettings }; - // Preferred color theme: 'light', 'dark' or 'auto'. Default: auto - default_theme: "auto", + // Set node of body container + this.mtSettings.mtBody = document + .getElementById(this.mtSettings.mtContainerId) + .getElementsByClassName("mt-body")[0]; - // Your Mastodon instance - instance_url: "https://mastdn.social", + this.mtInit(); + } - // Choose type of toots to show in the timeline: 'local', 'profile', 'hashtag'. Default: local - timeline_type: "local", + /** + * Initialize and build the timeline + */ + mtInit() { + // console.log("Init Mastodon timeline. Settings: ", this.mtSettings); + this.#loadColorTheme(); + this.#buildTimeline("newTimeline"); + } - // Your user ID number on Mastodon instance. Leave it empty if you didn't choose 'profile' as type of timeline - user_id: "", + /** + * Reload the timeline by fetching the lastest posts + */ + mtUpdate() { + this.mtSettings.mtBody.replaceChildren(); + this.mtSettings.mtBody.insertAdjacentHTML( + "afterbegin", + '
    ' + ); + this.#buildTimeline("updateTimeline"); + } - // Your user name on Mastodon instance (including the @ symbol at the beginning). Leave it empty if you didn't choose 'profile' as type of timeline - profile_name: "", + /** + * Apply the color theme in the timeline + * @param {string} themeType Type of color theme + */ + mtColorTheme(themeType) { + document + .getElementById(this.mtSettings.mtContainerId) + .setAttribute("data-theme", themeType); + } - // The name of the hashtag (not including the # symbol). Leave it empty if you didn't choose 'hashtag' as type of timeline - hashtag_name: "", + /** + * 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); + } + } - // Maximum amount of toots to get. Default: 20 - toots_limit: "20", + /** + * 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); - // Hide unlisted toots. Default: don't hide - hide_unlisted: false, + if (!response.ok) { + throw new Error( + "Failed to fetch the following Url:
    " + + url + + "
    " + + "Error status: " + + response.status + + "
    " + + "Error message: " + + response.statusText + ); + } - // Hide boosted toots. Default: don't hide - hide_reblog: false, + const data = await response.json(); + return data; + } - // Hide replies toots. Default: don't hide - hide_replies: false, + // Urls to fetch + let urls = {}; - // Hide video image preview and load video player instead. Default: don't hide - hide_video_preview: false, - - // Hide preview card if toot contains a link, photo or video from a URL. Default: don't hide - hide_preview_link: false, - - // Hide custom emojis available on the server. Default: don't hide - hide_emojos: false, - - // Converts Markdown symbol ">" at the beginning of a paragraph into a blockquote HTML tag. Ddefault: don't apply - markdown_blockquote: false, - - // Hide replies, boosts and favourites toots counter. Default: don't hide - hide_counter_bar: false, - - // Limit the text content to a maximum number of lines. Default: 0 (unlimited) - text_max_lines: "0", - - // Customize the text of the link pointing to the Mastodon page (appears after the last toot) - link_see_more: "See more posts at Mastodon", - }); -}); - -/** - * Set all variables with customized values or use default ones - * @param {object} params_ User customized values - * Trigger main function to build the timeline - */ -const MastodonApi = function (params_) { - this.CONTAINER_BODY_ID = document.getElementById( - params_.container_body_id || "mt-body" - ); - this.SPINNER_CLASS = params_.spinner_class || "loading-spinner"; - this.DEFAULT_THEME = params_.default_theme || "auto"; - this.INSTANCE_URL = params_.instance_url; - this.USER_ID = params_.user_id || ""; - this.PROFILE_NAME = this.USER_ID ? params_.profile_name : ""; - this.TIMELINE_TYPE = params_.timeline_type || "local"; - this.HASHTAG_NAME = params_.hashtag_name || ""; - this.TOOTS_LIMIT = params_.toots_limit || "20"; - this.HIDE_UNLISTED = - typeof params_.hide_unlisted !== "undefined" - ? params_.hide_unlisted - : false; - this.HIDE_REBLOG = - typeof params_.hide_reblog !== "undefined" ? params_.hide_reblog : false; - this.HIDE_REPLIES = - typeof params_.hide_replies !== "undefined" ? params_.hide_replies : false; - this.HIDE_VIDEO_PREVIEW = - typeof params_.hide_video_preview !== "undefined" - ? params_.hide_video_preview - : false; - this.HIDE_PREVIEW_LINK = - typeof params_.hide_preview_link !== "undefined" - ? params_.hide_preview_link - : false; - this.HIDE_EMOJOS = - typeof params_.hide_emojos !== "undefined" ? params_.hide_emojos : false; - this.MARKDOWN_BLOCKQUOTE = - typeof params_.markdown_blockquote !== "undefined" - ? params_.markdown_blockquote - : false; - this.HIDE_COUNTER_BAR = - params_.hide_counter_bar !== "undefined" ? params_.hide_counter_bar : false; - this.TEXT_MAX_LINES = params_.text_max_lines || "0"; - this.LINK_SEE_MORE = params_.link_see_more; - this.FETCHED_DATA = {}; - - this.buildTimeline(); -}; - -/** - * Trigger functions and construct timeline - */ -MastodonApi.prototype.buildTimeline = async function () { - // Apply color theme - this.setTheme(); - - // Get server data - await this.getTimelineData(); - - // Empty the
    container - this.CONTAINER_BODY_ID.innerHTML = ""; - - for (let i in this.FETCHED_DATA.timeline) { - // First filter (Public / Unlisted) - if ( - this.FETCHED_DATA.timeline[i].visibility == "public" || - (!this.HIDE_UNLISTED && - this.FETCHED_DATA.timeline[i].visibility == "unlisted") - ) { - // Second filter (Reblog / Replies) - if ( - (this.HIDE_REBLOG && this.FETCHED_DATA.timeline[i].reblog) || - (this.HIDE_REPLIES && this.FETCHED_DATA.timeline[i].in_reply_to_id) - ) { - // Nothing here (Don't append toots) + 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 { - // Append toots - this.appendToot(this.FETCHED_DATA.timeline[i], Number(i)); + this.#showError( + "Please check your instanceUrl value", + "âš ī¸" + ); + } + if (!this.mtSettings.hideEmojos) { + urls.emojos = this.mtSettings.instanceUrl + "/api/v1/custom_emojis"; + } + + const urlsPromises = Object.entries(urls).map(([key, url]) => { + return fetchData(url) + .then((data) => ({ [key]: data })) + .catch((error) => { + reject( + new Error("Something went wrong fetching data from: " + url) + ); + this.#showError(error.message); + return { [key]: [] }; + }); + }); + + // Fetch all urls simultaneously + Promise.all(urlsPromises).then((dataObjects) => { + this.mtSettings.fetchedData = dataObjects.reduce((result, dataItem) => { + return { ...result, ...dataItem }; + }, {}); + + // console.log("Timeline data fetched: ", this.mtSettings.fetchedData); + resolve(); + }); + }); + } + + /** + * Filter all fetched posts and append them on the timeline + * @param {string} t Type of build (new or reload) + */ + async #buildTimeline(t) { + await this.#fetchTimelineData(); + + // Empty container body + this.mtSettings.mtBody.replaceChildren(); + + // Set posts counter to 0 + let nbPostShow = 0; + + for (let i in this.mtSettings.fetchedData.timeline) { + // First filter (Public / Unlisted) + if ( + this.mtSettings.fetchedData.timeline[i].visibility == "public" || + (!this.mtSettings.hideUnlisted && + this.mtSettings.fetchedData.timeline[i].visibility == "unlisted") + ) { + // Second filter (Reblog / Replies) + if ( + (this.mtSettings.hideReblog && + this.mtSettings.fetchedData.timeline[i].reblog) || + (this.mtSettings.hideReplies && + this.mtSettings.fetchedData.timeline[i].in_reply_to_id) + ) { + // Nothing here (Don't append posts) + } else { + if (nbPostShow < this.mtSettings.maxNbPostShow) { + this.#appendPost( + this.mtSettings.fetchedData.timeline[i], + Number(i) + ); + nbPostShow++; + } else { + // Nothing here (Reached the limit of maximum number of posts to show) + } + } + } + } + + // If there are no posts to display, show an error message + if (this.mtSettings.mtBody.innerHTML === "") { + const errorMessage = + "No posts to show
    " + + (this.mtSettings.fetchedData.timeline?.length || 0) + + " posts have been fetched from the server
    This may be due to an incorrect configuration in the parameters or to filters applied (to hide certains type of posts)"; + this.#showError(errorMessage, "📭"); + } else { + if (t === "newTimeline") { + this.#manageSpinner(); + this.#setPostsInteracion(); + this.#buildFooter(); + } else if (t === "updateTimeline") { + this.#manageSpinner(); + } else { + this.#showError("The function buildTimeline() was expecting a param"); } } } - // Check if there are toots in the container (due to filters applied) - if (this.CONTAINER_BODY_ID.innerHTML === "") { - this.CONTAINER_BODY_ID.setAttribute("role", "none"); - this.CONTAINER_BODY_ID.innerHTML = - '
    📭
    Sorry, no toots to show
    Got ' + - this.FETCHED_DATA.timeline.length + - " toots from the server.
    This may be due to an incorrect configuration in the parameters or to filters applied to hide certains type of toots.
    "; - } else { - // Insert link after last toot to visit Mastodon page - if (this.LINK_SEE_MORE) { - let linkSeeMorePath = ""; - if (this.TIMELINE_TYPE === "profile") { - linkSeeMorePath = this.PROFILE_NAME; - } else if (this.TIMELINE_TYPE === "hashtag") { - linkSeeMorePath = "tags/" + this.HASHTAG_NAME; - } else if (this.TIMELINE_TYPE === "local") { - linkSeeMorePath = "public/local"; - } - const linkSeeMore = - '"; - this.CONTAINER_BODY_ID.parentNode.insertAdjacentHTML( - "beforeend", - linkSeeMore + /** + * Add each post in the timeline container + * @param {object} c Post content + * @param {number} i Index of post + */ + #appendPost(c, i) { + this.mtSettings.mtBody.insertAdjacentHTML( + "beforeend", + this.#assamblePost(c, i) + ); + } + + /** + * Build post structure + * @param {object} c Post content + * @param {number} i Index of post + */ + #assamblePost(c, i) { + let avatar, + user, + userName, + url, + date, + formattedDate, + favoritesCount, + reblogCount, + repliesCount; + + if (c.reblog) { + // BOOSTED post + // Post url + url = c.reblog.url; + + // Boosted avatar + avatar = + '' + + '
    ' + + '
    ' + + '' +
+        this.#escapeHtml(c.reblog.account.username) +
+        ' avatar' + + "
    " + + '
    ' + + '' +
+        this.#escapeHtml(c.account.username) +
+        ' avatar' + + "
    " + + "
    " + + "
    "; + + // User name and url + userName = this.#createEmoji( + c.reblog.account.display_name + ? c.reblog.account.display_name + : c.reblog.account.username, + this.mtSettings.fetchedData.emojos + ); + user = + '
    ' + + '' + + userName + + ' account' + + "" + + "
    "; + + // Date + date = c.reblog.created_at; + + // Counter bar + repliesCount = c.reblog.replies_count; + reblogCount = c.reblog.reblogs_count; + favoritesCount = c.reblog.favourites_count; + } else { + // STANDARD post + // Post url + url = c.url; + + // Avatar + avatar = + '' + + '
    ' + + '
    ' + + '' +
+        this.#escapeHtml(c.account.username) +
+        ' avatar' + + "
    " + + "
    " + + "
    "; + + // User name and url + userName = this.#createEmoji( + c.account.display_name ? c.account.display_name : c.account.username, + this.mtSettings.fetchedData.emojos + ); + user = + '
    ' + + '' + + userName + + ' account' + + "" + + "
    "; + + // Date + date = c.created_at; + + // Counter bar + repliesCount = c.replies_count; + reblogCount = c.reblogs_count; + favoritesCount = c.favourites_count; + } + + // Date + formattedDate = this.#formatDate(date); + const timestamp = + '
    ' + + '' + + '" + + "" + + "
    "; + + // Main text + let txtCss = ""; + if (this.mtSettings.txtMaxLines !== "0") { + txtCss = " truncate"; + this.mtSettings.mtBody.parentNode.style.setProperty( + "--mt-txt-max-lines", + this.mtSettings.txtMaxLines ); } - // Control loading spinners - this.manageSpinner(); + 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 = + '
    ' + + "
      " + + 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.join("") + + previewLink + + poll + + counterBar + + "
    "; + + return post; } - // Toot interactions - this.CONTAINER_BODY_ID.addEventListener("click", function (e) { - // Check if toot cointainer was clicked - if ( - e.target.localName == "article" || - e.target.offsetParent?.localName == "article" || - (e.target.localName == "img" && - !e.target.parentNode.classList.contains("video-ratio14_7")) - ) { - openTootURL(e); + /** + * 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); } - // Check if Show More/Less button was clicked - if (e.target.localName == "button" && e.target.className == "spoiler-btn") { - toogleSpoiler(e); + + // Convert markdown styles into HTML + if (this.mtSettings.markdownBlockquote) { + content = this.#replaceHTMLtag( + content, + "

    >", + "

    ", + "

    ", + "

    " + ); } - // Check if video preview image or play icon/button was clicked - if ( - e.target.className == "mt-toot-media-play-icon" || - (e.target.localName == "svg" && - e.target.parentNode.className == "mt-toot-media-play-icon") || - (e.target.localName == "path" && - e.target.parentNode.parentNode.className == - "mt-toot-media-play-icon") || - (e.target.localName == "img" && - e.target.parentNode.classList.contains("video-ratio14_7")) - ) { - loadTootVideo(e); - } - }); - this.CONTAINER_BODY_ID.addEventListener("keydown", function (e) { - // Check if Enter key was pressed with focus in an article - if (e.key === "Enter" && e.target.localName == "article") { - openTootURL(e); - } - }); + + return content; + } /** - * Open toot in a new page avoiding any other natural link + * 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 */ - const openTootURL = function (e) { - const urlToot = e.target.closest(".mt-toot").dataset.location; - if ( - e.target.localName !== "a" && - e.target.localName !== "span" && - e.target.localName !== "button" && - e.target.localName !== "time" && - e.target.className !== "mt-toot-preview-noImage" && - e.target.parentNode.className !== "mt-toot-avatar-image-big" && - e.target.parentNode.className !== "mt-toot-avatar-image-small" && - e.target.parentNode.className !== "mt-toot-preview-image" && - e.target.parentNode.className !== "mt-toot-preview" && - urlToot - ) { - window.open(urlToot, "_blank", "noopener"); - } - }; + #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 */ - const toogleSpoiler = function (e) { + #toogleSpoiler(e) { const nextSibling = e.target.nextSibling; if ( nextSibling.localName === "img" || nextSibling.localName === "audio" || nextSibling.localName === "video" ) { - e.target.parentNode.classList.remove("mt-toot-media-spoiler"); + e.target.parentNode.classList.remove("mt-post-media-spoiler"); e.target.style.display = "none"; } else if ( - nextSibling.classList.contains("spoiler-text-hidden") || - nextSibling.classList.contains("spoiler-text-visible") + nextSibling.classList.contains("spoiler-txt-hidden") || + nextSibling.classList.contains("spoiler-txt-visible") ) { - if (e.target.textContent == "Show more") { - nextSibling.classList.remove("spoiler-text-hidden"); - nextSibling.classList.add("spoiler-text-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 = "Show less"; + e.target.textContent = this.mtSettings.btnShowLess; } else { - nextSibling.classList.remove("spoiler-text-visible"); - nextSibling.classList.add("spoiler-text-hidden"); + nextSibling.classList.remove("spoiler-txt-visible"); + nextSibling.classList.add("spoiler-txt-hidden"); e.target.setAttribute("aria-expanded", "false"); - e.target.textContent = "Show more"; + e.target.textContent = this.mtSettings.btnShowMore; } } + } + + /** + * Create preview link + * @param {object} c Preview link content + * @returns {string} Preview link in HTML format + */ + #createPreviewLink = function (c) { + const card = + '' + + (c.image + ? '
    ' +
+          this.#escapeHtml(c.image_description) +
+          '
    ' + : '
    📄
    ') + + "
    " + + '
    ' + + (c.provider_name + ? '' + + this.#parseHTMLstring(c.provider_name) + + "" + : "") + + '' + + c.title + + "" + + (c.author_name + ? '' + + this.#parseHTMLstring(c.author_name) + + "" + : "") + + "
    " + + "
    "; + + return card; }; /** - * Replace the video preview image by the video player + * Parse HTML string + * @param {string} s HTML string + * @returns {string} Plain text + */ + #parseHTMLstring(s) { + const parser = new DOMParser(); + const txt = parser.parseFromString(s, "text/html"); + return txt.body.textContent; + } + + /** + * Build footer after last post + */ + #buildFooter() { + if (this.mtSettings.btnSeeMore || this.mtSettings.btnReload) { + // Add footer container + this.mtSettings.mtBody.parentNode.insertAdjacentHTML( + "beforeend", + '' + ); + + const containerFooter = document + .getElementById(this.mtSettings.mtContainerId) + .getElementsByClassName("mt-footer")[0]; + + // Create button to open Mastodon page + if (this.mtSettings.btnSeeMore) { + let btnSeeMorePath = ""; + if (this.mtSettings.timelineType === "profile") { + if (this.mtSettings.profileName) { + btnSeeMorePath = this.mtSettings.profileName; + } else { + this.#showError( + "Please check your profileName value", + "âš ī¸" + ); + } + } else if (this.mtSettings.timelineType === "hashtag") { + btnSeeMorePath = "tags/" + this.mtSettings.hashtagName; + } else if (this.mtSettings.timelineType === "local") { + btnSeeMorePath = "public/local"; + } + const btnSeeMoreHTML = + '' + + this.mtSettings.btnSeeMore + + ""; + + containerFooter.insertAdjacentHTML("beforeend", btnSeeMoreHTML); + } + + // Create button to refresh the timeline + if (this.mtSettings.btnReload) { + const btnReloadHTML = + '"; + + containerFooter.insertAdjacentHTML("beforeend", btnReloadHTML); + + const reloadBtn = document + .getElementById(this.mtSettings.mtContainerId) + .getElementsByClassName("btn-refresh")[0]; + reloadBtn.addEventListener("click", () => { + this.mtUpdate(); + }); + } + } + } + + /** + * Add EventListeners for timeline interactions and trigger functions + */ + #setPostsInteracion() { + this.mtSettings.mtBody.addEventListener("click", (e) => { + // Check if post cointainer was clicked + if ( + e.target.localName == "article" || + e.target.offsetParent?.localName == "article" || + (e.target.localName == "img" && + !e.target.parentNode.getAttribute("data-video-url")) + ) { + this.#openPostUrl(e); + } + // Check if Show More/Less button was clicked + if ( + e.target.localName == "button" && + e.target.classList.contains("mt-btn-spoiler") + ) { + this.#toogleSpoiler(e); + } + // Check if video preview image or play icon/button was clicked + if ( + e.target.className == "mt-post-media-play-icon" || + (e.target.localName == "svg" && + e.target.parentNode.className == "mt-post-media-play-icon") || + (e.target.localName == "path" && + e.target.parentNode.parentNode.className == + "mt-post-media-play-icon") || + (e.target.localName == "img" && + e.target.parentNode.getAttribute("data-video-url")) + ) { + this.#loadPostVideo(e); + } + }); + this.mtSettings.mtBody.addEventListener("keydown", (e) => { + // Check if Enter key was pressed with focus in an article + if (e.key === "Enter" && e.target.localName == "article") { + this.#openPostUrl(e); + } + }); + } + + /** + * Open post in a new page avoiding any other natural link * @param {event} e User interaction trigger */ - const loadTootVideo = function (e) { - const parentNode = e.target.closest("[data-video-url]"); - const videoURL = parentNode.dataset.videoUrl; - parentNode.replaceChildren(); - parentNode.innerHTML = - ''; - }; -}; + #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"); + } + } -/** - * Set the theme style chosen by the user or by the browser/OS - */ -MastodonApi.prototype.setTheme = function () { /** - * Set the theme value in the tag using the attribute "data-theme" - * @param {string} theme Type of theme to apply: dark or light + * Add/Remove EventListeners for loading spinner */ - const setTheme = function (theme) { - document.documentElement.setAttribute("data-theme", theme); - }; - - if (this.DEFAULT_THEME === "auto") { - let systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); - systemTheme.matches ? setTheme("dark") : setTheme("light"); - // Update the theme if user change browser/OS preference - systemTheme.addEventListener("change", (e) => { - e.matches ? setTheme("dark") : setTheme("light"); - }); - } else { - setTheme(this.DEFAULT_THEME); - } -}; - -/** - * Requests to the server to get all the data - */ -MastodonApi.prototype.getTimelineData = async function () { - return new Promise((resolve, reject) => { - /** - * Fetch data from server - * @param {string} url address to fetch - * @returns {object} 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.TIMELINE_TYPE === "profile") { - urls.timeline = `${this.INSTANCE_URL}/api/v1/accounts/${this.USER_ID}/statuses?limit=${this.TOOTS_LIMIT}`; - } else if (this.TIMELINE_TYPE === "hashtag") { - urls.timeline = `${this.INSTANCE_URL}/api/v1/timelines/tag/${this.HASHTAG_NAME}?limit=${this.TOOTS_LIMIT}`; - } else if (this.TIMELINE_TYPE === "local") { - urls.timeline = `${this.INSTANCE_URL}/api/v1/timelines/public?local=true&limit=${this.TOOTS_LIMIT}`; - } - if (!this.HIDE_EMOJOS) { - urls.emojos = this.INSTANCE_URL + "/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")); - this.CONTAINER_BODY_ID.innerHTML = - '
    ❌
    Sorry, request failed:
    ' + - error.message + - "
    "; - this.CONTAINER_BODY_ID.setAttribute("role", "none"); - return { [key]: [] }; - }); - }); - - // Fetch all urls simultaneously - Promise.all(urlsPromises).then((dataObjects) => { - this.FETCHED_DATA = dataObjects.reduce((result, dataItem) => { - return { ...result, ...dataItem }; - }, {}); - - // console.log("Timeline data fetched: ", this.FETCHED_DATA); - resolve(); - }); - }); -}; - -/** - * Inner function to add each toot in timeline container - * @param {object} c Toot content - * @param {number} i Index of toot - */ -MastodonApi.prototype.appendToot = function (c, i) { - this.CONTAINER_BODY_ID.insertAdjacentHTML( - "beforeend", - this.assambleToot(c, i) - ); -}; - -/** - * Build toot structure - * @param {object} c Toot content - * @param {number} i Index of toot - */ -MastodonApi.prototype.assambleToot = function (c, i) { - let avatar, - user, - userName, - url, - date, - formattedDate, - favoritesCount, - reblogCount, - repliesCount; - - if (c.reblog) { - // BOOSTED toot - // Toot url - url = c.reblog.url; - - // Boosted avatar - avatar = - '' + - '
    ' + - '
    ' + - '' +
-      this.escapeHtml(c.reblog.account.username) +
-      ' avatar' + - "
    " + - '
    ' + - '' +
-      this.escapeHtml(c.account.username) +
-      ' avatar' + - "
    " + - "
    " + - "
    "; - - // User name and url - userName = this.showEmojos( - c.reblog.account.display_name - ? c.reblog.account.display_name - : c.reblog.account.username, - this.FETCHED_DATA.emojos - ); - user = - '"; - - // 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 toot - // Toot url - url = c.url; - - // Avatar - avatar = - '' + - '
    ' + - '
    ' + - '' +
-      this.escapeHtml(c.account.username) +
-      ' avatar' + - "
    " + - "
    " + - "
    "; - - // User name and url - userName = this.showEmojos( - c.account.display_name ? c.account.display_name : c.account.username, - this.FETCHED_DATA.emojos - ); - user = - '"; - - // Date - date = c.created_at; - - // Counter bar - repliesCount = c.replies_count; - reblogCount = c.reblogs_count; - favoritesCount = c.favourites_count; + #manageSpinner() { + // Remove EventListener and CSS class to container + const removeSpinner = (e) => { + e.target.parentNode.classList.remove(this.mtSettings.spinnerClass); + e.target.removeEventListener("load", removeSpinner); + e.target.removeEventListener("error", removeSpinner); + }; + // Add EventListener to images + this.mtSettings.mtBody + .querySelectorAll(`.${this.mtSettings.spinnerClass} > img`) + .forEach((e) => { + e.addEventListener("load", removeSpinner); + e.addEventListener("error", removeSpinner); + }); } - // Date - formattedDate = this.formatDate(date); - const timestamp = - '"; - - // Main text - let text_css = ""; - if (this.TEXT_MAX_LINES !== "0") { - text_css = " truncate"; - document.documentElement.style.setProperty( - "--text-max-lines", - this.TEXT_MAX_LINES + /** + * Show an error on the timeline + * @param {string} e Error message + * @param {string} i Icon + */ + #showError(t, i) { + const icon = i || "❌"; + this.mtSettings.mtBody.innerHTML = + '
    ' + + icon + + '
    Oops, something\'s happened:
    ' + + t + + "
    "; + this.mtSettings.mtBody.setAttribute("role", "none"); + throw new Error( + "Stopping the script due to an error building the timeline." ); } - - let content = ""; - if (c.spoiler_text !== "") { - content = - '
    ' + - c.spoiler_text + - ' ' + - '
    ' + - this.formatTootText(c.content) + - "
    " + - "
    "; - } else if ( - c.reblog && - c.reblog.content !== "" && - c.reblog.spoiler_text !== "" - ) { - content = - '
    ' + - c.reblog.spoiler_text + - ' ' + - '
    ' + - this.formatTootText(c.reblog.content) + - "
    " + - "
    "; - } else if ( - c.reblog && - c.reblog.content !== "" && - c.reblog.spoiler_text === "" - ) { - content = - '
    ' + - '
    ' + - this.formatTootText(c.reblog.content) + - "
    " + - "
    "; - } else { - content = - '
    ' + - '
    ' + - this.formatTootText(c.content) + - "
    " + - "
    "; - } - - // Media attachments - let media = []; - if (c.media_attachments.length > 0) { - for (let i in c.media_attachments) { - media.push(this.placeMedias(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.placeMedias(c.reblog.media_attachments[i], c.reblog.sensitive) - ); - } - } - - // Preview link - let previewLink = ""; - if (!this.HIDE_PREVIEW_LINK && c.card) { - previewLink = this.placePreviewLink(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.HIDE_COUNTER_BAR) { - const repliesTag = - '
    ' + - '' + - repliesCount + - "
    "; - - const reblogTag = - '
    ' + - '' + - reblogCount + - "
    "; - - const favoritesTag = - '
    ' + - '' + - favoritesCount + - "
    "; - - counterBar = - '
    ' + - repliesTag + - reblogTag + - favoritesTag + - "
    "; - } - - // Add all to main toot container - const toot = - '
    ' + - '
    ' + - avatar + - user + - timestamp + - "
    " + - content + - media.join("") + - previewLink + - poll + - counterBar + - "
    "; - - return toot; -}; - -/** - * Handle text changes made to toots - * @param {string} c Text content - * @returns {string} Text content modified - */ -MastodonApi.prototype.formatTootText = function (c) { - let content = c; - - // Format hashtags and mentions - content = this.addTarget2hashtagMention(content); - - // Convert emojos shortcode into images - if (!this.HIDE_EMOJOS) { - content = this.showEmojos(content, this.FETCHED_DATA.emojos); - } - - // Convert markdown styles into HTML - if (this.MARKDOWN_BLOCKQUOTE) { - content = this.replaceHTMLtag( - content, - "

    >", - "

    ", - "

    ", - "

    " - ); - } - - return content; -}; - -/** - * Add target="_blank" to all #hashtags and @mentions in the toot - * @param {string} c Text content - * @returns {string} Text content modified - */ -MastodonApi.prototype.addTarget2hashtagMention = function (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 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 - */ -MastodonApi.prototype.showEmojos = function (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; - } -}; - -/** - * 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 - */ -MastodonApi.prototype.replaceHTMLtag = function ( - 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; - } -}; - -/** - * Place media - * @param {object} m Media content - * @param {boolean} s Spoiler/Sensitive status - * @returns {string} Media in HTML format - */ -MastodonApi.prototype.placeMedias = function (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") { - if (!this.HIDE_VIDEO_PREVIEW) { - media = - '
    ' + - (spoiler ? '' : "") + - '' +
-        (m.description ? this.escapeHtml(m.description) : ' + - '' + - "
    "; - } else { - media = - '
    ' + - (spoiler ? '' : "") + - '' + - "
    "; - } - } - - return media; -}; - -/** - * Place preview link - * @param {object} c Preview link content - * @returns {string} Preview link in HTML format - */ -MastodonApi.prototype.placePreviewLink = function (c) { - const card = - '' + - (c.image - ? '
    ' +
-        this.escapeHtml(c.image_description) +
-        '
    ' - : '
    📄
    ') + - "
    " + - '
    ' + - (c.provider_name - ? '' + - this.parseHTMLstring(c.provider_name) + - "" - : "") + - '' + - c.title + - "" + - (c.author_name - ? '' + - this.parseHTMLstring(c.author_name) + - "" - : "") + - "
    " + - "
    "; - - return card; -}; - -/** - * Format date - * @param {string} d Date in ISO format (YYYY-MM-DDTHH:mm:ss.sssZ) - * @returns {string} Date formated (MM DD, YYYY) - */ -MastodonApi.prototype.formatDate = function (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; -}; - -/** - * Parse HTML string - * @param {string} s HTML string - * @returns {string} Plain text - */ -MastodonApi.prototype.parseHTMLstring = function (s) { - const parser = new DOMParser(); - const txt = parser.parseFromString(s, "text/html"); - return txt.body.textContent; -}; - -/** - * 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 - */ -MastodonApi.prototype.escapeHtml = function (s) { - return (s ?? "") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -}; - -/** - * Add/Remove event listener for loading spinner - */ -MastodonApi.prototype.manageSpinner = function () { - // Remove CSS class to container and listener to images - const spinnerCSS = this.SPINNER_CLASS; - const removeSpinner = function () { - this.parentNode.classList.remove(spinnerCSS); - this.removeEventListener("load", removeSpinner); - this.removeEventListener("error", removeSpinner); - }; - - // Add listener to images - this.CONTAINER_BODY_ID.querySelectorAll( - `.${this.SPINNER_CLASS} > img` - ).forEach((e) => { - e.addEventListener("load", removeSpinner); - e.addEventListener("error", removeSpinner); - }); -}; +} diff --git a/src/mastodon-timeline.min.css b/src/mastodon-timeline.min.css deleted file mode 100644 index 57c33db..0000000 --- a/src/mastodon-timeline.min.css +++ /dev/null @@ -1 +0,0 @@ -:root{--text-max-lines:none}:root,html[data-theme=light]{--bg-color:#fff;--bg-hover-color:#d9e1e8;--line-gray-color:#c0cdd9;--contrast-gray-color:#606984;--content-text:#000;--link-color:#3a3bff;--error-text-color:#8b0000}html[data-theme=dark]{--bg-color:#282c37;--bg-hover-color:#313543;--line-gray-color:#393f4f;--contrast-gray-color:#606984;--content-text:#fff;--link-color:#8c8dff;--error-text-color:#fe6c6c}.mt-container{height:100%;overflow-y:auto;position:relative;background-color:var(--bg-color);scrollbar-color:var(--line-gray-color) var(--bg-color);scrollbar-width:thin}.mt-container::-webkit-scrollbar{width:.25rem;height:.25rem}.mt-container::-webkit-scrollbar-thumb{background-color:var(--line-gray-color);border:none;border-radius:3rem}.mt-container::-webkit-scrollbar-thumb:active,.mt-container::-webkit-scrollbar-thumb:hover{background-color:var(--line-gray-color)}.mt-container::-webkit-scrollbar-track{background-color:var(--bg-color);border:none;border-radius:0}.mt-container::-webkit-scrollbar-corner,.mt-container::-webkit-scrollbar-track:active,.mt-container::-webkit-scrollbar-track:hover{background-color:var(--bg-color)}.mt-container a,.mt-container a:active,.mt-container a:link{text-decoration:none;color:var(--link-color)}.mt-container a:not(.mt-toot-preview):hover{text-decoration:underline}.mt-body{padding:1rem clamp(.25rem,4vw,1.5rem);white-space:pre-wrap;word-wrap:break-word}.mt-body .invisible{font-size:0;line-height:0;display:inline-block;width:0;height:0;position:absolute}.mt-toot{margin:.25rem;padding:1rem .5rem;position:relative;min-height:3.75rem;background-color:transparent;border-bottom:1px solid var(--line-gray-color)}.mt-toot:focus,.mt-toot:hover{cursor:pointer;background-color:var(--bg-hover-color)}.mt-toot p:last-child{margin-bottom:0}.mt-toot-avatar{margin-right:.75rem}.mt-toot-avatar-standard{width:2.25rem;height:2.25rem}.mt-toot-avatar-boosted{width:3rem;height:3rem;position:relative}.mt-toot-avatar-image-big img{aspect-ratio:1/1;width:2.25rem;height:2.25rem;border-radius:.25rem;overflow:hidden}.mt-toot-avatar-image-small img{aspect-ratio:1/1;width:1.5rem;height:1.5rem;top:1.5rem;left:1.5rem;position:absolute;border-radius:.25rem;overflow:hidden}.mt-toot-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:1rem}.mt-toot-header-user{font-weight:600;margin-top:.5rem;padding-right:1rem}.mt-toot-header-user>a{display:flex;align-items:flex-start;color:var(--content-text)!important;overflow-wrap:anywhere}.mt-toot-header-date{font-size:.75rem;text-align:right;margin:.5rem 0 0 auto}.mt-toot-header-date>a{color:var(--contrast-gray-color)!important}.mt-toot-text{margin-bottom:1rem;color:var(--content-text)}.mt-toot-text .spoiler-btn{display:inline-block}.mt-toot-text .spoiler-text-hidden{display:none}.mt-toot-text.truncate{display:-webkit-box;overflow:hidden;-webkit-line-clamp:var(--text-max-lines);-webkit-box-orient:vertical}.mt-toot-text:not(.truncate) .ellipsis::after{content:"..."}.mt-toot-text blockquote{border-left:.25rem solid var(--line-gray-color);margin-left:0;padding-left:.5rem}.mt-toot-header-user .custom-emoji,.mt-toot-text .custom-emoji{height:1.5rem;min-width:1.5rem;margin-bottom:-.25rem;width:auto}.mt-toot-poll{margin-bottom:1rem;color:var(--content-text)}.mt-toot-poll ul{list-style:none;padding:0;margin:0}.mt-toot-poll ul li{font-size:.9rem;margin-bottom:.5rem}.mt-toot-poll.mt-toot-poll-expired ul li{color:var(--contrast-gray-color)}.mt-toot-poll ul li:not(:last-child){margin-bottom:.25rem}.mt-toot-poll ul li:before{content:"◯";padding-right:.5rem}.mt-toot-poll.mt-toot-poll-expired ul li:before{content:"";padding-right:0}.mt-toot-media{position:relative;overflow:hidden;margin-bottom:1rem}.mt-toot-media>.spoiler-btn{position:absolute;top:50%;left:50%;z-index:1;transform:translate(-50%,-50%)}.mt-toot-media-spoiler>.mt-toot-media-play-icon,.mt-toot-media-spoiler>audio,.mt-toot-media-spoiler>img,.mt-toot-media-spoiler>video{filter:blur(2rem);pointer-events:none}.mt-toot-media.img-ratio14_7,.mt-toot-media.video-ratio14_7{padding-top:56.95%;width:100%}.mt-toot-media>audio{width:100%;position:relative;z-index:1}.img-ratio14_7>img,.video-ratio14_7>img,.video-ratio14_7>video{width:100%;height:auto;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:var(--content-text)}.mt-toot-media.loading-spinner .mt-toot-media-play-icon{display:none}.mt-toot-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-toot-media-play-icon>svg{width:2.5rem;height:2.5rem;fill:var(--bg-color);stroke:var(--content-text);stroke-width:1px}.mt-toot-preview{min-height:4rem;display:flex;flex-direction:row;border:1px solid var(--line-gray-color);border-radius:.5rem;color:var(--link-color);font-size:.8rem;margin:1rem 0;overflow:hidden}.mt-toot-preview-image{width:40%;align-self:stretch}.mt-toot-preview-image img{display:block;width:100%;height:100%;object-fit:cover;color:var(--content-text)}.mt-toot-preview-noImage{width:40%;font-size:1.5rem;align-self:center;text-align:center}.mt-toot-preview-content{width:60%;display:flex;align-self:center;flex-direction:column;padding:.5rem 1rem;gap:.5rem}.mt-toot-preview-title{font-weight:600}.spoiler-btn{border-radius:2px;background-color:var(--line-gray-color);border:0;color:var(--content-text);font-weight:700;font-size:.7rem;padding:0 .35rem;text-transform:uppercase;line-height:1.25rem;cursor:pointer;vertical-align:top}.mt-toot-counter-bar{display:flex;min-width:6rem;max-width:40rem;justify-content:space-between;color:var(--contrast-gray-color)}.mt-toot-counter-bar-favorites,.mt-toot-counter-bar-reblog,.mt-toot-counter-bar-replies{display:flex;font-size:.75rem;gap:.25rem;align-items:center;opacity:.5}.mt-toot-counter-bar-favorites>svg,.mt-toot-counter-bar-reblog>svg,.mt-toot-counter-bar-replies>svg{width:1rem;fill:var(--contrast-gray-color)}.mt-error{position:absolute;display:flex;flex-direction:column;height:calc(100% - 3.5rem);width:calc(100% - 4.5rem);justify-content:center;align-items:center;color:var(--error-text-color);padding:.75rem;text-align:center}.mt-error-icon{font-size:2rem}.mt-error-message{padding:1rem 0}.mt-error-message hr{color:var(--line-gray-color)}.mt-body>.loading-spinner{position:absolute;width:3rem;height:3rem;margin:auto;top:calc(50% - 1.5rem);right:calc(50% - 1.5rem)}.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% - .5rem))}.mt-footer{margin:1rem auto 2rem auto;padding:0 2rem;text-align:center}.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/src/mastodon-timeline.min.js b/src/mastodon-timeline.min.js deleted file mode 100644 index 59645f6..0000000 --- a/src/mastodon-timeline.min.js +++ /dev/null @@ -1 +0,0 @@ -window.addEventListener("load",()=>{new MastodonApi({container_body_id:"mt-body",spinner_class:"loading-spinner",default_theme:"auto",instance_url:"https://mastdn.social",timeline_type:"local",user_id:"",profile_name:"",hashtag_name:"",toots_limit:"20",hide_unlisted:!1,hide_reblog:!1,hide_replies:!1,hide_video_preview:!1,hide_preview_link:!1,hide_emojos:!1,markdown_blockquote:!1,hide_counter_bar:!1,text_max_lines:"0",link_see_more:"See more posts at Mastodon"})});const MastodonApi=function(t){this.CONTAINER_BODY_ID=document.getElementById(t.container_body_id||"mt-body"),this.SPINNER_CLASS=t.spinner_class||"loading-spinner",this.DEFAULT_THEME=t.default_theme||"auto",this.INSTANCE_URL=t.instance_url,this.USER_ID=t.user_id||"",this.PROFILE_NAME=this.USER_ID?t.profile_name:"",this.TIMELINE_TYPE=t.timeline_type||"local",this.HASHTAG_NAME=t.hashtag_name||"",this.TOOTS_LIMIT=t.toots_limit||"20",this.HIDE_UNLISTED=void 0!==t.hide_unlisted&&t.hide_unlisted,this.HIDE_REBLOG=void 0!==t.hide_reblog&&t.hide_reblog,this.HIDE_REPLIES=void 0!==t.hide_replies&&t.hide_replies,this.HIDE_VIDEO_PREVIEW=void 0!==t.hide_video_preview&&t.hide_video_preview,this.HIDE_PREVIEW_LINK=void 0!==t.hide_preview_link&&t.hide_preview_link,this.HIDE_EMOJOS=void 0!==t.hide_emojos&&t.hide_emojos,this.MARKDOWN_BLOCKQUOTE=void 0!==t.markdown_blockquote&&t.markdown_blockquote,this.HIDE_COUNTER_BAR="undefined"!==t.hide_counter_bar&&t.hide_counter_bar,this.TEXT_MAX_LINES=t.text_max_lines||"0",this.LINK_SEE_MORE=t.link_see_more,this.FETCHED_DATA={},this.buildTimeline()};MastodonApi.prototype.buildTimeline=async function(){for(let t in this.setTheme(),await this.getTimelineData(),this.CONTAINER_BODY_ID.innerHTML="",this.FETCHED_DATA.timeline)("public"==this.FETCHED_DATA.timeline[t].visibility||!this.HIDE_UNLISTED&&"unlisted"==this.FETCHED_DATA.timeline[t].visibility)&&(this.HIDE_REBLOG&&this.FETCHED_DATA.timeline[t].reblog||this.HIDE_REPLIES&&this.FETCHED_DATA.timeline[t].in_reply_to_id||this.appendToot(this.FETCHED_DATA.timeline[t],Number(t)));if(""===this.CONTAINER_BODY_ID.innerHTML)this.CONTAINER_BODY_ID.setAttribute("role","none"),this.CONTAINER_BODY_ID.innerHTML='
    \uD83D\uDCED
    Sorry, no toots to show
    Got '+this.FETCHED_DATA.timeline.length+" toots from the server.
    This may be due to an incorrect configuration in the parameters or to filters applied to hide certains type of toots.
    ";else{if(this.LINK_SEE_MORE){let e="";"profile"===this.TIMELINE_TYPE?e=this.PROFILE_NAME:"hashtag"===this.TIMELINE_TYPE?e="tags/"+this.HASHTAG_NAME:"local"===this.TIMELINE_TYPE&&(e="public/local");let o='";this.CONTAINER_BODY_ID.parentNode.insertAdjacentHTML("beforeend",o)}this.manageSpinner()}this.CONTAINER_BODY_ID.addEventListener("click",function(t){"article"!=t.target.localName&&t.target.offsetParent?.localName!="article"&&("img"!=t.target.localName||t.target.parentNode.classList.contains("video-ratio14_7"))||i(t),"button"==t.target.localName&&"spoiler-btn"==t.target.className&&a(t),("mt-toot-media-play-icon"==t.target.className||"svg"==t.target.localName&&"mt-toot-media-play-icon"==t.target.parentNode.className||"path"==t.target.localName&&"mt-toot-media-play-icon"==t.target.parentNode.parentNode.className||"img"==t.target.localName&&t.target.parentNode.classList.contains("video-ratio14_7"))&&s(t)}),this.CONTAINER_BODY_ID.addEventListener("keydown",function(t){"Enter"===t.key&&"article"==t.target.localName&&i(t)});let i=function(t){let e=t.target.closest(".mt-toot").dataset.location;"a"!==t.target.localName&&"span"!==t.target.localName&&"button"!==t.target.localName&&"time"!==t.target.localName&&"mt-toot-preview-noImage"!==t.target.className&&"mt-toot-avatar-image-big"!==t.target.parentNode.className&&"mt-toot-avatar-image-small"!==t.target.parentNode.className&&"mt-toot-preview-image"!==t.target.parentNode.className&&"mt-toot-preview"!==t.target.parentNode.className&&e&&window.open(e,"_blank","noopener")},a=function(t){let e=t.target.nextSibling;"img"===e.localName||"audio"===e.localName||"video"===e.localName?(t.target.parentNode.classList.remove("mt-toot-media-spoiler"),t.target.style.display="none"):(e.classList.contains("spoiler-text-hidden")||e.classList.contains("spoiler-text-visible"))&&("Show more"==t.target.textContent?(e.classList.remove("spoiler-text-hidden"),e.classList.add("spoiler-text-visible"),t.target.setAttribute("aria-expanded","true"),t.target.textContent="Show less"):(e.classList.remove("spoiler-text-visible"),e.classList.add("spoiler-text-hidden"),t.target.setAttribute("aria-expanded","false"),t.target.textContent="Show more"))},s=function(t){let e=t.target.closest("[data-video-url]"),o=e.dataset.videoUrl;e.replaceChildren(),e.innerHTML=''}},MastodonApi.prototype.setTheme=function(){let t=function(t){document.documentElement.setAttribute("data-theme",t)};if("auto"===this.DEFAULT_THEME){let e=window.matchMedia("(prefers-color-scheme: dark)");e.matches?t("dark"):t("light"),e.addEventListener("change",e=>{e.matches?t("dark"):t("light")})}else t(this.DEFAULT_THEME)},MastodonApi.prototype.getTimelineData=async function(){return new Promise((t,e)=>{async function o(t){let e=await fetch(t);if(!e.ok)throw Error("Failed to fetch the following URL: "+t+"
    Error status: "+e.status+"
    Error message: "+e.statusText);let o=await e.json();return o}let i={};"profile"===this.TIMELINE_TYPE?i.timeline=`${this.INSTANCE_URL}/api/v1/accounts/${this.USER_ID}/statuses?limit=${this.TOOTS_LIMIT}`:"hashtag"===this.TIMELINE_TYPE?i.timeline=`${this.INSTANCE_URL}/api/v1/timelines/tag/${this.HASHTAG_NAME}?limit=${this.TOOTS_LIMIT}`:"local"===this.TIMELINE_TYPE&&(i.timeline=`${this.INSTANCE_URL}/api/v1/timelines/public?local=true&limit=${this.TOOTS_LIMIT}`),this.HIDE_EMOJOS||(i.emojos=this.INSTANCE_URL+"/api/v1/custom_emojis");let a=Object.entries(i).map(([t,i])=>o(i).then(e=>({[t]:e})).catch(o=>(e(Error("Something went wrong fetching data")),this.CONTAINER_BODY_ID.innerHTML='
    ❌
    Sorry, request failed:
    '+o.message+"
    ",this.CONTAINER_BODY_ID.setAttribute("role","none"),{[t]:[]})));Promise.all(a).then(e=>{this.FETCHED_DATA=e.reduce((t,e)=>({...t,...e}),{}),t()})})},MastodonApi.prototype.appendToot=function(t,e){this.CONTAINER_BODY_ID.insertAdjacentHTML("beforeend",this.assambleToot(t,e))},MastodonApi.prototype.assambleToot=function(t,e){let o,i,a,s,r,l,n,d,c;t.reblog?(s=t.reblog.url,o='
    '+this.escapeHtml(t.reblog.account.username)+' avatar
    '+this.escapeHtml(t.account.username)+' avatar
    ',a=this.showEmojos(t.reblog.account.display_name?t.reblog.account.display_name:t.reblog.account.username,this.FETCHED_DATA.emojos),i='',r=t.reblog.created_at,c=t.reblog.replies_count,d=t.reblog.reblogs_count,n=t.reblog.favourites_count):(s=t.url,o='
    '+this.escapeHtml(t.account.username)+' avatar
    ',a=this.showEmojos(t.account.display_name?t.account.display_name:t.account.username,this.FETCHED_DATA.emojos),i='',r=t.created_at,c=t.replies_count,d=t.reblogs_count,n=t.favourites_count),l=this.formatDate(r);let m='",p="";"0"!==this.TEXT_MAX_LINES&&(p=" truncate",document.documentElement.style.setProperty("--text-max-lines",this.TEXT_MAX_LINES));let h="";h=""!==t.spoiler_text?'
    '+t.spoiler_text+'
    '+this.formatTootText(t.content)+"
    ":t.reblog&&""!==t.reblog.content&&""!==t.reblog.spoiler_text?'
    '+t.reblog.spoiler_text+'
    '+this.formatTootText(t.reblog.content)+"
    ":t.reblog&&""!==t.reblog.content&&""===t.reblog.spoiler_text?'
    '+this.formatTootText(t.reblog.content)+"
    ":'
    '+this.formatTootText(t.content)+"
    ";let u=[];if(t.media_attachments.length>0)for(let v in t.media_attachments)u.push(this.placeMedias(t.media_attachments[v],t.sensitive));if(t.reblog&&t.reblog.media_attachments.length>0)for(let g in t.reblog.media_attachments)u.push(this.placeMedias(t.reblog.media_attachments[g],t.reblog.sensitive));let E="";!this.HIDE_PREVIEW_LINK&&t.card&&(E=this.placePreviewLink(t.card));let T="";if(t.poll){let b="";for(let N in t.poll.options)b+="
  • "+t.poll.options[N].title+"
  • ";T='
      '+b+"
    "}let A="";if(!this.HIDE_COUNTER_BAR){let I='
    '+c+"
    ",f='
    '+d+"
    ",_='
    '+n+"
    ";A='
    '+I+f+_+"
    "}let $='
    '+o+i+m+"
    "+h+u.join("")+E+T+A+"
    ";return $},MastodonApi.prototype.formatTootText=function(t){let e=t;return e=this.addTarget2hashtagMention(e),this.HIDE_EMOJOS||(e=this.showEmojos(e,this.FETCHED_DATA.emojos)),this.MARKDOWN_BLOCKQUOTE&&(e=this.replaceHTMLtag(e,"

    >","

    ","

    ","

    ")),e},MastodonApi.prototype.addTarget2hashtagMention=function(t){let e=t.replaceAll('rel="tag"','rel="tag" target="_blank"');return e.replaceAll('class="u-url mention"','class="u-url mention" target="_blank"')},MastodonApi.prototype.showEmojos=function(t,e){if(!t.includes(":"))return t;for(let o of e){let i=RegExp(`\\:${o.shortcode}\\:`,"g");t=t.replace(i,`Emoji ${o.shortcode}`)}return t},MastodonApi.prototype.replaceHTMLtag=function(t,e,o,i,a){if(!t.includes(e))return t;{let s=RegExp(e+"(.*?)"+o,"gi");return t.replace(s,i+"$1"+a)}},MastodonApi.prototype.placeMedias=function(t,e){let o=e||!1,i=t.type,a="";return"image"===i&&(a='
    '+(o?'':"")+''+(t.description?this.escapeHtml(t.description):
    '),"audio"===i&&(a=t.preview_url?'
    '+(o?'':"")+''+(t.description?this.escapeHtml(t.description):
    ':'
    '+(o?'':"")+'
    '),"video"===i&&(a=this.HIDE_VIDEO_PREVIEW?'
    '+(o?'':"")+'
    ':'
    '+(o?'':"")+''+(t.description?this.escapeHtml(t.description):
    '),a},MastodonApi.prototype.placePreviewLink=function(t){let e=''+(t.image?'
    '+this.escapeHtml(t.image_description)+'
    ':'
    \uD83D\uDCC4
    ')+'
    '+(t.provider_name?''+this.parseHTMLstring(t.provider_name)+"":"")+''+t.title+""+(t.author_name?''+this.parseHTMLstring(t.author_name)+"":"")+"
    ";return e},MastodonApi.prototype.formatDate=function(t){let e=new Date(t),o=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec",][e.getMonth()]+" "+e.getDate()+", "+e.getFullYear();return o},MastodonApi.prototype.parseHTMLstring=function(t){let e=new DOMParser,o=e.parseFromString(t,"text/html");return o.body.textContent},MastodonApi.prototype.escapeHtml=function(t){return(t??"").replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")},MastodonApi.prototype.manageSpinner=function(){let t=this.SPINNER_CLASS,e=function(){this.parentNode.classList.remove(t),this.removeEventListener("load",e),this.removeEventListener("error",e)};this.CONTAINER_BODY_ID.querySelectorAll(`.${this.SPINNER_CLASS} > img`).forEach(t=>{t.addEventListener("load",e),t.addEventListener("error",e)})};