container with id: "${
this.mtSettings.mtContainerId
}" after several attempts for ${
this.mtSettings.insistSearchContainerTime / 1000
} seconds`
);
}
}
};
findContainerNode();
} else {
if (document.getElementById(this.mtSettings.mtContainerId)) {
assignContainerNode();
} else {
console.error(
`Impossible to find the
container with id: "${this.mtSettings.mtContainerId}". Please try to add the option 'insistSearchContainer: true' when initializing the script`
);
}
}
}
/**
* Reload the timeline by fetching the lastest posts
*/
mtUpdate() {
this.#onDOMContentLoaded(() => {
this.mtBodyNode.replaceChildren();
this.mtBodyNode.insertAdjacentHTML(
"afterbegin",
'
'
);
this.#buildTimeline("updateTimeline");
});
}
/**
* Apply the color theme in the timeline
* @param {String} themeType Type of color theme ('light' or 'dark')
*/
mtColorTheme(themeType) {
this.#onDOMContentLoaded(() => {
this.mtContainerNode.setAttribute("data-theme", themeType);
});
}
/**
* Get the theme style chosen by the user or by the browser/OS
*/
#loadColorTheme() {
if (this.mtSettings.defaultTheme === "auto") {
let systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
systemTheme.matches
? this.mtColorTheme("dark")
: this.mtColorTheme("light");
// Update the theme if user change browser/OS preference
systemTheme.addEventListener("change", (e) => {
e.matches ? this.mtColorTheme("dark") : this.mtColorTheme("light");
});
} else {
this.mtColorTheme(this.mtSettings.defaultTheme);
}
}
/**
* Requests to the server to collect all the data
* @returns {Object} Data container
*/
#getTimelineData() {
return new Promise((resolve, reject) => {
const instanceApiUrl = this.mtSettings.instanceUrl
? `${this.mtSettings.instanceUrl}/api/v1/`
: this.#showError(
"Please check your
instanceUrl value",
"⚠️"
);
const urls = this.#setUrls(instanceApiUrl);
const urlsPromises = Object.entries(urls).map(([key, url]) => {
const headers = key === "timeline";
return this.#fetchData(url, headers)
.then((data) => ({ [key]: data }))
.catch((error) => {
reject(
new Error(`Something went wrong fetching data from: ${url}`)
);
this.#showError(error.message);
return { [key]: [] };
});
});
// Fetch all urls simultaneously
Promise.all(urlsPromises).then(async (dataObjects) => {
this.fetchedData = dataObjects.reduce((result, dataItem) => {
return { ...result, ...dataItem };
}, {});
// Merge pinned posts with timeline posts
if (
!this.mtSettings.hidePinnedPosts &&
this.fetchedData.pinned?.length !== undefined &&
this.fetchedData.pinned.length !== 0
) {
const pinnedPosts = this.fetchedData.pinned.map((obj) => ({
...obj,
pinned: true,
}));
this.fetchedData.timeline = [
...pinnedPosts,
...this.fetchedData.timeline,
];
}
// Fetch more posts if maxNbPostFetch is not reached
if (this.#isNbPostsFulfilled()) {
resolve();
} else {
do {
await this.#fetchMorePosts();
} while (!this.#isNbPostsFulfilled() && this.linkHeader.next);
resolve();
}
});
});
}
/**
* Set all urls before fetching the data
* @param {String} Instance url api
* @returns {Object}
*/
#setUrls(i) {
const {
timelineType,
userId,
hashtagName,
maxNbPostFetch,
hidePinnedPosts,
hideEmojos,
} = this.mtSettings;
const urls = {};
switch (timelineType) {
case "profile":
if (!userId) {
this.#showError(
"Please check your
userId value",
"⚠️"
);
break;
}
urls.timeline = `${i}accounts/${userId}/statuses?limit=${maxNbPostFetch}`;
if (!hidePinnedPosts) {
urls.pinned = `${i}accounts/${userId}/statuses?pinned=true`;
}
break;
case "hashtag":
if (!hashtagName) {
this.#showError(
"Please check your
hashtagName value",
"⚠️"
);
break;
}
urls.timeline = `${i}timelines/tag/${hashtagName}?limit=${maxNbPostFetch}`;
break;
case "local":
urls.timeline = `${i}timelines/public?local=true&limit=${maxNbPostFetch}`;
break;
default:
this.#showError(
"Please check your
timelineType value",
"⚠️"
);
}
if (!hideEmojos) {
urls.emojos = `${i}custom_emojis`;
}
return urls;
}
/**
* Fetch data from server
* @param {String} u Url address to fetch
* @param {Boolean} h gets the link header
* @returns {Array} List of objects
*/
async #fetchData(u, h = false) {
const response = await fetch(u);
if (!response.ok) {
throw new Error(`
Failed to fetch the following Url:
${u}
Error status: ${response.status}
Error message: ${response.statusText}
`);
}
const data = await response.json();
// Get Link headers for pagination
if (h && response.headers.get("Link")) {
this.linkHeader = this.#parseLinkHeader(response.headers.get("Link"));
}
return data;
}
/**
* Check if there are enough posts to reach the value of maxNbPostFetch
* @returns {Boolean}
*/
#isNbPostsFulfilled() {
return (
this.fetchedData.timeline.length >= Number(this.mtSettings.maxNbPostFetch)
);
}
/**
* Fetch extra posts
*/
#fetchMorePosts() {
return new Promise((resolve) => {
if (this.linkHeader.next) {
this.#fetchData(this.linkHeader.next, true).then((data) => {
this.fetchedData.timeline = [...this.fetchedData.timeline, ...data];
resolve();
});
} else {
resolve();
}
});
}
/**
* Parse link header into an object
* @param {String} l Link header
* @returns {Object}
*/
#parseLinkHeader(l) {
const linkArray = l.split(", ").map((header) => header.split("; "));
const linkMap = linkArray.map((header) => {
const linkRel = header[1].replace(/"/g, "").replace("rel=", "");
const linkURL = header[0].slice(1, -1);
return [linkRel, linkURL];
});
return Object.fromEntries(linkMap);
}
/**
* Filter all fetched posts and append them on the timeline
* @param {String} t Type of build (new or reload)
*/
async #buildTimeline(t) {
await this.#getTimelineData();
// console.log("Mastodon timeline data fetched: ", this.fetchedData);
const {
hideUnlisted,
hideReblog,
hideReplies,
maxNbPostShow,
filterByLanguage,
} = this.mtSettings;
const posts = this.fetchedData.timeline;
let nbPostToShow = 0;
this.mtBodyNode.replaceChildren();
const filteredPosts = posts.filter((post) => {
const isPublicOrUnlisted =
post.visibility === "public" ||
(!hideUnlisted && post.visibility === "unlisted");
const shouldHideReblog = hideReblog && post.reblog;
const shouldHideReplies = hideReplies && post.in_reply_to_id;
const postLanguage =
post.language || (post.reblog ? post.reblog.language : null);
const matchesLanguage =
filterByLanguage === "" || postLanguage === filterByLanguage;
return (
isPublicOrUnlisted &&
!shouldHideReblog &&
!shouldHideReplies &&
matchesLanguage
);
});
filteredPosts.forEach((post, index) => {
if (index < maxNbPostShow) {
this.#appendPost(post, index);
}
});
// Check if there are posts to display or not
if (this.mtBodyNode.innerHTML !== "") {
if (t === "newTimeline") {
this.#manageSpinner();
this.#setCSSvariables();
this.#addAriaSetsize(nbPostToShow);
this.#addPostListener();
if (this.mtSettings.btnSeeMore || this.mtSettings.btnReload)
this.#buildFooter();
} else if (t === "updateTimeline") {
this.#manageSpinner();
} else {
this.#showError("The function buildTimeline() was expecting a param");
}
} else {
const errorMessage = `No posts to show
${
posts?.length || 0
} posts have been fetched from the server
This may be due to an incorrect configuration with the parameters or with the filters applied (to hide certains type of posts)`;
this.#showError(errorMessage, "📭");
}
}
/**
* Establishes the defined CSS variables
*/
#setCSSvariables() {
if (
this.mtSettings.txtMaxLines !== "0" &&
this.mtSettings.txtMaxLines.length !== 0
) {
this.mtBodyNode.parentNode.style.setProperty(
"--mt-txt-max-lines",
this.mtSettings.txtMaxLines
);
}
if (
this.mtSettings.previewMaxLines !== "0" &&
this.mtSettings.previewMaxLines.length !== 0
) {
this.mtBodyNode.parentNode.style.setProperty(
"--mt-preview-max-lines",
this.mtSettings.previewMaxLines
);
}
}
/**
* Add the attribute Aria-setsize to all posts
* @param {Number} n The total number of posts showed in the timeline
*/
#addAriaSetsize(n) {
const articles = this.mtBodyNode.getElementsByTagName("article");
for (let i = 0; i < n; i++) {
articles[i].setAttribute("aria-setsize", n);
}
}
/**
* Add each post in the timeline container
* @param {Object} c Post content
* @param {Number} i Index of post
*/
#appendPost(c, i) {
this.mtBodyNode.insertAdjacentHTML("beforeend", this.#assamblePost(c, i));
}
/**
* Build post structure
* @param {Object} c Post content
* @param {Number} i Index of post
*/
#assamblePost(c, i) {
const isReblog = Boolean(c.reblog);
const post = isReblog ? c.reblog : c;
const {
url,
created_at: date,
replies_count,
reblogs_count,
favourites_count,
} = post;
const {
avatar,
url: accountUrl,
username,
display_name,
emojis,
} = post.account;
// Avatar
const avatarHTML =
'
' +
'' +
'
' +
'
' +
"
" +
(isReblog
? '
' +
'
' +
"
"
: "") +
"
" +
" ";
// User
const userNameFull =
!this.mtSettings.hideEmojos && display_name
? this.#shortcode2Emojos(display_name, emojis)
: display_name || username;
const accountName = this.mtSettings.hideUserAccount
? ""
: '
";
const userHTML =
'";
// Date
const formattedDate = this.#formatDate(date);
const dateHTML =
'";
// Post text
const txtTruncateCss =
this.mtSettings.txtMaxLines !== "0" ? " truncate" : "";
let postTxt = "";
const textSource = post.spoiler_text ? post.spoiler_text : post.content;
if (textSource) {
postTxt =
'
' +
'
' +
this.#formatPostText(textSource) +
"
" +
"
";
}
// Media
const media = [
...c.media_attachments,
...(c.reblog?.media_attachments || []),
]
.map((attachment) => this.#createMedia(attachment, post.sensitive))
.join("");
const mediaHTML = media
? `
${media}
`
: "";
// Preview link
const previewLinkHTML =
!this.mtSettings.hidePreviewLink && c.card
? this.#createPreviewLink(c.card)
: "";
// Poll
const pollHTML = c.poll
? '
' +
"
" +
c.poll.options
.map(function (opt) {
return "" + opt.title + " ";
})
.join("") +
" " +
"
"
: "";
// Counter bar
const counterBarHTML = !this.mtSettings.hideCounterBar
? '
' +
this.#counteBarItem("replies", replies_count) +
this.#counteBarItem("reblog", reblogs_count) +
this.#counteBarItem("favorites", favourites_count) +
"
"
: "";
return (
'
' +
'" +
postTxt +
mediaHTML +
previewLinkHTML +
pollHTML +
counterBarHTML +
" "
);
}
/**
* Build counter bar items
* @param {string} t Type of icon
* @param {Number} i Counter
*/
#counteBarItem(t, c) {
const icons = {
replies:
'
',
reblog:
'
',
favorites:
'
',
};
return `
${icons[t]}${c}
`;
}
/**
* Sanitize an HTML string
* (c) Chris Ferdinandi, MIT License, https://gomakethings.com
* @param {String} s The HTML string to sanitize
* @param {Boolean} n If true, returns HTML nodes instead of a string
* @return {String|NodeList} The sanitized string or nodes
*/
#cleanHTML(s, n) {
/**
* Convert the string to an HTML document
* @return {Node} An HTML document
*/
function stringToHTML() {
let parser = new DOMParser();
let doc = parser.parseFromString(s, "text/html");
return doc.body || document.createElement("body");
}
/**
* Remove