code.gitea.io/gitea@v1.22.3/web_src/js/features/stopwatch.js (about) 1 import {createTippy} from '../modules/tippy.js'; 2 import {GET} from '../modules/fetch.js'; 3 import {hideElem, showElem} from '../utils/dom.js'; 4 import {logoutFromWorker} from '../modules/worker.js'; 5 6 const {appSubUrl, notificationSettings, enableTimeTracking, assetVersionEncoded} = window.config; 7 8 export function initStopwatch() { 9 if (!enableTimeTracking) { 10 return; 11 } 12 13 const stopwatchEls = document.querySelectorAll('.active-stopwatch'); 14 const stopwatchPopup = document.querySelector('.active-stopwatch-popup'); 15 16 if (!stopwatchEls.length || !stopwatchPopup) { 17 return; 18 } 19 20 // global stop watch (in the head_navbar), it should always work in any case either the EventSource or the PeriodicPoller is used. 21 const seconds = stopwatchEls[0]?.getAttribute('data-seconds'); 22 if (seconds) { 23 updateStopwatchTime(parseInt(seconds)); 24 } 25 26 for (const stopwatchEl of stopwatchEls) { 27 stopwatchEl.removeAttribute('href'); // intended for noscript mode only 28 29 createTippy(stopwatchEl, { 30 content: stopwatchPopup.cloneNode(true), 31 placement: 'bottom-end', 32 trigger: 'click', 33 maxWidth: 'none', 34 interactive: true, 35 hideOnClick: true, 36 theme: 'default', 37 }); 38 } 39 40 let usingPeriodicPoller = false; 41 const startPeriodicPoller = (timeout) => { 42 if (timeout <= 0 || !Number.isFinite(timeout)) return; 43 usingPeriodicPoller = true; 44 setTimeout(() => updateStopwatchWithCallback(startPeriodicPoller, timeout), timeout); 45 }; 46 47 // if the browser supports EventSource and SharedWorker, use it instead of the periodic poller 48 if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) { 49 // Try to connect to the event source via the shared worker first 50 const worker = new SharedWorker(`${__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker'); 51 worker.addEventListener('error', (event) => { 52 console.error('worker error', event); 53 }); 54 worker.port.addEventListener('messageerror', () => { 55 console.error('unable to deserialize message'); 56 }); 57 worker.port.postMessage({ 58 type: 'start', 59 url: `${window.location.origin}${appSubUrl}/user/events`, 60 }); 61 worker.port.addEventListener('message', (event) => { 62 if (!event.data || !event.data.type) { 63 console.error('unknown worker message event', event); 64 return; 65 } 66 if (event.data.type === 'stopwatches') { 67 updateStopwatchData(JSON.parse(event.data.data)); 68 } else if (event.data.type === 'no-event-source') { 69 // browser doesn't support EventSource, falling back to periodic poller 70 if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout); 71 } else if (event.data.type === 'error') { 72 console.error('worker port event error', event.data); 73 } else if (event.data.type === 'logout') { 74 if (event.data.data !== 'here') { 75 return; 76 } 77 worker.port.postMessage({ 78 type: 'close', 79 }); 80 worker.port.close(); 81 logoutFromWorker(); 82 } else if (event.data.type === 'close') { 83 worker.port.postMessage({ 84 type: 'close', 85 }); 86 worker.port.close(); 87 } 88 }); 89 worker.port.addEventListener('error', (e) => { 90 console.error('worker port error', e); 91 }); 92 worker.port.start(); 93 window.addEventListener('beforeunload', () => { 94 worker.port.postMessage({ 95 type: 'close', 96 }); 97 worker.port.close(); 98 }); 99 100 return; 101 } 102 103 startPeriodicPoller(notificationSettings.MinTimeout); 104 } 105 106 async function updateStopwatchWithCallback(callback, timeout) { 107 const isSet = await updateStopwatch(); 108 109 if (!isSet) { 110 timeout = notificationSettings.MinTimeout; 111 } else if (timeout < notificationSettings.MaxTimeout) { 112 timeout += notificationSettings.TimeoutStep; 113 } 114 115 callback(timeout); 116 } 117 118 async function updateStopwatch() { 119 const response = await GET(`${appSubUrl}/user/stopwatches`); 120 if (!response.ok) { 121 console.error('Failed to fetch stopwatch data'); 122 return false; 123 } 124 const data = await response.json(); 125 return updateStopwatchData(data); 126 } 127 128 function updateStopwatchData(data) { 129 const watch = data[0]; 130 const btnEls = document.querySelectorAll('.active-stopwatch'); 131 if (!watch) { 132 hideElem(btnEls); 133 } else { 134 const {repo_owner_name, repo_name, issue_index, seconds} = watch; 135 const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`; 136 document.querySelector('.stopwatch-link')?.setAttribute('href', issueUrl); 137 document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/toggle`); 138 document.querySelector('.stopwatch-cancel')?.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`); 139 const stopwatchIssue = document.querySelector('.stopwatch-issue'); 140 if (stopwatchIssue) stopwatchIssue.textContent = `${repo_owner_name}/${repo_name}#${issue_index}`; 141 updateStopwatchTime(seconds); 142 showElem(btnEls); 143 } 144 return Boolean(data.length); 145 } 146 147 // TODO: This flickers on page load, we could avoid this by making a custom 148 // element to render time periods. Feeding a datetime in backend does not work 149 // when time zone between server and client differs. 150 function updateStopwatchTime(seconds) { 151 if (!Number.isFinite(seconds)) return; 152 const datetime = (new Date(Date.now() - seconds * 1000)).toISOString(); 153 for (const parent of document.querySelectorAll('.header-stopwatch-dot')) { 154 const existing = parent.querySelector(':scope > relative-time'); 155 if (existing) { 156 existing.setAttribute('datetime', datetime); 157 } else { 158 const el = document.createElement('relative-time'); 159 el.setAttribute('format', 'micro'); 160 el.setAttribute('datetime', datetime); 161 el.setAttribute('lang', 'en-US'); 162 el.setAttribute('title', ''); // make <relative-time> show no title and therefor no tooltip 163 parent.append(el); 164 } 165 } 166 }