From b4f3c6d419d5cf5b1b290f92341c53d8acf0d497 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 25 Aug 2026 05:09:32 +0000 Subject: [PATCH] feat: react to mobile conversation comments (Closes #1380) --- frontend/comment-actions.js | 97 +++++- frontend/dashboard.css | 6 + src/gitea_proxy.py | 80 +++++ src/main.py | 142 ++++++++ .../test_mobile_comment_reactions_release.py | 77 +++++ tests/test_comment_reactions.py | 308 ++++++++++++++++++ 6 files changed, 708 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/test_mobile_comment_reactions_release.py create mode 100644 tests/test_comment_reactions.py diff --git a/frontend/comment-actions.js b/frontend/comment-actions.js index 15e42b9..8b26308 100644 --- a/frontend/comment-actions.js +++ b/frontend/comment-actions.js @@ -1,4 +1,8 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) { + const reactionLabels = { + '+1': 'Thumbs up', '-1': 'Thumbs down', laugh: 'Laugh', hooray: 'Hooray', + confused: 'Confused', heart: 'Heart', rocket: 'Rocket', eyes: 'Eyes', + }; const encodedRepository = repository => String(repository || '').split('/') .map(encodeURIComponent).join('/'); @@ -14,6 +18,12 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false '/comments/' + encodeURIComponent(commentId); } + function reactionPath(context, commentId, content = '') { + return pathFor(context, commentId) + '/reactions' + + (content ? '/' + encodeURIComponent(content) : ''); + } + + const pendingReactions = new Set(); const controller = { isOwned(comment) { const login = String(getLogin() || '').trim(); @@ -41,15 +51,98 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false pager.remove(commentId); return pager.snapshot(); }, + async loadReactions(context, commentId) { + return fetchJson(reactionPath(context, commentId), { + headers: { Accept: 'application/json' }, + }); + }, + async setReaction(context, commentId, content, active) { + return fetchJson(reactionPath(context, commentId, content), { + method: 'PUT', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ active: Boolean(active) }), + }); + }, + reactionHtml(state) { + const current = new Map((state?.reactions || []).map(item => [item.content, item])); + return Object.entries(reactionLabels).map(([content, label]) => { + const item = current.get(content) || {}; + const count = Number.isInteger(item.count) && item.count > 0 ? item.count : 0; + const selected = item.selected === true; + return ''; + }).join('') + ''; + }, actionHtml(comment) { - return controller.isOwned(comment) ? + const owned = controller.isOwned(comment) ? '
' + '' + '
' : ''; + return owned + '
' + + '' + + '
'; }, wire({ root, getSurface, isOffline, escapeHtml }) { root.addEventListener('click', async event => { - const button = event.target.closest('[data-comment-action]'); + const actionButton = event.target.closest('[data-comment-action]'); + const reactionClose = actionButton ? null : event.target.closest('[data-comment-reactions-close]'); + const reactionButton = actionButton ? null : event.target.closest('[data-comment-reaction]'); + const reactionTrigger = actionButton ? null : event.target.closest('[data-comment-reactions-open]'); + if (reactionClose) { + const card = reactionClose.closest('.issue-comment'); + const menu = card?.querySelector('[data-comment-reaction-menu]'); + const trigger = card?.querySelector('[data-comment-reactions-open]'); + if (menu && trigger) { + menu.hidden = true; + trigger.setAttribute('aria-expanded', 'false'); + trigger.focus(); + } + return; + } + if (reactionButton || reactionTrigger) { + const control = reactionButton || reactionTrigger; + const card = control.closest('.issue-comment'); + const commentId = Number(card?.dataset.commentId); + const surface = getSurface(); + const menu = card?.querySelector('[data-comment-reaction-menu]'); + const trigger = card?.querySelector('[data-comment-reactions-open]'); + if (!commentId || !menu || !trigger) return; + if (isOffline()) { + surface.status.textContent = 'Reconnect to react.'; + return; + } + if (pendingReactions.has(commentId)) return; + pendingReactions.add(commentId); + control.disabled = true; + try { + if (reactionButton) { + const content = reactionButton.dataset.commentReaction; + const active = reactionButton.getAttribute('aria-pressed') !== 'true'; + const state = await controller.setReaction(surface.context, commentId, content, active); + menu.innerHTML = controller.reactionHtml(state); + menu.hidden = true; + trigger.setAttribute('aria-expanded', 'false'); + trigger.focus(); + surface.status.textContent = 'Reaction updated.'; + } else { + surface.status.textContent = 'Loading reactions…'; + const state = await controller.loadReactions(surface.context, commentId); + menu.innerHTML = controller.reactionHtml(state); + menu.hidden = false; + trigger.setAttribute('aria-expanded', 'true'); + surface.status.textContent = ''; + } + } catch (error) { + surface.status.textContent = error.message || 'Reactions are unavailable. Please retry.'; + } finally { + pendingReactions.delete(commentId); + control.disabled = false; + } + return; + } + const button = actionButton; if (!button) return; const card = button.closest('.issue-comment'); const commentId = Number(card?.dataset.commentId); diff --git a/frontend/dashboard.css b/frontend/dashboard.css index e1ae6a9..0dd7ffd 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -146,6 +146,12 @@ button:hover { filter: brightness(1.15); } .issue-comment { min-width:0; overflow-wrap:anywhere; } .comment-owned-actions { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; } .comment-owned-actions button { min-height:44px; min-width:72px; } +.comment-reactions { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin:8px 0; min-width:0; } +.comment-reactions > button { min-height:44px; min-width:72px; } +.comment-reaction-menu { display:flex; gap:8px; flex-wrap:wrap; width:100%; min-width:0; } +.comment-reaction-menu[hidden] { display:none; } +.comment-reaction-menu button { min-height:44px; min-width:88px; flex:1 1 104px; overflow-wrap:anywhere; } +.comment-reaction-menu button[aria-pressed="true"] { border-color:#4ade80; background:#123c2b; color:#dcfce7; } .comment-edit-textarea { box-sizing:border-box; display:block; width:100%; max-width:100%; min-height:132px; resize:vertical; overflow-wrap:anywhere; } .panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); } .panel > summary { cursor: pointer; list-style-position: inside; } diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index d14f300..a2dc298 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -1381,6 +1381,86 @@ def _normalize_issue_comment(comment: dict) -> dict: } +COMMENT_REACTIONS = ("+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes") + + +async def _conversation_comment(repository: str, number: int, comment_id: int) -> dict: + comment = await fetch(f"repos/{repository}/issues/comments/{comment_id}") + expected_issue_path = f"repos/{repository}/issues/{number}" + issue_url = comment.get("issue_url") if isinstance(comment, dict) else None + safe_issue_url = _safe_gitea_web_url(issue_url) + web_path = urlsplit(safe_issue_url).path.rstrip("/") if safe_issue_url else "" + if ( + not isinstance(comment, dict) + or ( + _gitea_api_path(issue_url) != expected_issue_path + and not web_path.endswith(f"/{repository}/issues/{number}") + ) + ): + raise CommentMutationForbiddenError("Comment is not part of this conversation") + return comment + + +async def comment_reactions(repository: str, number: int, comment_id: int) -> dict: + user = await current_user() + login = user.get("login") if isinstance(user, dict) else None + if not isinstance(login, str) or not login: + raise ValueError("Authenticated Gitea user is unavailable") + await _conversation_comment(repository, number, comment_id) + path = f"/api/v1/repos/{repository}/issues/comments/{comment_id}/reactions" + response = await _get_client().get(path, headers=_auth()) + response.raise_for_status() + payload = response.json() + counts: dict[str, int] = {} + selected: set[str] = set() + for reaction in payload if isinstance(payload, list) else []: + content = reaction.get("content") if isinstance(reaction, dict) else None + author = reaction.get("user") if isinstance(reaction, dict) else None + author_login = author.get("login") if isinstance(author, dict) else None + if content not in COMMENT_REACTIONS or not isinstance(author_login, str) or not author_login: + continue + counts[content] = counts.get(content, 0) + 1 + if author_login == login: + selected.add(content) + return { + "comment_id": comment_id, + "reactions": [ + {"content": content, "count": counts[content], "selected": content in selected} + for content in COMMENT_REACTIONS + if counts.get(content) + ], + } + + +async def set_comment_reaction( + repository: str, number: int, comment_id: int, content: str, active: bool +) -> dict: + if content not in COMMENT_REACTIONS: + raise ValueError("Unsupported comment reaction") + before = await comment_reactions(repository, number, comment_id) + selected = any( + item["content"] == content and item["selected"] + for item in before["reactions"] + ) + path = f"/api/v1/repos/{repository}/issues/comments/{comment_id}/reactions" + if selected != active: + client = _get_client() + response = ( + await client.post(path, headers=_auth(), json={"content": content}) + if active + else await client.delete(path, headers=_auth(), json={"content": content}) + ) + response.raise_for_status() + confirmed = await comment_reactions(repository, number, comment_id) + confirmed_selected = any( + item["content"] == content and item["selected"] + for item in confirmed["reactions"] + ) + if confirmed_selected != active: + raise ValueError("Gitea did not confirm the requested reaction state") + return confirmed + + async def comment_on_issue(repository: str, number: int, body: str) -> dict: response = await _get_client().post( f"/api/v1/repos/{repository}/issues/{number}/comments", diff --git a/src/main.py b/src/main.py index 0a8f300..b9c812b 100644 --- a/src/main.py +++ b/src/main.py @@ -987,6 +987,10 @@ class IssueComment(BaseModel): return value +class CommentReactionState(BaseModel): + active: bool + + def _validate_attachment_metadata(filename: str, content_type: str) -> None: expected = { "image/png": {"png"}, @@ -6441,6 +6445,144 @@ async def delete_notification_comment( ) +async def _read_conversation_reactions( + repository: str, number: int, comment_id: int +) -> JSONResponse: + try: + result = await asyncio.wait_for( + gitea_proxy.comment_reactions(repository, number, comment_id), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except gitea_proxy.CommentMutationForbiddenError as exc: + raise HTTPException(status_code=403, detail="Comment is not part of this conversation") from exc + except Exception: + return JSONResponse( + {"error": "Reactions could not be loaded. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result) + + +async def _set_conversation_reaction( + repository: str, + number: int, + comment_id: int, + content: str, + active: bool, +) -> JSONResponse: + try: + result = await asyncio.wait_for( + gitea_proxy.set_comment_reaction( + repository, number, comment_id, content, active + ), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except gitea_proxy.CommentMutationForbiddenError as exc: + raise HTTPException(status_code=403, detail="Comment is not part of this conversation") from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except Exception: + return JSONResponse( + {"error": "The reaction could not be confirmed. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result) + + +@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}/reactions") +async def assigned_issue_comment_reactions( + owner: str, + repo: str, + number: int = PathParam(gt=0), + comment_id: int = PathParam(gt=0), +): + repository = f"{owner}/{repo}" + if not await gitea_proxy.is_assigned_issue(repository, number): + raise HTTPException(status_code=404, detail="Assigned issue not found") + return await _read_conversation_reactions(repository, number, comment_id) + + +@app.put("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}/reactions/{content}") +async def set_assigned_issue_comment_reaction( + state: CommentReactionState, + owner: str, + repo: str, + content: str, + number: int = PathParam(gt=0), + comment_id: int = PathParam(gt=0), +): + repository = f"{owner}/{repo}" + if not await gitea_proxy.is_assigned_issue(repository, number): + raise HTTPException(status_code=404, detail="Assigned issue not found") + return await _set_conversation_reaction( + repository, number, comment_id, content, state.active + ) + + +@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}/reactions") +async def assigned_pull_comment_reactions( + owner: str, + repo: str, + number: int = PathParam(gt=0), + comment_id: int = PathParam(gt=0), +): + repository = f"{owner}/{repo}" + if not _has_pull_workspace_access( + await _pull_workspace_capabilities(repository, number) + ): + raise HTTPException(status_code=404, detail="Pull request not found") + return await _read_conversation_reactions(repository, number, comment_id) + + +@app.put("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}/reactions/{content}") +async def set_assigned_pull_comment_reaction( + state: CommentReactionState, + owner: str, + repo: str, + content: str, + number: int = PathParam(gt=0), + comment_id: int = PathParam(gt=0), +): + repository = f"{owner}/{repo}" + if not _has_pull_workspace_access( + await _pull_workspace_capabilities(repository, number) + ): + raise HTTPException(status_code=404, detail="Pull request not found") + return await _set_conversation_reaction( + repository, number, comment_id, content, state.active + ) + + +@app.get("/api/v1/notifications/{thread_id}/comments/{comment_id}/reactions") +async def notification_comment_reactions( + thread_id: int = PathParam(gt=0), + comment_id: int = PathParam(gt=0), +): + try: + repository, number = await gitea_proxy.notification_conversation_target(thread_id) + except Exception as exc: + raise HTTPException(status_code=404, detail="Notification conversation not found") from exc + return await _read_conversation_reactions(repository, number, comment_id) + + +@app.put("/api/v1/notifications/{thread_id}/comments/{comment_id}/reactions/{content}") +async def set_notification_comment_reaction( + state: CommentReactionState, + content: str, + thread_id: int = PathParam(gt=0), + comment_id: int = PathParam(gt=0), +): + try: + repository, number = await gitea_proxy.notification_conversation_target(thread_id) + except Exception as exc: + raise HTTPException(status_code=404, detail="Notification conversation not found") from exc + return await _set_conversation_reaction( + repository, number, comment_id, content, state.active + ) + + @app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201) async def attach_to_assigned_issue( request: Request, diff --git a/tests/e2e/test_mobile_comment_reactions_release.py b/tests/e2e/test_mobile_comment_reactions_release.py new file mode 100644 index 0000000..b48600b --- /dev/null +++ b/tests/e2e/test_mobile_comment_reactions_release.py @@ -0,0 +1,77 @@ +import os +from pathlib import Path + +import pytest + +if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1": + pytest.skip( + "packaged mobile comment-reaction journey runs only in its gated CI job", + allow_module_level=True, + ) +pytest.importorskip("playwright.sync_api") +from playwright.sync_api import expect, sync_playwright + + +ROOT = Path(__file__).parents[2] +FRONTEND = ROOT / "frontend" + + +def test_comment_reactions_are_touch_safe_and_focus_preserving_at_320px(): + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 320, "height": 568}) + page.set_content( + '
' + '
' + '

Ship feedback

' + ) + page.add_style_tag(path=FRONTEND / "dashboard.css") + page.add_script_tag(path=FRONTEND / "comment-actions.js") + page.evaluate( + """ + window.reactionCalls = []; + window.offline = false; + const controller = createCommentActions({ + getLogin: () => 'timmy', + fetchJson: async (path, options = {}) => { + reactionCalls.push([path, options.method || 'GET']); + return { + comment_id: 91, + reactions: [{content: 'heart', count: options.method === 'PUT' ? 2 : 1, + selected: options.method === 'PUT'}], + }; + }, + }); + document.querySelector('#actions').innerHTML = controller.actionHtml({id: 91, author: 'alex'}); + controller.wire({ + root: document.querySelector('#comments'), + getSurface: () => ({ + context: {kind: 'issue', item: {repository: 'stackchain/api', number: 7}}, + status: document.querySelector('#status'), + }), + isOffline: () => window.offline, + escapeHtml: value => String(value), + }); + """ + ) + + trigger = page.get_by_role("button", name="React to this comment") + trigger.click() + menu = page.locator("[data-comment-reaction-menu]") + expect(menu).to_be_visible() + for control in menu.get_by_role("button").all(): + box = control.bounding_box() + assert box is not None and box["height"] >= 44 + + page.get_by_role("button", name="Heart 1").click() + expect(menu).to_be_hidden() + expect(trigger).to_be_focused() + expect(page.locator("#status")).to_have_text("Reaction updated.") + assert page.evaluate( + "document.documentElement.scrollWidth > document.documentElement.clientWidth" + ) is False + assert page.evaluate("window.reactionCalls") == [ + ["api/v1/repos/stackchain/api/issues/7/comments/91/reactions", "GET"], + ["api/v1/repos/stackchain/api/issues/7/comments/91/reactions/heart", "PUT"], + ] + browser.close() diff --git a/tests/test_comment_reactions.py b/tests/test_comment_reactions.py new file mode 100644 index 0000000..c834e81 --- /dev/null +++ b/tests/test_comment_reactions.py @@ -0,0 +1,308 @@ +import json +from pathlib import Path + +import httpx +import pytest + +from src import gitea_proxy, main + + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.mark.anyio +async def test_comment_reactions_are_bound_to_conversation_and_aggregate_operator_state(monkeypatch): + calls = [] + + async def fake_fetch(path): + calls.append(("fetch", path)) + if path == "user": + return {"login": "timmy"} + if path == "repos/stackchain/api/issues/comments/91": + return { + "issue_url": "https://forge.example/api/v1/repos/stackchain/api/issues/7", + } + raise AssertionError(path) + + class Response: + def raise_for_status(self): + return None + + def json(self): + return [ + {"content": "heart", "user": {"login": "alex"}}, + {"content": "heart", "user": {"login": "timmy"}}, + {"content": "+1", "user": {"login": "alex"}}, + {"content": "unsupported", "user": {"login": "alex"}}, + {"content": "heart", "user": {}}, + ] + + class Client: + async def get(self, path, headers): + calls.append(("get", path)) + return Response() + + monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example") + monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch) + monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client()) + monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"}) + + result = await gitea_proxy.comment_reactions("stackchain/api", 7, 91) + + assert result == { + "comment_id": 91, + "reactions": [ + {"content": "+1", "count": 1, "selected": False}, + {"content": "heart", "count": 2, "selected": True}, + ], + } + assert calls == [ + ("fetch", "user"), + ("fetch", "repos/stackchain/api/issues/comments/91"), + ("get", "/api/v1/repos/stackchain/api/issues/comments/91/reactions"), + ] + + +@pytest.mark.anyio +async def test_comment_binding_accepts_gitea_ui_issue_url_shape(monkeypatch): + async def fake_fetch(path): + if path == "user": + return {"login": "timmy"} + return { + "issue_url": "https://forge.example/git/stackchain/api/issues/7", + } + + class Response: + def raise_for_status(self): + return None + + def json(self): + return [] + + class Client: + async def get(self, path, headers): + return Response() + + monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example") + monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch) + monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client()) + + result = await gitea_proxy.comment_reactions("stackchain/api", 7, 91) + + assert result == {"comment_id": 91, "reactions": []} + + +@pytest.mark.anyio +async def test_comment_reaction_mutation_is_idempotent_and_returns_confirmed_state(monkeypatch): + payloads = [[], [{"content": "heart", "user": {"login": "timmy"}}]] + mutations = [] + + async def fake_fetch(path): + if path == "user": + return {"login": "timmy"} + return {"issue_url": "https://forge.example/api/v1/repos/stackchain/api/issues/7"} + + class Response: + def __init__(self, payload=None): + self.payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self.payload + + class Client: + async def get(self, path, headers): + return Response(payloads.pop(0)) + + async def post(self, path, headers, json): + mutations.append(("post", path, json)) + return Response() + + async def delete(self, path, headers, json): + mutations.append(("delete", path, json)) + return Response() + + monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example") + monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch) + monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client()) + monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"}) + + result = await gitea_proxy.set_comment_reaction("stackchain/api", 7, 91, "heart", True) + + path = "/api/v1/repos/stackchain/api/issues/comments/91/reactions" + assert mutations == [("post", path, {"content": "heart"})] + assert result == { + "comment_id": 91, + "reactions": [{"content": "heart", "count": 1, "selected": True}], + } + + +@pytest.mark.anyio +async def test_reaction_api_authorizes_issue_pull_and_notification_contexts(monkeypatch): + reads = [] + writes = [] + + async def assigned(repository, number): + return (repository, number) == ("stackchain/api", 7) + + async def pull_capabilities(repository, number): + return {"can_open": (repository, number) == ("stackchain/api", 8)} + + async def notification_target(thread_id): + assert thread_id == 42 + return "stackchain/api", 9 + + async def reactions(repository, number, comment_id): + reads.append((repository, number, comment_id)) + return {"comment_id": comment_id, "reactions": []} + + async def set_reaction(repository, number, comment_id, content, active): + writes.append((repository, number, comment_id, content, active)) + return { + "comment_id": comment_id, + "reactions": [{"content": content, "count": 1, "selected": active}], + } + + monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned) + monkeypatch.setattr(main, "_pull_workspace_capabilities", pull_capabilities) + monkeypatch.setattr(main, "_has_pull_workspace_access", lambda value: value.get("can_open") is True) + monkeypatch.setattr(main.gitea_proxy, "notification_conversation_target", notification_target) + monkeypatch.setattr(main.gitea_proxy, "comment_reactions", reactions, raising=False) + monkeypatch.setattr(main.gitea_proxy, "set_comment_reaction", set_reaction, raising=False) + + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + issue = await client.get("/api/v1/repos/stackchain/api/issues/7/comments/91/reactions") + pull = await client.get("/api/v1/repos/stackchain/api/pulls/8/comments/92/reactions") + update = await client.get("/api/v1/notifications/42/comments/93/reactions") + changed = await client.put( + "/api/v1/notifications/42/comments/93/reactions/heart", + json={"active": True}, + ) + + assert [issue.status_code, pull.status_code, update.status_code, changed.status_code] == [200, 200, 200, 200] + assert reads == [ + ("stackchain/api", 7, 91), + ("stackchain/api", 8, 92), + ("stackchain/api", 9, 93), + ] + assert writes == [("stackchain/api", 9, 93, "heart", True)] + assert changed.json()["reactions"][0]["selected"] is True + + +def run_node(script: str) -> dict: + import subprocess + + result = subprocess.run( + ["node", "-e", script], cwd=ROOT, text=True, capture_output=True, check=True + ) + return json.loads(result.stdout) + + +def test_comment_action_controller_loads_and_toggles_authoritative_reactions(): + module = json.dumps(str(ROOT / "frontend" / "comment-actions.js")) + script = f""" +const createActions=require({module}); +const calls=[]; +const actions=createActions({{ + fetchJson:async (path, options={{}})=>{{ + calls.push([path, options.method || 'GET', options.body || null]); + return options.method === 'PUT' + ? {{comment_id:91,reactions:[{{content:'heart',count:2,selected:true}}]}} + : {{comment_id:91,reactions:[{{content:'heart',count:1,selected:false}}]}}; + }}, + getLogin:()=> 'timmy', +}}); +(async()=>{{ + const context={{kind:'issue',item:{{repository:'stackchain/api',number:7}}}}; + const loaded=await actions.loadReactions(context,91); + const changed=await actions.setReaction(context,91,'heart',true); + process.stdout.write(JSON.stringify({{loaded,changed,calls,html:actions.actionHtml({{id:91,author:'alex'}})}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + output = run_node(script) + base = "api/v1/repos/stackchain/api/issues/7/comments/91/reactions" + assert output["calls"] == [ + [base, "GET", None], + [base + "/heart", "PUT", '{"active":true}'], + ] + assert output["loaded"]["reactions"][0]["count"] == 1 + assert output["changed"]["reactions"][0]["selected"] is True + assert 'data-comment-reactions-open' in output["html"] + assert 'aria-label="React to this comment"' in output["html"] + + +def test_comment_reaction_menu_renders_mobile_safe_counts_and_selection(): + module = json.dumps(str(ROOT / "frontend" / "comment-actions.js")) + script = f""" +const createActions=require({module}); +const actions=createActions({{fetchJson:async()=>{{}},getLogin:()=> 'timmy'}}); +process.stdout.write(JSON.stringify({{html:actions.reactionHtml({{ + comment_id:91, + reactions:[{{content:'heart',count:2,selected:true}},{{content:'+1',count:1,selected:false}}], +}})}})); +""" + html = run_node(script)["html"] + assert 'data-comment-reaction="heart"' in html + assert 'aria-pressed="true"' in html + assert "Heart 2" in html + assert 'data-comment-reaction="rocket"' in html + assert "Rocket 0" in html + assert 'data-comment-reactions-close' in html + + css = (ROOT / "frontend" / "dashboard.css").read_text() + assert ".comment-reactions" in css + assert "flex-wrap:wrap" in css + assert ".comment-reaction-menu button" in css + assert "min-height:44px" in css + + +def test_comment_reaction_wire_loads_menu_and_returns_focus_after_toggle(): + module = json.dumps(str(ROOT / "frontend" / "comment-actions.js")) + script = f""" +const createActions=require({module}); +const calls=[]; +const menu={{hidden:true,innerHTML:''}}; +const trigger={{disabled:false,expanded:'false',focused:0,setAttribute:(n,v)=>trigger.expanded=v,focus:()=>trigger.focused++,closest:s=>s==='[data-comment-reactions-open]'?trigger:(s==='.issue-comment'?card:null)}}; +const reaction={{disabled:false,dataset:{{commentReaction:'heart'}},getAttribute:()=> 'false',closest:s=>s==='[data-comment-reaction]'?reaction:(s==='.issue-comment'?card:null)}}; +const card={{dataset:{{commentId:'91'}},querySelector:s=>s==='[data-comment-reaction-menu]'?menu:trigger}}; +const root={{listeners:{{}},addEventListener:(n,fn)=>root.listeners[n]=fn}}; +const status={{textContent:''}}; +const surface={{context:{{kind:'update',item:{{notification_id:42}}}},status}}; +const actions=createActions({{ + fetchJson:async (path,options={{}})=>{{calls.push([path,options.method||'GET']);return {{comment_id:91,reactions:[{{content:'heart',count:1,selected:options.method==='PUT'}}]}};}}, + getLogin:()=> 'timmy', +}}); +actions.wire({{root,getSurface:()=>surface,isOffline:()=>false,escapeHtml:String}}); +(async()=>{{ + await root.listeners.click({{target:trigger}}); + await root.listeners.click({{target:reaction}}); + process.stdout.write(JSON.stringify({{calls,hidden:menu.hidden,html:menu.innerHTML,expanded:trigger.expanded,focused:trigger.focused,status:status.textContent}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + output = run_node(script) + base = "api/v1/notifications/42/comments/91/reactions" + assert output["calls"] == [[base, "GET"], [base + "/heart", "PUT"]] + assert output["hidden"] is True + assert output["expanded"] == "false" + assert output["focused"] == 1 + assert output["status"] == "Reaction updated." + + +def test_comment_reaction_wire_blocks_offline_mutation_with_reconnect_guidance(): + module = json.dumps(str(ROOT / "frontend" / "comment-actions.js")) + script = f""" +const createActions=require({module}); +let calls=0; +const menu={{hidden:true,innerHTML:''}}; +const card={{dataset:{{commentId:'91'}},querySelector:s=>s.includes('menu')?menu:trigger}}; +const trigger={{disabled:false,closest:s=>s==='[data-comment-reactions-open]'?trigger:(s==='.issue-comment'?card:null)}}; +const root={{listeners:{{}},addEventListener:(n,fn)=>root.listeners[n]=fn}}; +const status={{textContent:''}}; +const actions=createActions({{fetchJson:async()=>{{calls++;}},getLogin:()=> 'timmy'}}); +actions.wire({{root,getSurface:()=>({{context:{{kind:'issue',item:{{repository:'stackchain/api',number:7}}}},status}}),isOffline:()=>true,escapeHtml:String}}); +(async()=>{{await root.listeners.click({{target:trigger}});process.stdout.write(JSON.stringify({{calls,status:status.textContent}}));}})(); +""" + assert run_node(script) == {"calls": 0, "status": "Reconnect to react."} -- 2.43.0