feat: capture shared mobile content (#223)
All checks were successful
CI / lint (pull_request) Successful in 19s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy 2026-08-07 20:22:21 +00:00
parent bca27f0330
commit f655a5115a
9 changed files with 313 additions and 3 deletions

View File

@ -8,8 +8,20 @@ function newIssueOperationId() {
return String(Date.now()) + '-' + Math.random().toString(16).slice(2); return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
} }
function normalizeSharedContent(value = {}) {
const clean = input => String(input || '').replace(/\s+/g, ' ').trim();
const text = String(value.text || '').trim().slice(0, 9500);
const textSummary = clean(text);
const sentence = (textSummary.match(/^.{1,80}?[.!?](?:\s|$)/)?.[0] || textSummary.slice(0, 80)).trim();
const title = (clean(value.title) || sentence).slice(0, 240);
const url = clean(value.url).slice(0, 2000);
const body = text && url && text.includes(url) ? text : [text, url].filter(Boolean).join('\n\n');
return { title, body };
}
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) { function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
const storageKey = 'stackchain.issue-capture.v1'; const storageKey = 'stackchain.issue-capture.v1';
const sharedStorageKey = 'stackchain.issue-share.v1';
let pending = null; let pending = null;
const safeLabelIds = value => Array.from(new Set( const safeLabelIds = value => Array.from(new Set(
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0) (Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
@ -73,6 +85,42 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
catch (_error) { /* Confirmed creation remains authoritative. */ } catch (_error) { /* Confirmed creation remains authoritative. */ }
} }
function pendingSharedContent() {
try {
const parsed = JSON.parse(storage.getItem(sharedStorageKey) || 'null');
if (!parsed || typeof parsed !== 'object') return null;
const shared = normalizeSharedContent({title: parsed.title, text: parsed.body});
return shared.title || shared.body ? shared : null;
} catch (_error) {
return null;
}
}
function acceptSharedContent() {
const shared = pendingSharedContent();
if (!shared) return loadDraft();
const accepted = saveDraft({...loadDraft(), ...shared});
try { storage.removeItem(sharedStorageKey); }
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
return accepted;
}
function discardSharedContent() {
try { storage.removeItem(sharedStorageKey); }
catch (_error) { /* The existing issue draft remains authoritative. */ }
}
function stageSharedContent(value) {
const shared = normalizeSharedContent(value);
if (!shared.title && !shared.body) return {status: 'empty'};
try { storage.setItem(sharedStorageKey, JSON.stringify(shared)); }
catch (_error) { /* The caller can still use the in-page share payload. */ }
const existing = loadDraft();
if (existing.title || existing.body) return {status: 'conflict'};
acceptSharedContent();
return {status: 'ready'};
}
function loadLabels(repository) { function loadLabels(repository) {
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/'); const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
const priorities = new Set(['p0', 'priority-high', 'critical']); const priorities = new Set(['p0', 'priority-high', 'critical']);
@ -117,7 +165,12 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
return pending; return pending;
} }
return { saveDraft, loadDraft, loadLabels, loadMilestones, submit }; return {
saveDraft, loadDraft, loadLabels, loadMilestones, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
};
} }
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture; if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -3,6 +3,8 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0b1220" />
<link rel="manifest" href="manifest.webmanifest" />
<title>Stackchain Dashboard</title> <title>Stackchain Dashboard</title>
<style> <style>
:root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; } :root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; }
@ -212,6 +214,9 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; } .create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
.create-issue-label-option input { width:20px; height:20px; margin:0; } .create-issue-label-option input { width:20px; height:20px; margin:0; }
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; } .create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.shared-content-conflict { display:grid; gap:8px; padding:12px; border:1px solid #8b5cf6; border-radius:10px; background:#16142b; }
.shared-content-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.shared-content-actions button { min-height:44px; }
.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } .pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.pull-sheet.open { display:flex; } .pull-sheet.open { display:flex; }
.pull-sheet-panel { width:min(560px,100%); height:100dvh; overflow:auto; padding:18px; padding-bottom:calc(90px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; } .pull-sheet-panel { width:min(560px,100%); height:100dvh; overflow:auto; padding:18px; padding-bottom:calc(90px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
@ -509,6 +514,14 @@ textarea { resize: vertical; min-height: 120px; }
<h3 id="create-issue-heading">New issue</h3> <h3 id="create-issue-heading">New issue</h3>
<button id="cancel-new-issue" type="button">Cancel</button> <button id="cancel-new-issue" type="button">Cancel</button>
</div> </div>
<aside class="shared-content-conflict" id="shared-content-conflict" aria-live="assertive" hidden>
<strong>You already have an unfinished issue draft.</strong>
<span class="small">Resume it, or replace its title and description with the content shared to Stackchain.</span>
<div class="shared-content-actions">
<button id="resume-issue-draft" type="button">Resume existing draft</button>
<button id="use-shared-content" type="button">Use shared content</button>
</div>
</aside>
<form class="create-issue-form" id="create-issue-form"> <form class="create-issue-form" id="create-issue-form">
<label for="create-issue-repository">Repository <label for="create-issue-repository">Repository
<select id="create-issue-repository" required></select> <select id="create-issue-repository" required></select>
@ -794,6 +807,14 @@ textarea { resize: vertical; min-height: 120px; }
loadMilestones: item => issueController.loadMilestones(item), loadMilestones: item => issueController.loadMilestones(item),
}); });
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const shareParams = new URLSearchParams(location.search);
const sharedLaunch = {
title: shareParams.get('title') || '',
text: shareParams.get('text') || '',
url: shareParams.get('url') || '',
};
let sharedLaunchState = Object.values(sharedLaunch).some(Boolean) ?
issueCapture.stageSharedContent(sharedLaunch) : null;
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage }); const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const draftInbox = createDraftInbox({ storage: localStorage }); const draftInbox = createDraftInbox({ storage: localStorage });
const findWorkController = createFindWork({ const findWorkController = createFindWork({
@ -1117,6 +1138,7 @@ textarea { resize: vertical; min-height: 120px; }
if (data.work_pagination) workPager.reset(data.work_pagination); if (data.work_pagination) workPager.reset(data.work_pagination);
if (data.error && lastMyWork.length) markMyWorkStale(); if (data.error && lastMyWork.length) markMyWorkStale();
else paintMyWork(data); else paintMyWork(data);
openStagedSharedContent();
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' + qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
'<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' + '<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' +
'<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' + '<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' +
@ -1818,6 +1840,25 @@ textarea { resize: vertical; min-height: 120px; }
} }
} }
function clearSharedLaunchUrl() {
const cleanUrl = location.pathname + location.hash;
history.replaceState({}, '', cleanUrl);
}
function openStagedSharedContent() {
if (!sharedLaunchState || sharedLaunchState.status === 'shown') return;
openCreateIssueSheet();
if (sharedLaunchState.status === 'conflict') {
qs('#shared-content-conflict').hidden = false;
qs('#create-issue-status').textContent = 'Choose which draft to continue.';
qs('#resume-issue-draft').focus();
sharedLaunchState = {status: 'shown'};
return;
}
clearSharedLaunchUrl();
sharedLaunchState = null;
}
function openCreateIssueSheet() { function openCreateIssueSheet() {
const captureDraft = issueCapture.loadDraft(); const captureDraft = issueCapture.loadDraft();
const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean); const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
@ -2400,6 +2441,22 @@ textarea { resize: vertical; min-height: 120px; }
} }
}); });
qs('#new-issue').addEventListener('click', openCreateIssueSheet); qs('#new-issue').addEventListener('click', openCreateIssueSheet);
qs('#use-shared-content').addEventListener('click', () => {
issueCapture.acceptSharedContent();
qs('#shared-content-conflict').hidden = true;
sharedLaunchState = null;
clearSharedLaunchUrl();
openCreateIssueSheet();
qs('#create-issue-status').textContent = 'Shared content added. Choose a repository and finish planning.';
});
qs('#resume-issue-draft').addEventListener('click', () => {
issueCapture.discardSharedContent();
qs('#shared-content-conflict').hidden = true;
sharedLaunchState = null;
clearSharedLaunchUrl();
qs('#create-issue-status').textContent = 'Existing draft restored.';
qs('#create-issue-title').focus();
});
qs('#cancel-new-issue').addEventListener('click', () => { qs('#cancel-new-issue').addEventListener('click', () => {
saveIssueCaptureDraft(); saveIssueCaptureDraft();
closeCreateIssueSheet(); closeCreateIssueSheet();
@ -3009,6 +3066,11 @@ textarea { resize: vertical; min-height: 120px; }
renderMyWork(); renderMyWork();
if (workSession.active()) workSession.reconcile(); if (workSession.active()) workSession.reconcile();
}); });
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js').catch(error =>
console.warn('Stackchain install support unavailable', error)
);
}
contextPoller.start(); contextPoller.start();
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden); contextPoller.setVisible(!document.hidden);

