Undo accidental mobile update acknowledgements #650

Merged
timmy merged 1 commits from timmy/649-undo-update-acknowledgement into main 2026-08-12 12:37:13 +00:00
12 changed files with 247 additions and 5 deletions

View File

@ -10,6 +10,9 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.app-menu > summary { display:none; }
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
.notification-undo[hidden] { display:none; }
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.draft-capacity-sheet[hidden] { display:none; }
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }

View File

@ -786,6 +786,15 @@
if (state.message) qs('#update-sheet-status').textContent = state.message;
},
});
const notificationUndo = createDashboardNotificationUndo({
restore: requestNotificationUnread,
getItems: () => lastMyWork,
setItems: items => { lastMyWork = items; },
getNotifications: () => lastNotifications,
setNotifications: items => { lastNotifications = items; },
refresh: refreshMyWorkView,
select: qs,
});
const notificationReader = createNotificationReader({
load: fetchNotificationDetail,
loadConversation: fetchNotificationConversation,
@ -5034,6 +5043,9 @@
if (result?.accepted) qs('#my-work-action-status').textContent =
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
'Reply and read acknowledgement queued for sync.';
if (result?.delivery === 'posted' && result?.next) {
notificationUndo.offer(item, result.next.items);
}
} catch (error) {
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#update-reply').focus();
@ -5046,7 +5058,8 @@
qs('#mark-update-read-next').addEventListener('click', async () => {
qs('#mark-update-read-next').disabled = true;
try {
await notificationReader.markReadAndNext(lastMyWork);
const result = await notificationReader.markReadAndNext(lastMyWork);
if (result) notificationUndo.offer(result.item, result.items);
} finally {
qs('#mark-update-read-next').disabled = false;
}
@ -5056,11 +5069,17 @@
button.disabled = true;
try {
const result = await notificationReader.acknowledgeAndNext(lastMyWork);
if (result) qs('#my-work-action-status').textContent = 'Update acknowledged with 👍.';
if (result) notificationUndo.offer(result.item, result.items);
} finally {
button.disabled = offlineWorkMode;
}
});
qs('#undo-notification').addEventListener('click', async () => {
const button = qs('#undo-notification');
button.disabled = true;
const restored = await notificationUndo.run();
button.disabled = restored;
});
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
qs('#retry-review-load').addEventListener('click', () => {
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);

View File

@ -688,6 +688,11 @@
</section>
</div>
<div class="notification-undo" id="notification-undo" role="status" aria-live="assertive" hidden>
<span id="notification-undo-status"></span>
<button id="undo-notification" type="button" aria-label="Undo marking update read">Undo</button>
</div>
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
<section class="pull-sheet-panel">
<div class="pull-sheet-header">
@ -899,6 +904,7 @@
<script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script>
<script src="static/notification-undo.js"></script>
<script src="static/card-planning.js"></script>
<script src="static/today-work.js"></script>
<script src="static/today-timer.js"></script>

View File

@ -362,7 +362,7 @@ function createNotificationReader({
onClose();
onStatus('Inbox cleared.');
}
return { items: updated, next: next || null };
return { items: updated, item: current, next: next || null };
}
async function open(item, savedDetail = null) {

View File

@ -0,0 +1,85 @@
function createNotificationUndo({ restore, onItems, onStatus, onOffer = () => {}, onClear = () => {} }) {
let offered = null;
let pending = false;
return {
offer(item, items) {
if (!item || !Number.isInteger(item.notification_id)) return false;
offered = { item, items: Array.isArray(items) ? [...items] : [] };
const label = item.key || 'Update';
onStatus(label + ' marked read. Undo?');
onOffer(item);
return true;
},
async run() {
if (!offered || pending) return false;
const current = offered;
const label = current.item.key || 'Update';
pending = true;
onStatus('Restoring ' + label + '…');
try {
await restore(current.item.notification_id);
if (offered !== current) return false;
const restored = [current.item].concat(current.items.filter(
item => item?.notification_id !== current.item.notification_id
));
offered = null;
onItems(restored, current.item);
onStatus(label + ' is unread again.');
onClear();
return true;
} catch (_error) {
onStatus('Could not restore ' + label + '. Retry Undo.');
return false;
} finally {
pending = false;
}
},
clear() {
offered = null;
onClear();
},
};
}
async function requestNotificationUnread(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/unread', {
method: 'PATCH', headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Restore unread failed.');
return payload;
}
function createDashboardNotificationUndo({ restore, getItems, setItems, getNotifications, setNotifications, refresh, select }) {
let timer = null;
const controller = createNotificationUndo({
restore,
onItems: (items, restored) => {
setItems(items);
const notifications = getNotifications();
if (!notifications.some(item => item.id === restored.notification_id)) {
setNotifications([{ id:restored.notification_id }].concat(notifications));
}
refresh();
},
onStatus: message => {
select('#notification-undo-status').textContent = message;
select('#my-work-action-status').textContent = message;
},
onOffer: item => {
clearTimeout(timer);
select('#notification-undo').hidden = false;
select('#undo-notification').disabled = false;
select('#undo-notification').setAttribute('aria-label', 'Undo marking ' + (item.key || 'update') + ' read');
timer = setTimeout(() => controller.clear(), 10000);
},
onClear: () => { select('#notification-undo').hidden = true; },
});
return controller;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = createNotificationUndo;
module.exports.createDashboardNotificationUndo = createDashboardNotificationUndo;
}

View File

@ -32,6 +32,7 @@ const SHELL = [
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/notification-undo.js',
BASE + 'static/card-planning.js',
BASE + 'static/today-work.js',
BASE + 'static/today-timer.js',

View File

@ -28,7 +28,7 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",),
"today-timer": (
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
"static/mobile-task-dock.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/drafts.js", "static/unfiled-captures.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js",
),

View File

@ -804,6 +804,14 @@ async def mark_notification_read(thread_id: int) -> None:
response.raise_for_status()
async def mark_notification_unread(thread_id: int) -> None:
response = await _get_client().patch(
f"/api/v1/notifications/threads/{thread_id}?to-status=unread",
headers=_auth(),
)
response.raise_for_status()
async def acknowledge_notification(thread_id: int) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):

View File

@ -3452,6 +3452,27 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
return JSONResponse({"id": thread_id, "status": "read"})
@app.patch("/api/v1/notifications/{thread_id}/unread")
async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
try:
await asyncio.wait_for(
gitea_proxy.mark_notification_unread(thread_id),
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
)
except TimeoutError:
return JSONResponse(
{"error": "Restoring the update timed out. Retry Undo."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "The update could not be restored. Retry Undo."},
status_code=503,
)
return JSONResponse({"id": thread_id, "status": "unread"})
@app.patch("/api/v1/notifications/{thread_id}/later")
async def defer_notification(
payload: NotificationLaterRequest,

View File

@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
NOTIFICATION_UNDO = Path(__file__).parents[1] / "frontend" / "notification-undo.js"
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
@ -4123,6 +4124,58 @@ const reader = buildMyWork.createNotificationReader({{
assert output["results"][1] is False
def test_notification_undo_restores_exact_item_once_and_keeps_failure_retryable():
script = f"""
const createNotificationUndo = require({json.dumps(str(NOTIFICATION_UNDO))});
const states = [];
const statuses = [];
const calls = [];
let attempts = 0;
const item = {{kind:'update', key:'repo#7', notification_id:42, has_update:true}};
const undo = createNotificationUndo({{
restore: async id => {{ calls.push(id); attempts += 1; if (attempts === 1) throw new Error('offline'); }},
onItems: items => states.push(items.map(value => value.notification_id)),
onStatus: status => statuses.push(status),
}});
(async () => {{
undo.offer(item, [{{kind:'update', notification_id:43, has_update:true}}]);
const first = undo.run();
const duplicate = undo.run();
const firstResults = await Promise.all([first, duplicate]);
const retry = await undo.run();
process.stdout.write(JSON.stringify({{calls, states, statuses, firstResults, retry}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["calls"] == [42, 42]
assert output["states"] == [[42, 43]]
assert output["firstResults"] == [False, False]
assert output["retry"] is True
assert output["statuses"] == [
"repo#7 marked read. Undo?",
"Restoring repo#7…",
"Could not restore repo#7. Retry Undo.",
"Restoring repo#7…",
"repo#7 is unread again.",
]
@pytest.mark.anyio
async def test_mobile_update_undo_is_accessible_and_safe_area_aware():
html = await dashboard()
assert 'id="notification-undo"' in html
assert 'id="undo-notification" type="button"' in html
assert "requestNotificationUnread" in html
assert "createDashboardNotificationUndo({" in html
assert ".notification-undo { position:fixed;" in html
assert "env(safe-area-inset-bottom)" in html
assert ".notification-undo button { min-height:44px;" in html
@pytest.mark.anyio
async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next():
html = await dashboard()
@ -4401,7 +4454,9 @@ reader.open(item, [item]).then(() => {{
)
output = json.loads(result.stdout)
assert output["result"] == {"items": [], "next": None}
assert output["result"] == {"items": [], "item": {
"kind": "update", "notification_id": 42, "has_update": True,
}, "next": None}
assert output["events"] == [
["status", "Marking update read…"],
["items", []],

View File

@ -41,6 +41,29 @@ async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monk
]
@pytest.mark.anyio
async def test_mark_notification_unread_calls_supported_gitea_thread_endpoint(monkeypatch):
calls = []
class Response:
def raise_for_status(self):
return None
class Client:
async def patch(self, path, headers):
calls.append((path, headers))
return Response()
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
await gitea_proxy.mark_notification_unread(42)
assert calls == [
("/api/v1/notifications/threads/42?to-status=unread", {"Authorization": "token test"})
]
@pytest.mark.anyio
async def test_acknowledge_notification_resolves_latest_comment_and_adds_one_reaction(monkeypatch):
calls = []
@ -172,6 +195,26 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
assert marked == [42]
@pytest.mark.anyio
async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monkeypatch):
restored = []
async def mark_unread(thread_id):
restored.append(thread_id)
monkeypatch.setattr(main.gitea_proxy, "mark_notification_unread", mark_unread, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch("/api/v1/notifications/42/unread")
invalid = await client.patch("/api/v1/notifications/0/unread")
assert response.status_code == 200
assert response.json() == {"id": 42, "status": "unread"}
assert response.headers["cache-control"] == "no-store"
assert invalid.status_code == 422
assert restored == [42]
@pytest.mark.anyio
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
calls = []

View File

@ -695,6 +695,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",
"/dashboard/static/notification-undo.js",
"/dashboard/static/card-planning.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-timer.js",