421 lines
20 KiB
JavaScript
421 lines
20 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.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
|
'/comments?kind=' + encodeURIComponent(item.kind);
|
|
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
|
'/subscription?kind=' + encodeURIComponent(item.kind);
|
|
root.searchPreviewSubscriptionOptions = fetchJson => ({
|
|
load:async detail => {
|
|
if (!(detail.kind === 'issue' && 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'},
|
|
}),
|
|
});
|
|
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 issue to repair.',
|
|
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
|
|
})[state.status] || '';
|
|
root.renderSearchPreviewWatch = (detail, state, button) => {
|
|
button.hidden = !(detail.kind === 'issue' && detail.state === 'open');
|
|
button.textContent = detail.watching ? 'Stop watching' : 'Watch issue';
|
|
button.disabled = state.status === 'watching' || state.status === 'unwatching';
|
|
};
|
|
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;
|
|
}
|
|
comments.innerHTML = (conversation.comments || []).map(comment =>
|
|
'<article class="search-preview-comment" data-comment-id="' + Number(comment.id || 0) + '">' +
|
|
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
|
|
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') + '</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.');
|
|
};
|
|
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.';
|
|
};
|
|
}
|
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
return function createSearchPreview({ fetchJson, fetchConversation, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, 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;
|
|
|
|
function sameItem(left, right) {
|
|
return left && right && left.kind === right.kind && left.repository === right.repository &&
|
|
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 : [];
|
|
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) {
|
|
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 };
|
|
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,
|
|
};
|
|
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,
|
|
};
|
|
publish({ status:'ready', item:current, detail:current, conversation });
|
|
}
|
|
return null;
|
|
});
|
|
}
|
|
|
|
const api = {
|
|
hasReplyAttachments() {
|
|
return Boolean(hasAttachments?.());
|
|
},
|
|
open(item) {
|
|
if (current && !sameItem(current, item) && hasAttachments?.()) clearAttachments?.();
|
|
generation += 1;
|
|
const requestGeneration = generation;
|
|
current = { ...item };
|
|
conversation = null;
|
|
publish({ status: 'loading', item: current });
|
|
return fetchJson(current).then(async detail => {
|
|
if (requestGeneration === generation) {
|
|
current = { ...current, ...detail };
|
|
if (typeof fetchConversation === 'function') {
|
|
loadConversation(current, requestGeneration);
|
|
} else {
|
|
publish({ status: 'ready', item: current, detail });
|
|
}
|
|
await onOpened?.({...current});
|
|
}
|
|
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;
|
|
},
|
|
retryConversation() {
|
|
if (!current) return Promise.resolve(null);
|
|
return loadConversation(current, generation);
|
|
},
|
|
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);
|
|
},
|
|
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(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 });
|
|
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('#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;
|
|
};
|
|
});
|