Add a favicon badge or title prefix for fresh unread activity in background tabs #3009
Service Desk opened 8 hours ago

Summary

When a signed-in user has an issue, pull request, or list page open in a background browser tab, show a small unread cue on that tab if fresh unread activity arrives after they last looked at the tab. Clear the cue when the tab becomes visible again.

Use exactly one cue per browser:

  • Chromium and Firefox: paint a small circle on the bottom-right of the favicon
  • Safari / WebKit (including iOS): prefix the document title with a filled circle
  • Never show both on the same tab
  • Never mutate <link rel="icon"> on Safari / WebKit (Apple ignores scripted favicons; rewriting the icon URL can leave a blank or sticky icon because of aggressive favicon caching)

This is new UI behavior. It is not implemented today.

Motivation

Unread activity is already visible inside the page:

  • List rows get a left stripe via class new on tr.issue, tr.request, or tr.comment (column class new-indicator, background var(--primary))
  • Issue and pull request activity streams mark unseen items with class new on the activity row

Those markers do nothing for a tab that is not in view. A favicon badge (or a title prefix where a badge cannot work) is the in-page equivalent of a browser-extension toolbar badge, limited to tabs that are already loaded.

A favicon cannot be badged if no OneDev tab exists. Closing the tab drops the cue. That limitation is accepted.

Browser cue

Favicon badge (Chromium, Firefox)

  1. Draw the current site icon onto a canvas
  2. Paint a small circle in the bottom-right corner (use the same primary color as .new-indicator)
  3. Replace <link rel="icon"> with the canvas image (data URL)
  4. Restore the original href when the cue is cleared

The icon is same-origin, so canvas drawing is not blocked by CORS:

  • Default: /~img/logo.png (BasePage.html, wicket id siteIcon)
  • Custom branding: /logo.png?v=… (BasePage.java when site/assets/logo.png exists)

Detect Safari / WebKit and skip the icon swap there. Reuse or extend onedev.server.util.isSafari() in base.js (today: safari in the UA and not chrome). Treat iOS / iPadOS WebKit the same way (including iOS Chrome, which is WebKit). Chromium (desktop Chrome, Edge) and Firefox get the badge.

Title prefix (Safari / WebKit only)

Prefix document.title with and a space, for example ● Fix login (PROJ-12).

OneDev already overwrites document.title during in-page history navigation (onedev.server.history.pushState / replaceState in server-core/src/main/java/io/onedev/server/web/page/base/base.js). The prefix must go through that path so AJAX navigation does not drop it or double it. When the cue is active, re-apply the prefix after every title write. When the cue is cleared, strip it.

When the cue appears

The cue means something happened since you last looked at this tab, not “this page currently has unread items.”

Shared lifecycle

  1. Baseline on page load and again whenever the tab becomes visible / focused (document.visibilityState === 'visible' / visibilitychange, plus focus if needed)
  2. Show the cue only while the tab is in the background (document.hidden) and a fresh qualifying update arrives after the last baseline
  3. Clear the cue when the user opens the tab, then re-baseline immediately so leftover unread markers do not re-fire when they switch away again
  4. A second update after that look may show the cue again. Reloading the page is not required, and is not sufficient by itself on list pages (see below)

Anonymous users never get unread markers (isVisitedAfter returns true when there is no auth user). The feature is a no-op for them.

Issue and pull request detail pages

Pages: IssueDetailPage and subclasses (including IssueActivitiesPage); PullRequestDetailPage and subclasses (including the activities tab).

Signal: new unseen activity on the loaded issue or pull request after the last baseline.

In-page markers already used for this:

  • Issues: IssueActivitiesPanel adds class new to activity rows when !issue.isVisitedAfter(activity.getDate()). Live WebSocket updates (ChangeObserverupdateActivities) append new rows with class new. CSS: .issue-activities > .activity.new
  • Pull requests: same pattern in PullRequestActivitiesPage. CSS: .pull-request-detail > .card-body > .main > .activities > .activity.new

Refresh after visiting the entity clears those .new markers (visit date is updated). That is why a detail-page cue may follow any in-page .new activity: refresh is enough to drop the stripe.

On non-activities tabs of the same detail page, the activity list may not be in the DOM. Still show the cue if the loaded issue or PR receives new activity while that browser tab is in the background (the same WebSocket / ChangeObserver notifications those pages already use). Do not drive the detail-page cue from nested lists on that page (for example IssuePullRequestsPanel).

