636 lines
31 KiB
JavaScript
636 lines
31 KiB
JavaScript
(function (root, factory) {
|
||
const createSearchPreview = factory();
|
||
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
|
||
if (root) {
|
||
root.createSearchPreview = createSearchPreview;
|
||
root.safeSearchUrl = value => {
|
||
try {
|
||
const url = new URL(value);
|
||
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||
} catch (_) { return ''; }
|
||
};
|
||
root.searchPreviewUrl = (state, defaultScope, location) => {
|
||
const { query, preview, scope = defaultScope } = state;
|
||
const params = new URLSearchParams({
|
||
search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number,
|
||
search_kind:scope.kind, search_state:scope.state,
|
||
});
|
||
if (scope.repository) params.set('search_repository', scope.repository);
|
||
return new URL('?' + params, location.origin + location.pathname).href;
|
||
};
|
||
root.searchPreviewPath = item => {
|
||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||
'/preview?kind=' + encodeURIComponent(item.kind);
|
||
};
|
||
root.searchPreviewConversationPath = (item, page) => {
|
||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||
const query = new URLSearchParams({ kind:item.kind, limit:'20' });
|
||
if (page) query.set('page', String(page));
|
||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||
'/preview/conversation?' + query.toString();
|
||
};
|
||
root.followingPullReviewPath = item => {
|
||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||
return 'api/v1/following/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review-data';
|
||
};
|
||
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||
'/comments?kind=' + encodeURIComponent(item.kind);
|
||
root.searchPreviewMutation = fetchJson => (detail, action) => {
|
||
if (action === 'reopen-pull') {
|
||
const path = root.searchPreviewPath(detail).replace('/issues/', '/pulls/').replace(/\/preview.*$/, '/reopen');
|
||
return fetchJson(path, {
|
||
method:'PATCH', headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({expected_head_sha:detail.head_sha}),
|
||
});
|
||
}
|
||
return fetchJson(root.searchPreviewPath(detail).replace(/\?.*$/, '') + '/' + action, {method:'PATCH'});
|
||
};
|
||
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||
'/subscription?kind=' + encodeURIComponent(item.kind);
|
||
root.searchPreviewSubscriptionOptions = fetchJson => {
|
||
const options = {
|
||
load:async detail => {
|
||
if (['issue', 'pull'].includes(detail.kind) && detail.state === 'closed' && detail.following === true) {
|
||
return {...detail, watching:true};
|
||
}
|
||
if (!(['issue', 'pull'].includes(detail.kind) && detail.state === 'open')) return detail;
|
||
const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
|
||
return {...detail, watching:result.watching === true};
|
||
},
|
||
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
|
||
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
|
||
}),
|
||
review:item => fetchJson(root.followingPullReviewPath(item)),
|
||
};
|
||
options.preview = async item => options.load({...item, ...await fetchJson(root.searchPreviewPath(item), {
|
||
headers:{Accept:'application/json'},
|
||
})});
|
||
return options;
|
||
};
|
||
root.createDetailWatch = ({fetchJson,refreshFollowing,onState}) => {
|
||
const api=root.searchPreviewSubscriptionOptions(fetchJson);
|
||
let item,watching=false,mutation;
|
||
return {
|
||
async open(next) {
|
||
item=next; onState('loading',watching);
|
||
const result=await api.load(next).catch(error=>{
|
||
if(item===next)onState('error',watching,error);throw error;
|
||
});
|
||
if (item !== next) return;
|
||
watching=result.watching === true; onState('ready',watching);
|
||
},
|
||
toggle() {
|
||
if (mutation) return mutation;
|
||
const next=!watching;
|
||
onState(next?'watching':'unwatching',watching);
|
||
mutation=api.watch(item,next).then(async result => {
|
||
if (result?.watching !== next || result?.following_synced !== true)
|
||
throw new Error(result?.error || 'Unconfirmed.');
|
||
watching=next; await refreshFollowing();
|
||
onState(watching?'watched':'unwatched',watching);
|
||
}).catch(error => {onState('error',watching,error);throw error;})
|
||
.finally(()=>{mutation=null;});
|
||
return mutation;
|
||
},
|
||
};
|
||
};
|
||
root.searchPreviewWatchStatus = state => ({
|
||
watching:'Starting watch…', unwatching:'Stopping watch…',
|
||
watched:'Watching · available in Following. Future activity will appear in Updates.',
|
||
unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.',
|
||
'watch-partial':'Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch ' +
|
||
(state.detail?.kind === 'pull' ? 'pull request' : 'issue') + ' to repair.',
|
||
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
|
||
})[state.status] || '';
|
||
root.renderSearchPreviewWatch = (detail, state, button) => {
|
||
const watchableKind = ['issue', 'pull'].includes(detail.kind);
|
||
const retiring = watchableKind && detail.state === 'closed' &&
|
||
detail.following === true && detail.watching === true;
|
||
button.hidden = !(watchableKind && detail.state === 'open') && !retiring;
|
||
button.textContent = retiring ? 'Stop watching & next' :
|
||
(detail.watching ? 'Stop watching' : 'Watch ' + (detail.kind === 'pull' ? 'pull request' : 'issue'));
|
||
button.disabled = state.status === 'watching' || state.status === 'unwatching';
|
||
};
|
||
root.renderSearchPreviewStart = (detail, state, button) => {
|
||
const pull = detail.kind === 'pull' && detail.state === 'closed' &&
|
||
detail.authored_pull_reopenable === true;
|
||
const issue = detail.kind === 'issue' &&
|
||
(detail.reopenable || (detail.state === 'open' && (detail.claimable || detail.assigned_to_me)));
|
||
button.hidden = !(detail.reviewable || pull || issue);
|
||
button.textContent = detail.reviewable ? 'Review now' : pull ? 'Reopen in My Work' :
|
||
detail.reopenable ? 'Reopen & resume' :
|
||
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
|
||
button.disabled = state.status === 'claiming' || state.status === 'reopening';
|
||
};
|
||
root.createSearchAuthoredPullRecovery = o =>
|
||
async d => {
|
||
if (!o.confirm('Reopen ' + d.repository + ' #' + d.number + '?')) return 'canceled';
|
||
await o.reopen(d);
|
||
await o.refresh();
|
||
const item = o.find(d);
|
||
if (!item) {
|
||
o.unavailable('Reopened. Refresh My Work.');
|
||
return 'unavailable';
|
||
}
|
||
o.open(item);
|
||
return 'opened';
|
||
};
|
||
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
|
||
const detail = getDetail();
|
||
if (detail) preview.setWatching(detail.watching !== true).catch(() => {});
|
||
});
|
||
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');
|
||
const retry = document.querySelector('#retry-search-preview-conversation');
|
||
const older = document.querySelector('#load-older-search-preview-comments');
|
||
if (!conversation) {
|
||
comments.textContent = status.textContent = '';
|
||
retry.hidden = older.hidden = true;
|
||
return;
|
||
}
|
||
const isNew = comment => Boolean(conversation.reviewedAt && comment.created_at &&
|
||
new Date(comment.created_at).getTime() > new Date(conversation.reviewedAt).getTime());
|
||
const newCount = (conversation.comments || []).filter(isNew).length;
|
||
comments.innerHTML = (conversation.comments || []).map(comment =>
|
||
'<article class="search-preview-comment' + (isNew(comment) ? ' new-since-review' : '') +
|
||
'" data-comment-id="' + Number(comment.id || 0) + '">' +
|
||
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
|
||
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') +
|
||
(isNew(comment) ? ' · <strong>New since last review</strong>' : '') + '</div>' +
|
||
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
|
||
).join('');
|
||
retry.hidden = conversation.status !== 'error';
|
||
older.hidden = conversation.status !== 'ready' || !conversation.olderPage;
|
||
older.disabled = conversation.status === 'loading';
|
||
if (conversation.status === 'loading') status.textContent = conversation.comments?.length
|
||
? 'Loading older messages…' : 'Loading current conversation…';
|
||
else if (conversation.status === 'error') status.textContent =
|
||
'Conversation unavailable. Preview and planning actions still work.';
|
||
else if (!conversation.comments?.length) status.textContent = 'No conversation yet.';
|
||
else status.textContent = conversation.comments.length +
|
||
(conversation.comments.length === 1 ? ' message' : ' messages') +
|
||
(newCount ? ' · ' + newCount + ' new since last review' : '') + '.';
|
||
};
|
||
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() && !preview.hasReplyAttachments?.());
|
||
});
|
||
if (replying) status.textContent = 'Saving reply for delivery…';
|
||
else if (state.status === 'queued') status.textContent = 'Reply queued. It will send when connected.';
|
||
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.';
|
||
};
|
||
root.renderSearchPreviewReview = (review, document, escapeHtml) => {
|
||
const section = document.querySelector('#search-preview-review');
|
||
const status = document.querySelector('#search-preview-review-status');
|
||
const files = document.querySelector('#search-preview-files');
|
||
const retry = document.querySelector('#retry-search-preview-review');
|
||
section.hidden = !review;
|
||
retry.hidden = review?.status !== 'error';
|
||
files.innerHTML = '';
|
||
if (!review) {
|
||
status.textContent = '';
|
||
return;
|
||
}
|
||
if (review.status === 'loading') {
|
||
status.textContent = 'Loading CI and changed files…';
|
||
return;
|
||
}
|
||
if (review.status === 'error') {
|
||
status.textContent = 'Changes unavailable. This revision has not been marked reviewed.';
|
||
return;
|
||
}
|
||
const data = review.data || {};
|
||
const changed = Array.isArray(data.files) ? data.files : [];
|
||
const ci = ({success:'CI passed', failure:'CI failed', error:'CI failed', pending:'CI pending'})[
|
||
data.ci_state
|
||
] || 'CI status unavailable';
|
||
status.textContent = ci + ' · ' + changed.length + ' changed ' +
|
||
(changed.length === 1 ? 'file.' : 'files.');
|
||
files.innerHTML = changed.map(file => {
|
||
const lines = (Array.isArray(file.diff_lines) ? file.diff_lines : []).map(raw => {
|
||
const line = String(raw);
|
||
const kind = line.startsWith('@@') ? 'hunk' : line.startsWith('+') ? 'added' :
|
||
line.startsWith('-') ? 'removed' : 'context';
|
||
return '<span class="pull-diff-line ' + kind + '">' + escapeHtml(line) + '</span>';
|
||
}).join('');
|
||
const diff = file.diff_available
|
||
? '<pre class="pull-diff">' + lines +
|
||
(file.diff_truncated ? '<span class="pull-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') + '</pre>'
|
||
: '<div class="pull-diff-empty">' +
|
||
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') + '</div>';
|
||
return '<article class="search-preview-file"><strong>' + escapeHtml(file.filename || 'Unknown file') +
|
||
'</strong><span class="small">' + escapeHtml(file.status || 'changed') + ' · +' +
|
||
Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</span>' + diff + '</article>';
|
||
}).join('');
|
||
};
|
||
root.renderSearchPreviewWorkspaces = (state, detail, preview, document, escapeHtml) => {
|
||
root.renderSearchPreviewReply(state, detail, preview, document);
|
||
root.renderSearchPreviewReview(state.review, document, escapeHtml);
|
||
document.querySelector('[data-search-preview-section="changes"]').hidden = !state.review;
|
||
};
|
||
}
|
||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||
return function createSearchPreview({ fetchJson, fetchConversation, fetchReview, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, afterUnwatch, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) {
|
||
if (Array.isArray(session)) {
|
||
getSession = session[0];
|
||
loadMore = () => session[1].loadMore();
|
||
onNavigate = session[2];
|
||
navigationRoot = document;
|
||
}
|
||
let generation = 0;
|
||
let current = null;
|
||
let mutationRequest = null;
|
||
let shareRequest = null;
|
||
let moveRequest = null;
|
||
let replyRequest = null;
|
||
let watchRequest = null;
|
||
let conversation = null;
|
||
let review = null;
|
||
let openedRevision = null;
|
||
|
||
function sameItem(left, right) {
|
||
return left && right && left.kind === right.kind && left.repository === right.repository &&
|
||
Number(left.number) === Number(right.number);
|
||
}
|
||
|
||
async function notifyOpened(item, requestGeneration) {
|
||
const revision = [item?.kind, item?.repository, item?.number, item?.updated_at].join(':');
|
||
if (requestGeneration !== generation || openedRevision === revision) return false;
|
||
await onOpened?.({...item});
|
||
if (requestGeneration === generation) openedRevision = revision;
|
||
return true;
|
||
}
|
||
|
||
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 : [];
|
||
const index = items.findIndex(candidate => sameItem(candidate, item));
|
||
if (index < 0) return null;
|
||
return {
|
||
position:index + 1,
|
||
total:items.length,
|
||
hasPrevious:index > 0,
|
||
hasNext:index < items.length - 1 || session.more === true,
|
||
};
|
||
}
|
||
|
||
function publish(state) {
|
||
if (conversation && !Object.prototype.hasOwnProperty.call(state, 'conversation')) {
|
||
state = {...state, conversation};
|
||
}
|
||
if (review && !Object.prototype.hasOwnProperty.call(state, 'review')) state = {...state, review};
|
||
const position = navigation(state.item || current);
|
||
if (navigationRoot) {
|
||
const bar = navigationRoot.querySelector('.search-preview-navigation');
|
||
bar.hidden = !position;
|
||
if (position) {
|
||
navigationRoot.querySelector('#search-preview-position').textContent = position.position + ' of ' + position.total;
|
||
navigationRoot.querySelector('#previous-search-result').disabled = !position.hasPrevious;
|
||
navigationRoot.querySelector('#next-search-result').disabled = !position.hasNext;
|
||
}
|
||
}
|
||
onState(position ? { ...state, navigation:position } : state);
|
||
if (state.complete && navigationRoot) {
|
||
navigationRoot.querySelector('#search-preview-status').textContent = 'Search pass complete.';
|
||
}
|
||
}
|
||
|
||
function run(action, pending, success, detail) {
|
||
if (mutationRequest) return mutationRequest;
|
||
publish({ status: pending, item: current, detail });
|
||
mutationRequest = mutate(detail, action).then(result => {
|
||
publish({ status: success, item: current, detail, result });
|
||
return result;
|
||
}).catch(error => {
|
||
publish({ status: 'ready', item: current, detail, error });
|
||
throw error;
|
||
}).finally(() => { mutationRequest = null; });
|
||
return mutationRequest;
|
||
}
|
||
|
||
function loadConversation(detail, requestGeneration, page) {
|
||
if (typeof fetchConversation !== 'function') return Promise.resolve(null);
|
||
const previousComments = page && Array.isArray(conversation?.comments)
|
||
? conversation.comments : [];
|
||
conversation = {
|
||
status:'loading', comments:previousComments, olderPage:page ?? null,
|
||
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
|
||
};
|
||
publish({ status:'ready', item:current, detail, conversation });
|
||
return fetchConversation(detail, page).then(result => {
|
||
if (requestGeneration !== generation) return result;
|
||
const incoming = Array.isArray(result?.comments) ? result.comments : [];
|
||
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,
|
||
olderPage:result?.older_page ?? null,
|
||
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
|
||
};
|
||
publish({ status:'ready', item:current, detail:current, conversation });
|
||
return result;
|
||
}).catch(error => {
|
||
if (requestGeneration === generation) {
|
||
conversation = {
|
||
status:'error', comments:previousComments,
|
||
olderPage:page ?? conversation?.olderPage ?? null, error,
|
||
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
|
||
};
|
||
publish({ status:'ready', item:current, detail:current, conversation });
|
||
}
|
||
return null;
|
||
});
|
||
}
|
||
|
||
function loadReview(detail, requestGeneration) {
|
||
if (!(detail?.following === true && detail?.kind === 'pull' && typeof fetchReview === 'function')) {
|
||
review = null;
|
||
return Promise.resolve(null);
|
||
}
|
||
review = {status:'loading', data:null};
|
||
publish({status:'ready', item:current, detail:current, conversation, review});
|
||
return fetchReview(detail).then(data => {
|
||
if (requestGeneration === generation) {
|
||
review = {status:'ready', data};
|
||
publish({status:'ready', item:current, detail:current, conversation, review});
|
||
}
|
||
return data;
|
||
}).catch(error => {
|
||
if (requestGeneration === generation) {
|
||
review = {status:'error', data:null, error};
|
||
publish({status:'ready', item:current, detail:current, conversation, review});
|
||
}
|
||
return null;
|
||
});
|
||
}
|
||
|
||
async function acknowledgeWhenContextReady(requestGeneration) {
|
||
const conversationReady = typeof fetchConversation !== 'function' || conversation?.status === 'ready';
|
||
const reviewRequired = current?.following === true && current?.kind === 'pull' &&
|
||
typeof fetchReview === 'function';
|
||
if (conversationReady && (!reviewRequired || review?.status === 'ready')) {
|
||
await notifyOpened(current, requestGeneration);
|
||
}
|
||
}
|
||
|
||
const api = {
|
||
hasReplyAttachments() {
|
||
return Boolean(hasAttachments?.());
|
||
},
|
||
open(item) {
|
||
if (current && !sameItem(current, item) && hasAttachments?.()) clearAttachments?.();
|
||
generation += 1;
|
||
const requestGeneration = generation;
|
||
current = { ...item };
|
||
conversation = null;
|
||
review = null;
|
||
openedRevision = null;
|
||
publish({ status: 'loading', item: current });
|
||
return fetchJson(current).then(async detail => {
|
||
if (requestGeneration === generation) {
|
||
current = { ...current, ...detail };
|
||
if (typeof fetchConversation === 'function') {
|
||
const context = loadConversation(current, requestGeneration);
|
||
if (current.following === true) {
|
||
const reviewContext = loadReview(current, requestGeneration);
|
||
await Promise.all([context, reviewContext]);
|
||
await acknowledgeWhenContextReady(requestGeneration);
|
||
} else {
|
||
await notifyOpened(current, requestGeneration);
|
||
}
|
||
} else {
|
||
publish({ status: 'ready', item: current, detail });
|
||
await notifyOpened(current, requestGeneration);
|
||
}
|
||
}
|
||
return detail;
|
||
}).catch(error => {
|
||
if (requestGeneration === generation) {
|
||
publish({ status: 'error', item: current, error });
|
||
}
|
||
throw error;
|
||
});
|
||
},
|
||
close() {
|
||
clearAttachments?.();
|
||
generation += 1;
|
||
current = null;
|
||
onState({ status: 'closed' });
|
||
},
|
||
previous() {
|
||
const session = typeof getSession === 'function' ? getSession() : null;
|
||
const items = Array.isArray(session?.items) ? session.items : [];
|
||
const index = items.findIndex(item => sameItem(item, current));
|
||
if (index <= 0) return Promise.resolve(current);
|
||
const item = items[index - 1];
|
||
if (typeof onNavigate === 'function') onNavigate(item);
|
||
return api.open(item);
|
||
},
|
||
next() {
|
||
if (moveRequest) return moveRequest;
|
||
moveRequest = (async () => {
|
||
let session = typeof getSession === 'function' ? getSession() : null;
|
||
let items = Array.isArray(session?.items) ? session.items : [];
|
||
let index = items.findIndex(item => sameItem(item, current));
|
||
if (index < 0) return current;
|
||
if (index === items.length - 1 && session?.more === true && typeof loadMore === 'function') {
|
||
await loadMore();
|
||
session = getSession();
|
||
items = Array.isArray(session?.items) ? session.items : [];
|
||
index = items.findIndex(item => sameItem(item, current));
|
||
}
|
||
if (index >= 0 && index < items.length - 1) {
|
||
const item = items[index + 1];
|
||
if (typeof onNavigate === 'function') onNavigate(item);
|
||
return api.open(item);
|
||
}
|
||
publish({ status:'ready', item:current, detail:current, complete:true });
|
||
return current;
|
||
})().finally(() => { moveRequest = null; });
|
||
return moveRequest;
|
||
},
|
||
async retryConversation() {
|
||
if (!current) return null;
|
||
const requestGeneration = generation;
|
||
const result = await loadConversation(current, requestGeneration);
|
||
if (current?.following === true) await acknowledgeWhenContextReady(requestGeneration);
|
||
return result;
|
||
},
|
||
async retryReview() {
|
||
if (!current) return null;
|
||
const requestGeneration = generation;
|
||
const result = await loadReview(current, requestGeneration);
|
||
await acknowledgeWhenContextReady(requestGeneration);
|
||
return result;
|
||
},
|
||
loadOlderConversation() {
|
||
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' && typeof queueReply !== 'function')) {
|
||
return Promise.reject(new Error('Replying is unavailable.'));
|
||
}
|
||
const body = api.replyDraft().trim();
|
||
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);
|
||
if (!operationId) {
|
||
operationId = String(createOperationId?.() || Date.now()).slice(0, 128);
|
||
save(operationKey, operationId);
|
||
}
|
||
publish({ status:'replying', item:current, detail:current, conversation });
|
||
replyRequest = (typeof queueReply === 'function' ?
|
||
Promise.resolve(queueReply(item, body, operationId)) :
|
||
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(async comment => {
|
||
await afterReply?.(item);
|
||
if (!comment?.queued) {
|
||
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:comment?.queued ? 'queued' : '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.'));
|
||
const detail = current;
|
||
publish({ status:'sharing', item:current, detail });
|
||
shareRequest = share(url).then(result => {
|
||
publish({ status:result, item:current, detail });
|
||
return result;
|
||
}).catch(error => {
|
||
publish({ 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);
|
||
},
|
||
reopen(detail) {
|
||
return run('reopen', 'reopening', 'reopened', detail);
|
||
},
|
||
reopenPull(detail) {
|
||
return run('reopen-pull', 'reopening', 'reopened', detail);
|
||
},
|
||
setWatching(watching) {
|
||
if (watchRequest) return watchRequest;
|
||
if (!current || typeof watch !== 'function') {
|
||
return Promise.reject(new Error('Watching is unavailable.'));
|
||
}
|
||
const detail = current;
|
||
publish({ status:watching ? 'watching' : 'unwatching', item:current, detail });
|
||
watchRequest = watch(detail, watching).then(async result => {
|
||
current = { ...current, watching:result?.watching === true };
|
||
const status = result?.following_synced === false
|
||
? 'watch-partial' : (watching ? 'watched' : 'unwatched');
|
||
publish({ status, item:current, detail:current, result });
|
||
if (!watching && result?.watching === false && typeof afterUnwatch === 'function') {
|
||
const next = await afterUnwatch({...current});
|
||
if (next) {
|
||
onNavigate?.(next);
|
||
await api.open(next);
|
||
}
|
||
}
|
||
return result;
|
||
}).catch(error => {
|
||
publish({ status:'watch-error', item:current, detail, error });
|
||
throw error;
|
||
}).finally(() => { watchRequest = null; });
|
||
return watchRequest;
|
||
},
|
||
};
|
||
if (navigationRoot) {
|
||
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
|
||
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
|
||
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
|
||
navigationRoot.querySelector('#retry-search-preview-review').addEventListener('click', () => api.retryReview());
|
||
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;
|
||
};
|
||
});
|