feat: share resumable mobile search previews (Closes #787)
CI / lint (pull_request) Successful in 1m41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-14 01:04:20 +00:00
1 parent 66a2b78860
commit 43163ed926
11 files changed
+234 -10

No files matched your search

+1
View File
@@ -541,6 +541,7 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.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; }
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
+24 -6
View File
@@ -4319,6 +4319,7 @@
const status = qs('#search-preview-status');
const claimButton = qs('#claim-search-result');
const startButton = qs('#start-search-result');
const shareButton = qs('#share-search-result');
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
? 'Back to blockers' : 'Back to search';
@@ -4331,6 +4332,7 @@
claimButton.disabled = false;
startButton.hidden = true;
startButton.disabled = false;
shareButton.disabled = true;
if (state.status === 'loading') {
searchPreviewDetail = null;
@@ -4349,6 +4351,7 @@
const detail = state.detail;
if (!detail) return;
searchPreviewDetail = detail;
shareButton.disabled = state.status === 'sharing';
qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number;
qs('#search-preview-title').textContent = detail.title || 'Untitled work item';
qs('#search-preview-meta').textContent =
@@ -4366,12 +4369,18 @@
startButton.textContent = detail.reopenable ? 'Reopen & resume' :
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
status.textContent = state.status === 'reopening' ? 'Reopening…' :
(state.status === 'claiming' ? 'Assigning this issue to you…' :
(state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' :
(detail.claimable ? 'This issue is open and unassigned.' :
(detail.assigned_to_me ? 'This item is already in My Work.' :
(detail.reopenable ? 'Closed—reopen to resume.' : 'Read-only preview.')))));
const shareStatus = {
sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.',
'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.',
};
if (shareStatus[state.status]) status.textContent = shareStatus[state.status];
else if (state.status === 'reopening') status.textContent = 'Reopening…';
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
else if (state.status === 'claimed') status.textContent = 'Assignment confirmed. Opening My Work…';
else if (detail.claimable) status.textContent = 'This issue is open and unassigned.';
else if (detail.assigned_to_me) status.textContent = 'This item is already in My Work.';
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
else status.textContent = 'Read-only preview.';
}
const searchPreview = createSearchPreview({
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
@@ -4380,8 +4389,14 @@
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
{ method:'PATCH', headers:{ Accept:'application/json' } }
),
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
onState: renderSearchPreview,
});
function canonicalSearchPreviewUrl() {
const { query, preview } = taskOverlayHistory.currentState();
const params = new URLSearchParams({ search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number });
return new URL('?' + params, window.location.origin + window.location.pathname).href;
}
function createSearchStart(claim) {
return createAssignAndStart({
available: createAndStart.available,
@@ -4613,6 +4628,9 @@
}
});
qs('#close-search-preview').addEventListener('click', closeSearchPreview);
qs('#share-search-result').addEventListener('click', () => {
searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {});
});
document.querySelectorAll('.share-work-route').forEach(button => {
button.addEventListener('click', async () => {
const status = qs('#work-route-share-status');
+1
View File
@@ -451,6 +451,7 @@
<button id="start-search-result" type="button" hidden>Assign &amp; start</button>
</div>
<button id="share-search-result" type="button" disabled>Share result</button>
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
</section>
+15 -2
View File
@@ -4,7 +4,7 @@
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
function validShareContinuation(value) {
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5 };
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200, preview: 200 };
const params = new URLSearchParams(value.slice(3));
const entries = Array.from(params.entries());
if (!entries.length) return './';
@@ -15,6 +15,14 @@
));
if (invalid) return './';
if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './';
const search = params.get('search');
const preview = params.get('preview');
if (search !== null || preview !== null) {
if (entries.some(([name]) => !['search', 'preview'].includes(name))) return './';
if (search === null || preview === null) return './';
if (!/^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9]\d*$/.test(preview)) return './';
return value;
}
const launch = params.get('launch');
const shared = params.get('shared');
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
@@ -87,7 +95,12 @@
};
}
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
if (continuation !== './') {
const continuationParams = new URLSearchParams(continuation.slice(3));
status.textContent = continuationParams.has('preview')
? 'Sign in to open the shared Search result.'
: 'Sign in to continue your shared capture.';
}
async function showReason(reason) {
if (reason === 'session-expired') {
+17 -1
View File
@@ -3,10 +3,11 @@
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
if (root) root.createSearchPreview = createSearchPreview;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, mutate, onState }) {
return function createSearchPreview({ fetchJson, mutate, share, onState }) {
let generation = 0;
let current = null;
let mutationRequest = null;
let shareRequest = null;
function run(action, pending, success, detail) {
if (mutationRequest) return mutationRequest;
@@ -29,6 +30,7 @@
onState({ status: 'loading', item: current });
return fetchJson(current).then(detail => {
if (requestGeneration === generation) {
current = { ...current, ...detail };
onState({ status: 'ready', item: current, detail });
}
return detail;
@@ -44,6 +46,20 @@
current = null;
onState({ status: 'closed' });
},
share(url) {
if (shareRequest) return shareRequest;
if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.'));
const detail = current;
onState({ status:'sharing', item:current, detail });
shareRequest = share(url).then(result => {
onState({ status:result, item:current, detail });
return result;
}).catch(error => {
onState({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error });
throw error;
}).finally(() => { shareRequest = null; });
return shareRequest;
},
claim(detail) {
return run('claim', 'claiming', 'claimed', detail);
},