List pages (issues, pull requests, code comments)

Unread on lists is sticky until you open the item. Refresh does not clear an unread stripe. So the list cue must ignore rows that were already unread at the last baseline.

Signal: a row that was not unread at last baseline becomes unread. No cue for further activity on a row that was already unread.

The unread stripe is a boolean class (tr.issue.new, tr.request.new, tr.comment.new). Another comment on an already-unread item does not add a second stripe. Track unread entity ids at baseline (not merely “is there any .new in the table”), then watch live row updates:

  • Existing visited row whose class changes from issue / request / comment to … new → cue
  • Newly inserted unread row whose id was not in the baseline unread set → cue
  • Row that was already new at baseline, even if last activity changes → no cue

Lists already re-render rows via ChangeObserver (handler.add(component)). IssueListPanel does not currently insert brand-new issues into the table without a query refresh; do not expand list live-update scope for this work. Piggyback on whatever DOM / Wicket updates already happen.

Where the list rule applies (same unread column meaning everywhere these lists appear):

ListRow classPanelTypical pages
Issuestr.issue.newIssueListPanelProjectIssueListPage, IssueListPage (global / “my”), IterationIssuesPage, FixedIssuesPage
Pull requeststr.request.newPullRequestListPanelProjectPullRequestsPage, PullRequestListPage
Code commentstr.comment.newCodeCommentListPanelProjectCodeCommentsPage, PullRequestCodeCommentsPage

Unread computation today:

  • IssueListPanel: issue.isVisitedAfter(issue.getLastActivity().getDate()) ? "issue" : "issue new"
  • PullRequestListPanel: same with request
  • CodeCommentListPanel: same with comment

On PullRequestCodeCommentsPage (a PR detail tab that is also a code-comment list), apply the list rule to that comment table. Do not treat leftover unread comments from page load as a reason to badge.

Out of scope

  • Browser-extension toolbar icons
  • Issue boards / CardDetailPanel
  • Code comment detail (CodeCommentPanel, .code-comment > div.new)
  • Dashboards and other pages that use unrelated CSS class new (for example diff “new” lines)
  • Showing favicon badge and title prefix together
  • Mutating the favicon on Safari / WebKit
  • Re-badging when the user switches away if the only unread items are ones they already saw on this load / last focus

Implementation notes

  • Centralize cue state (active flag, original icon href, original title, baseline unread ids) in shared page JavaScript, likely next to base.js / BasePage, and enable it on the pages above.
  • Hook visibility (document.hidden / visibilitychange) to clear and re-baseline.
  • After Wicket AJAX / ChangeObserver updates, re-evaluate against the baseline. A MutationObserver on the activities list / table body is acceptable if it is cheaper than per-panel hooks.
  • Title prefix must survive onedev.server.history.pushState / replaceState.
  • Favicon element: <link wicket:id="siteIcon" rel="icon" type="image/png" href="/~img/logo.png"> in BasePage.html.
  • Do not badge or prefix until a qualifying fresh update exists; do not flash the cue on load just because the list already has unread rows.

Acceptance

  • Signed-in user, issue or PR detail in a background tab: a new comment/activity shows the Chromium/Firefox favicon badge or the Safari title prefix; opening the tab clears it; another new activity after that look can show it again.
  • Signed-in user, issue / PR / code-comment list in a background tab: unread rows that were already there at load (or last focus) do not show the cue; a row that newly becomes unread does; opening the tab clears the cue and does not re-show it until another row becomes newly unread.
  • Refresh on a detail page can drop in-page .new activity markers; refresh on a list does not drop unread stripes, and must not by itself show the tab cue.
  • Chromium/Firefox never use the title prefix; Safari / WebKit never change the favicon; a tab never shows both.
  • Custom branding logo still badges correctly (same-origin /logo.png).
  • Anonymous users unchanged (no unread markers, no cue).
  • In-page AJAX navigation does not drop, duplicate, or leave a stale prefix.
1/1
Type
Improvement
Priority
Normal
Assignee
Labels
No labels
Issue Votes (1)
Watchers (3)
Reference
OD-3009
Please wait...
Connection lost or session expired, reload to recover
Page is in error, reload to recover