feat: reply and continue from mobile search (Closes #805)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-14 06:41:34 +00:00
1 parent 7bcab03b8a
commit 5a4fbd1564
8 files changed
+384 -5

No files matched your search

+6
View File
@@ -550,6 +550,12 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-conversation h2 { margin:0; font-size:1rem; }
.search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
.search-preview-conversation button { min-height:44px; width:100%; }
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
.search-preview-reply[hidden] { display:none; }
.search-preview-reply h2 { margin:0; font-size:1rem; }
.search-preview-reply textarea { box-sizing:border-box; width:100%; min-height:110px; resize:vertical; scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom)); }
.search-preview-reply-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.search-preview-reply-actions button { min-height:44px; }
.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; }
+3
View File
@@ -4348,6 +4348,7 @@
startButton.hidden = true;
startButton.disabled = false;
shareButton.disabled = true;
renderSearchPreviewReply(state, null, searchPreview, document);
if (state.status === 'loading') {
searchPreviewDetail = null;
@@ -4377,6 +4378,7 @@
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
renderSearchConversation(state.conversation);
renderSearchPreviewReply(state, detail, searchPreview, document);
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
claimButton.hidden = !(detail.claimable || detail.assigned_to_me);
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
@@ -4416,6 +4418,7 @@
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
{ method:'PATCH', headers:{ Accept:'application/json' } }
),
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})],
onState: renderSearchPreview,
+9
View File
@@ -472,6 +472,15 @@
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
</section>
<section class="search-preview-reply" aria-labelledby="search-preview-reply-title" hidden>
<h2 id="search-preview-reply-title">Reply</h2>
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<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>
<button id="send-search-preview-reply-next" type="button">Send &amp; next</button>
</div>
</section>
<div class="search-preview-actions">
<nav class="search-preview-navigation" aria-label="Search result navigation" hidden>
<button id="previous-search-result" type="button">Previous</button>
+101 -5
View File
@@ -30,6 +30,16 @@
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/conversation?' + query.toString();
};
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/comments?kind=' + encodeURIComponent(item.kind);
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
storage,
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
postReply:(item,body,operationId) => fetchJson(root.searchPreviewReplyPath(item), {
method:'POST', headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':operationId},
body:JSON.stringify({body}),
}),
});
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
const comments = document.querySelector('#search-preview-comments');
const status = document.querySelector('#search-preview-conversation-status');
@@ -57,9 +67,29 @@
else status.textContent = conversation.comments.length +
(conversation.comments.length === 1 ? ' message.' : ' messages.');
};
root.renderSearchPreviewReply = (state, detail, preview, document) => {
const section = document.querySelector('.search-preview-reply');
const input = document.querySelector('#search-preview-reply');
const status = document.querySelector('#search-preview-reply-status');
const buttons = ['#send-search-preview-reply', '#send-search-preview-reply-next']
.map(selector => document.querySelector(selector));
section.hidden = detail?.commentable !== true;
status.textContent = '';
if (section.hidden) {
buttons.forEach(button => { button.disabled = true; });
return;
}
input.value = preview.replyDraft();
const replying = state.status === 'replying';
buttons.forEach(button => { button.disabled = replying || !input.value.trim(); });
if (replying) status.textContent = 'Sending reply…';
else if (state.status === 'replied') status.textContent = 'Reply posted.';
else if (state.status === 'reply-error') status.textContent =
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
if (Array.isArray(session)) {
getSession = session[0];
loadMore = () => session[1].loadMore();
@@ -71,6 +101,7 @@
let mutationRequest = null;
let shareRequest = null;
let moveRequest = null;
let replyRequest = null;
let conversation = null;
function sameItem(left, right) {
@@ -78,6 +109,23 @@
Number(left.number) === Number(right.number);
}
function replyKey(item, suffix) {
return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number]
.map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix;
}
function stored(key) {
try { return storage?.getItem(key) || ''; }
catch (_) { return ''; }
}
function save(key, value) {
try {
if (value) storage?.setItem(key, value);
else storage?.removeItem(key);
} catch (_) { /* Volatile drafts still remain in the textarea. */ }
}
function navigation(item) {
const session = typeof getSession === 'function' ? getSession() : null;
const items = Array.isArray(session?.items) ? session.items : [];
@@ -130,10 +178,9 @@
return fetchConversation(detail, page).then(result => {
if (requestGeneration !== generation) return result;
const incoming = Array.isArray(result?.comments) ? result.comments : [];
const comments = page
? [...incoming, ...previousComments].filter((comment, index, all) =>
all.findIndex(candidate => candidate?.id === comment?.id) === index)
: incoming;
const liveComments = Array.isArray(conversation?.comments) ? conversation.comments : previousComments;
const comments = [...incoming, ...liveComments].filter((comment, index, all) =>
all.findIndex(candidate => candidate?.id === comment?.id) === index);
conversation = {
status:'ready',
comments,
@@ -222,6 +269,44 @@
if (!current || !conversation?.olderPage) return Promise.resolve(null);
return loadConversation(current, generation, conversation.olderPage);
},
saveReplyDraft(body) {
if (!current) return '';
const key = replyKey(current, 'draft');
const next = String(body || '');
if (stored(key) !== next) save(replyKey(current, 'operation'), '');
save(key, next);
return next;
},
replyDraft() {
return current ? stored(replyKey(current, 'draft')) : '';
},
reply({ advance = false } = {}) {
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.'));
const item = { ...current };
const operationKey = replyKey(item, 'operation');
let operationId = stored(operationKey);
if (!operationId) {
operationId = String(createOperationId?.() || Date.now()).slice(0, 128);
save(operationKey, operationId);
}
publish({ status:'replying', item:current, detail:current, conversation });
replyRequest = postReply(item, body, 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, '');
publish({ status:'replied', item:current, detail:current, conversation, result:comment });
return advance ? api.next().then(() => comment) : comment;
}).catch(error => {
publish({ status:'reply-error', item:current, detail:current, conversation, error });
throw error;
}).finally(() => { replyRequest = null; });
return replyRequest;
},
share(url) {
if (shareRequest) return shareRequest;
if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.'));
@@ -248,6 +333,17 @@
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
const reply = navigationRoot.querySelector('#search-preview-reply');
reply?.addEventListener('input', event => {
api.saveReplyDraft(event.target.value);
const disabled = !event.target.value.trim();
navigationRoot.querySelector('#send-search-preview-reply').disabled = disabled;
navigationRoot.querySelector('#send-search-preview-reply-next').disabled = disabled;
});
reply?.addEventListener('focus', event => event.target.scrollIntoView({ block:'center', behavior:'smooth' }));
navigationRoot.querySelector('#send-search-preview-reply')?.addEventListener('click', () => api.reply().catch(() => {}));
navigationRoot.querySelector('#send-search-preview-reply-next')?.addEventListener('click', () =>
api.reply({ advance:true }).catch(() => {}));
}
return api;
};