mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-13 13:55:12 +02:00
The stopwatch navbar icon and popup were only rendered by the server when a stopwatch was already active at page load. If a tab was opened before the stopwatch started, `initStopwatch()` found no `.active-stopwatch` element in the DOM, returned early, and never registered a SharedWorker listener. As a result the WebSocket push from the stopwatch notifier had nowhere to land and the icon never appeared. Fix by always rendering both the icon anchor and the popup skeleton in the navbar (hidden with `tw-hidden` when no stopwatch is active). `initStopwatch()` can now set up the SharedWorker in every tab, and `updateStopwatchData` can call `showElem`/`hideElem` as stopwatch state changes arrive in real time. Also add `onShow` to `createTippy` so the popup content is re-cloned from the (JS-updated) original each time the tooltip opens, keeping it current even when the stopwatch was started after page load. Add a new e2e test (`stopwatch appears via real-time push`) that verifies the icon appears after `apiStartStopwatch` is called with the page already loaded.
119 lines
4.4 KiB
TypeScript
119 lines
4.4 KiB
TypeScript
import {createTippy} from '../modules/tippy.ts';
|
|
import {GET} from '../modules/fetch.ts';
|
|
import {hideElem, queryElems, showElem} from '../utils/dom.ts';
|
|
import {UserEventsSharedWorker} from '../modules/worker.ts';
|
|
|
|
const {appSubUrl, notificationSettings, enableTimeTracking} = window.config;
|
|
|
|
export function initStopwatch() {
|
|
if (!enableTimeTracking) {
|
|
return;
|
|
}
|
|
|
|
const stopwatchEls = document.querySelectorAll('.active-stopwatch');
|
|
const stopwatchPopup = document.querySelector('.active-stopwatch-popup');
|
|
|
|
if (!stopwatchEls.length || !stopwatchPopup) {
|
|
return;
|
|
}
|
|
|
|
// global stop watch (in the head_navbar), it should always work in any case either the EventSource or the PeriodicPoller is used.
|
|
const seconds = stopwatchEls[0]?.getAttribute('data-seconds');
|
|
if (seconds) {
|
|
updateStopwatchTime(parseInt(seconds));
|
|
}
|
|
|
|
for (const stopwatchEl of stopwatchEls) {
|
|
stopwatchEl.removeAttribute('href'); // intended for noscript mode only
|
|
|
|
createTippy(stopwatchEl, {
|
|
content: stopwatchPopup.cloneNode(true) as Element,
|
|
placement: 'bottom-end',
|
|
trigger: 'click',
|
|
maxWidth: 'none',
|
|
interactive: true,
|
|
hideOnClick: true,
|
|
theme: 'default',
|
|
onShow(instance) {
|
|
// Re-clone so the tooltip always reflects the latest stopwatch state,
|
|
// even when the icon became visible via a real-time WebSocket push.
|
|
instance.setContent(stopwatchPopup.cloneNode(true) as Element);
|
|
},
|
|
});
|
|
}
|
|
|
|
let usingPeriodicPoller = false;
|
|
const startPeriodicPoller = (timeout: number) => {
|
|
if (timeout <= 0 || !Number.isFinite(timeout)) return;
|
|
usingPeriodicPoller = true;
|
|
setTimeout(() => updateStopwatchWithCallback(startPeriodicPoller, timeout), timeout);
|
|
};
|
|
|
|
// if the browser supports EventSource and SharedWorker, use it instead of the periodic poller
|
|
if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
|
|
// Try to connect to the event source via the shared worker first
|
|
const worker = new UserEventsSharedWorker('stopwatch-worker');
|
|
worker.addMessageEventListener((event) => {
|
|
if (event.data.type === 'no-event-source') {
|
|
// browser doesn't support EventSource, falling back to periodic poller
|
|
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
|
|
} else if (event.data.type === 'stopwatches') {
|
|
updateStopwatchData(JSON.parse(event.data.data));
|
|
}
|
|
});
|
|
worker.startPort();
|
|
return;
|
|
}
|
|
|
|
startPeriodicPoller(notificationSettings.MinTimeout);
|
|
}
|
|
|
|
async function updateStopwatchWithCallback(callback: (timeout: number) => void, timeout: number) {
|
|
const isSet = await updateStopwatch();
|
|
|
|
if (!isSet) {
|
|
timeout = notificationSettings.MinTimeout;
|
|
} else if (timeout < notificationSettings.MaxTimeout) {
|
|
timeout += notificationSettings.TimeoutStep;
|
|
}
|
|
|
|
callback(timeout);
|
|
}
|
|
|
|
async function updateStopwatch() {
|
|
const response = await GET(`${appSubUrl}/user/stopwatches`);
|
|
if (!response.ok) {
|
|
console.error('Failed to fetch stopwatch data');
|
|
return false;
|
|
}
|
|
const data = await response.json();
|
|
return updateStopwatchData(data);
|
|
}
|
|
|
|
function updateStopwatchData(data: any) {
|
|
const watch = data[0];
|
|
const btnEls = document.querySelectorAll('.active-stopwatch');
|
|
if (!watch) {
|
|
hideElem(btnEls);
|
|
} else {
|
|
const {repo_owner_name, repo_name, issue_index, seconds} = watch;
|
|
const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`;
|
|
document.querySelector('.stopwatch-link')?.setAttribute('href', issueUrl);
|
|
document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/stop`);
|
|
document.querySelector('.stopwatch-cancel')?.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`);
|
|
const stopwatchIssue = document.querySelector('.stopwatch-issue');
|
|
if (stopwatchIssue) stopwatchIssue.textContent = `${repo_owner_name}/${repo_name}#${issue_index}`;
|
|
updateStopwatchTime(seconds);
|
|
showElem(btnEls);
|
|
}
|
|
return Boolean(data.length);
|
|
}
|
|
|
|
// TODO: This flickers on page load, we could avoid this by making a custom element to render time periods.
|
|
function updateStopwatchTime(seconds: number) {
|
|
const hours = seconds / 3600 || 0;
|
|
const minutes = seconds / 60 || 0;
|
|
const timeText = hours >= 1 ? `${Math.round(hours)}h` : `${Math.round(minutes)}m`;
|
|
queryElems(document, '.header-stopwatch-dot', (el) => el.textContent = timeText);
|
|
}
|