From 184139713d1194c5a891705f575d743108a3dc0c Mon Sep 17 00:00:00 2001 From: Oleks Date: Sat, 16 May 2026 14:31:58 +0300 Subject: [PATCH] feat(sse): toast notifications for project-board and milestone events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces SSE board/milestone changes as Gitea toasts (showInfoToast / showWarningToast) in addition to the silent DOM patch: - project board: card moved/linked/unlinked, column renamed/removed, project deleted (warning + delayed redirect so the reason is visible) - milestone: progress change (title · closed/total · pct), milestone deleted (warning + delayed redirect on single view) Own-tab echoes are suppressed via the existing session_tag guard; toast.ts preventDuplicates (default) coalesces bursts. column.reordered and project.updated intentionally do not toast (noise). Frontend only; no backend/event changes. tsc + vite build clean. --- web_src/js/features/repo-milestone-sse.ts | 30 ++++++++++++++--- web_src/js/features/repo-projects.ts | 40 +++++++++++++++++++---- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/web_src/js/features/repo-milestone-sse.ts b/web_src/js/features/repo-milestone-sse.ts index 10e4379495..0753ee9c8c 100644 --- a/web_src/js/features/repo-milestone-sse.ts +++ b/web_src/js/features/repo-milestone-sse.ts @@ -1,4 +1,21 @@ import {UserEventsSharedWorker} from '../modules/worker.ts'; +import {showInfoToast, showWarningToast} from '../modules/toast.ts'; + +// milestoneTitle does a best-effort lookup of the milestone's display +// name for toast text. List cards expose it as a heading/link inside the +// card; the single-milestone view puts it in the page header. Falls back +// to a generic label so a toast still fires if the markup shifts. +function milestoneTitle(milestoneID: number): string { + const card = document.querySelector(`li.milestone-card[data-milestone-id="${milestoneID}"]`); + const fromCard = card?.querySelector('.milestone-card-title, h3, .title, a[href*="/milestone/"]')?.textContent?.trim(); + if (fromCard) return fromCard; + const onSingle = document.querySelector(`progress[data-milestone-id="${milestoneID}"]`); + if (onSingle) { + const h = document.querySelector('.repository.milestone-issue-list .milestone-title, .page-content .milestone-title, h1, h2')?.textContent?.trim(); + if (h) return h; + } + return 'Milestone'; +} // sessionTag is generated once per page load. The mutation requests on // milestone pages (close/open/delete/edit) flow through the existing @@ -86,11 +103,10 @@ function handleMilestoneDeleted(payload: MilestoneDeletedPayload): void { 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 = '/'; - } + const dest = idx > 0 ? `${parts.slice(0, idx).join('/')}/milestones` : '/'; + // Delay so the "milestone deleted" warning toast is visible before + // the page navigates out from under the viewer. + setTimeout(() => { window.location.href = dest }, 1500); } } @@ -98,8 +114,12 @@ function dispatchMilestoneEvent(payload: any): void { if (payload.session_tag && payload.session_tag === sessionTag) return; if (isProgressPayload(payload)) { patchMilestoneCard(payload); + const total = payload.open_issues + payload.closed_issues; + showInfoToast(`${milestoneTitle(payload.milestone_id)} · ${payload.closed_issues}/${total} closed (${payload.completeness}%)`); } else if ('milestone_id' in payload && 'repo_id' in payload) { + const title = milestoneTitle(payload.milestone_id); handleMilestoneDeleted(payload as MilestoneDeletedPayload); + showWarningToast(title === 'Milestone' ? 'A milestone was deleted' : `Milestone “${title}” was deleted`); } } diff --git a/web_src/js/features/repo-projects.ts b/web_src/js/features/repo-projects.ts index 1039051eff..314a4a4825 100644 --- a/web_src/js/features/repo-projects.ts +++ b/web_src/js/features/repo-projects.ts @@ -8,6 +8,20 @@ import {toggleFullScreen} from '../utils.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts'; import {localUserSettings} from '../modules/user-settings.ts'; import {UserEventsSharedWorker} from '../modules/worker.ts'; +import {showInfoToast, showWarningToast} from '../modules/toast.ts'; + +// issueRef returns a short human label for a card, preferring the +// rendered "#index" anchor text and falling back to the internal id. +function issueRef(card: HTMLElement | null, issueID: number): string { + const idx = card?.querySelector('.issue-card-title, .ref-issue, a[href*="/issues/"]')?.textContent?.trim(); + const m = idx?.match(/#\d+/); + return m ? m[0] : `#${issueID}`; +} + +function columnName(board: HTMLElement, columnID: number): string { + const t = board.querySelector(`.project-column[data-id="${columnID}"] .project-column-title-text`)?.textContent?.trim(); + return t || `column ${columnID}`; +} const SESSION_TAG_HEADER = 'X-Session-Tag'; @@ -281,8 +295,10 @@ function handleCardMoved(board: HTMLElement, payload: CardMovedPayload): void { // Card is not currently rendered (filtered out, or new since // page load). A targeted column re-fetch is the safe fallback. refetchColumn(board, payload.to_column_id); + showInfoToast(`#${payload.issue_id} → ${columnName(board, payload.to_column_id)}`); return; } + const ref = issueRef(card, payload.issue_id); const target = board.querySelector(`#board_${payload.to_column_id}`); if (!target) return; const fromColumn = card.parentElement; @@ -293,6 +309,7 @@ function handleCardMoved(board: HTMLElement, payload: CardMovedPayload): void { } const toColumnEl = target.closest('.project-column'); if (toColumnEl) updateColumnCount(toColumnEl); + showInfoToast(`${ref} → ${columnName(board, payload.to_column_id)}`); } async function refetchColumn(board: HTMLElement, columnID: number): Promise { @@ -321,15 +338,18 @@ async function refetchColumn(board: HTMLElement, columnID: number): Promise(`.issue-card[data-issue="${payload.issue_id}"]`); if (!card) return; + const ref = issueRef(card, payload.issue_id); const colEl = card.closest('.project-column'); card.remove(); if (colEl) updateColumnCount(colEl); + showInfoToast(`${ref} removed from board`); } function handleColumnCreated(): void { @@ -341,7 +361,11 @@ function handleColumnUpdated(board: HTMLElement, payload: ColumnUpdatedPayload): const colEl = board.querySelector(`.project-column[data-id="${payload.column_id}"]`); if (!colEl) return; const titleEl = colEl.querySelector('.project-column-title-text'); + const oldTitle = titleEl?.textContent?.trim(); if (titleEl) titleEl.textContent = payload.title; + if (oldTitle && oldTitle !== payload.title) { + showInfoToast(`Column renamed to “${payload.title}”`); + } if (payload.color) { const textColor = contrastColor(payload.color); colEl.style.setProperty('background', payload.color, 'important'); @@ -356,7 +380,10 @@ function handleColumnUpdated(board: HTMLElement, payload: ColumnUpdatedPayload): function handleColumnDeleted(board: HTMLElement, payload: ColumnDeletedPayload): void { const colEl = board.querySelector(`.project-column[data-id="${payload.column_id}"]`); - if (colEl) colEl.remove(); + if (!colEl) return; + const name = colEl.querySelector('.project-column-title-text')?.textContent?.trim(); + colEl.remove(); + showInfoToast(name ? `Column “${name}” removed` : 'A column was removed'); } function handleColumnReordered(board: HTMLElement, payload: ColumnReorderedPayload): void { @@ -383,13 +410,12 @@ function handleProjectDeleted(): void { // The board lives at .../projects/{id}; the listing page is the // parent. Falling back to the homepage on any URL we don't // recognise is acceptable since this is a destructive event. + // Show a sticky warning first and delay the redirect briefly so the + // user understands why the page is about to change under them. + showWarningToast('This project was deleted — returning to the project list'); const parts = window.location.pathname.split('/'); - if (parts.length > 1) { - parts.pop(); - window.location.href = parts.join('/') || '/'; - } else { - window.location.href = '/'; - } + const dest = parts.length > 1 ? (parts.slice(0, -1).join('/') || '/') : '/'; + setTimeout(() => { window.location.href = dest }, 1500); } // dispatchProjectEvent picks the right handler for an SSE payload.