1
0
Fork 0
mirror of https://github.com/miniflux/v2.git synced 2025-08-11 17:51:01 +00:00
miniflux-v2/internal/ui/static/js/app.js

1233 lines
41 KiB
JavaScript
Raw Normal View History

2025-08-02 15:07:56 -07:00
// Sentinel values for specific list navigation.
const TOP = 9999;
const BOTTOM = -9999;
/**
2025-08-02 15:07:56 -07:00
* Send a POST request to the specified URL with the given body.
*
2025-08-02 15:07:56 -07:00
* @param {string} url - The URL to send the request to.
* @param {Object} [body] - The body of the request (optional).
* @returns {Promise<Response>} The response from the fetch request.
*/
2025-08-02 15:07:56 -07:00
function sendPOSTRequest(url, body = null) {
const options = {
method: "POST",
headers: {
"X-Csrf-Token": document.body.dataset.csrfToken || ""
}
};
if (body !== null) {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(body);
}
return fetch(url, options);
}
/**
* Open a new tab with the given URL.
*
* @param {string} url
*/
function openNewTab(url) {
const win = window.open("");
win.opener = null;
win.location = url;
win.focus();
}
/**
* Scroll the page to the given element.
*
* @param {Element} element
* @param {boolean} evenIfOnScreen
*/
function scrollPageTo(element, evenIfOnScreen) {
const windowScrollPosition = window.scrollY;
const windowHeight = document.documentElement.clientHeight;
const viewportPosition = windowScrollPosition + windowHeight;
const itemBottomPosition = element.offsetTop + element.offsetHeight;
if (evenIfOnScreen || viewportPosition - itemBottomPosition < 0 || viewportPosition - element.offsetTop > windowHeight) {
window.scrollTo(0, element.offsetTop - 10);
}
}
/**
* Attach a click event listener to elements matching the selector.
*
* @param {string} selector
* @param {function} callback
* @param {boolean} noPreventDefault
*/
2019-07-17 20:27:39 -07:00
function onClick(selector, callback, noPreventDefault) {
document.querySelectorAll(selector).forEach((element) => {
2019-07-17 20:27:39 -07:00
element.onclick = (event) => {
if (!noPreventDefault) {
event.preventDefault();
}
callback(event);
};
});
}
/**
* Attach an auxiliary click event listener to elements matching the selector.
*
* @param {string} selector
* @param {function} callback
* @param {boolean} noPreventDefault
*/
function onAuxClick(selector, callback, noPreventDefault) {
document.querySelectorAll(selector).forEach((element) => {
element.onauxclick = (event) => {
if (!noPreventDefault) {
event.preventDefault();
}
callback(event);
};
});
}
/**
* Filter visible elements based on the selector.
*
* @param {string} selector
* @returns {Array<Element>}
*/
function getVisibleElements(selector) {
const elements = document.querySelectorAll(selector);
return [...elements].filter((element) => element.offsetParent !== null);
}
/**
* Get all visible entries on the current page.
*
* @return {Array<Element>}
*/
function getVisibleEntries() {
return getVisibleElements(".items .item");
}
/**
* Check if the current view is a list view.
*
* @returns {boolean}
*/
function isListView() {
return document.querySelector(".items") !== null;
}
/**
* Check if the current view is an entry view.
*
* @return {boolean}
*/
function isEntryView() {
return document.querySelector("section.entry") !== null;
}
/**
* Find the entry element for the given element.
*
* @returns {Element|null}
*/
function findEntry(element) {
if (isListView()) {
if (element) {
return element.closest(".item");
}
return document.querySelector(".current-item");
}
return document.querySelector(".entry");
}
/**
* Insert an icon label element into the parent element.
*
* @param {Element} parentElement The parent element to insert the icon label into.
* @param {string} iconLabelText The text to display in the icon label.
* @returns {void}
*/
function insertIconLabelElement(parentElement, iconLabelText) {
const span = document.createElement('span');
span.classList.add('icon-label');
span.textContent = iconLabelText;
parentElement.appendChild(span);
}
/**
* Navigate to a specific page.
*
* @param {string} page - The page to navigate to.
* @param {boolean} reloadOnFail - If true, reload the current page if the target page is not found.
*/
function goToPage(page, reloadOnFail = false) {
const element = document.querySelector(":is(a, button)[data-page=" + page + "]");
if (element) {
document.location.href = element.href;
} else if (reloadOnFail) {
window.location.reload();
}
}
/**
* Navigate to the previous page.
*
* If the offset is a KeyboardEvent, it will navigate to the previous item in the list.
* If the offset is a number, it will jump that many items in the list.
* If the offset is TOP, it will jump to the first item in the list.
* If the offset is BOTTOM, it will jump to the last item in the list.
* If the current view is an entry view, it will redirect to the previous page.
*
* @param {number|KeyboardEvent} offset - How many items to jump for focus.
*/
function goToPreviousPage(offset) {
if (offset instanceof KeyboardEvent) offset = -1;
if (isListView()) {
goToListItem(offset);
} else {
goToPage("previous");
}
}
/**
* Navigate to the next page.
*
* If the offset is a KeyboardEvent, it will navigate to the next item in the list.
* If the offset is a number, it will jump that many items in the list.
* If the offset is TOP, it will jump to the first item in the list.
* If the offset is BOTTOM, it will jump to the last item in the list.
* If the current view is an entry view, it will redirect to the next page.
*
* @param {number|KeyboardEvent} offset - How many items to jump for focus.
*/
function goToNextPage(offset) {
if (offset instanceof KeyboardEvent) offset = 1;
if (isListView()) {
goToListItem(offset);
} else {
goToPage("next");
}
}
/**
* Navigate to the individual feed or feeds page.
*
* If the current view is an entry view, it will redirect to the feed link of the entry.
* If the current view is a list view, it will redirect to the feeds page.
*/
function goToFeedOrFeedsPage() {
if (isEntryView()) {
goToFeedPage();
} else {
goToPage("feeds");
}
}
/**
* Navigate to the feed page of the current entry.
*
* If the current view is an entry view, it will redirect to the feed link of the entry.
* If the current view is a list view, it will redirect to the feed link of the currently selected item.
* If no feed link is available, it will do nothing.
*/
function goToFeedPage() {
if (isEntryView()) {
const feedAnchor = document.querySelector("span.entry-website a");
if (feedAnchor !== null) {
window.location.href = feedAnchor.href;
}
} else {
const currentItemFeed = document.querySelector(".current-item :is(a, button)[data-feed-link]");
if (currentItemFeed !== null) {
window.location.href = currentItemFeed.getAttribute("href");
}
}
}
/**
* Navigate to the add subscription page.
*
* @returns {void}
*/
function goToAddSubscriptionPage() {
window.location.href = document.body.dataset.addSubscriptionUrl;
}
/**
* Navigate to the next or previous item in the list.
*
* If the offset is TOP, it will jump to the first item in the list.
* If the offset is BOTTOM, it will jump to the last item in the list.
* If the offset is a number, it will jump that many items in the list.
* If the current view is an entry view, it will redirect to the next or previous page.
*
* @param {number} offset - How many items to jump for focus.
* @return {void}
*/
function goToListItem(offset) {
const items = getVisibleEntries();
if (items.length === 0) {
return;
}
const currentItem = document.querySelector(".current-item");
// If no current item exists, select the first item
if (!currentItem) {
items[0].classList.add("current-item");
items[0].focus();
scrollPageTo(items[0]);
return;
}
// Find the index of the current item
const currentIndex = items.indexOf(currentItem);
if (currentIndex === -1) {
// Current item not found in visible items, select first item
currentItem.classList.remove("current-item");
items[0].classList.add("current-item");
items[0].focus();
scrollPageTo(items[0]);
return;
}
// Calculate the new item index
let newIndex;
if (offset === TOP) {
newIndex = 0;
} else if (offset === BOTTOM) {
newIndex = items.length - 1;
} else {
newIndex = (currentIndex + offset + items.length) % items.length;
}
// Update selection if moving to a different item
if (newIndex !== currentIndex) {
const newItem = items[newIndex];
currentItem.classList.remove("current-item");
newItem.classList.add("current-item");
newItem.focus();
scrollPageTo(newItem);
}
}
/**
* Handle the share action for the entry.
*
* If the share status is "shared", it will trigger the Web Share API.
* If the share status is "share", it will send an Ajax request to fetch the share URL and then trigger the Web Share API.
* If the Web Share API is not supported, it will redirect to the entry URL.
*/
async function handleShare() {
const link = document.querySelector(':is(a, button)[data-share-status]');
if (link.dataset.shareStatus === "shared") {
2025-08-02 15:07:56 -07:00
const title = document.querySelector(".entry-header > h1 > a");
await triggerWebShare(title, link.href);
}
}
/**
* Trigger the Web Share API to share the entry.
*
* If the Web Share API is not supported, it will redirect to the entry URL.
*
* @param {Element} title - The title element of the entry.
* @param {string} url - The URL of the entry to share.
*/
async function triggerWebShare(title, url) {
if (!navigator.canShare) {
console.error("Your browser doesn't support the Web Share API.");
window.location = url;
return;
}
try {
await navigator.share({
title: title ? title.textContent : url,
url: url
});
} catch (err) {
console.error(err);
}
}
2025-08-02 13:05:20 -07:00
/**
* Toggle the ARIA attributes on the main menu based on the viewport width.
*/
function toggleAriaAttributesOnMainMenu() {
const logoElement = document.querySelector(".logo");
const homePageLinkElement = document.querySelector(".logo > a");
2025-08-02 13:05:20 -07:00
if (!logoElement || !homePageLinkElement) return;
const isMobile = document.documentElement.clientWidth < 650;
if (isMobile) {
const navMenuElement = document.getElementById("header-menu");
2025-08-02 13:05:20 -07:00
const isExpanded = navMenuElement?.classList.contains("js-menu-show") ?? false;
const toggleButtonLabel = logoElement.getAttribute("data-toggle-button-label");
// Set mobile menu button attributes
Object.assign(logoElement, {
role: "button",
tabIndex: 0,
ariaLabel: toggleButtonLabel,
ariaExpanded: isExpanded.toString()
});
homePageLinkElement.tabIndex = -1;
} else {
2025-08-02 13:05:20 -07:00
// Remove mobile menu button attributes
["role", "tabindex", "aria-expanded", "aria-label"].forEach(attr =>
logoElement.removeAttribute(attr)
);
homePageLinkElement.removeAttribute("tabindex");
}
}
2025-08-02 13:05:20 -07:00
/**
* Toggle the main menu dropdown.
*
* @param {Event} event - The event object.
*/
function toggleMainMenuDropdown(event) {
// Only handle Enter, Space, or click events
if (event.type === "keydown" && !["Enter", " "].includes(event.key)) {
return;
}
2025-08-02 13:05:20 -07:00
// Prevent default only if element has role attribute (mobile menu button)
if (event.currentTarget.getAttribute("role")) {
event.preventDefault();
}
2025-08-02 13:05:20 -07:00
const navigationMenu = document.querySelector(".header nav ul");
const menuToggleButton = document.querySelector(".logo");
2025-08-02 13:05:20 -07:00
if (!navigationMenu || !menuToggleButton) {
return;
2019-07-17 20:27:39 -07:00
}
2025-08-02 13:05:20 -07:00
const isShowing = navigationMenu.classList.toggle("js-menu-show");
menuToggleButton.setAttribute("aria-expanded", isShowing.toString());
2019-07-17 20:27:39 -07:00
}
2025-08-02 13:05:20 -07:00
/**
* Initialize the main menu handlers.
*/
function initializeMainMenuHandlers() {
toggleAriaAttributesOnMainMenu();
window.addEventListener("resize", toggleAriaAttributesOnMainMenu, { passive: true });
2019-07-17 20:27:39 -07:00
2025-08-02 13:05:20 -07:00
const logoElement = document.querySelector(".logo");
if (logoElement) {
logoElement.addEventListener("click", toggleMainMenuDropdown);
logoElement.addEventListener("keydown", toggleMainMenuDropdown);
2019-07-17 20:27:39 -07:00
}
2025-08-02 13:05:20 -07:00
onClick(".header nav li", (event) => {
const linkElement = event.target.closest("a") || event.target.querySelector("a");
if (linkElement) {
window.location.href = linkElement.getAttribute("href");
}
});
2019-07-17 20:27:39 -07:00
}
/**
* This function changes the button label to the loading state and disables the button.
*
* @returns {void}
*/
2025-08-02 13:05:20 -07:00
function initializeFormHandlers() {
document.querySelectorAll("form").forEach((element) => {
2019-07-17 20:27:39 -07:00
element.onsubmit = () => {
const buttons = element.querySelectorAll("button[type=submit]");
buttons.forEach((button) => {
if (button.dataset.labelLoading) {
button.textContent = button.dataset.labelLoading;
}
2019-07-17 20:27:39 -07:00
button.disabled = true;
});
2019-07-17 20:27:39 -07:00
};
});
}
2025-08-02 13:05:20 -07:00
/**
* Show the keyboard shortcuts modal.
*/
2019-07-17 20:27:39 -07:00
function showKeyboardShortcuts() {
const template = document.getElementById("keyboard-shortcuts");
ModalHandler.open(template.content, "dialog-title");
2019-07-17 20:27:39 -07:00
}
2025-08-02 13:05:20 -07:00
/**
* Mark all visible entries on the current page as read.
*/
2019-07-17 20:27:39 -07:00
function markPageAsRead() {
const items = getVisibleEntries();
if (items.length === 0) return;
2019-07-17 20:27:39 -07:00
const entryIDs = items.map((element) => {
2019-07-17 20:27:39 -07:00
element.classList.add("item-status-read");
return parseInt(element.dataset.id, 10);
2019-07-17 20:27:39 -07:00
});
updateEntriesStatus(entryIDs, "read", () => {
const element = document.querySelector(":is(a, button)[data-action=markPageAsRead]");
const showOnlyUnread = element?.dataset.showOnlyUnread || false;
2019-07-17 20:27:39 -07:00
if (showOnlyUnread) {
window.location.reload();
} else {
goToPage("next", true);
}
});
2019-07-17 20:27:39 -07:00
}
/**
* Handle entry status changes from the list view and entry view.
* Focus the next or the previous entry if it exists.
*
* @param {string} navigationDirection Navigation direction: "previous" or "next".
* @param {Element} element Element that triggered the action.
* @param {boolean} setToRead If true, set the entry to read instead of toggling the status.
* @returns {void}
*/
function handleEntryStatus(navigationDirection, element, setToRead) {
const toasting = !element;
const currentEntry = findEntry(element);
if (currentEntry) {
if (!setToRead || currentEntry.querySelector(":is(a, button)[data-toggle-status]").dataset.value === "unread") {
toggleEntryStatus(currentEntry, toasting);
}
if (isListView() && currentEntry.classList.contains('current-item')) {
switch (navigationDirection) {
case "previous":
goToListItem(-1);
break;
case "next":
goToListItem(1);
break;
}
}
}
}
/**
* Toggle the entry status between "read" and "unread".
*
* @param {Element} element The entry element to toggle the status for.
* @param {boolean} toasting If true, show a toast notification after toggling the status.
*/
function toggleEntryStatus(element, toasting) {
const entryID = parseInt(element.dataset.id, 10);
const link = element.querySelector(":is(a, button)[data-toggle-status]");
if (!link) {
return;
}
2019-07-17 20:27:39 -07:00
const currentStatus = link.dataset.value;
const newStatus = currentStatus === "read" ? "unread" : "read";
2019-07-17 20:27:39 -07:00
link.querySelector("span").textContent = link.dataset.labelLoading;
updateEntriesStatus([entryID], newStatus, () => {
let iconElement, label;
if (currentStatus === "read") {
iconElement = document.querySelector("template#icon-read");
label = link.dataset.labelRead;
if (toasting) {
showToast(link.dataset.toastUnread, iconElement);
}
} else {
iconElement = document.querySelector("template#icon-unread");
label = link.dataset.labelUnread;
if (toasting) {
showToast(link.dataset.toastRead, iconElement);
}
}
2019-07-17 20:27:39 -07:00
2024-03-14 12:56:48 +01:00
link.replaceChildren(iconElement.content.cloneNode(true));
insertIconLabelElement(link, label);
link.dataset.value = newStatus;
2020-12-29 20:47:18 -08:00
if (element.classList.contains("item-status-" + currentStatus)) {
element.classList.remove("item-status-" + currentStatus);
element.classList.add("item-status-" + newStatus);
}
if (isListView() && getVisibleEntries().length === 0) {
window.location.reload();
}
});
2019-07-17 20:27:39 -07:00
}
/**
* Mark the entry as read if it is currently unread.
*
* @param {Element} element The entry element to mark as read.
*/
2019-07-17 20:27:39 -07:00
function markEntryAsRead(element) {
if (element.classList.contains("item-status-unread")) {
element.classList.remove("item-status-unread");
element.classList.add("item-status-read");
const entryID = parseInt(element.dataset.id, 10);
2019-07-17 20:27:39 -07:00
updateEntriesStatus([entryID], "read");
}
}
/**
* Handle the refresh of all feeds.
*
* This function redirects the user to the URL specified in the data-refresh-all-feeds-url attribute of the body element.
*/
function handleRefreshAllFeeds() {
const refreshAllFeedsUrl = document.body.dataset.refreshAllFeedsUrl;
if (refreshAllFeedsUrl) {
window.location.href = refreshAllFeedsUrl;
}
}
/**
* Update the status of multiple entries.
*
* @param {Array<number>} entryIDs - The IDs of the entries to update.
* @param {string} status - The new status to set for the entries (e.g., "read", "unread").
*/
2019-07-17 20:27:39 -07:00
function updateEntriesStatus(entryIDs, status, callback) {
const url = document.body.dataset.entriesStatusUrl;
2025-08-02 15:07:56 -07:00
sendPOSTRequest(url, { entry_ids: entryIDs, status: status }).then((resp) => {
resp.json().then(count => {
2023-08-01 06:11:39 +02:00
if (callback) {
callback(resp);
}
updateUnreadCounterValue(status === "read" ? -count : count);
});
});
2019-07-17 20:27:39 -07:00
}
/**
* Handle save entry from list view and entry view.
*
* @param {Element} element
*/
function handleSaveEntry(element) {
const toasting = !element;
const currentEntry = findEntry(element);
if (currentEntry) {
saveEntry(currentEntry.querySelector(":is(a, button)[data-save-entry]"), toasting);
2019-07-17 20:27:39 -07:00
}
}
/**
* Save the entry by sending an Ajax request to the server.
*
* @param {Element} element The element that triggered the save action.
* @param {boolean} toasting If true, show a toast notification after saving the entry.
* @return {void}
*/
function saveEntry(element, toasting) {
if (!element || element.dataset.completed) {
2019-07-17 20:27:39 -07:00
return;
}
element.textContent = "";
insertIconLabelElement(element, element.dataset.labelLoading);
2019-07-17 20:27:39 -07:00
2025-08-02 15:07:56 -07:00
sendPOSTRequest(element.dataset.saveUrl).then(() => {
element.textContent = "";
insertIconLabelElement(element, element.dataset.labelDone);
2025-01-20 16:17:58 +01:00
element.dataset.completed = "true";
if (toasting) {
const iconElement = document.querySelector("template#icon-save");
2021-03-07 11:55:43 -08:00
showToast(element.dataset.toastDone, iconElement);
}
2019-07-17 20:27:39 -07:00
});
}
/**
* Handle bookmarking an entry.
*
* @param {Element} element - The element that triggered the bookmark action.
*/
function handleBookmark(element) {
const toasting = !element;
const currentEntry = findEntry(element);
if (currentEntry) {
toggleBookmark(currentEntry, toasting);
2019-07-17 20:27:39 -07:00
}
}
/**
* Toggle the bookmark status of an entry.
*
* @param {Element} parentElement - The parent element containing the bookmark button.
* @param {boolean} toasting - Whether to show a toast notification.
* @returns {void}
*/
function toggleBookmark(parentElement, toasting) {
2024-03-15 20:19:38 -07:00
const buttonElement = parentElement.querySelector(":is(a, button)[data-toggle-bookmark]");
if (!buttonElement) return;
2019-07-17 20:27:39 -07:00
2024-03-15 20:19:38 -07:00
buttonElement.textContent = "";
insertIconLabelElement(buttonElement, buttonElement.dataset.labelLoading);
2019-07-17 20:27:39 -07:00
2025-08-02 15:07:56 -07:00
sendPOSTRequest(buttonElement.dataset.bookmarkUrl).then(() => {
2024-03-15 20:19:38 -07:00
const currentStarStatus = buttonElement.dataset.value;
const newStarStatus = currentStarStatus === "star" ? "unstar" : "star";
const isStarred = currentStarStatus === "star";
2020-12-29 20:47:18 -08:00
const iconElement = document.querySelector(isStarred ? "template#icon-star" : "template#icon-unstar");
const label = isStarred ? buttonElement.dataset.labelStar : buttonElement.dataset.labelUnstar;
if (toasting) {
const toastKey = isStarred ? "toastUnstar" : "toastStar";
showToast(buttonElement.dataset[toastKey], iconElement);
2019-07-17 20:27:39 -07:00
}
2020-12-29 20:47:18 -08:00
2024-03-15 20:19:38 -07:00
buttonElement.replaceChildren(iconElement.content.cloneNode(true));
insertIconLabelElement(buttonElement, label);
2024-03-15 20:19:38 -07:00
buttonElement.dataset.value = newStarStatus;
2019-07-17 20:27:39 -07:00
});
}
/**
* Handle fetching the original content of an entry.
*
* @returns {void}
*/
2019-07-17 20:27:39 -07:00
function handleFetchOriginalContent() {
if (isListView()) return;
2019-07-17 20:27:39 -07:00
2024-03-15 20:19:38 -07:00
const buttonElement = document.querySelector(":is(a, button)[data-fetch-content-entry]");
if (!buttonElement) return;
2019-07-17 20:27:39 -07:00
2024-03-15 20:19:38 -07:00
const previousElement = buttonElement.cloneNode(true);
buttonElement.textContent = "";
insertIconLabelElement(buttonElement, buttonElement.dataset.labelLoading);
2019-07-17 20:27:39 -07:00
2025-08-02 15:07:56 -07:00
sendPOSTRequest(buttonElement.dataset.fetchContentUrl).then((response) => {
2024-03-15 20:19:38 -07:00
buttonElement.textContent = '';
buttonElement.appendChild(previousElement);
2019-07-17 20:27:39 -07:00
response.json().then((data) => {
if (data.content && data.reading_time) {
document.querySelector(".entry-content").innerHTML = ttpolicy.createHTML(data.content);
const entryReadingtimeElement = document.querySelector(".entry-reading-time");
if (entryReadingtimeElement) {
2024-03-14 12:56:48 +01:00
entryReadingtimeElement.textContent = data.reading_time;
}
2019-07-17 20:27:39 -07:00
}
});
});
}
/**
* Open the original link of an entry.
*
* @param {boolean} openLinkInCurrentTab - Whether to open the link in the current tab.
* @returns {void}
*/
function openOriginalLink(openLinkInCurrentTab) {
const entryLink = document.querySelector(".entry h1 a");
if (entryLink) {
if (openLinkInCurrentTab) {
window.location.href = entryLink.getAttribute("href");
} else {
openNewTab(entryLink.getAttribute("href"));
}
2019-07-17 20:27:39 -07:00
return;
}
const currentItemOriginalLink = document.querySelector(".current-item :is(a, button)[data-original-link]");
if (currentItemOriginalLink) {
openNewTab(currentItemOriginalLink.getAttribute("href"));
2019-07-17 20:27:39 -07:00
const currentItem = document.querySelector(".current-item");
// If we are not on the list of starred items, move to the next item
if (document.location.href !== document.querySelector(':is(a, button)[data-page=starred]').href) {
goToListItem(1);
}
2019-07-17 20:27:39 -07:00
markEntryAsRead(currentItem);
}
}
/**
* Open the comments link of an entry.
*
* @param {boolean} openLinkInCurrentTab - Whether to open the link in the current tab.
* @returns {void}
*/
2020-01-07 00:02:02 -06:00
function openCommentLink(openLinkInCurrentTab) {
const entryLink = document.querySelector(isListView() ? ".current-item :is(a, button)[data-comments-link]" : ":is(a, button)[data-comments-link]");
if (entryLink) {
if (openLinkInCurrentTab) {
window.location.href = entryLink.getAttribute("href");
} else {
openNewTab(entryLink.getAttribute("href"));
2020-01-07 00:02:02 -06:00
}
}
}
/**
* Open the selected item in the current view.
*
* If the current view is a list view, it will navigate to the link of the currently selected item.
* If the current view is an entry view, it will navigate to the link of the entry.
*/
2019-07-17 20:27:39 -07:00
function openSelectedItem() {
const currentItemLink = document.querySelector(".current-item .item-title a");
if (currentItemLink) {
2019-07-17 20:27:39 -07:00
window.location.href = currentItemLink.getAttribute("href");
}
}
/**
* Unsubscribe from the feed of the currently selected item.
*/
2019-07-17 20:27:39 -07:00
function unsubscribeFromFeed() {
const unsubscribeLink = document.querySelector("[data-action=remove-feed]");
if (unsubscribeLink) {
2025-08-02 15:07:56 -07:00
sendPOSTRequest(unsubscribeLink.dataset.url).then(() => {
window.location.href = unsubscribeLink.dataset.redirectUrl || window.location.href;
2019-07-17 20:27:39 -07:00
});
}
}
/**
* Scroll the page to the currently selected item.
*/
function scrollToCurrentItem() {
const currentItem = document.querySelector(".current-item");
if (currentItem) {
scrollPageTo(currentItem, true);
}
}
/**
* Update the unread counter value.
*
* @param {number} delta - The amount to change the counter by.
*/
function updateUnreadCounterValue(delta) {
document.querySelectorAll("span.unread-counter").forEach((element) => {
const oldValue = parseInt(element.textContent, 10);
element.textContent = oldValue + delta;
2019-07-17 20:27:39 -07:00
});
if (window.location.href.endsWith('/unread')) {
const oldValue = parseInt(document.title.split('(')[1], 10);
const newValue = oldValue + delta;
document.title = document.title.replace(/(.*?)\(\d+\)(.*?)/, `$1(${newValue})$2`);
2019-07-17 20:27:39 -07:00
}
}
/**
* Handle confirmation messages for actions that require user confirmation.
*
* This function modifies the link element to show a confirmation question with "Yes" and "No" buttons.
* If the user clicks "Yes", it calls the provided callback with the URL and redirect URL.
* If the user clicks "No", it either redirects to a no-action URL or restores the link element.
*
* @param {Element} linkElement - The link or button element that triggered the confirmation.
* @param {function} callback - The callback function to execute if the user confirms the action.
* @returns {void}
*/
2019-07-17 21:07:29 -07:00
function handleConfirmationMessage(linkElement, callback) {
if (linkElement.tagName !== 'A' && linkElement.tagName !== "BUTTON") {
2020-06-14 19:00:41 -07:00
linkElement = linkElement.parentNode;
}
2019-07-17 21:07:29 -07:00
2020-06-14 19:00:41 -07:00
linkElement.style.display = "none";
2023-08-01 06:11:39 +02:00
const containerElement = linkElement.parentNode;
const questionElement = document.createElement("span");
2019-07-17 21:07:29 -07:00
function createLoadingElement() {
const loadingElement = document.createElement("span");
2019-07-17 21:07:29 -07:00
loadingElement.className = "loading";
loadingElement.appendChild(document.createTextNode(linkElement.dataset.labelLoading));
questionElement.remove();
containerElement.appendChild(loadingElement);
}
const yesElement = document.createElement("button");
yesElement.appendChild(document.createTextNode(linkElement.dataset.labelYes));
yesElement.onclick = (event) => {
event.preventDefault();
createLoadingElement();
2019-07-17 21:07:29 -07:00
callback(linkElement.dataset.url, linkElement.dataset.redirectUrl);
};
const noElement = document.createElement("button");
2019-07-17 21:07:29 -07:00
noElement.appendChild(document.createTextNode(linkElement.dataset.labelNo));
noElement.onclick = (event) => {
event.preventDefault();
const noActionUrl = linkElement.dataset.noActionUrl;
if (noActionUrl) {
createLoadingElement();
callback(noActionUrl, linkElement.dataset.redirectUrl);
} else {
linkElement.style.display = "inline";
questionElement.remove();
}
2019-07-17 21:07:29 -07:00
};
questionElement.className = "confirm";
questionElement.appendChild(document.createTextNode(linkElement.dataset.labelQuestion + " "));
questionElement.appendChild(yesElement);
questionElement.appendChild(document.createTextNode(", "));
questionElement.appendChild(noElement);
containerElement.appendChild(questionElement);
}
/**
* Show a toast notification.
*
* @param {string} toastMessage - The label to display in the toast.
* @param {Element} iconElement - The icon element to display in the toast.
* @returns {void}
*/
function showToast(toastMessage, iconElement) {
if (!toastMessage || !iconElement) {
2021-03-07 11:55:43 -08:00
return;
}
const toastMsgElement = document.getElementById("toast-msg");
toastMsgElement.replaceChildren(iconElement.content.cloneNode(true));
insertIconLabelElement(toastMsgElement, toastMessage);
const toastElementWrapper = document.getElementById("toast-wrapper");
toastElementWrapper.classList.remove('toast-animate');
setTimeout(() => toastElementWrapper.classList.add('toast-animate'), 100);
}
Add Media Player and resume to last playback position In order to ease podcast listening, the player can be put on top of the feed entry as main content. Use the `Use podcast player` option to enable that. It works on audio and video. Also, when playing audio or video, progression will be saved in order to be able to resume listening later. This position saving is done using the original attachement/enclosures player AND podcast player and do not rely on the podcast player option ti be enabled. Additionally, I made the player fill the width with the entry container to ease seeking and have a bigger video. updateEnclosures now keep existing enclosures based on URL When feeds get updated, enclosures entries are always wiped and re-created. This cause two issue - enclosure progression get lost in the process - enclosure ID changes I used the URL as identifier of an enclosure. Not perfect but hopefully should work. When an enclosure already exist, I simply do nothing and leave the entry as is in the database. If anyone is listening/watching to this enclosure during the refresh, the id stay coherent and progression saving still works. The updateEnclosures function got a bit more complex. I tried to make it the more clear I could. Some optimisation are possible but would make the function harder to read in my opinion. I'm not sure if this is often the case, but some feeds may include tracking or simply change the url each time we update the feed. In those situation, enclosures ids and progression will be lost. I have no idea how to handle this last situation. Use the size instead/alongside url to define the identity of an enclosure ? Translation: english as placeholder for every language except French Aside, I tested a video feed and fixed a few things for it. In fact, the MimeType was not working at all on my side, and found a pretty old stackoverflow discussion that suggest to use an Apple non-standard MimeType for m4v video format. I only did one substitution because I only have one feed to test. Any new video feed can make this go away or evolve depending on the situation. Real video feeds does not tend to be easy to find and test extensively this. Co-authored-by: toastal
2023-04-13 11:46:43 +02:00
/**
* Check if the player is actually playing a media
*
* @param mediaElement the player element itself
* @returns {boolean}
*/
function isPlayerPlaying(mediaElement) {
return mediaElement &&
mediaElement.currentTime > 0 &&
!mediaElement.paused &&
!mediaElement.ended &&
mediaElement.readyState > 2; // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState
}
/**
* Handle player progression save and mark as read on completion.
*
* This function is triggered on the `timeupdate` event of the media player.
* It saves the current playback position and marks the entry as read if the completion percentage is reached.
*
* @param {Element} playerElement The media player element (audio or video).
Add Media Player and resume to last playback position In order to ease podcast listening, the player can be put on top of the feed entry as main content. Use the `Use podcast player` option to enable that. It works on audio and video. Also, when playing audio or video, progression will be saved in order to be able to resume listening later. This position saving is done using the original attachement/enclosures player AND podcast player and do not rely on the podcast player option ti be enabled. Additionally, I made the player fill the width with the entry container to ease seeking and have a bigger video. updateEnclosures now keep existing enclosures based on URL When feeds get updated, enclosures entries are always wiped and re-created. This cause two issue - enclosure progression get lost in the process - enclosure ID changes I used the URL as identifier of an enclosure. Not perfect but hopefully should work. When an enclosure already exist, I simply do nothing and leave the entry as is in the database. If anyone is listening/watching to this enclosure during the refresh, the id stay coherent and progression saving still works. The updateEnclosures function got a bit more complex. I tried to make it the more clear I could. Some optimisation are possible but would make the function harder to read in my opinion. I'm not sure if this is often the case, but some feeds may include tracking or simply change the url each time we update the feed. In those situation, enclosures ids and progression will be lost. I have no idea how to handle this last situation. Use the size instead/alongside url to define the identity of an enclosure ? Translation: english as placeholder for every language except French Aside, I tested a video feed and fixed a few things for it. In fact, the MimeType was not working at all on my side, and found a pretty old stackoverflow discussion that suggest to use an Apple non-standard MimeType for m4v video format. I only did one substitution because I only have one feed to test. Any new video feed can make this go away or evolve depending on the situation. Real video feeds does not tend to be easy to find and test extensively this. Co-authored-by: toastal
2023-04-13 11:46:43 +02:00
*/
function handlePlayerProgressionSaveAndMarkAsReadOnCompletion(playerElement) {
if (!isPlayerPlaying(playerElement)) {
return;
}
const currentPositionInSeconds = Math.floor(playerElement.currentTime);
Add Media Player and resume to last playback position In order to ease podcast listening, the player can be put on top of the feed entry as main content. Use the `Use podcast player` option to enable that. It works on audio and video. Also, when playing audio or video, progression will be saved in order to be able to resume listening later. This position saving is done using the original attachement/enclosures player AND podcast player and do not rely on the podcast player option ti be enabled. Additionally, I made the player fill the width with the entry container to ease seeking and have a bigger video. updateEnclosures now keep existing enclosures based on URL When feeds get updated, enclosures entries are always wiped and re-created. This cause two issue - enclosure progression get lost in the process - enclosure ID changes I used the URL as identifier of an enclosure. Not perfect but hopefully should work. When an enclosure already exist, I simply do nothing and leave the entry as is in the database. If anyone is listening/watching to this enclosure during the refresh, the id stay coherent and progression saving still works. The updateEnclosures function got a bit more complex. I tried to make it the more clear I could. Some optimisation are possible but would make the function harder to read in my opinion. I'm not sure if this is often the case, but some feeds may include tracking or simply change the url each time we update the feed. In those situation, enclosures ids and progression will be lost. I have no idea how to handle this last situation. Use the size instead/alongside url to define the identity of an enclosure ? Translation: english as placeholder for every language except French Aside, I tested a video feed and fixed a few things for it. In fact, the MimeType was not working at all on my side, and found a pretty old stackoverflow discussion that suggest to use an Apple non-standard MimeType for m4v video format. I only did one substitution because I only have one feed to test. Any new video feed can make this go away or evolve depending on the situation. Real video feeds does not tend to be easy to find and test extensively this. Co-authored-by: toastal
2023-04-13 11:46:43 +02:00
const lastKnownPositionInSeconds = parseInt(playerElement.dataset.lastPosition, 10);
const markAsReadOnCompletion = parseFloat(playerElement.dataset.markReadOnCompletion);
Add Media Player and resume to last playback position In order to ease podcast listening, the player can be put on top of the feed entry as main content. Use the `Use podcast player` option to enable that. It works on audio and video. Also, when playing audio or video, progression will be saved in order to be able to resume listening later. This position saving is done using the original attachement/enclosures player AND podcast player and do not rely on the podcast player option ti be enabled. Additionally, I made the player fill the width with the entry container to ease seeking and have a bigger video. updateEnclosures now keep existing enclosures based on URL When feeds get updated, enclosures entries are always wiped and re-created. This cause two issue - enclosure progression get lost in the process - enclosure ID changes I used the URL as identifier of an enclosure. Not perfect but hopefully should work. When an enclosure already exist, I simply do nothing and leave the entry as is in the database. If anyone is listening/watching to this enclosure during the refresh, the id stay coherent and progression saving still works. The updateEnclosures function got a bit more complex. I tried to make it the more clear I could. Some optimisation are possible but would make the function harder to read in my opinion. I'm not sure if this is often the case, but some feeds may include tracking or simply change the url each time we update the feed. In those situation, enclosures ids and progression will be lost. I have no idea how to handle this last situation. Use the size instead/alongside url to define the identity of an enclosure ? Translation: english as placeholder for every language except French Aside, I tested a video feed and fixed a few things for it. In fact, the MimeType was not working at all on my side, and found a pretty old stackoverflow discussion that suggest to use an Apple non-standard MimeType for m4v video format. I only did one substitution because I only have one feed to test. Any new video feed can make this go away or evolve depending on the situation. Real video feeds does not tend to be easy to find and test extensively this. Co-authored-by: toastal
2023-04-13 11:46:43 +02:00
const recordInterval = 10;
// We limit the number of update to only one by interval. Otherwise, we would have multiple update per seconds
Add Media Player and resume to last playback position In order to ease podcast listening, the player can be put on top of the feed entry as main content. Use the `Use podcast player` option to enable that. It works on audio and video. Also, when playing audio or video, progression will be saved in order to be able to resume listening later. This position saving is done using the original attachement/enclosures player AND podcast player and do not rely on the podcast player option ti be enabled. Additionally, I made the player fill the width with the entry container to ease seeking and have a bigger video. updateEnclosures now keep existing enclosures based on URL When feeds get updated, enclosures entries are always wiped and re-created. This cause two issue - enclosure progression get lost in the process - enclosure ID changes I used the URL as identifier of an enclosure. Not perfect but hopefully should work. When an enclosure already exist, I simply do nothing and leave the entry as is in the database. If anyone is listening/watching to this enclosure during the refresh, the id stay coherent and progression saving still works. The updateEnclosures function got a bit more complex. I tried to make it the more clear I could. Some optimisation are possible but would make the function harder to read in my opinion. I'm not sure if this is often the case, but some feeds may include tracking or simply change the url each time we update the feed. In those situation, enclosures ids and progression will be lost. I have no idea how to handle this last situation. Use the size instead/alongside url to define the identity of an enclosure ? Translation: english as placeholder for every language except French Aside, I tested a video feed and fixed a few things for it. In fact, the MimeType was not working at all on my side, and found a pretty old stackoverflow discussion that suggest to use an Apple non-standard MimeType for m4v video format. I only did one substitution because I only have one feed to test. Any new video feed can make this go away or evolve depending on the situation. Real video feeds does not tend to be easy to find and test extensively this. Co-authored-by: toastal
2023-04-13 11:46:43 +02:00
if (currentPositionInSeconds >= (lastKnownPositionInSeconds + recordInterval) ||
currentPositionInSeconds <= (lastKnownPositionInSeconds - recordInterval)
) {
playerElement.dataset.lastPosition = currentPositionInSeconds.toString();
2025-08-02 15:07:56 -07:00
sendPOSTRequest(playerElement.dataset.saveUrl, { progression: currentPositionInSeconds });
// Handle the mark as read on completion
if (markAsReadOnCompletion >= 0 && playerElement.duration > 0) {
const completion = currentPositionInSeconds / playerElement.duration;
if (completion >= markAsReadOnCompletion) {
handleEntryStatus("none", document.querySelector(":is(a, button)[data-toggle-status]"), true);
}
}
Add Media Player and resume to last playback position In order to ease podcast listening, the player can be put on top of the feed entry as main content. Use the `Use podcast player` option to enable that. It works on audio and video. Also, when playing audio or video, progression will be saved in order to be able to resume listening later. This position saving is done using the original attachement/enclosures player AND podcast player and do not rely on the podcast player option ti be enabled. Additionally, I made the player fill the width with the entry container to ease seeking and have a bigger video. updateEnclosures now keep existing enclosures based on URL When feeds get updated, enclosures entries are always wiped and re-created. This cause two issue - enclosure progression get lost in the process - enclosure ID changes I used the URL as identifier of an enclosure. Not perfect but hopefully should work. When an enclosure already exist, I simply do nothing and leave the entry as is in the database. If anyone is listening/watching to this enclosure during the refresh, the id stay coherent and progression saving still works. The updateEnclosures function got a bit more complex. I tried to make it the more clear I could. Some optimisation are possible but would make the function harder to read in my opinion. I'm not sure if this is often the case, but some feeds may include tracking or simply change the url each time we update the feed. In those situation, enclosures ids and progression will be lost. I have no idea how to handle this last situation. Use the size instead/alongside url to define the identity of an enclosure ? Translation: english as placeholder for every language except French Aside, I tested a video feed and fixed a few things for it. In fact, the MimeType was not working at all on my side, and found a pretty old stackoverflow discussion that suggest to use an Apple non-standard MimeType for m4v video format. I only did one substitution because I only have one feed to test. Any new video feed can make this go away or evolve depending on the situation. Real video feeds does not tend to be easy to find and test extensively this. Co-authored-by: toastal
2023-04-13 11:46:43 +02:00
}
}
2023-08-01 06:11:39 +02:00
/**
* Handle media control actions like seeking and changing playback speed.
*
* This function is triggered by clicking on media control buttons.
* It adjusts the playback position or speed of media elements with the same enclosure ID.
*
* @param {Element} mediaPlayerButtonElement
*/
function handleMediaControlButtonClick(mediaPlayerButtonElement) {
const actionType = mediaPlayerButtonElement.dataset.enclosureAction;
const actionValue = parseFloat(mediaPlayerButtonElement.dataset.actionValue);
const enclosureID = mediaPlayerButtonElement.dataset.enclosureId;
const mediaElements = document.querySelectorAll(`audio[data-enclosure-id="${enclosureID}"],video[data-enclosure-id="${enclosureID}"]`);
const speedIndicatorElements = document.querySelectorAll(`span.speed-indicator[data-enclosure-id="${enclosureID}"]`);
mediaElements.forEach((mediaElement) => {
switch (actionType) {
case "seek":
mediaElement.currentTime = Math.max(mediaElement.currentTime + actionValue, 0);
break;
case "speed":
// 0.25 was chosen because it will allow to get back to 1x in two "faster" clicks.
// A lower value would result in a playback rate of 0, effectively pausing playback.
mediaElement.playbackRate = Math.max(0.25, mediaElement.playbackRate + actionValue);
speedIndicatorElements.forEach((speedIndicatorElement) => {
speedIndicatorElement.innerText = `${mediaElement.playbackRate.toFixed(2)}x`;
});
break;
case "speed-reset":
mediaElement.playbackRate = actionValue ;
speedIndicatorElements.forEach((speedIndicatorElement) => {
// Two digit precision to ensure we always have the same number of characters (4) to avoid controls moving when clicking buttons because of more or less characters.
// The trick only works on rates less than 10, but it feels an acceptable trade-off considering the feature
speedIndicatorElement.innerText = `${mediaElement.playbackRate.toFixed(2)}x`;
});
break;
}
});
}
/**
* Initialize media player event handlers.
*/
function initializeMediaPlayerHandlers() {
document.querySelectorAll("button[data-enclosure-action]").forEach((element) => {
element.addEventListener("click", () => handleMediaControlButtonClick(element));
});
// Set playback from the last position if available
document.querySelectorAll("audio[data-last-position],video[data-last-position]").forEach((element) => {
if (element.dataset.lastPosition) {
element.currentTime = element.dataset.lastPosition;
}
element.ontimeupdate = () => handlePlayerProgressionSaveAndMarkAsReadOnCompletion(element);
});
// Set playback speed from the data attribute if available
document.querySelectorAll("audio[data-playback-rate],video[data-playback-rate]").forEach((element) => {
if (element.dataset.playbackRate) {
element.playbackRate = element.dataset.playbackRate;
if (element.dataset.enclosureId) {
document.querySelectorAll(`span.speed-indicator[data-enclosure-id="${element.dataset.enclosureId}"]`).forEach((speedIndicatorElement) => {
speedIndicatorElement.innerText = `${parseFloat(element.dataset.playbackRate).toFixed(2)}x`;
});
}
}
});
2025-08-02 13:33:47 -07:00
}
/**
* Initialize the service worker and PWA installation prompt.
*/
function initializeServiceWorker() {
// Register service worker if supported
if ("serviceWorker" in navigator) {
const serviceWorkerURL = document.body.dataset.serviceWorkerUrl;
if (serviceWorkerURL) {
navigator.serviceWorker.register(ttpolicy.createScriptURL(serviceWorkerURL), {
type: "module"
}).catch((error) => {
console.error("Service Worker registration failed:", error);
});
}
}
// PWA installation prompt handling
window.addEventListener("beforeinstallprompt", (event) => {
let deferredPrompt = event;
const promptHomeScreen = document.getElementById("prompt-home-screen");
const btnAddToHomeScreen = document.getElementById("btn-add-to-home-screen");
if (!promptHomeScreen || !btnAddToHomeScreen) return;
promptHomeScreen.style.display = "block";
btnAddToHomeScreen.addEventListener("click", (event) => {
event.preventDefault();
deferredPrompt.prompt();
deferredPrompt.userChoice.then(() => {
deferredPrompt = null;
promptHomeScreen.style.display = "none";
});
});
});
}
/**
* Initialize WebAuthn handlers if supported.
*/
function initializeWebAuthn() {
if (!WebAuthnHandler.isWebAuthnSupported()) return;
const webauthnHandler = new WebAuthnHandler();
// Setup delete credentials handler
onClick("#webauthn-delete", () => { webauthnHandler.removeAllCredentials(); });
// Setup registration
const registerButton = document.getElementById("webauthn-register");
if (registerButton) {
registerButton.disabled = false;
onClick("#webauthn-register", () => {
webauthnHandler.register().catch((err) => WebAuthnHandler.showErrorMessage(err));
});
}
// Setup login
const loginButton = document.getElementById("webauthn-login");
const usernameField = document.getElementById("form-username");
if (loginButton && usernameField) {
const abortController = new AbortController();
loginButton.disabled = false;
onClick("#webauthn-login", () => {
abortController.abort();
webauthnHandler.login(usernameField.value).catch(err => WebAuthnHandler.showErrorMessage(err));
});
webauthnHandler.conditionalLogin(abortController).catch(err => WebAuthnHandler.showErrorMessage(err));
}
}
/**
* Initialize keyboard shortcuts for navigation and actions.
*/
function initializeKeyboardShortcuts() {
if (document.querySelector("body[data-disable-keyboard-shortcuts=true]")) return;
const keyboardHandler = new KeyboardHandler();
// Navigation shortcuts
keyboardHandler.on("g u", () => goToPage("unread"));
keyboardHandler.on("g b", () => goToPage("starred"));
keyboardHandler.on("g h", () => goToPage("history"));
keyboardHandler.on("g f", goToFeedOrFeedsPage);
keyboardHandler.on("g c", () => goToPage("categories"));
keyboardHandler.on("g s", () => goToPage("settings"));
keyboardHandler.on("g g", () => goToPreviousPage(TOP));
keyboardHandler.on("G", () => goToNextPage(BOTTOM));
keyboardHandler.on("/", () => goToPage("search"));
// Item navigation
keyboardHandler.on("ArrowLeft", goToPreviousPage);
keyboardHandler.on("ArrowRight", goToNextPage);
keyboardHandler.on("k", goToPreviousPage);
keyboardHandler.on("p", goToPreviousPage);
keyboardHandler.on("j", goToNextPage);
keyboardHandler.on("n", goToNextPage);
keyboardHandler.on("h", () => goToPage("previous"));
keyboardHandler.on("l", () => goToPage("next"));
keyboardHandler.on("z t", scrollToCurrentItem);
// Item actions
keyboardHandler.on("o", openSelectedItem);
keyboardHandler.on("Enter", () => openSelectedItem());
keyboardHandler.on("v", () => openOriginalLink(false));
keyboardHandler.on("V", () => openOriginalLink(true));
keyboardHandler.on("c", () => openCommentLink(false));
keyboardHandler.on("C", () => openCommentLink(true));
// Entry management
keyboardHandler.on("m", () => handleEntryStatus("next"));
keyboardHandler.on("M", () => handleEntryStatus("previous"));
keyboardHandler.on("A", markPageAsRead);
keyboardHandler.on("s", () => handleSaveEntry());
keyboardHandler.on("d", handleFetchOriginalContent);
keyboardHandler.on("f", () => handleBookmark());
// Feed actions
keyboardHandler.on("F", goToFeedPage);
keyboardHandler.on("R", handleRefreshAllFeeds);
keyboardHandler.on("+", goToAddSubscriptionPage);
keyboardHandler.on("#", unsubscribeFromFeed);
// UI actions
keyboardHandler.on("?", showKeyboardShortcuts);
keyboardHandler.on("Escape", () => ModalHandler.close());
keyboardHandler.on("a", () => {
const enclosureElement = document.querySelector('.entry-enclosures');
if (enclosureElement) {
enclosureElement.toggleAttribute('open');
}
});
keyboardHandler.listen();
}
/**
* Initialize touch handler for mobile devices.
*/
function initializeTouchHandler() {
const touchHandler = new TouchHandler();
touchHandler.listen();
}
/**
* Initialize click handlers for various UI elements.
*/
function initializeClickHandlers() {
// Entry actions
onClick(":is(a, button)[data-save-entry]", (event) => handleSaveEntry(event.target));
onClick(":is(a, button)[data-toggle-bookmark]", (event) => handleBookmark(event.target));
onClick(":is(a, button)[data-toggle-status]", (event) => handleEntryStatus("next", event.target));
onClick(":is(a, button)[data-fetch-content-entry]", handleFetchOriginalContent);
onClick(":is(a, button)[data-share-status]", handleShare);
// Page actions with confirmation
onClick(":is(a, button)[data-action=markPageAsRead]", (event) =>
handleConfirmationMessage(event.target, markPageAsRead));
// Generic confirmation handler
onClick(":is(a, button)[data-confirm]", (event) => {
handleConfirmationMessage(event.target, (url, redirectURL) => {
2025-08-02 15:07:56 -07:00
sendPOSTRequest(url).then((response) => {
2025-08-02 13:33:47 -07:00
if (redirectURL) {
window.location.href = redirectURL;
} else if (response?.redirected && response.url) {
window.location.href = response.url;
} else {
window.location.reload();
}
});
});
});
// Original link handlers (both click and middle-click)
const handleOriginalLink = (event) => handleEntryStatus("next", event.target, true);
onClick("a[data-original-link='true']", handleOriginalLink, true);
onAuxClick("a[data-original-link='true']", (event) => {
if (event.button === 1) {
handleOriginalLink(event);
}
}, true);
}
// Initialize application handlers
initializeMainMenuHandlers();
initializeFormHandlers();
initializeMediaPlayerHandlers();
initializeWebAuthn();
initializeKeyboardShortcuts();
initializeTouchHandler();
initializeClickHandlers();
initializeServiceWorker();