app.js 12 KB

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