app.js 15 KB

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