From d3a57f222eee00de9b0e3f2b449102a081445c6c Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 16 Aug 2026 09:30:18 +0000 Subject: [PATCH] feat: attach photos from Search preview replies (Closes #955) --- README.md | 5 +++ frontend/dashboard.css | 1 + frontend/dashboard.js | 64 ++++++++++++++++++++++++++++++ frontend/index.html | 21 ++++++++++ frontend/issue-attachment.js | 3 ++ frontend/search-preview.js | 21 ++++++++-- src/gitea_proxy.py | 26 +++++++++++++ src/main.py | 65 +++++++++++++++++++++++++++++++ tests/test_global_search.py | 64 ++++++++++++++++++++++++++++++ tests/test_issue_attachment_ui.py | 54 +++++++++++++++++++++++++ 10 files changed, 320 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 14ea875..4307bb0 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,11 @@ Today or interrupting active work. Cancel and browser Back preserve the Search p confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success. +Commentable mobile Search previews support camera capture and gallery selection for up to five ordered +photos, including captions, crop/annotation/redaction review, and metadata-stripping re-encoding. Operators +can send photo-only or text-plus-photo replies without leaving their Search pass. Each upload and the final +comment use stable idempotency keys: a failed attempt keeps the draft, evidence order, and confirmed upload +checkpoints, while **Send & next** advances only after Gitea confirms the comment. Named mobile Search views preserve the query, type, status, and optional repository scope. They are bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 5e847b0..99499e2 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -646,6 +646,7 @@ textarea { resize: vertical; min-height: 120px; } .search-preview-reply-actions button { min-height:44px; } .search-preview-reply .voice-conversation-controls button, .search-preview-reply .voice-conversation-review-actions button { min-height:44px; } +.search-preview-reply .conversation-photo-actions { max-width:100%; } .search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; } .search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; } .search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index b81dfdc..a30e61b 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -494,6 +494,61 @@ window.location.hash = '#/my-work/agenda'; qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.'; }); + let searchReplyAttachmentTarget = null; + const updateSearchReplyButtons = state => { + const enabled = Boolean(state) || Boolean(qs('#search-preview-reply').value.trim()); + qs('#send-search-preview-reply').disabled = !enabled; + qs('#send-search-preview-reply-next').disabled = !enabled; + }; + const searchReplyAttachmentController = issueAttachment.mount({ + maxFiles: 5, + input: qs('#search-reply-attachment'), + inputs: [qs('#take-search-reply-photo'), qs('#search-reply-attachment')], + preview: qs('#search-reply-attachment-preview'), + image: qs('#search-reply-attachment-image'), + meta: qs('#search-reply-attachment-meta'), + remove: qs('#remove-search-reply-attachment'), + tray: qs('#search-reply-attachment-tray'), + earlier: qs('#move-search-reply-attachment-earlier'), + later: qs('#move-search-reply-attachment-later'), + note: qs('#search-reply-attachment-note'), + noteLabel: qs('#search-reply-attachment-note-label'), + status: qs('#search-preview-reply-status'), + readyMessage: 'Photo ready to send with this Search reply.', + removedMessage: 'Photo removed. Your Search reply is unchanged.', + onChange: updateSearchReplyButtons, + editor: { + document, + edit: qs('#edit-search-reply-attachment'), + dialog: qs('#issue-evidence-editor'), + canvas: qs('#issue-evidence-editor-canvas'), + exportCanvas: qs('#issue-evidence-editor-export'), + crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'), + highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'), + undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'), + cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'), + status: qs('#issue-evidence-editor-status'), + appliedMessage: 'Edited photo flattened and ready to send.', + }, + createObjectURL: file => URL.createObjectURL(file), + revokeObjectURL: url => URL.revokeObjectURL(url), + upload: payload => { + const target = searchReplyAttachmentTarget; + if (!target || target.repository !== payload.repository || Number(target.number) !== Number(payload.number)) { + return Promise.reject(new Error('Search result changed. Reopen it before sending this photo.')); + } + const repository = payload.repository.split('/').map(encodeURIComponent).join('/'); + return fetchReviewJson( + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(payload.number) + + '/preview/attachments?kind=' + encodeURIComponent(target.kind), + { + method:'POST', + headers:{Accept:'application/json','Idempotency-Key':payload.operation_id}, + body:issueAttachment.multipart(payload), + }, + ); + }, + }); const issueAttachmentController = issueAttachment.mount({ maxFiles: 5, input: qs('#issue-attachment'), @@ -5034,6 +5089,15 @@ { method:'PATCH', headers:{ Accept:'application/json' } } ), ...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto), + prepareReply:(item,body) => { + searchReplyAttachmentTarget = item; + return searchReplyAttachmentController.prepareComment(item, body); + }, + hasAttachments:() => Boolean(searchReplyAttachmentController.state()), + clearAttachments:() => { + searchReplyAttachmentTarget = null; + searchReplyAttachmentController.clear(); + }, share: url => createWorkRoute.share(url, navigator, navigator.clipboard), session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})], onState: renderSearchPreview, diff --git a/frontend/index.html b/frontend/index.html index 1bd266e..9ab7cd9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -562,6 +562,27 @@ +
+ + + + +
+
diff --git a/frontend/issue-attachment.js b/frontend/issue-attachment.js index 2e1d9f1..63ce3b7 100644 --- a/frontend/issue-attachment.js +++ b/frontend/issue-attachment.js @@ -255,6 +255,7 @@ input.disabled = false; }); clearSelection(); + options.onChange?.(controller.state()); } function showPreview(file, optimized) { @@ -311,6 +312,7 @@ options.status.textContent = values.length + ' screenshots ready to file in this order.'; } } + options.onChange?.(controller.state()); }).catch(error => { if (sequence === selectionSequence) { options.status.textContent = error.message; @@ -343,6 +345,7 @@ options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename; } options.status.textContent = options.removedMessage || 'Latest screenshot removed. Your text is unchanged.'; + options.onChange?.(controller.state()); }); }); diff --git a/frontend/search-preview.js b/frontend/search-preview.js index 4d2d492..fd49d5b 100644 --- a/frontend/search-preview.js +++ b/frontend/search-preview.js @@ -81,7 +81,9 @@ } input.value = preview.replyDraft(); const replying = state.status === 'replying'; - buttons.forEach(button => { button.disabled = replying || !input.value.trim(); }); + buttons.forEach(button => { + button.disabled = replying || (!input.value.trim() && !preview.hasReplyAttachments?.()); + }); if (replying) status.textContent = 'Sending reply…'; else if (state.status === 'replied') status.textContent = 'Reply posted.'; else if (state.status === 'reply-error') status.textContent = @@ -89,7 +91,7 @@ }; } })(typeof globalThis !== 'undefined' ? globalThis : this, function () { - return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) { + return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, prepareReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) { if (Array.isArray(session)) { getSession = session[0]; loadMore = () => session[1].loadMore(); @@ -201,7 +203,11 @@ } const api = { + hasReplyAttachments() { + return Boolean(hasAttachments?.()); + }, open(item) { + if (current && !sameItem(current, item) && hasAttachments?.()) clearAttachments?.(); generation += 1; const requestGeneration = generation; current = { ...item }; @@ -225,6 +231,7 @@ }); }, close() { + clearAttachments?.(); generation += 1; current = null; onState({ status: 'closed' }); @@ -284,7 +291,7 @@ if (replyRequest) return replyRequest; if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.')); const body = api.replyDraft().trim(); - if (!body) return Promise.reject(new Error('Write a reply first.')); + if (!body && !hasAttachments?.()) return Promise.reject(new Error('Write a reply or add a photo first.')); const item = { ...current }; const operationKey = replyKey(item, 'operation'); let operationId = stored(operationKey); @@ -293,12 +300,18 @@ save(operationKey, operationId); } publish({ status:'replying', item:current, detail:current, conversation }); - replyRequest = postReply(item, body, operationId).then(comment => { + replyRequest = Promise.resolve( + typeof prepareReply === 'function' ? prepareReply(item, body) : body + ).then(preparedBody => { + if (!String(preparedBody || '').trim()) throw new Error('Write a reply or add a photo first.'); + return postReply(item, preparedBody, operationId); + }).then(comment => { const comments = [...(conversation?.comments || [])]; if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment); conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null }; save(replyKey(item, 'draft'), ''); save(operationKey, ''); + clearAttachments?.(); publish({ status:'replied', item:current, detail:current, conversation, result:comment }); return advance ? api.next().then(() => comment) : comment; }).catch(error => { diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 85de895..973b82d 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -1364,6 +1364,32 @@ async def upload_assigned_issue_attachment( return {"name": name, "url": url, "size": size} +async def upload_preview_attachment( + repository: str, + number: int, + filename: str, + content_type: str, + content: bytes, +) -> dict: + """Upload evidence after the caller verifies an exact Search preview target.""" + response = await _get_client().post( + f"/api/v1/repos/{repository}/issues/{number}/assets", + headers=_auth(), + params={"name": filename}, + files={"attachment": (filename, content, content_type)}, + ) + response.raise_for_status() + attachment = response.json() + if not isinstance(attachment, dict): + raise ValueError("Gitea attachment response was not an object") + name = attachment.get("name") + url = _safe_web_url(attachment.get("browser_download_url")) + size = attachment.get("size") + if not isinstance(name, str) or not name or not url or not isinstance(size, int): + raise ValueError("Gitea did not confirm the attachment") + return {"name": name, "url": url, "size": size} + + async def upload_assigned_pull_attachment( repository: str, number: int, diff --git a/src/main.py b/src/main.py index c0010cc..7ef83db 100644 --- a/src/main.py +++ b/src/main.py @@ -3136,6 +3136,71 @@ async def comment_on_global_search_preview( return JSONResponse(result, status_code=201) +@app.post( + "/api/v1/repos/{owner}/{repo}/issues/{number}/preview/attachments", + status_code=201, +) +async def attach_to_global_search_preview( + request: Request, + owner: str, + repo: str, + number: int = PathParam(gt=0), + kind: Literal["issue", "pull"] = Query(), + idempotency_key: str | None = Header(default=None, max_length=128), +) -> JSONResponse: + repository = f"{owner}/{repo}" + try: + form = await request.form() + uploaded = form.get("file") + if not isinstance(uploaded, UploadFile): + raise ValueError("screenshot file is required") + filename = str(uploaded.filename or "") + content_type = str(uploaded.content_type or "") + content = _validate_binary_attachment(filename, content_type, await uploaded.read()) + except (ValueError, ValidationError) as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + async def upload_attachment(): + preview = await gitea_proxy.work_preview(repository, kind, number) + if ( + preview.get("repository") != repository + or preview.get("kind") != kind + or preview.get("number") != number + or preview.get("commentable") is not True + ): + raise HTTPException(status_code=404, detail="Search result not found") + result = await gitea_proxy.upload_preview_attachment( + repository, number, filename, content_type, content + ) + safe_name = ( + result["name"].replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]") + .replace("\r", " ").replace("\n", " ") + ) + safe_url = result["url"].replace("<", "%3C").replace(">", "%3E") + result["markdown"] = f"![{safe_name}](<{safe_url}>)" + return result + + try: + result = await _run_idempotent_authored_action( + upload_attachment(), + idempotency_key=idempotency_key, + fingerprint=( + "search-preview-attachment", repository, kind, number, filename, + content_type, hashlib.sha256(content).hexdigest(), + ), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except HTTPException: + raise + except Exception: + return JSONResponse( + {"error": "The photo could not be uploaded. Your reply is safe; please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result, status_code=201) + + @app.get("/api/v1/work-route") async def resolve_work_route( kind: Literal["issue", "filed", "pull", "review", "update"] = Query(), diff --git a/tests/test_global_search.py b/tests/test_global_search.py index bca5f2a..cc5b570 100644 --- a/tests/test_global_search.py +++ b/tests/test_global_search.py @@ -325,6 +325,70 @@ async def test_global_search_preview_reply_rejects_a_mismatched_target_before_co assert comments == [] +@pytest.mark.anyio +async def test_global_search_preview_attachment_is_idempotent_for_exact_visible_target(monkeypatch): + main._idempotency_ledger.clear() + previews = [] + uploads = [] + + async def preview(repository, kind, number): + previews.append((repository, kind, number)) + return {"repository": repository, "kind": kind, "number": number, "commentable": True} + + async def upload(repository, number, filename, content_type, content): + uploads.append((repository, number, filename, content_type, content)) + return {"name": filename, "url": "https://forge.example/evidence/photo.png", "size": len(content)} + + monkeypatch.setattr(main.gitea_proxy, "work_preview", preview) + monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload) + transport = httpx.ASGITransport(app=main.app) + headers = {"Idempotency-Key": "search-photo-pull-42"} + image = b"\x89PNG\r\n\x1a\nsafe-pixels" + files = {"file": ("photo.png", image, "image/png")} + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + first = await client.post( + "/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull", + files=files, + headers=headers, + ) + replay = await client.post( + "/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull", + files=files, + headers=headers, + ) + + assert first.status_code == replay.status_code == 201 + assert replay.json() == first.json() + assert first.json()["markdown"] == "![photo.png]()" + assert previews == [("stackchain/api", "pull", 42)] + assert uploads == [("stackchain/api", 42, "photo.png", "image/png", image)] + + +@pytest.mark.anyio +async def test_global_search_preview_attachment_rejects_kind_mismatch_before_upload(monkeypatch): + main._idempotency_ledger.clear() + uploads = [] + + async def preview(repository, kind, number): + return {"repository": repository, "kind": "issue", "number": number} + + async def upload(*args): + uploads.append(args) + + monkeypatch.setattr(main.gitea_proxy, "work_preview", preview) + monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull", + files={"file": ("photo.png", b"\x89PNG\r\n\x1a\nsafe-pixels", "image/png")}, + headers={"Idempotency-Key": "search-photo-mismatch-42"}, + ) + + assert response.status_code == 404 + assert uploads == [] + + @pytest.mark.anyio async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results(): requests = [] diff --git a/tests/test_issue_attachment_ui.py b/tests/test_issue_attachment_ui.py index 25571b6..8dcd650 100644 --- a/tests/test_issue_attachment_ui.py +++ b/tests/test_issue_attachment_ui.py @@ -113,6 +113,60 @@ def test_mobile_conversation_composers_accept_five_ordered_photos(): assert "maxFiles: 5" in mount.group(1), controller +def test_mobile_search_preview_exposes_and_mounts_photo_evidence_controls(): + html = INDEX.read_text() + dashboard = DASHBOARD.read_text() + css = CSS.read_text() + + assert 'for="take-search-reply-photo">Take photo' in html + assert 'id="take-search-reply-photo" type="file" accept="image/*" capture="environment"' in html + assert 'for="search-reply-attachment">Choose existing' in html + assert 'id="search-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple' in html + mount = re.search( + r"const searchReplyAttachmentController = issueAttachment\.mount\(\{(.*?)\n \}\);", + dashboard, + re.DOTALL, + ) + assert mount + assert "maxFiles: 5" in mount.group(1) + assert "'/preview/attachments?kind='" in mount.group(1) + assert ".search-preview-reply .conversation-photo-actions" in css + + +def test_search_preview_can_send_photo_only_and_advances_after_confirmed_comment(): + search_preview = Path(__file__).parents[1] / "frontend" / "search-preview.js" + script = f""" +const create = require({json.dumps(str(search_preview))}); +const posted=[]; const prepared=[]; let cleared=0; let navigated=[]; let attached=true; +const item={{kind:'issue',repository:'stackchain/api',number:42}}; +const next={{kind:'pull',repository:'stackchain/web',number:9}}; +const api=create({{ + fetchJson:async value=>({{...value,commentable:true}}), + postReply:async (target,body,key)=>{{posted.push([target.number,body,key]);return {{id:91,body}};}}, + prepareReply:async (target,body)=>{{prepared.push([target.number,body]);return '![photo]()';}}, + hasAttachments:()=>attached, + clearAttachments:()=>{{cleared += 1;attached=false;}}, + storage:{{getItem:()=>'',setItem:()=>{{}},removeItem:()=>{{}}}}, + createOperationId:()=> 'reply-photo-42', + getSession:()=>({{items:[item,next],more:false}}), + onNavigate:value=>navigated.push(value.number), + onState:()=>{{}}, +}}); +(async()=>{{ + await api.open(item); + await api.reply({{advance:true}}); + process.stdout.write(JSON.stringify({{posted,prepared,cleared,navigated}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + output = json.loads(run_node(script)) + assert output == { + "posted": [[42, "![photo]()", "reply-photo-42"]], + "prepared": [[42, ""]], + "cleared": 1, + "navigated": [9], + } + + def test_mobile_conversation_composers_review_reorder_remove_and_caption_every_photo(): html = INDEX.read_text() dashboard = DASHBOARD.read_text()