From 707ee4c1bcb87e8ff10732210caff0748e22b764 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 13 Aug 2026 17:55:33 +0000 Subject: [PATCH] feat: open updates at newest activity (Closes #759) --- frontend/dashboard.css | 3 ++ frontend/dashboard.js | 8 +++ frontend/index.html | 2 + frontend/service-worker.js | 1 + frontend/update-read-position.js | 49 +++++++++++++++++ tests/test_service_worker.py | 1 + tests/test_update_read_position.py | 87 ++++++++++++++++++++++++++++++ 7 files changed, 151 insertions(+) create mode 100644 frontend/update-read-position.js create mode 100644 tests/test_update_read_position.py diff --git a/frontend/dashboard.css b/frontend/dashboard.css index a1b8e37..023a443 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -385,6 +385,9 @@ textarea { resize: vertical; min-height: 120px; } .update-sheet-header button { min-height:44px; } .update-triage-progress { margin:10px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; } .update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; } +.update-new-activity { scroll-margin-block:18px 96px; border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); } +.update-new-activity::before { content:'New activity'; display:inline-block; margin-bottom:6px; padding:2px 7px; border-radius:999px; background:#1d4ed8; color:#fff; font-size:12px; font-weight:700; } +.update-new-activity-jump { position:sticky; bottom:92px; z-index:4; min-height:44px; width:100%; margin:10px 0; border-color:#60a5fa; background:#17345a; } .update-reply { display:grid; gap:8px; margin-top:16px; } .update-reply textarea { width:100%; min-height:112px; resize:vertical; } .update-reply button { min-height:44px; width:100%; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 1c2548f..7f49d4a 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -910,6 +910,11 @@ refresh: refreshMyWorkView, select: qs, }); + const updateReadPosition = createUpdateReadPosition({ + panel: qs('#update-sheet .update-sheet-panel'), + jump: qs('#jump-update-new-activity'), + }); + qs('#jump-update-new-activity').addEventListener('click', () => updateReadPosition.jump()); const notificationReader = createNotificationReader({ load: fetchNotificationDetail, getScope: () => confirmedOwnerLogin, @@ -924,6 +929,7 @@ } selectedUpdate = item; selectedUpdateDetail = null; + updateReadPosition.open(String(item.notification_id)); updateMentions.dismiss(); qs('#update-sheet').classList.add('open'); qs('#update-sheet-key').textContent = item.key || ''; @@ -2984,6 +2990,8 @@ qs('#load-older-update-comments').hidden = !Number.isInteger(state?.older_page); qs('#update-conversation-status').textContent = comments.length ? comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.'; + const newest = qs('#update-comments .issue-comment:last-child'); + updateReadPosition.ready(String(selectedUpdate?.notification_id || ''), newest); } function renderPullConversation(state) { diff --git a/frontend/index.html b/frontend/index.html index 94f7c8f..e9a804a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -760,6 +760,7 @@

Full conversation

+
@@ -1074,6 +1075,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index c389342..193cb4a 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -77,6 +77,7 @@ const SHELL = [ BASE + 'static/mobile-work-entry.js', BASE + 'static/mobile-queue-launcher.js', BASE + 'static/update-triage-session.js', + BASE + 'static/update-read-position.js', BASE + 'static/update-triage-launcher.js', BASE + 'static/agenda-session-launcher.js', BASE + 'static/mobile-launch.js', diff --git a/frontend/update-read-position.js b/frontend/update-read-position.js new file mode 100644 index 0000000..c2a524f --- /dev/null +++ b/frontend/update-read-position.js @@ -0,0 +1,49 @@ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory; + else root.createUpdateReadPosition = factory; +})(typeof self !== 'undefined' ? self : this, function createUpdateReadPosition(options) { + const panel = options.panel; + const jump = options.jump; + let identity = ''; + let openingTop = 0; + let movedByUser = false; + let positioned = false; + let target = null; + + function moveToTarget() { + if (!target) return false; + target.classList?.add('update-new-activity'); + target.scrollIntoView({ block:'end' }); + positioned = true; + jump.hidden = true; + return true; + } + + panel.addEventListener('scroll', () => { + if (identity && !positioned && panel.scrollTop !== openingTop) movedByUser = true; + }, { passive:true }); + + return { + open(nextIdentity) { + identity = String(nextIdentity || ''); + openingTop = panel.scrollTop; + movedByUser = false; + positioned = false; + target = null; + jump.hidden = true; + }, + ready(readyIdentity, newest) { + if (!identity || String(readyIdentity || '') !== identity || positioned || !newest) return false; + target = newest; + target.classList?.add('update-new-activity'); + if (movedByUser) { + jump.hidden = false; + return false; + } + return moveToTarget(); + }, + jump() { + return moveToTarget(); + }, + }; +}); diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 8bbdb1e..045122f 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -847,6 +847,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/mobile-work-entry.js", "/dashboard/static/mobile-queue-launcher.js", "/dashboard/static/update-triage-session.js", + "/dashboard/static/update-read-position.js", "/dashboard/static/update-triage-launcher.js", "/dashboard/static/agenda-session-launcher.js", "/dashboard/static/mobile-launch.js", diff --git a/tests/test_update_read_position.py b/tests/test_update_read_position.py new file mode 100644 index 0000000..98bded8 --- /dev/null +++ b/tests/test_update_read_position.py @@ -0,0 +1,87 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from tests.dashboard_bundle import dashboard + + +POSITIONER = Path(__file__).resolve().parents[1] / "frontend" / "update-read-position.js" + + +def run_positioner(script: str) -> dict: + source = f"const createPositioner = require({json.dumps(str(POSITIONER))});\n" + script + result = subprocess.run(["node", "-e", source], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_new_update_scrolls_to_newest_activity_once_without_reusing_previous_offset(): + result = run_positioner(""" +const listeners = {}; +const panel = {scrollTop:480, addEventListener:(name, fn)=>listeners[name]=fn}; +const jump = {hidden:true, focus(){}}; +const first = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}}; +const second = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}}; +const positioner = createPositioner({panel, jump}); +positioner.open('101'); +positioner.ready('101', first); +positioner.ready('101', first); +panel.scrollTop = 920; +positioner.open('102'); +positioner.ready('102', second); +process.stdout.write(JSON.stringify({first:first.calls, second:second.calls, jumpHidden:jump.hidden})); +""") + + assert result == {"first": 1, "second": 1, "jumpHidden": True} + + +def test_user_scroll_during_loading_preserves_position_until_explicit_jump(): + result = run_positioner(""" +const listeners = {}; +const panel = {scrollTop:120, addEventListener:(name, fn)=>listeners[name]=fn}; +const jump = {hidden:true, focus(){}}; +const target = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}}; +const positioner = createPositioner({panel, jump}); +positioner.open('201'); +panel.scrollTop = 260; +listeners.scroll(); +positioner.ready('201', target); +const before = {calls:target.calls, hidden:jump.hidden, top:panel.scrollTop}; +positioner.jump(); +process.stdout.write(JSON.stringify({before, after:{calls:target.calls, hidden:jump.hidden}})); +""") + + assert result == { + "before": {"calls": 0, "hidden": False, "top": 260}, + "after": {"calls": 1, "hidden": True}, + } + + +def test_stale_or_prefetched_conversation_cannot_move_the_visible_update(): + result = run_positioner(""" +const panel = {scrollTop:0, addEventListener(){}}; +const jump = {hidden:true}; +const stale = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}}; +const current = {calls:0, scrollIntoView(){this.calls += 1}, classList:{add(){}}}; +const positioner = createPositioner({panel, jump}); +positioner.open('302'); +positioner.ready('301', stale); +positioner.ready('302', current); +process.stdout.write(JSON.stringify({stale:stale.calls, current:current.calls})); +""") + + assert result == {"stale": 0, "current": 1} + + +@pytest.mark.anyio +async def test_dashboard_mounts_mobile_new_activity_positioning_flow(): + html = await dashboard() + + assert '' in html + assert 'id="jump-update-new-activity"' in html + assert "updateReadPosition.open(String(item.notification_id))" in html + assert "updateReadPosition.ready(String(selectedUpdate?.notification_id || ''), newest)" in html + assert ".update-new-activity" in html + assert "BASE + 'static/update-read-position.js'" in (POSITIONER.parent / "service-worker.js").read_text() -- 2.43.0