Merge pull request 'Attach photo evidence to mobile Search preview replies' (#956) from timmy/955-search-preview-photo-evidence into main
All checks were successful
CI / lint (push) Successful in 1m58s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 51s
CI / release-candidate (push) Successful in 6s

This commit is contained in:
timmy 2026-08-16 09:34:05 +00:00
commit cab5802547
10 changed files with 320 additions and 4 deletions

View File

@ -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

View File

@ -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; }

View File

@ -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,

View File

@ -562,6 +562,27 @@
</div>
</div>
</section>
<div class="issue-attachment-controls conversation-photo-actions">
<label class="issue-attachment-trigger" for="take-search-reply-photo">Take photo</label>
<input class="visually-hidden" id="take-search-reply-photo" type="file" accept="image/*" capture="environment" />
<label class="issue-attachment-trigger" for="search-reply-attachment">Choose existing</label>
<input class="visually-hidden" id="search-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
</div>
<div class="issue-attachment-preview conversation-evidence-review" id="search-reply-attachment-preview" hidden>
<img id="search-reply-attachment-image" alt="Selected reply photo preview" />
<span class="small" id="search-reply-attachment-meta"></span>
<div class="issue-evidence-tray" id="search-reply-attachment-tray" role="toolbar" aria-label="Search reply photos" hidden></div>
<label class="issue-evidence-note" for="search-reply-attachment-note">
<span id="search-reply-attachment-note-label">Photo caption (optional)</span>
<textarea id="search-reply-attachment-note" maxlength="240" rows="2" disabled placeholder="What does this photo show?"></textarea>
</label>
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
<button id="move-search-reply-attachment-earlier" type="button">Move earlier</button>
<button id="edit-search-reply-attachment" type="button">Edit photo</button>
<button id="remove-search-reply-attachment" type="button">Remove selected</button>
<button id="move-search-reply-attachment-later" type="button">Move later</button>
</div>
</div>
<div id="search-preview-reply-status" class="small" aria-live="polite"></div>
<div class="search-preview-reply-actions">
<button id="send-search-preview-reply" type="button">Send</button>

View File

@ -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());
});
});

View File

@ -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 => {

View File

@ -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,

View File

@ -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(),

View File

@ -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](<https://forge.example/evidence/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 = []

View File

@ -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</label>' in html
assert 'id="take-search-reply-photo" type="file" accept="image/*" capture="environment"' in html
assert 'for="search-reply-attachment">Choose existing</label>' 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](<safe-url>)';}},
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](<safe-url>)", "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()