View File

@ -0,0 +1,21 @@
{
"name": "Stackchain Dashboard",
"short_name": "Stackchain",
"description": "Capture, plan, and complete Gitea work from mobile.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#07111f",
"theme_color": "#0b1220",
"icons": [
{"src": "/static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
],
"share_target": {
"action": "/",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {"title": "title", "text": "text", "url": "url"}
}
}

View File

@ -0,0 +1,25 @@
const CACHE = 'stackchain-shell-v1';
const SHELL = ['/manifest.webmanifest', '/static/icons/stackchain-192.png', '/static/icons/stackchain-512.png'];
self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', event => {
event.waitUntil(caches.keys().then(keys => Promise.all(
keys.filter(key => key !== CACHE).map(key => caches.delete(key))
)).then(() => self.clients.claim()));
});
self.addEventListener('fetch', event => {
const request = event.request;
if (request.method !== 'GET' || request.url.includes('/api/')) return;
const url = new URL(request.url);
if (request.mode === 'navigate') {
event.respondWith(fetch(request));
return;
}
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
}
});

View File

@ -1,12 +1,28 @@
from pathlib import Path from pathlib import Path
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.responses import HTMLResponse from fastapi.responses import FileResponse, HTMLResponse
router = APIRouter() router = APIRouter()
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html" DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def dashboard() -> str: async def dashboard() -> str:
return DASHBOARD_FILE.read_text() return DASHBOARD_FILE.read_text()
@router.get("/manifest.webmanifest", response_class=FileResponse)
async def web_app_manifest() -> FileResponse:
return FileResponse(MANIFEST_FILE, media_type="application/manifest+json")
@router.get("/service-worker.js", response_class=FileResponse)
async def service_worker() -> FileResponse:
return FileResponse(
SERVICE_WORKER_FILE,
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
)

