app.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. /**
  2. * Hello, dear curious visitor. I am not a web-guy, so please don't judge my horrible JS code.
  3. * In fact, please do tell me about all the things I did wrong and that I could improve. I've been trying
  4. * to read up on modern JS, but it's just a little much.
  5. *
  6. * Feel free to open tickets at https://github.com/binwiederhier/ntfy/issues. Thank you!
  7. */
  8. /* All the things */
  9. let topics = {};
  10. let currentTopic = "";
  11. let currentTopicUnsubscribeOnClose = false;
  12. let currentUrl = window.location.hostname;
  13. if (window.location.port) {
  14. currentUrl += ':' + window.location.port
  15. }
  16. /* Main view */
  17. const main = document.getElementById("main");
  18. const topicsHeader = document.getElementById("topicsHeader");
  19. const topicsList = document.getElementById("topicsList");
  20. const topicField = document.getElementById("topicField");
  21. const notifySound = document.getElementById("notifySound");
  22. const subscribeButton = document.getElementById("subscribeButton");
  23. const errorField = document.getElementById("error");
  24. const originalTitle = document.title;
  25. /* Detail view */
  26. const detailView = document.getElementById("detail");
  27. const detailTitle = document.getElementById("detailTitle");
  28. const detailEventsList = document.getElementById("detailEventsList");
  29. const detailTopicUrl = document.getElementById("detailTopicUrl");
  30. const detailNoNotifications = document.getElementById("detailNoNotifications");
  31. const detailCloseButton = document.getElementById("detailCloseButton");
  32. const detailNotificationsDisallowed = document.getElementById("detailNotificationsDisallowed");
  33. /* Screenshots */
  34. const lightbox = document.getElementById("lightbox");
  35. const subscribe = (topic) => {
  36. if (Notification.permission !== "granted") {
  37. Notification.requestPermission().then((permission) => {
  38. if (permission === "granted") {
  39. subscribeInternal(topic, true, 0);
  40. } else {
  41. showNotificationDeniedError();
  42. }
  43. });
  44. } else {
  45. subscribeInternal(topic, true,0);
  46. }
  47. };
  48. const subscribeInternal = (topic, persist, delaySec) => {
  49. setTimeout(() => {
  50. // Render list entry
  51. let topicEntry = document.getElementById(`topic-${topic}`);
  52. if (!topicEntry) {
  53. topicEntry = document.createElement('li');
  54. topicEntry.id = `topic-${topic}`;
  55. topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <button onclick="test('${topic}'); return false;"> <img src="static/img/send.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/unsubscribe.svg"> Unsubscribe</button>`;
  56. topicsList.appendChild(topicEntry);
  57. }
  58. topicsHeader.style.display = '';
  59. // Open event source
  60. let eventSource = new EventSource(`${topic}/sse`);
  61. eventSource.onopen = () => {
  62. topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <button onclick="test('${topic}'); return false;"> <img src="static/img/send.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/unsubscribe.svg"> Unsubscribe</button>`;
  63. delaySec = 0; // Reset on successful connection
  64. };
  65. eventSource.onerror = (e) => {
  66. topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <i>(Reconnecting)</i> <button disabled="disabled">Test</button> <button onclick="unsubscribe('${topic}'); return false;">Unsubscribe</button>`;
  67. eventSource.close();
  68. const newDelaySec = (delaySec + 5 <= 15) ? delaySec + 5 : 15;
  69. subscribeInternal(topic, persist, newDelaySec);
  70. };
  71. eventSource.onmessage = (e) => {
  72. const event = JSON.parse(e.data);
  73. topics[topic]['messages'].push(event);
  74. topics[topic]['messages'].sort((a, b) => { return a.time < b.time ? 1 : -1; }); // Newest first
  75. if (currentTopic === topic) {
  76. rerenderDetailView();
  77. }
  78. if (Notification.permission === "granted") {
  79. notifySound.play();
  80. const title = (event.title) ? event.title : `${location.host}/${topic}`;
  81. const notification = new Notification(title, {
  82. body: event.message,
  83. icon: '/static/img/favicon.png'
  84. });
  85. notification.onclick = (e) => {
  86. showDetail(event.topic);
  87. };
  88. }
  89. };
  90. topics[topic] = {
  91. 'eventSource': eventSource,
  92. 'messages': [],
  93. 'persist': persist
  94. };
  95. fetchCachedMessages(topic).then(() => {
  96. if (currentTopic === topic) {
  97. rerenderDetailView();
  98. }
  99. })
  100. let persistedTopicKeys = Object.keys(topics).filter(t => topics[t].persist);
  101. localStorage.setItem('topics', JSON.stringify(persistedTopicKeys));
  102. }, delaySec * 1000);
  103. };
  104. const unsubscribe = (topic) => {
  105. topics[topic]['eventSource'].close();
  106. delete topics[topic];
  107. localStorage.setItem('topics', JSON.stringify(Object.keys(topics)));
  108. document.getElementById(`topic-${topic}`).remove();
  109. if (Object.keys(topics).length === 0) {
  110. topicsHeader.style.display = 'none';
  111. }
  112. };
  113. const test = (topic) => {
  114. fetch(`/${topic}`, {
  115. method: 'PUT',
  116. body: `This is a test notification sent by the ntfy.sh Web UI at ${new Date().toString()}.`
  117. });
  118. };
  119. const fetchCachedMessages = async (topic) => {
  120. const topicJsonUrl = `/${topic}/json?poll=1`; // Poll!
  121. for await (let line of makeTextFileLineIterator(topicJsonUrl)) {
  122. const message = JSON.parse(line);
  123. topics[topic]['messages'].push(message);
  124. }
  125. topics[topic]['messages'].sort((a, b) => { return a.time < b.time ? 1 : -1; }); // Newest first
  126. };
  127. const showDetail = (topic) => {
  128. currentTopic = topic;
  129. history.replaceState(topic, `${currentUrl}/${topic}`, `/${topic}`);
  130. window.scrollTo(0, 0);
  131. rerenderDetailView();
  132. return false;
  133. };
  134. const rerenderDetailView = () => {
  135. detailTitle.innerHTML = `${currentUrl}/${currentTopic}`; // document.location.replaceAll(..)
  136. detailTopicUrl.innerHTML = `${currentUrl}/${currentTopic}`;
  137. while (detailEventsList.firstChild) {
  138. detailEventsList.removeChild(detailEventsList.firstChild);
  139. }
  140. topics[currentTopic]['messages'].forEach(m => {
  141. const entryDiv = document.createElement('div');
  142. const dateDiv = document.createElement('div');
  143. const titleDiv = document.createElement('div');
  144. const messageDiv = document.createElement('div');
  145. const tagsDiv = document.createElement('div');
  146. entryDiv.classList.add('detailEntry');
  147. dateDiv.classList.add('detailDate');
  148. const dateStr = new Date(m.time * 1000).toLocaleString();
  149. if (m.priority && [1,2,4,5].includes(m.priority)) {
  150. dateDiv.innerHTML = `${dateStr} <img src="static/img/priority-${m.priority}.svg"/>`;
  151. } else {
  152. dateDiv.innerHTML = `${dateStr}`;
  153. }
  154. messageDiv.classList.add('detailMessage');
  155. messageDiv.innerText = m.message;
  156. entryDiv.appendChild(dateDiv);
  157. if (m.title) {
  158. titleDiv.classList.add('detailTitle');
  159. titleDiv.innerText = m.title;
  160. entryDiv.appendChild(titleDiv);
  161. }
  162. entryDiv.appendChild(messageDiv);
  163. if (m.tags) {
  164. tagsDiv.classList.add('detailTags');
  165. tagsDiv.innerText = "Tags: " + m.tags.join(", ");
  166. entryDiv.appendChild(tagsDiv);
  167. }
  168. detailEventsList.appendChild(entryDiv);
  169. })
  170. if (topics[currentTopic]['messages'].length === 0) {
  171. detailNoNotifications.style.display = '';
  172. } else {
  173. detailNoNotifications.style.display = 'none';
  174. }
  175. if (Notification.permission === "granted") {
  176. detailNotificationsDisallowed.style.display = 'none';
  177. } else {
  178. detailNotificationsDisallowed.style.display = 'block';
  179. }
  180. detailView.style.display = 'block';
  181. main.style.display = 'none';
  182. };
  183. const hideDetailView = () => {
  184. if (currentTopicUnsubscribeOnClose) {
  185. unsubscribe(currentTopic);
  186. currentTopicUnsubscribeOnClose = false;
  187. }
  188. currentTopic = "";
  189. history.replaceState('', originalTitle, '/');
  190. detailView.style.display = 'none';
  191. main.style.display = 'block';
  192. return false;
  193. };
  194. const requestPermission = () => {
  195. if (Notification.permission !== "granted") {
  196. Notification.requestPermission().then((permission) => {
  197. if (permission === "granted") {
  198. detailNotificationsDisallowed.style.display = 'none';
  199. }
  200. });
  201. }
  202. return false;
  203. };
  204. const showError = (msg) => {
  205. errorField.innerHTML = msg;
  206. topicField.disabled = true;
  207. subscribeButton.disabled = true;
  208. };
  209. const showBrowserIncompatibleError = () => {
  210. showError("Your browser is not compatible to use the web-based desktop notifications.");
  211. };
  212. const showNotificationDeniedError = () => {
  213. showError("You have blocked desktop notifications for this website. Please unblock them and refresh to use the web-based desktop notifications.");
  214. };
  215. const showScreenshotOverlay = (e, el, index) => {
  216. lightbox.classList.add('show');
  217. document.addEventListener('keydown', nextScreenshotKeyboardListener);
  218. return showScreenshot(e, index);
  219. };
  220. const showScreenshot = (e, index) => {
  221. const actualIndex = resolveScreenshotIndex(index);
  222. lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[actualIndex].innerHTML;
  223. lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e,actualIndex+1); };
  224. currentScreenshotIndex = actualIndex;
  225. e.stopPropagation();
  226. return false;
  227. };
  228. const nextScreenshot = (e) => {
  229. return showScreenshot(e, currentScreenshotIndex+1);
  230. };
  231. const previousScreenshot = (e) => {
  232. return showScreenshot(e, currentScreenshotIndex-1);
  233. };
  234. const resolveScreenshotIndex = (index) => {
  235. if (index < 0) {
  236. return screenshots.length - 1;
  237. } else if (index > screenshots.length - 1) {
  238. return 0;
  239. }
  240. return index;
  241. };
  242. const hideScreenshotOverlay = (e) => {
  243. lightbox.classList.remove('show');
  244. document.removeEventListener('keydown', nextScreenshotKeyboardListener);
  245. };
  246. const nextScreenshotKeyboardListener = (e) => {
  247. switch (e.keyCode) {
  248. case 37:
  249. previousScreenshot(e);
  250. break;
  251. case 39:
  252. nextScreenshot(e);
  253. break;
  254. }
  255. };
  256. // From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
  257. async function* makeTextFileLineIterator(fileURL) {
  258. const utf8Decoder = new TextDecoder('utf-8');
  259. const response = await fetch(fileURL);
  260. const reader = response.body.getReader();
  261. let { value: chunk, done: readerDone } = await reader.read();
  262. chunk = chunk ? utf8Decoder.decode(chunk) : '';
  263. const re = /\n|\r|\r\n/gm;
  264. let startIndex = 0;
  265. let result;
  266. for (;;) {
  267. let result = re.exec(chunk);
  268. if (!result) {
  269. if (readerDone) {
  270. break;
  271. }
  272. let remainder = chunk.substr(startIndex);
  273. ({ value: chunk, done: readerDone } = await reader.read());
  274. chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : '');
  275. startIndex = re.lastIndex = 0;
  276. continue;
  277. }
  278. yield chunk.substring(startIndex, result.index);
  279. startIndex = re.lastIndex;
  280. }
  281. if (startIndex < chunk.length) {
  282. yield chunk.substr(startIndex); // last line didn't end in a newline char
  283. }
  284. }
  285. subscribeButton.onclick = () => {
  286. if (!topicField.value) {
  287. return false;
  288. }
  289. subscribe(topicField.value);
  290. topicField.value = "";
  291. return false;
  292. };
  293. detailCloseButton.onclick = () => {
  294. hideDetailView();
  295. };
  296. let currentScreenshotIndex = 0;
  297. const screenshots = [...document.querySelectorAll("#screenshots a")];
  298. screenshots.forEach((el, index) => {
  299. el.onclick = (e) => { return showScreenshotOverlay(e, el, index); };
  300. });
  301. lightbox.onclick = hideScreenshotOverlay;
  302. // Disable Web UI if notifications of EventSource are not available
  303. if (!window["Notification"] || !window["EventSource"]) {
  304. showBrowserIncompatibleError();
  305. } else if (Notification.permission === "denied") {
  306. showNotificationDeniedError();
  307. }
  308. // Reset UI
  309. topicField.value = "";
  310. // Restore topics
  311. const storedTopics = JSON.parse(localStorage.getItem('topics') || "[]");
  312. if (storedTopics) {
  313. storedTopics.forEach((topic) => { subscribeInternal(topic, true, 0); });
  314. if (storedTopics.length === 0) {
  315. topicsHeader.style.display = 'none';
  316. }
  317. } else {
  318. topicsHeader.style.display = 'none';
  319. }
  320. // (Temporarily) subscribe topic if we navigated to /sometopic URL
  321. const match = location.pathname.match(/^\/([-_a-zA-Z0-9]{1,64})$/) // Regex must match Go & Android app!
  322. if (match) {
  323. currentTopic = match[1];
  324. if (!storedTopics.includes(currentTopic)) {
  325. subscribeInternal(currentTopic, false,0);
  326. currentTopicUnsubscribeOnClose = true;
  327. }
  328. }
  329. // Add anchor links
  330. document.querySelectorAll('.anchor').forEach((el) => {
  331. if (el.hasAttribute('id')) {
  332. const id = el.getAttribute('id');
  333. const anchor = document.createElement('a');
  334. anchor.innerHTML = `<a href="#${id}" class="anchorLink">#</a>`;
  335. el.appendChild(anchor);
  336. }
  337. });
  338. // Change ntfy.sh url and protocol to match self-hosted one
  339. document.querySelectorAll('.ntfyUrl').forEach((el) => {
  340. el.innerHTML = currentUrl;
  341. });
  342. document.querySelectorAll('.ntfyProtocol').forEach((el) => {
  343. el.innerHTML = window.location.protocol + "//";
  344. });