app.js 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235
  1. // Sentinel values for specific list navigation
  2. const TOP = 9999;
  3. const BOTTOM = -9999;
  4. /**
  5. * Get the CSRF token from the HTML document.
  6. *
  7. * @returns {string} The CSRF token.
  8. */
  9. function getCsrfToken() {
  10. return document.body.dataset.csrfToken || "";
  11. }
  12. /**
  13. * Open a new tab with the given URL.
  14. *
  15. * @param {string} url
  16. */
  17. function openNewTab(url) {
  18. const win = window.open("");
  19. win.opener = null;
  20. win.location = url;
  21. win.focus();
  22. }
  23. /**
  24. * Scroll the page to the given element.
  25. *
  26. * @param {Element} element
  27. * @param {boolean} evenIfOnScreen
  28. */
  29. function scrollPageTo(element, evenIfOnScreen) {
  30. const windowScrollPosition = window.scrollY;
  31. const windowHeight = document.documentElement.clientHeight;
  32. const viewportPosition = windowScrollPosition + windowHeight;
  33. const itemBottomPosition = element.offsetTop + element.offsetHeight;
  34. if (evenIfOnScreen || viewportPosition - itemBottomPosition < 0 || viewportPosition - element.offsetTop > windowHeight) {
  35. window.scrollTo(0, element.offsetTop - 10);
  36. }
  37. }
  38. /**
  39. * Attach a click event listener to elements matching the selector.
  40. *
  41. * @param {string} selector
  42. * @param {function} callback
  43. * @param {boolean} noPreventDefault
  44. */
  45. function onClick(selector, callback, noPreventDefault) {
  46. document.querySelectorAll(selector).forEach((element) => {
  47. element.onclick = (event) => {
  48. if (!noPreventDefault) {
  49. event.preventDefault();
  50. }
  51. callback(event);
  52. };
  53. });
  54. }
  55. /**
  56. * Attach an auxiliary click event listener to elements matching the selector.
  57. *
  58. * @param {string} selector
  59. * @param {function} callback
  60. * @param {boolean} noPreventDefault
  61. */
  62. function onAuxClick(selector, callback, noPreventDefault) {
  63. document.querySelectorAll(selector).forEach((element) => {
  64. element.onauxclick = (event) => {
  65. if (!noPreventDefault) {
  66. event.preventDefault();
  67. }
  68. callback(event);
  69. };
  70. });
  71. }
  72. /**
  73. * Filter visible elements based on the selector.
  74. *
  75. * @param {string} selector
  76. * @returns {Array<Element>}
  77. */
  78. function getVisibleElements(selector) {
  79. const elements = document.querySelectorAll(selector);
  80. return [...elements].filter((element) => element.offsetParent !== null);
  81. }
  82. /**
  83. * Get all visible entries on the current page.
  84. *
  85. * @return {Array<Element>}
  86. */
  87. function getVisibleEntries() {
  88. return getVisibleElements(".items .item");
  89. }
  90. /**
  91. * Check if the current view is a list view.
  92. *
  93. * @returns {boolean}
  94. */
  95. function isListView() {
  96. return document.querySelector(".items") !== null;
  97. }
  98. /**
  99. * Check if the current view is an entry view.
  100. *
  101. * @return {boolean}
  102. */
  103. function isEntryView() {
  104. return document.querySelector("section.entry") !== null;
  105. }
  106. /**
  107. * Find the entry element for the given element.
  108. *
  109. * @returns {Element|null}
  110. */
  111. function findEntry(element) {
  112. if (isListView()) {
  113. if (element) {
  114. return element.closest(".item");
  115. }
  116. return document.querySelector(".current-item");
  117. }
  118. return document.querySelector(".entry");
  119. }
  120. /**
  121. * Insert an icon label element into the parent element.
  122. *
  123. * @param {Element} parentElement The parent element to insert the icon label into.
  124. * @param {string} iconLabelText The text to display in the icon label.
  125. * @returns {void}
  126. */
  127. function insertIconLabelElement(parentElement, iconLabelText) {
  128. const span = document.createElement('span');
  129. span.classList.add('icon-label');
  130. span.textContent = iconLabelText;
  131. parentElement.appendChild(span);
  132. }
  133. /**
  134. * Navigate to a specific page.
  135. *
  136. * @param {string} page - The page to navigate to.
  137. * @param {boolean} reloadOnFail - If true, reload the current page if the target page is not found.
  138. */
  139. function goToPage(page, reloadOnFail = false) {
  140. const element = document.querySelector(":is(a, button)[data-page=" + page + "]");
  141. if (element) {
  142. document.location.href = element.href;
  143. } else if (reloadOnFail) {
  144. window.location.reload();
  145. }
  146. }
  147. /**
  148. * Navigate to the previous page.
  149. *
  150. * If the offset is a KeyboardEvent, it will navigate to the previous item in the list.
  151. * If the offset is a number, it will jump that many items in the list.
  152. * If the offset is TOP, it will jump to the first item in the list.
  153. * If the offset is BOTTOM, it will jump to the last item in the list.
  154. * If the current view is an entry view, it will redirect to the previous page.
  155. *
  156. * @param {number|KeyboardEvent} offset - How many items to jump for focus.
  157. */
  158. function goToPreviousPage(offset) {
  159. if (offset instanceof KeyboardEvent) offset = -1;
  160. if (isListView()) {
  161. goToListItem(offset);
  162. } else {
  163. goToPage("previous");
  164. }
  165. }
  166. /**
  167. * Navigate to the next page.
  168. *
  169. * If the offset is a KeyboardEvent, it will navigate to the next item in the list.
  170. * If the offset is a number, it will jump that many items in the list.
  171. * If the offset is TOP, it will jump to the first item in the list.
  172. * If the offset is BOTTOM, it will jump to the last item in the list.
  173. * If the current view is an entry view, it will redirect to the next page.
  174. *
  175. * @param {number|KeyboardEvent} offset - How many items to jump for focus.
  176. */
  177. function goToNextPage(offset) {
  178. if (offset instanceof KeyboardEvent) offset = 1;
  179. if (isListView()) {
  180. goToListItem(offset);
  181. } else {
  182. goToPage("next");
  183. }
  184. }
  185. /**
  186. * Navigate to the individual feed or feeds page.
  187. *
  188. * If the current view is an entry view, it will redirect to the feed link of the entry.
  189. * If the current view is a list view, it will redirect to the feeds page.
  190. */
  191. function goToFeedOrFeedsPage() {
  192. if (isEntryView()) {
  193. goToFeedPage();
  194. } else {
  195. goToPage("feeds");
  196. }
  197. }
  198. /**
  199. * Navigate to the feed page of the current entry.
  200. *
  201. * If the current view is an entry view, it will redirect to the feed link of the entry.
  202. * If the current view is a list view, it will redirect to the feed link of the currently selected item.
  203. * If no feed link is available, it will do nothing.
  204. */
  205. function goToFeedPage() {
  206. if (isEntryView()) {
  207. const feedAnchor = document.querySelector("span.entry-website a");
  208. if (feedAnchor !== null) {
  209. window.location.href = feedAnchor.href;
  210. }
  211. } else {
  212. const currentItemFeed = document.querySelector(".current-item :is(a, button)[data-feed-link]");
  213. if (currentItemFeed !== null) {
  214. window.location.href = currentItemFeed.getAttribute("href");
  215. }
  216. }
  217. }
  218. /**
  219. * Navigate to the add subscription page.
  220. *
  221. * @returns {void}
  222. */
  223. function goToAddSubscriptionPage() {
  224. window.location.href = document.body.dataset.addSubscriptionUrl;
  225. }
  226. /**
  227. * Navigate to the next or previous item in the list.
  228. *
  229. * If the offset is TOP, it will jump to the first item in the list.
  230. * If the offset is BOTTOM, it will jump to the last item in the list.
  231. * If the offset is a number, it will jump that many items in the list.
  232. * If the current view is an entry view, it will redirect to the next or previous page.
  233. *
  234. * @param {number} offset - How many items to jump for focus.
  235. * @return {void}
  236. */
  237. function goToListItem(offset) {
  238. const items = getVisibleEntries();
  239. if (items.length === 0) {
  240. return;
  241. }
  242. if (document.querySelector(".current-item") === null) {
  243. items[0].classList.add("current-item");
  244. items[0].focus();
  245. return;
  246. }
  247. for (let i = 0; i < items.length; i++) {
  248. if (items[i].classList.contains("current-item")) {
  249. items[i].classList.remove("current-item");
  250. // By default adjust selection to the next item
  251. let itemOffset = (i + offset + items.length) % items.length;
  252. // Allow jumping to top or bottom
  253. if (offset === TOP) {
  254. itemOffset = 0;
  255. } else if (offset === BOTTOM) {
  256. itemOffset = items.length - 1;
  257. }
  258. const item = items[itemOffset];
  259. item.classList.add("current-item");
  260. scrollPageTo(item);
  261. item.focus();
  262. break;
  263. }
  264. }
  265. }
  266. /**
  267. * Handle the share action for the entry.
  268. *
  269. * If the share status is "shared", it will trigger the Web Share API.
  270. * If the share status is "share", it will send an Ajax request to fetch the share URL and then trigger the Web Share API.
  271. * If the Web Share API is not supported, it will redirect to the entry URL.
  272. */
  273. async function handleShare() {
  274. const link = document.querySelector(':is(a, button)[data-share-status]');
  275. const title = document.querySelector(".entry-header > h1 > a");
  276. if (link.dataset.shareStatus === "shared") {
  277. await triggerWebShare(title, link.href);
  278. }
  279. else if (link.dataset.shareStatus === "share") {
  280. const request = new RequestBuilder(link.href);
  281. request.withCallback((r) => {
  282. // Ensure title is not null before passing to triggerWebShare
  283. triggerWebShare(title, r.url);
  284. });
  285. request.withHttpMethod("GET");
  286. request.execute();
  287. }
  288. }
  289. /**
  290. * Trigger the Web Share API to share the entry.
  291. *
  292. * If the Web Share API is not supported, it will redirect to the entry URL.
  293. *
  294. * @param {Element} title - The title element of the entry.
  295. * @param {string} url - The URL of the entry to share.
  296. */
  297. async function triggerWebShare(title, url) {
  298. if (!navigator.canShare) {
  299. console.error("Your browser doesn't support the Web Share API.");
  300. window.location = url;
  301. return;
  302. }
  303. try {
  304. await navigator.share({
  305. title: title ? title.textContent : url,
  306. url: url
  307. });
  308. } catch (err) {
  309. console.error(err);
  310. }
  311. window.location.reload();
  312. }
  313. /**
  314. * Toggle the ARIA attributes on the main menu based on the viewport width.
  315. */
  316. function toggleAriaAttributesOnMainMenu() {
  317. const logoElement = document.querySelector(".logo");
  318. const homePageLinkElement = document.querySelector(".logo > a");
  319. if (!logoElement || !homePageLinkElement) return;
  320. const isMobile = document.documentElement.clientWidth < 650;
  321. if (isMobile) {
  322. const navMenuElement = document.getElementById("header-menu");
  323. const isExpanded = navMenuElement?.classList.contains("js-menu-show") ?? false;
  324. const toggleButtonLabel = logoElement.getAttribute("data-toggle-button-label");
  325. // Set mobile menu button attributes
  326. Object.assign(logoElement, {
  327. role: "button",
  328. tabIndex: 0,
  329. ariaLabel: toggleButtonLabel,
  330. ariaExpanded: isExpanded.toString()
  331. });
  332. homePageLinkElement.tabIndex = -1;
  333. } else {
  334. // Remove mobile menu button attributes
  335. ["role", "tabindex", "aria-expanded", "aria-label"].forEach(attr =>
  336. logoElement.removeAttribute(attr)
  337. );
  338. homePageLinkElement.removeAttribute("tabindex");
  339. }
  340. }
  341. /**
  342. * Toggle the main menu dropdown.
  343. *
  344. * @param {Event} event - The event object.
  345. */
  346. function toggleMainMenuDropdown(event) {
  347. // Only handle Enter, Space, or click events
  348. if (event.type === "keydown" && !["Enter", " "].includes(event.key)) {
  349. return;
  350. }
  351. // Prevent default only if element has role attribute (mobile menu button)
  352. if (event.currentTarget.getAttribute("role")) {
  353. event.preventDefault();
  354. }
  355. const navigationMenu = document.querySelector(".header nav ul");
  356. const menuToggleButton = document.querySelector(".logo");
  357. if (!navigationMenu || !menuToggleButton) {
  358. return;
  359. }
  360. const isShowing = navigationMenu.classList.toggle("js-menu-show");
  361. menuToggleButton.setAttribute("aria-expanded", isShowing.toString());
  362. }
  363. /**
  364. * Initialize the main menu handlers.
  365. */
  366. function initializeMainMenuHandlers() {
  367. toggleAriaAttributesOnMainMenu();
  368. window.addEventListener("resize", toggleAriaAttributesOnMainMenu, { passive: true });
  369. const logoElement = document.querySelector(".logo");
  370. if (logoElement) {
  371. logoElement.addEventListener("click", toggleMainMenuDropdown);
  372. logoElement.addEventListener("keydown", toggleMainMenuDropdown);
  373. }
  374. onClick(".header nav li", (event) => {
  375. const linkElement = event.target.closest("a") || event.target.querySelector("a");
  376. if (linkElement) {
  377. window.location.href = linkElement.getAttribute("href");
  378. }
  379. });
  380. }
  381. /**
  382. * This function changes the button label to the loading state and disables the button.
  383. *
  384. * @returns {void}
  385. */
  386. function initializeFormHandlers() {
  387. document.querySelectorAll("form").forEach((element) => {
  388. element.onsubmit = () => {
  389. const buttons = element.querySelectorAll("button[type=submit]");
  390. buttons.forEach((button) => {
  391. if (button.dataset.labelLoading) {
  392. button.textContent = button.dataset.labelLoading;
  393. }
  394. button.disabled = true;
  395. });
  396. };
  397. });
  398. }
  399. /**
  400. * Show the keyboard shortcuts modal.
  401. */
  402. function showKeyboardShortcuts() {
  403. const template = document.getElementById("keyboard-shortcuts");
  404. ModalHandler.open(template.content, "dialog-title");
  405. }
  406. /**
  407. * Mark all visible entries on the current page as read.
  408. */
  409. function markPageAsRead() {
  410. const items = getVisibleEntries();
  411. if (items.length === 0) return;
  412. const entryIDs = items.map((element) => {
  413. element.classList.add("item-status-read");
  414. return parseInt(element.dataset.id, 10);
  415. });
  416. updateEntriesStatus(entryIDs, "read", () => {
  417. const element = document.querySelector(":is(a, button)[data-action=markPageAsRead]");
  418. const showOnlyUnread = element?.dataset.showOnlyUnread || false;
  419. if (showOnlyUnread) {
  420. window.location.reload();
  421. } else {
  422. goToPage("next", true);
  423. }
  424. });
  425. }
  426. /**
  427. * Handle entry status changes from the list view and entry view.
  428. * Focus the next or the previous entry if it exists.
  429. *
  430. * @param {string} navigationDirection Navigation direction: "previous" or "next".
  431. * @param {Element} element Element that triggered the action.
  432. * @param {boolean} setToRead If true, set the entry to read instead of toggling the status.
  433. * @returns {void}
  434. */
  435. function handleEntryStatus(navigationDirection, element, setToRead) {
  436. const toasting = !element;
  437. const currentEntry = findEntry(element);
  438. if (currentEntry) {
  439. if (!setToRead || currentEntry.querySelector(":is(a, button)[data-toggle-status]").dataset.value === "unread") {
  440. toggleEntryStatus(currentEntry, toasting);
  441. }
  442. if (isListView() && currentEntry.classList.contains('current-item')) {
  443. switch (navigationDirection) {
  444. case "previous":
  445. goToListItem(-1);
  446. break;
  447. case "next":
  448. goToListItem(1);
  449. break;
  450. }
  451. }
  452. }
  453. }
  454. /**
  455. * Toggle the entry status between "read" and "unread".
  456. *
  457. * @param {Element} element The entry element to toggle the status for.
  458. * @param {boolean} toasting If true, show a toast notification after toggling the status.
  459. */
  460. function toggleEntryStatus(element, toasting) {
  461. const entryID = parseInt(element.dataset.id, 10);
  462. const link = element.querySelector(":is(a, button)[data-toggle-status]");
  463. if (!link) {
  464. return;
  465. }
  466. const currentStatus = link.dataset.value;
  467. const newStatus = currentStatus === "read" ? "unread" : "read";
  468. link.querySelector("span").textContent = link.dataset.labelLoading;
  469. updateEntriesStatus([entryID], newStatus, () => {
  470. let iconElement, label;
  471. if (currentStatus === "read") {
  472. iconElement = document.querySelector("template#icon-read");
  473. label = link.dataset.labelRead;
  474. if (toasting) {
  475. showToast(link.dataset.toastUnread, iconElement);
  476. }
  477. } else {
  478. iconElement = document.querySelector("template#icon-unread");
  479. label = link.dataset.labelUnread;
  480. if (toasting) {
  481. showToast(link.dataset.toastRead, iconElement);
  482. }
  483. }
  484. link.replaceChildren(iconElement.content.cloneNode(true));
  485. insertIconLabelElement(link, label);
  486. link.dataset.value = newStatus;
  487. if (element.classList.contains("item-status-" + currentStatus)) {
  488. element.classList.remove("item-status-" + currentStatus);
  489. element.classList.add("item-status-" + newStatus);
  490. }
  491. if (isListView() && getVisibleEntries().length === 0) {
  492. window.location.reload();
  493. }
  494. });
  495. }
  496. /**
  497. * Mark the entry as read if it is currently unread.
  498. *
  499. * @param {Element} element The entry element to mark as read.
  500. */
  501. function markEntryAsRead(element) {
  502. if (element.classList.contains("item-status-unread")) {
  503. element.classList.remove("item-status-unread");
  504. element.classList.add("item-status-read");
  505. const entryID = parseInt(element.dataset.id, 10);
  506. updateEntriesStatus([entryID], "read");
  507. }
  508. }
  509. /**
  510. * Handle the refresh of all feeds.
  511. *
  512. * This function redirects the user to the URL specified in the data-refresh-all-feeds-url attribute of the body element.
  513. */
  514. function handleRefreshAllFeeds() {
  515. const refreshAllFeedsUrl = document.body.dataset.refreshAllFeedsUrl;
  516. if (refreshAllFeedsUrl) {
  517. window.location.href = refreshAllFeedsUrl;
  518. }
  519. }
  520. /**
  521. * Update the status of multiple entries.
  522. *
  523. * @param {Array<number>} entryIDs - The IDs of the entries to update.
  524. * @param {string} status - The new status to set for the entries (e.g., "read", "unread").
  525. */
  526. function updateEntriesStatus(entryIDs, status, callback) {
  527. const url = document.body.dataset.entriesStatusUrl;
  528. const request = new RequestBuilder(url);
  529. request.withBody({ entry_ids: entryIDs, status: status });
  530. request.withCallback((resp) => {
  531. resp.json().then(count => {
  532. if (callback) {
  533. callback(resp);
  534. }
  535. if (status === "read") {
  536. updateUnreadCounterValue(-count);
  537. } else {
  538. updateUnreadCounterValue(count);
  539. }
  540. });
  541. });
  542. request.execute();
  543. }
  544. /**
  545. * Handle save entry from list view and entry view.
  546. *
  547. * @param {Element} element
  548. */
  549. function handleSaveEntry(element) {
  550. const toasting = !element;
  551. const currentEntry = findEntry(element);
  552. if (currentEntry) {
  553. saveEntry(currentEntry.querySelector(":is(a, button)[data-save-entry]"), toasting);
  554. }
  555. }
  556. /**
  557. * Save the entry by sending an Ajax request to the server.
  558. *
  559. * @param {Element} element The element that triggered the save action.
  560. * @param {boolean} toasting If true, show a toast notification after saving the entry.
  561. * @return {void}
  562. */
  563. function saveEntry(element, toasting) {
  564. if (!element || element.dataset.completed) {
  565. return;
  566. }
  567. element.textContent = "";
  568. insertIconLabelElement(element, element.dataset.labelLoading);
  569. const request = new RequestBuilder(element.dataset.saveUrl);
  570. request.withCallback(() => {
  571. element.textContent = "";
  572. insertIconLabelElement(element, element.dataset.labelDone);
  573. element.dataset.completed = "true";
  574. if (toasting) {
  575. const iconElement = document.querySelector("template#icon-save");
  576. showToast(element.dataset.toastDone, iconElement);
  577. }
  578. });
  579. request.execute();
  580. }
  581. /**
  582. * Handle bookmarking an entry.
  583. *
  584. * @param {Element} element - The element that triggered the bookmark action.
  585. */
  586. function handleBookmark(element) {
  587. const toasting = !element;
  588. const currentEntry = findEntry(element);
  589. if (currentEntry) {
  590. toggleBookmark(currentEntry, toasting);
  591. }
  592. }
  593. /**
  594. * Toggle the bookmark status of an entry.
  595. *
  596. * @param {Element} parentElement - The parent element containing the bookmark button.
  597. * @param {boolean} toasting - Whether to show a toast notification.
  598. * @returns {void}
  599. */
  600. function toggleBookmark(parentElement, toasting) {
  601. const buttonElement = parentElement.querySelector(":is(a, button)[data-toggle-bookmark]");
  602. if (!buttonElement) return;
  603. buttonElement.textContent = "";
  604. insertIconLabelElement(buttonElement, buttonElement.dataset.labelLoading);
  605. const request = new RequestBuilder(buttonElement.dataset.bookmarkUrl);
  606. request.withCallback(() => {
  607. const currentStarStatus = buttonElement.dataset.value;
  608. const newStarStatus = currentStarStatus === "star" ? "unstar" : "star";
  609. const isStarred = currentStarStatus === "star";
  610. const iconElement = document.querySelector(isStarred ? "template#icon-star" : "template#icon-unstar");
  611. const label = isStarred ? buttonElement.dataset.labelStar : buttonElement.dataset.labelUnstar;
  612. if (toasting) {
  613. const toastKey = isStarred ? "toastUnstar" : "toastStar";
  614. showToast(buttonElement.dataset[toastKey], iconElement);
  615. }
  616. buttonElement.replaceChildren(iconElement.content.cloneNode(true));
  617. insertIconLabelElement(buttonElement, label);
  618. buttonElement.dataset.value = newStarStatus;
  619. });
  620. request.execute();
  621. }
  622. /**
  623. * Handle fetching the original content of an entry.
  624. *
  625. * @returns {void}
  626. */
  627. function handleFetchOriginalContent() {
  628. if (isListView()) return;
  629. const buttonElement = document.querySelector(":is(a, button)[data-fetch-content-entry]");
  630. if (!buttonElement) return;
  631. const previousElement = buttonElement.cloneNode(true);
  632. buttonElement.textContent = "";
  633. insertIconLabelElement(buttonElement, buttonElement.dataset.labelLoading);
  634. const request = new RequestBuilder(buttonElement.dataset.fetchContentUrl);
  635. request.withCallback((response) => {
  636. buttonElement.textContent = '';
  637. buttonElement.appendChild(previousElement);
  638. response.json().then((data) => {
  639. if (data.content && data.reading_time) {
  640. document.querySelector(".entry-content").innerHTML = ttpolicy.createHTML(data.content);
  641. const entryReadingtimeElement = document.querySelector(".entry-reading-time");
  642. if (entryReadingtimeElement) {
  643. entryReadingtimeElement.textContent = data.reading_time;
  644. }
  645. }
  646. });
  647. });
  648. request.execute();
  649. }
  650. /**
  651. * Open the original link of an entry.
  652. *
  653. * @param {boolean} openLinkInCurrentTab - Whether to open the link in the current tab.
  654. * @returns {void}
  655. */
  656. function openOriginalLink(openLinkInCurrentTab) {
  657. const entryLink = document.querySelector(".entry h1 a");
  658. if (entryLink) {
  659. if (openLinkInCurrentTab) {
  660. window.location.href = entryLink.getAttribute("href");
  661. } else {
  662. openNewTab(entryLink.getAttribute("href"));
  663. }
  664. return;
  665. }
  666. const currentItemOriginalLink = document.querySelector(".current-item :is(a, button)[data-original-link]");
  667. if (currentItemOriginalLink) {
  668. openNewTab(currentItemOriginalLink.getAttribute("href"));
  669. const currentItem = document.querySelector(".current-item");
  670. // If we are not on the list of starred items, move to the next item
  671. if (document.location.href !== document.querySelector(':is(a, button)[data-page=starred]').href) {
  672. goToListItem(1);
  673. }
  674. markEntryAsRead(currentItem);
  675. }
  676. }
  677. /**
  678. * Open the comments link of an entry.
  679. *
  680. * @param {boolean} openLinkInCurrentTab - Whether to open the link in the current tab.
  681. * @returns {void}
  682. */
  683. function openCommentLink(openLinkInCurrentTab) {
  684. const entryLink = document.querySelector(isListView() ? ".current-item :is(a, button)[data-comments-link]" : ":is(a, button)[data-comments-link]");
  685. if (entryLink) {
  686. if (openLinkInCurrentTab) {
  687. window.location.href = entryLink.getAttribute("href");
  688. } else {
  689. openNewTab(entryLink.getAttribute("href"));
  690. }
  691. }
  692. }
  693. /**
  694. * Open the selected item in the current view.
  695. *
  696. * If the current view is a list view, it will navigate to the link of the currently selected item.
  697. * If the current view is an entry view, it will navigate to the link of the entry.
  698. */
  699. function openSelectedItem() {
  700. const currentItemLink = document.querySelector(".current-item .item-title a");
  701. if (currentItemLink) {
  702. window.location.href = currentItemLink.getAttribute("href");
  703. }
  704. }
  705. /**
  706. * Unsubscribe from the feed of the currently selected item.
  707. */
  708. function unsubscribeFromFeed() {
  709. const unsubscribeLink = document.querySelector("[data-action=remove-feed]");
  710. if (unsubscribeLink) {
  711. const request = new RequestBuilder(unsubscribeLink.dataset.url);
  712. request.withCallback(() => {
  713. window.location.href = unsubscribeLink.dataset.redirectUrl || window.location.href;
  714. });
  715. request.execute();
  716. }
  717. }
  718. /**
  719. * Scroll the page to the currently selected item.
  720. */
  721. function scrollToCurrentItem() {
  722. const currentItem = document.querySelector(".current-item");
  723. if (currentItem) {
  724. scrollPageTo(currentItem, true);
  725. }
  726. }
  727. /**
  728. * Update the unread counter value.
  729. *
  730. * @param {number} delta - The amount to change the counter by.
  731. */
  732. function updateUnreadCounterValue(delta) {
  733. document.querySelectorAll("span.unread-counter").forEach((element) => {
  734. const oldValue = parseInt(element.textContent, 10);
  735. element.textContent = oldValue + delta;
  736. });
  737. if (window.location.href.endsWith('/unread')) {
  738. const oldValue = parseInt(document.title.split('(')[1], 10);
  739. const newValue = oldValue + delta;
  740. document.title = document.title.replace(/(.*?)\(\d+\)(.*?)/, `$1(${newValue})$2`);
  741. }
  742. }
  743. /**
  744. * Handle confirmation messages for actions that require user confirmation.
  745. *
  746. * This function modifies the link element to show a confirmation question with "Yes" and "No" buttons.
  747. * If the user clicks "Yes", it calls the provided callback with the URL and redirect URL.
  748. * If the user clicks "No", it either redirects to a no-action URL or restores the link element.
  749. *
  750. * @param {Element} linkElement - The link or button element that triggered the confirmation.
  751. * @param {function} callback - The callback function to execute if the user confirms the action.
  752. * @returns {void}
  753. */
  754. function handleConfirmationMessage(linkElement, callback) {
  755. if (linkElement.tagName !== 'A' && linkElement.tagName !== "BUTTON") {
  756. linkElement = linkElement.parentNode;
  757. }
  758. linkElement.style.display = "none";
  759. const containerElement = linkElement.parentNode;
  760. const questionElement = document.createElement("span");
  761. function createLoadingElement() {
  762. const loadingElement = document.createElement("span");
  763. loadingElement.className = "loading";
  764. loadingElement.appendChild(document.createTextNode(linkElement.dataset.labelLoading));
  765. questionElement.remove();
  766. containerElement.appendChild(loadingElement);
  767. }
  768. const yesElement = document.createElement("button");
  769. yesElement.appendChild(document.createTextNode(linkElement.dataset.labelYes));
  770. yesElement.onclick = (event) => {
  771. event.preventDefault();
  772. createLoadingElement();
  773. callback(linkElement.dataset.url, linkElement.dataset.redirectUrl);
  774. };
  775. const noElement = document.createElement("button");
  776. noElement.appendChild(document.createTextNode(linkElement.dataset.labelNo));
  777. noElement.onclick = (event) => {
  778. event.preventDefault();
  779. const noActionUrl = linkElement.dataset.noActionUrl;
  780. if (noActionUrl) {
  781. createLoadingElement();
  782. callback(noActionUrl, linkElement.dataset.redirectUrl);
  783. } else {
  784. linkElement.style.display = "inline";
  785. questionElement.remove();
  786. }
  787. };
  788. questionElement.className = "confirm";
  789. questionElement.appendChild(document.createTextNode(linkElement.dataset.labelQuestion + " "));
  790. questionElement.appendChild(yesElement);
  791. questionElement.appendChild(document.createTextNode(", "));
  792. questionElement.appendChild(noElement);
  793. containerElement.appendChild(questionElement);
  794. }
  795. /**
  796. * Show a toast notification.
  797. *
  798. * @param {string} toastMessage - The label to display in the toast.
  799. * @param {Element} iconElement - The icon element to display in the toast.
  800. * @returns {void}
  801. */
  802. function showToast(toastMessage, iconElement) {
  803. if (!toastMessage || !iconElement) {
  804. return;
  805. }
  806. const toastMsgElement = document.getElementById("toast-msg");
  807. toastMsgElement.replaceChildren(iconElement.content.cloneNode(true));
  808. insertIconLabelElement(toastMsgElement, toastMessage);
  809. const toastElementWrapper = document.getElementById("toast-wrapper");
  810. toastElementWrapper.classList.remove('toast-animate');
  811. setTimeout(() => toastElementWrapper.classList.add('toast-animate'), 100);
  812. }
  813. /**
  814. * Check if the player is actually playing a media
  815. *
  816. * @param mediaElement the player element itself
  817. * @returns {boolean}
  818. */
  819. function isPlayerPlaying(mediaElement) {
  820. return mediaElement &&
  821. mediaElement.currentTime > 0 &&
  822. !mediaElement.paused &&
  823. !mediaElement.ended &&
  824. mediaElement.readyState > 2; // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState
  825. }
  826. /**
  827. * Handle player progression save and mark as read on completion.
  828. *
  829. * This function is triggered on the `timeupdate` event of the media player.
  830. * It saves the current playback position and marks the entry as read if the completion percentage is reached.
  831. *
  832. * @param {Element} playerElement The media player element (audio or video).
  833. */
  834. function handlePlayerProgressionSaveAndMarkAsReadOnCompletion(playerElement) {
  835. if (!isPlayerPlaying(playerElement)) {
  836. return;
  837. }
  838. const currentPositionInSeconds = Math.floor(playerElement.currentTime);
  839. const lastKnownPositionInSeconds = parseInt(playerElement.dataset.lastPosition, 10);
  840. const markAsReadOnCompletion = parseFloat(playerElement.dataset.markReadOnCompletion);
  841. const recordInterval = 10;
  842. // We limit the number of update to only one by interval. Otherwise, we would have multiple update per seconds
  843. if (currentPositionInSeconds >= (lastKnownPositionInSeconds + recordInterval) ||
  844. currentPositionInSeconds <= (lastKnownPositionInSeconds - recordInterval)
  845. ) {
  846. playerElement.dataset.lastPosition = currentPositionInSeconds.toString();
  847. const request = new RequestBuilder(playerElement.dataset.saveUrl);
  848. request.withBody({ progression: currentPositionInSeconds });
  849. request.execute();
  850. // Handle the mark as read on completion
  851. if (markAsReadOnCompletion >= 0 && playerElement.duration > 0) {
  852. const completion = currentPositionInSeconds / playerElement.duration;
  853. if (completion >= markAsReadOnCompletion) {
  854. handleEntryStatus("none", document.querySelector(":is(a, button)[data-toggle-status]"), true);
  855. }
  856. }
  857. }
  858. }
  859. /**
  860. * Handle media control actions like seeking and changing playback speed.
  861. *
  862. * This function is triggered by clicking on media control buttons.
  863. * It adjusts the playback position or speed of media elements with the same enclosure ID.
  864. *
  865. * @param {Element} mediaPlayerButtonElement
  866. */
  867. function handleMediaControlButtonClick(mediaPlayerButtonElement) {
  868. const actionType = mediaPlayerButtonElement.dataset.enclosureAction;
  869. const actionValue = parseFloat(mediaPlayerButtonElement.dataset.actionValue);
  870. const enclosureID = mediaPlayerButtonElement.dataset.enclosureId;
  871. const mediaElements = document.querySelectorAll(`audio[data-enclosure-id="${enclosureID}"],video[data-enclosure-id="${enclosureID}"]`);
  872. const speedIndicatorElements = document.querySelectorAll(`span.speed-indicator[data-enclosure-id="${enclosureID}"]`);
  873. mediaElements.forEach((mediaElement) => {
  874. switch (actionType) {
  875. case "seek":
  876. mediaElement.currentTime = Math.max(mediaElement.currentTime + actionValue, 0);
  877. break;
  878. case "speed":
  879. // 0.25 was chosen because it will allow to get back to 1x in two "faster" clicks.
  880. // A lower value would result in a playback rate of 0, effectively pausing playback.
  881. mediaElement.playbackRate = Math.max(0.25, mediaElement.playbackRate + actionValue);
  882. speedIndicatorElements.forEach((speedIndicatorElement) => {
  883. speedIndicatorElement.innerText = `${mediaElement.playbackRate.toFixed(2)}x`;
  884. });
  885. break;
  886. case "speed-reset":
  887. mediaElement.playbackRate = actionValue ;
  888. speedIndicatorElements.forEach((speedIndicatorElement) => {
  889. // 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.
  890. // The trick only works on rates less than 10, but it feels an acceptable trade-off considering the feature
  891. speedIndicatorElement.innerText = `${mediaElement.playbackRate.toFixed(2)}x`;
  892. });
  893. break;
  894. }
  895. });
  896. }
  897. /**
  898. * Initialize media player event handlers.
  899. */
  900. function initializeMediaPlayerHandlers() {
  901. document.querySelectorAll("button[data-enclosure-action]").forEach((element) => {
  902. element.addEventListener("click", () => handleMediaControlButtonClick(element));
  903. });
  904. // Set playback from the last position if available
  905. document.querySelectorAll("audio[data-last-position],video[data-last-position]").forEach((element) => {
  906. if (element.dataset.lastPosition) {
  907. element.currentTime = element.dataset.lastPosition;
  908. }
  909. element.ontimeupdate = () => handlePlayerProgressionSaveAndMarkAsReadOnCompletion(element);
  910. });
  911. // Set playback speed from the data attribute if available
  912. document.querySelectorAll("audio[data-playback-rate],video[data-playback-rate]").forEach((element) => {
  913. if (element.dataset.playbackRate) {
  914. element.playbackRate = element.dataset.playbackRate;
  915. if (element.dataset.enclosureId) {
  916. document.querySelectorAll(`span.speed-indicator[data-enclosure-id="${element.dataset.enclosureId}"]`).forEach((speedIndicatorElement) => {
  917. speedIndicatorElement.innerText = `${parseFloat(element.dataset.playbackRate).toFixed(2)}x`;
  918. });
  919. }
  920. }
  921. });
  922. }
  923. /**
  924. * Initialize the service worker and PWA installation prompt.
  925. */
  926. function initializeServiceWorker() {
  927. // Register service worker if supported
  928. if ("serviceWorker" in navigator) {
  929. const serviceWorkerURL = document.body.dataset.serviceWorkerUrl;
  930. if (serviceWorkerURL) {
  931. navigator.serviceWorker.register(ttpolicy.createScriptURL(serviceWorkerURL), {
  932. type: "module"
  933. }).catch((error) => {
  934. console.error("Service Worker registration failed:", error);
  935. });
  936. }
  937. }
  938. // PWA installation prompt handling
  939. window.addEventListener("beforeinstallprompt", (event) => {
  940. let deferredPrompt = event;
  941. const promptHomeScreen = document.getElementById("prompt-home-screen");
  942. const btnAddToHomeScreen = document.getElementById("btn-add-to-home-screen");
  943. if (!promptHomeScreen || !btnAddToHomeScreen) return;
  944. promptHomeScreen.style.display = "block";
  945. btnAddToHomeScreen.addEventListener("click", (event) => {
  946. event.preventDefault();
  947. deferredPrompt.prompt();
  948. deferredPrompt.userChoice.then(() => {
  949. deferredPrompt = null;
  950. promptHomeScreen.style.display = "none";
  951. });
  952. });
  953. });
  954. }
  955. /**
  956. * Initialize WebAuthn handlers if supported.
  957. */
  958. function initializeWebAuthn() {
  959. if (!WebAuthnHandler.isWebAuthnSupported()) return;
  960. const webauthnHandler = new WebAuthnHandler();
  961. // Setup delete credentials handler
  962. onClick("#webauthn-delete", () => { webauthnHandler.removeAllCredentials(); });
  963. // Setup registration
  964. const registerButton = document.getElementById("webauthn-register");
  965. if (registerButton) {
  966. registerButton.disabled = false;
  967. onClick("#webauthn-register", () => {
  968. webauthnHandler.register().catch((err) => WebAuthnHandler.showErrorMessage(err));
  969. });
  970. }
  971. // Setup login
  972. const loginButton = document.getElementById("webauthn-login");
  973. const usernameField = document.getElementById("form-username");
  974. if (loginButton && usernameField) {
  975. const abortController = new AbortController();
  976. loginButton.disabled = false;
  977. onClick("#webauthn-login", () => {
  978. abortController.abort();
  979. webauthnHandler.login(usernameField.value).catch(err => WebAuthnHandler.showErrorMessage(err));
  980. });
  981. webauthnHandler.conditionalLogin(abortController).catch(err => WebAuthnHandler.showErrorMessage(err));
  982. }
  983. }
  984. /**
  985. * Initialize keyboard shortcuts for navigation and actions.
  986. */
  987. function initializeKeyboardShortcuts() {
  988. if (document.querySelector("body[data-disable-keyboard-shortcuts=true]")) return;
  989. const keyboardHandler = new KeyboardHandler();
  990. // Navigation shortcuts
  991. keyboardHandler.on("g u", () => goToPage("unread"));
  992. keyboardHandler.on("g b", () => goToPage("starred"));
  993. keyboardHandler.on("g h", () => goToPage("history"));
  994. keyboardHandler.on("g f", goToFeedOrFeedsPage);
  995. keyboardHandler.on("g c", () => goToPage("categories"));
  996. keyboardHandler.on("g s", () => goToPage("settings"));
  997. keyboardHandler.on("g g", () => goToPreviousPage(TOP));
  998. keyboardHandler.on("G", () => goToNextPage(BOTTOM));
  999. keyboardHandler.on("/", () => goToPage("search"));
  1000. // Item navigation
  1001. keyboardHandler.on("ArrowLeft", goToPreviousPage);
  1002. keyboardHandler.on("ArrowRight", goToNextPage);
  1003. keyboardHandler.on("k", goToPreviousPage);
  1004. keyboardHandler.on("p", goToPreviousPage);
  1005. keyboardHandler.on("j", goToNextPage);
  1006. keyboardHandler.on("n", goToNextPage);
  1007. keyboardHandler.on("h", () => goToPage("previous"));
  1008. keyboardHandler.on("l", () => goToPage("next"));
  1009. keyboardHandler.on("z t", scrollToCurrentItem);
  1010. // Item actions
  1011. keyboardHandler.on("o", openSelectedItem);
  1012. keyboardHandler.on("Enter", () => openSelectedItem());
  1013. keyboardHandler.on("v", () => openOriginalLink(false));
  1014. keyboardHandler.on("V", () => openOriginalLink(true));
  1015. keyboardHandler.on("c", () => openCommentLink(false));
  1016. keyboardHandler.on("C", () => openCommentLink(true));
  1017. // Entry management
  1018. keyboardHandler.on("m", () => handleEntryStatus("next"));
  1019. keyboardHandler.on("M", () => handleEntryStatus("previous"));
  1020. keyboardHandler.on("A", markPageAsRead);
  1021. keyboardHandler.on("s", () => handleSaveEntry());
  1022. keyboardHandler.on("d", handleFetchOriginalContent);
  1023. keyboardHandler.on("f", () => handleBookmark());
  1024. // Feed actions
  1025. keyboardHandler.on("F", goToFeedPage);
  1026. keyboardHandler.on("R", handleRefreshAllFeeds);
  1027. keyboardHandler.on("+", goToAddSubscriptionPage);
  1028. keyboardHandler.on("#", unsubscribeFromFeed);
  1029. // UI actions
  1030. keyboardHandler.on("?", showKeyboardShortcuts);
  1031. keyboardHandler.on("Escape", () => ModalHandler.close());
  1032. keyboardHandler.on("a", () => {
  1033. const enclosureElement = document.querySelector('.entry-enclosures');
  1034. if (enclosureElement) {
  1035. enclosureElement.toggleAttribute('open');
  1036. }
  1037. });
  1038. keyboardHandler.listen();
  1039. }
  1040. /**
  1041. * Initialize touch handler for mobile devices.
  1042. */
  1043. function initializeTouchHandler() {
  1044. const touchHandler = new TouchHandler();
  1045. touchHandler.listen();
  1046. }
  1047. /**
  1048. * Initialize click handlers for various UI elements.
  1049. */
  1050. function initializeClickHandlers() {
  1051. // Entry actions
  1052. onClick(":is(a, button)[data-save-entry]", (event) => handleSaveEntry(event.target));
  1053. onClick(":is(a, button)[data-toggle-bookmark]", (event) => handleBookmark(event.target));
  1054. onClick(":is(a, button)[data-toggle-status]", (event) => handleEntryStatus("next", event.target));
  1055. onClick(":is(a, button)[data-fetch-content-entry]", handleFetchOriginalContent);
  1056. onClick(":is(a, button)[data-share-status]", handleShare);
  1057. // Page actions with confirmation
  1058. onClick(":is(a, button)[data-action=markPageAsRead]", (event) =>
  1059. handleConfirmationMessage(event.target, markPageAsRead));
  1060. // Generic confirmation handler
  1061. onClick(":is(a, button)[data-confirm]", (event) => {
  1062. handleConfirmationMessage(event.target, (url, redirectURL) => {
  1063. const request = new RequestBuilder(url);
  1064. request.withCallback((response) => {
  1065. if (redirectURL) {
  1066. window.location.href = redirectURL;
  1067. } else if (response?.redirected && response.url) {
  1068. window.location.href = response.url;
  1069. } else {
  1070. window.location.reload();
  1071. }
  1072. });
  1073. request.execute();
  1074. });
  1075. });
  1076. // Original link handlers (both click and middle-click)
  1077. const handleOriginalLink = (event) => handleEntryStatus("next", event.target, true);
  1078. onClick("a[data-original-link='true']", handleOriginalLink, true);
  1079. onAuxClick("a[data-original-link='true']", (event) => {
  1080. if (event.button === 1) {
  1081. handleOriginalLink(event);
  1082. }
  1083. }, true);
  1084. }
  1085. // Initialize application handlers
  1086. initializeMainMenuHandlers();
  1087. initializeFormHandlers();
  1088. initializeMediaPlayerHandlers();
  1089. initializeWebAuthn();
  1090. initializeKeyboardShortcuts();
  1091. initializeTouchHandler();
  1092. initializeClickHandlers();
  1093. initializeServiceWorker();