app.js 10 KB

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