feat: react to mobile conversation comments (Closes #1380)
This commit is contained in:
parent
fc7a83fbd7
commit
b4f3c6d419
|
|
@ -1,4 +1,8 @@
|
||||||
function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) {
|
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('/')
|
const encodedRepository = repository => String(repository || '').split('/')
|
||||||
.map(encodeURIComponent).join('/');
|
.map(encodeURIComponent).join('/');
|
||||||
|
|
||||||
|
|
@ -14,6 +18,12 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false
|
||||||
'/comments/' + encodeURIComponent(commentId);
|
'/comments/' + encodeURIComponent(commentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reactionPath(context, commentId, content = '') {
|
||||||
|
return pathFor(context, commentId) + '/reactions' +
|
||||||
|
(content ? '/' + encodeURIComponent(content) : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingReactions = new Set();
|
||||||
const controller = {
|
const controller = {
|
||||||
isOwned(comment) {
|
isOwned(comment) {
|
||||||
const login = String(getLogin() || '').trim();
|
const login = String(getLogin() || '').trim();
|
||||||
|
|
@ -41,15 +51,98 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false
|
||||||
pager.remove(commentId);
|
pager.remove(commentId);
|
||||||
return pager.snapshot();
|
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 '<button type="button" data-comment-reaction="' + content + '" ' +
|
||||||
|
'aria-pressed="' + selected + '" aria-label="' + label + ' ' + count + '">' +
|
||||||
|
label + ' ' + count + '</button>';
|
||||||
|
}).join('') + '<button type="button" data-comment-reactions-close>Cancel</button>';
|
||||||
|
},
|
||||||
actionHtml(comment) {
|
actionHtml(comment) {
|
||||||
return controller.isOwned(comment) ?
|
const owned = controller.isOwned(comment) ?
|
||||||
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
|
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
|
||||||
'<button type="button" data-comment-action="edit">Edit</button>' +
|
'<button type="button" data-comment-action="edit">Edit</button>' +
|
||||||
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
|
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
|
||||||
|
return owned + '<div class="comment-reactions" aria-label="Comment reactions">' +
|
||||||
|
'<button type="button" data-comment-reactions-open aria-expanded="false" ' +
|
||||||
|
'aria-label="React to this comment">React</button>' +
|
||||||
|
'<div class="comment-reaction-menu" data-comment-reaction-menu hidden></div></div>';
|
||||||
},
|
},
|
||||||
wire({ root, getSurface, isOffline, escapeHtml }) {
|
wire({ root, getSurface, isOffline, escapeHtml }) {
|
||||||
root.addEventListener('click', async event => {
|
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;
|
if (!button) return;
|
||||||
const card = button.closest('.issue-comment');
|
const card = button.closest('.issue-comment');
|
||||||
const commentId = Number(card?.dataset.commentId);
|
const commentId = Number(card?.dataset.commentId);
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,12 @@ button:hover { filter: brightness(1.15); }
|
||||||
.issue-comment { min-width:0; overflow-wrap:anywhere; }
|
.issue-comment { min-width:0; overflow-wrap:anywhere; }
|
||||||
.comment-owned-actions { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; }
|
.comment-owned-actions { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; }
|
||||||
.comment-owned-actions button { min-height:44px; min-width:72px; }
|
.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; }
|
.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 { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
||||||
.panel > summary { cursor: pointer; list-style-position: inside; }
|
.panel > summary { cursor: pointer; list-style-position: inside; }
|
||||||
|
|
|
||||||
|
|
@ -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:
|
async def comment_on_issue(repository: str, number: int, body: str) -> dict:
|
||||||
response = await _get_client().post(
|
response = await _get_client().post(
|
||||||
f"/api/v1/repos/{repository}/issues/{number}/comments",
|
f"/api/v1/repos/{repository}/issues/{number}/comments",
|
||||||
|
|
|
||||||
142
src/main.py
142
src/main.py
|
|
@ -987,6 +987,10 @@ class IssueComment(BaseModel):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class CommentReactionState(BaseModel):
|
||||||
|
active: bool
|
||||||
|
|
||||||
|
|
||||||
def _validate_attachment_metadata(filename: str, content_type: str) -> None:
|
def _validate_attachment_metadata(filename: str, content_type: str) -> None:
|
||||||
expected = {
|
expected = {
|
||||||
"image/png": {"png"},
|
"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)
|
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201)
|
||||||
async def attach_to_assigned_issue(
|
async def attach_to_assigned_issue(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
77
tests/e2e/test_mobile_comment_reactions_release.py
Normal file
77
tests/e2e/test_mobile_comment_reactions_release.py
Normal file
|
|
@ -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(
|
||||||
|
'<main><div id="status" aria-live="polite"></div>'
|
||||||
|
'<div id="comments"><article class="issue-comment" data-comment-id="91">'
|
||||||
|
'<p>Ship feedback</p><div id="actions"></div></article></div></main>'
|
||||||
|
)
|
||||||
|
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()
|
||||||
308
tests/test_comment_reactions.py
Normal file
308
tests/test_comment_reactions.py
Normal file
|
|
@ -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."}
|
||||||
Loading…
Reference in New Issue
Block a user