Files
gitea/web_src/js/features/repo-milestone-sse.ts
T

174 lines
6.1 KiB
TypeScript

import {UserEventsSharedWorker} from '../modules/worker.ts';
// sessionTag is generated once per page load. The mutation requests on
// milestone pages (close/open/delete/edit) flow through the existing
// fetch helpers which attach the X-Session-Tag header; the backend
// echoes it back inside SSE payloads so the originating tab can suppress
// its own echo. We only need the read side here: skip any event whose
// session_tag matches ours.
let sessionTag = '';
function ensureSessionTag(): string {
if (sessionTag) return sessionTag;
if (globalThis.crypto?.randomUUID) {
sessionTag = globalThis.crypto.randomUUID();
} else {
sessionTag = `st-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
}
return sessionTag;
}
type MilestoneProgressPayload = {
repo_id: number;
milestone_id: number;
open_issues: number;
closed_issues: number;
completeness: number;
session_tag?: string;
};
type MilestoneDeletedPayload = {
repo_id: number;
milestone_id: number;
};
function isProgressPayload(p: any): p is MilestoneProgressPayload {
return p && typeof p.completeness === 'number' && 'open_issues' in p;
}
// patchMilestoneCard updates every progress-bar / counter site for a
// single milestone id, covering both the list-card layout (milestones
// list, dashboard) and the single-milestone big progress bar.
function patchMilestoneCard(payload: MilestoneProgressPayload): void {
const selector = `[data-milestone-id="${payload.milestone_id}"]`;
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
// The element itself may be a <progress> (single view) or a card
// <li> containing a <progress> (list views).
const progressEls = el instanceof HTMLProgressElement
? [el]
: Array.from(el.querySelectorAll<HTMLProgressElement>('progress'));
for (const pe of progressEls) {
pe.value = payload.completeness;
}
const scope: ParentNode = el instanceof HTMLProgressElement ? document : el;
for (const pct of scope.querySelectorAll<HTMLElement>('.milestone-completeness-pct')) {
// The list cards render just the number; the single-milestone
// view renders an i18n HTML fragment ("<strong>N%</strong>
// Completed"). Detect which by whether the node already holds a
// <strong> child.
const strong = pct.querySelector('strong');
if (strong) {
strong.textContent = `${payload.completeness}%`;
} else {
pct.textContent = String(payload.completeness);
}
}
for (const oc of scope.querySelectorAll<HTMLElement>('.milestone-open-count')) {
oc.textContent = String(payload.open_issues);
}
for (const cc of scope.querySelectorAll<HTMLElement>('.milestone-closed-count')) {
cc.textContent = String(payload.closed_issues);
}
}
}
function handleMilestoneDeleted(payload: MilestoneDeletedPayload): void {
const card = document.querySelector<HTMLElement>(`li.milestone-card[data-milestone-id="${payload.milestone_id}"]`);
if (card) {
card.remove();
return;
}
// Single-milestone view: the milestone we are looking at is gone.
const single = document.querySelector<HTMLElement>(`progress[data-milestone-id="${payload.milestone_id}"]`);
if (single) {
const parts = window.location.pathname.split('/');
// .../milestone/{id} -> go up to the milestones listing.
const idx = parts.lastIndexOf('milestone');
if (idx > 0) {
window.location.href = `${parts.slice(0, idx).join('/')}/milestones`;
} else {
window.location.href = '/';
}
}
}
function dispatchMilestoneEvent(payload: any): void {
if (payload.session_tag && payload.session_tag === sessionTag) return;
if (isProgressPayload(payload)) {
patchMilestoneCard(payload);
} else if ('milestone_id' in payload && 'repo_id' in payload) {
handleMilestoneDeleted(payload as MilestoneDeletedPayload);
}
}
// subscribed guards against a double subscription if more than one init
// entry point matches the same page (e.g. the dashboard milestones page
// is wired both from repo-legacy and dashboard).
let subscribed = false;
// subscribeRepos opens one SharedWorker subscription per distinct repo
// id and dispatches every "repo-milestones.{repoID}" event by payload.
function subscribeRepos(repoIDs: Set<string>): void {
if (subscribed) return;
if (!repoIDs.size) return;
if (!window.EventSource || !window.SharedWorker) return;
subscribed = true;
ensureSessionTag();
let worker: UserEventsSharedWorker;
try {
worker = new UserEventsSharedWorker('repo-milestone-worker');
} catch (error) {
console.error('milestone SSE: failed to start worker', error);
return;
}
const eventNames = new Set<string>();
for (const repoID of repoIDs) {
eventNames.add(`repo-milestones.${repoID}`);
}
worker.addMessageEventListener((event: MessageEvent) => {
if (!event.data || !eventNames.has(event.data.type)) return;
let payload: any;
try {
payload = JSON.parse(event.data.data);
} catch (error) {
console.error('milestone SSE: malformed payload', error, event.data);
return;
}
dispatchMilestoneEvent(payload);
});
worker.startPort();
for (const name of eventNames) {
worker.sharedWorker.port.postMessage({type: 'listen', eventType: name});
}
}
// initRepoMilestoneListSSE wires the milestone list page and the
// dashboard milestones page: collect every distinct data-repo-id present
// on the cards (the dashboard mixes many repos) and subscribe to each.
export function initRepoMilestoneListSSE(): void {
const cards = document.querySelectorAll<HTMLElement>('li.milestone-card[data-repo-id]');
if (!cards.length) return;
const repoIDs = new Set<string>();
for (const card of cards) {
const id = card.getAttribute('data-repo-id');
if (id) repoIDs.add(id);
}
subscribeRepos(repoIDs);
}
// initRepoMilestoneSingleSSE wires the single-milestone issue list view.
export function initRepoMilestoneSingleSSE(): void {
const progress = document.querySelector<HTMLElement>('progress[data-milestone-id][data-repo-id]');
if (!progress) return;
const repoID = progress.getAttribute('data-repo-id');
if (!repoID) return;
subscribeRepos(new Set([repoID]));
}