app.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  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_black_24dp.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/clear_black_24dp.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_black_24dp.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/clear_black_24dp.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. new Notification(`${location.host}/${topic}`, {
  81. body: event.message,
  82. icon: '/static/img/favicon.png'
  83. });
  84. }
  85. };
  86. topics[topic] = {
  87. 'eventSource': eventSource,
  88. 'messages': [],
  89. 'persist': persist
  90. };
  91. fetchCachedMessages(topic).then(() => {
  92. if (currentTopic === topic) {
  93. rerenderDetailView();
  94. }
  95. })
  96. let persistedTopicKeys = Object.keys(topics).filter(t => topics[t].persist);
  97. localStorage.setItem('topics', JSON.stringify(persistedTopicKeys));
  98. }, delaySec * 1000);
  99. };
  100. const unsubscribe = (topic) => {
  101. topics[topic]['eventSource'].close();
  102. delete topics[topic];
  103. localStorage.setItem('topics', JSON.stringify(Object.keys(topics)));
  104. document.getElementById(`topic-${topic}`).remove();
  105. if (Object.keys(topics).length === 0) {
  106. topicsHeader.style.display = 'none';
  107. }
  108. };
  109. const test = (topic) => {
  110. fetch(`/${topic}`, {
  111. method: 'PUT',
  112. body: `This is a test notification sent by the ntfy.sh Web UI at ${new Date().toString()}.`
  113. });
  114. };
  115. const fetchCachedMessages = async (topic) => {
  116. const topicJsonUrl = `/${topic}/json?poll=1`; // Poll!
  117. for await (let line of makeTextFileLineIterator(topicJsonUrl)) {
  118. const message = JSON.parse(line);
  119. topics[topic]['messages'].push(message);
  120. }
  121. topics[topic]['messages'].sort((a, b) => { return a.time < b.time ? 1 : -1; }); // Newest first
  122. };
  123. const showDetail = (topic) => {
  124. currentTopic = topic;
  125. history.replaceState(topic, `${currentUrl}/${topic}`, `/${topic}`);
  126. window.scrollTo(0, 0);
  127. rerenderDetailView();
  128. return false;
  129. };
  130. const rerenderDetailView = () => {
  131. detailTitle.innerHTML = `${currentUrl}/${currentTopic}`; // document.location.replaceAll(..)
  132. detailTopicUrl.innerHTML = `${currentUrl}/${currentTopic}`;
  133. while (detailEventsList.firstChild) {
  134. detailEventsList.removeChild(detailEventsList.firstChild);
  135. }
  136. topics[currentTopic]['messages'].forEach(m => {
  137. let dateDiv = document.createElement('div');
  138. let messageDiv = document.createElement('div');
  139. let eventDiv = document.createElement('div');
  140. dateDiv.classList.add('detailDate');
  141. dateDiv.innerHTML = new Date(m.time * 1000).toLocaleString();
  142. messageDiv.classList.add('detailMessage');
  143. messageDiv.innerText = m.message;
  144. eventDiv.appendChild(dateDiv);
  145. eventDiv.appendChild(messageDiv);
  146. detailEventsList.appendChild(eventDiv);
  147. })
  148. if (topics[currentTopic]['messages'].length === 0) {
  149. detailNoNotifications.style.display = '';
  150. } else {
  151. detailNoNotifications.style.display = 'none';
  152. }
  153. if (Notification.permission === "granted") {
  154. detailNotificationsDisallowed.style.display = 'none';
  155. } else {
  156. detailNotificationsDisallowed.style.display = 'block';
  157. }
  158. detailView.style.display = 'block';
  159. main.style.display = 'none';
  160. };
  161. const hideDetailView = () => {
  162. if (currentTopicUnsubscribeOnClose) {
  163. unsubscribe(currentTopic);
  164. currentTopicUnsubscribeOnClose = false;
  165. }
  166. currentTopic = "";
  167. history.replaceState('', originalTitle, '/');
  168. detailView.style.display = 'none';
  169. main.style.display = '';
  170. return false;
  171. };
  172. const requestPermission = () => {
  173. if (Notification.permission !== "granted") {
  174. Notification.requestPermission().then((permission) => {
  175. if (permission === "granted") {
  176. detailNotificationsDisallowed.style.display = 'none';
  177. }
  178. });
  179. }
  180. return false;
  181. };
  182. const showError = (msg) => {
  183. errorField.innerHTML = msg;
  184. topicField.disabled = true;
  185. subscribeButton.disabled = true;
  186. };
  187. const showBrowserIncompatibleError = () => {
  188. showError("Your browser is not compatible to use the web-based desktop notifications.");
  189. };
  190. const showNotificationDeniedError = () => {
  191. showError("You have blocked desktop notifications for this website. Please unblock them and refresh to use the web-based desktop notifications.");
  192. };
  193. const showScreenshotOverlay = (e, el, index) => {
  194. lightbox.classList.add('show');
  195. document.addEventListener('keydown', nextScreenshotKeyboardListener);
  196. return showScreenshot(e, index);
  197. };
  198. const showScreenshot = (e, index) => {
  199. const actualIndex = resolveScreenshotIndex(index);
  200. lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[actualIndex].innerHTML;
  201. lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e,actualIndex+1); };
  202. currentScreenshotIndex = actualIndex;
  203. e.stopPropagation();
  204. return false;
  205. };
  206. const nextScreenshot = (e) => {
  207. return showScreenshot(e, currentScreenshotIndex+1);
  208. };
  209. const previousScreenshot = (e) => {
  210. return showScreenshot(e, currentScreenshotIndex-1);
  211. };
  212. const resolveScreenshotIndex = (index) => {
  213. if (index < 0) {
  214. return screenshots.length - 1;
  215. } else if (index > screenshots.length - 1) {
  216. return 0;
  217. }
  218. return index;
  219. };
  220. const hideScreenshotOverlay = (e) => {
  221. lightbox.classList.remove('show');
  222. document.removeEventListener('keydown', nextScreenshotKeyboardListener);
  223. };
  224. const nextScreenshotKeyboardListener = (e) => {
  225. switch (e.keyCode) {
  226. case 37:
  227. previousScreenshot(e);
  228. break;
  229. case 39:
  230. nextScreenshot(e);
  231. break;
  232. }
  233. };
  234. // From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
  235. async function* makeTextFileLineIterator(fileURL) {
  236. const utf8Decoder = new TextDecoder('utf-8');
  237. const response = await fetch(fileURL);
  238. const reader = response.body.getReader();
  239. let { value: chunk, done: readerDone } = await reader.read();
  240. chunk = chunk ? utf8Decoder.decode(chunk) : '';
  241. const re = /\n|\r|\r\n/gm;
  242. let startIndex = 0;
  243. let result;
  244. for (;;) {
  245. let result = re.exec(chunk);
  246. if (!result) {
  247. if (readerDone) {
  248. break;
  249. }
  250. let remainder = chunk.substr(startIndex);
  251. ({ value: chunk, done: readerDone } = await reader.read());
  252. chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : '');
  253. startIndex = re.lastIndex = 0;
  254. continue;
  255. }
  256. yield chunk.substring(startIndex, result.index);
  257. startIndex = re.lastIndex;
  258. }
  259. if (startIndex < chunk.length) {
  260. yield chunk.substr(startIndex); // last line didn't end in a newline char
  261. }
  262. }
  263. subscribeButton.onclick = () => {
  264. if (!topicField.value) {
  265. return false;
  266. }
  267. subscribe(topicField.value);
  268. topicField.value = "";
  269. return false;
  270. };
  271. detailCloseButton.onclick = () => {
  272. hideDetailView();
  273. };
  274. let currentScreenshotIndex = 0;
  275. const screenshots = [...document.querySelectorAll("#screenshots a")];
  276. screenshots.forEach((el, index) => {
  277. el.onclick = (e) => { return showScreenshotOverlay(e, el, index); };
  278. });
  279. lightbox.onclick = hideScreenshotOverlay;
  280. // Disable Web UI if notifications of EventSource are not available
  281. if (!window["Notification"] || !window["EventSource"]) {
  282. showBrowserIncompatibleError();
  283. } else if (Notification.permission === "denied") {
  284. showNotificationDeniedError();
  285. }
  286. // Reset UI
  287. topicField.value = "";
  288. // Restore topics
  289. const storedTopics = JSON.parse(localStorage.getItem('topics') || "[]");
  290. if (storedTopics) {
  291. storedTopics.forEach((topic) => { subscribeInternal(topic, true, 0); });
  292. if (storedTopics.length === 0) {
  293. topicsHeader.style.display = 'none';
  294. }
  295. } else {
  296. topicsHeader.style.display = 'none';
  297. }
  298. // (Temporarily) subscribe topic if we navigated to /sometopic URL
  299. const match = location.pathname.match(/^\/([-_a-zA-Z0-9]{1,64})$/) // Regex must match Go & Android app!
  300. if (match) {
  301. currentTopic = match[1];
  302. if (!storedTopics.includes(currentTopic)) {
  303. subscribeInternal(currentTopic, false,0);
  304. currentTopicUnsubscribeOnClose = true;
  305. }
  306. }
  307. // Add anchor links
  308. document.querySelectorAll('.anchor').forEach((el) => {
  309. if (el.hasAttribute('id')) {
  310. const id = el.getAttribute('id');
  311. const anchor = document.createElement('a');
  312. anchor.innerHTML = `<a href="#${id}" class="anchorLink">#</a>`;
  313. el.appendChild(anchor);
  314. }
  315. });
  316. // Change ntfy.sh url and protocol to match self-hosted one
  317. document.querySelectorAll('.ntfyUrl').forEach((el) => {
  318. el.innerHTML = currentUrl;
  319. });
  320. document.querySelectorAll('.ntfyProtocol').forEach((el) => {
  321. el.innerHTML = window.location.protocol + "//";
  322. });