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(
+ ''
+ ''
+ )
+ 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."}