View File

@ -534,6 +534,20 @@ async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
assert "dueDate: qs('#create-issue-due-date').value" in html assert "dueDate: qs('#create-issue-due-date').value" in html
@pytest.mark.anyio
async def test_dashboard_launches_share_capture_with_draft_conflict_choices():
html = await dashboard()
assert '<link rel="manifest" href="manifest.webmanifest"' in html
assert "issueCapture.stageSharedContent(sharedLaunch)" in html
assert 'id="use-shared-content"' in html
assert 'id="resume-issue-draft"' in html
assert 'id="shared-content-conflict"' in html
assert '.shared-content-actions button { min-height:44px;' in html
assert "history.replaceState({}, '', cleanUrl)" in html
assert "navigator.serviceWorker.register('/service-worker.js')" in html
@pytest.mark.anyio @pytest.mark.anyio
async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor(): async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor():
html = await dashboard() html = await dashboard()
@ -1232,6 +1246,105 @@ first.submit(draft).catch(() => {{
assert output["number"] == 17 assert output["number"] == 17
def test_issue_capture_normalizes_shared_mobile_content_without_repeating_source_url():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const normalized = createIssueCapture.normalizeSharedContent({{
title: ' Production alert ',
text: 'Latency crossed the threshold https://status.example/incidents/42',
url: 'https://status.example/incidents/42',
}});
process.stdout.write(JSON.stringify(normalized));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"title": "Production alert",
"body": "Latency crossed the threshold https://status.example/incidents/42",
}
def test_issue_capture_suggests_a_title_when_mobile_share_only_contains_text():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
process.stdout.write(JSON.stringify(createIssueCapture.normalizeSharedContent({{
text: 'Investigate checkout latency before the release window opens. More diagnostic context follows.'
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"title": "Investigate checkout latency before the release window opens.",
"body": "Investigate checkout latency before the release window opens. More diagnostic context follows.",
}
def test_issue_capture_keeps_existing_draft_until_shared_content_is_accepted():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
capture.saveDraft({{
repository:'stackchain/api', title:'Existing draft', body:'Keep me', labelIds:[3], milestoneId:9,
}});
const staged = capture.stageSharedContent({{title:'Shared alert', text:'Investigate', url:'https://status.example/42'}});
const before = capture.loadDraft();
capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
const pendingAfterReload = capture.pendingSharedContent();
const accepted = capture.acceptSharedContent();
process.stdout.write(JSON.stringify({{staged, before, pendingAfterReload, accepted, pending:capture.pendingSharedContent()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"staged": {"status": "conflict"},
"before": {
"repository": "stackchain/api", "title": "Existing draft", "body": "Keep me",
"labelIds": [3], "milestoneId": 9,
},
"pendingAfterReload": {
"title": "Shared alert", "body": "Investigate\n\nhttps://status.example/42",
},
"accepted": {
"repository": "stackchain/api", "title": "Shared alert",
"body": "Investigate\n\nhttps://status.example/42", "labelIds": [3], "milestoneId": 9,
},
"pending": None,
}
def test_issue_capture_can_resume_existing_draft_and_discard_staged_share():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const storage = {{getItem:key=>values.get(key)||null, setItem:(key,value)=>values.set(key,value), removeItem:key=>values.delete(key)}};
const capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
capture.saveDraft({{repository:'stackchain/api', title:'Existing', body:'Keep', labelIds:[]}});
capture.stageSharedContent({{title:'Incoming', text:'Replace'}});
capture.discardSharedContent();
process.stdout.write(JSON.stringify({{draft:capture.loadDraft(), pending:capture.pendingSharedContent()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"draft": {"repository": "stackchain/api", "title": "Existing", "body": "Keep", "labelIds": []},
"pending": None,
}
def test_issue_capture_loads_repository_labels_with_priorities_first(): def test_issue_capture_loads_repository_labels_with_priorities_first():
script = f""" script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))}); const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});

View File

@ -61,4 +61,24 @@ async def test_work_page_endpoint_rejects_unknown_stream_before_upstream_io(monk
response = await client.get("/api/v1/work/unknown?page=2") response = await client.get("/api/v1/work/unknown?page=2")
assert response.status_code == 422 assert response.status_code == 422
assert called is False assert called is False
@pytest.mark.anyio
async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_data():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
manifest = await client.get("/manifest.webmanifest")
worker = await client.get("/service-worker.js")
assert manifest.status_code == 200
assert manifest.headers["content-type"].startswith("application/manifest+json")
assert manifest.json()["share_target"] == {
"action": "/", "method": "GET", "enctype": "application/x-www-form-urlencoded",
"params": {"title": "title", "text": "text", "url": "url"},
}
assert worker.status_code == 200
assert worker.headers["content-type"].startswith("application/javascript")
assert worker.headers["service-worker-allowed"] == "/"
assert "request.url.includes('/api/')" in worker.text
assert "request.method !== 'GET'" in worker.text