feat: add mobile Following queue for watched issues (Closes #1293)
This commit is contained in:
1 parent
4b4e55dbe2
commit
5c43a5678a
19 files changed
+562
-14
No files matched your search
@@ -1083,6 +1083,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
@media (min-width:601px) { .mobile-search-preview-nav { display:none; } }
|
||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.following-sheet { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; margin:auto 0 0 auto; padding:0; border:1px solid #2a496e; color:var(--text); background:#102641; }
|
||||
.following-sheet::backdrop { background:rgba(3,9,18,.74); }
|
||||
.following-panel { box-sizing:border-box; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
|
||||
.following-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.following-panel h2, .following-panel p { margin:0; }
|
||||
.following-panel header button, #retry-following { min-height:44px; }
|
||||
.following-list { display:grid; gap:8px; min-width:0; max-height:70dvh; overflow:auto; }
|
||||
.following-card { box-sizing:border-box; display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; min-width:0; min-height:52px; padding:10px 12px; text-align:left; }
|
||||
.following-card span:first-child { min-width:0; display:grid; gap:3px; }
|
||||
.following-card strong, .following-card small { overflow-wrap:anywhere; }
|
||||
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
||||
.markdown-content > :first-child { margin-top:0; }
|
||||
.markdown-content > :last-child { margin-bottom:0; }
|
||||
@@ -1367,6 +1377,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.following-sheet { width:100%; max-width:none; border:0; border-radius:18px 18px 0 0; }
|
||||
|
||||
.mobile-first-task { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.mobile-first-task::backdrop { background:rgba(3,9,18,.78); }
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
}
|
||||
let mobileQueueCounts = {};
|
||||
let mobilePreparationItems = {};
|
||||
const followingQueue = attachFollowing(item => searchPreview.open(item));
|
||||
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
|
||||
getItems: () => draftInbox.partition(lastDrafts).deliveries,
|
||||
getIndex: item => lastDrafts.indexOf(item),
|
||||
@@ -132,6 +133,7 @@
|
||||
openToday: () => mobileWorkEntry.open(),
|
||||
openAgenda: openAgendaSession,
|
||||
openUpdates: openUpdateTriage,
|
||||
openFollowing:followingQueue.open,
|
||||
openFiled: openFiledFollowUp,
|
||||
selectFilter: selectMobileQueue,
|
||||
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
||||
@@ -5719,7 +5721,8 @@
|
||||
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
|
||||
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
||||
),
|
||||
watch:searchSubscription.watch,
|
||||
watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result =>
|
||||
followingQueue.load().catch(() => {}).then(() => result)),
|
||||
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
|
||||
queueReply:async (item,body,operationId) => {
|
||||
searchReplyAttachmentTarget = item;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
(function (root, factory) {
|
||||
const exports = factory();
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports = exports.createFollowing;
|
||||
module.exports.attachFollowing = exports.attachFollowing;
|
||||
} else {
|
||||
root.createFollowing = exports.createFollowing;
|
||||
root.attachFollowing = exports.attachFollowing;
|
||||
}
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
function createFollowing(options) {
|
||||
let generation = 0;
|
||||
let snapshot = {revision:0, items:[]};
|
||||
|
||||
function publish(status, error) {
|
||||
const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
|
||||
if (error) state.error = error;
|
||||
options.render?.(state);
|
||||
if (status === 'ready') options.onCount?.(snapshot.items.length);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const requestGeneration = ++generation;
|
||||
publish('loading');
|
||||
try {
|
||||
const result = await options.fetchJson('api/v1/following', {headers:{Accept:'application/json'}});
|
||||
if (requestGeneration !== generation) return snapshot;
|
||||
snapshot = {
|
||||
revision:Number(result?.revision) || 0,
|
||||
items:Array.isArray(result?.items) ? result.items.slice(0, 50) : [],
|
||||
};
|
||||
publish('ready');
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (requestGeneration === generation) publish('error', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function open(index) {
|
||||
const item = snapshot.items[Number(index)];
|
||||
if (!item) return false;
|
||||
Promise.resolve(options.onOpen?.({...item, kind:'issue'})).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
return {load, open, count:() => snapshot.items.length};
|
||||
}
|
||||
|
||||
function attachFollowing(onOpen) {
|
||||
const document = globalThis.document;
|
||||
const query = selector => document.querySelector(selector);
|
||||
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''})[character]);
|
||||
const formatTime = value => new Date(value).toLocaleString();
|
||||
const fetchJson = async (url, options) => {
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.');
|
||||
return payload;
|
||||
};
|
||||
let feature;
|
||||
function render(state) {
|
||||
const list = query('#following-list');
|
||||
const status = query('#following-status');
|
||||
query('#retry-following').hidden = state.status !== 'error';
|
||||
if (state.status === 'loading') return void (status.textContent = 'Loading watched issues…');
|
||||
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
|
||||
status.textContent = state.items.length
|
||||
? state.items.length + (state.items.length === 1 ? ' watched issue.' : ' watched issues.')
|
||||
: 'No watched issues yet. Watch one from Search to keep it here.';
|
||||
list.innerHTML = state.items.map((item, index) =>
|
||||
'<button class="following-card" type="button" data-following-index="' + index + '"><span><strong>' +
|
||||
escapeHtml(item.title) + '</strong><small>' + escapeHtml(item.repository + ' #' + item.number +
|
||||
' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
|
||||
'</small></span><span aria-hidden="true">›</span></button>').join('');
|
||||
list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
|
||||
query('#following-sheet').close();
|
||||
feature.open(Number(button.dataset.followingIndex));
|
||||
}));
|
||||
}
|
||||
feature = createFollowing({
|
||||
fetchJson, render,
|
||||
onCount:count => {
|
||||
const value = query('[data-mobile-queue-count="following"]');
|
||||
value.textContent = count;
|
||||
value.closest('button').setAttribute('aria-label', 'Following, ' + count +
|
||||
(count === 1 ? ' watched issue' : ' watched issues'));
|
||||
},
|
||||
onOpen,
|
||||
});
|
||||
query('#close-following').addEventListener('click', () => query('#following-sheet').close());
|
||||
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
|
||||
feature.load().catch(() => {});
|
||||
return {
|
||||
load:feature.load,
|
||||
open() {
|
||||
const sheet = query('#following-sheet');
|
||||
if (!sheet.open) sheet.showModal();
|
||||
feature.load().catch(() => {});
|
||||
return 'opened-following';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {createFollowing, attachFollowing};
|
||||
});
|
||||
@@ -2016,6 +2016,7 @@
|
||||
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||
<button data-mobile-queue="following" type="button" aria-label="Following, 0 watched issues"><span><strong>Following</strong><small>Issues you watch</small></span><span data-mobile-queue-count="following">0</span></button>
|
||||
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
|
||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||
@@ -2025,6 +2026,15 @@
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<dialog class="following-sheet" id="following-sheet" aria-labelledby="following-title">
|
||||
<section class="following-panel">
|
||||
<header><div><p class="small muted">Read without taking ownership</p><h2 id="following-title">Following</h2></div><button id="close-following" type="button">Close</button></header>
|
||||
<p id="following-status" class="small" role="status" aria-live="polite"></p>
|
||||
<div id="following-list" class="following-list"></div>
|
||||
<button id="retry-following" type="button" hidden>Retry</button>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<dialog id="mobile-delivery-recovery" class="mobile-delivery-recovery" aria-labelledby="mobile-delivery-recovery-title">
|
||||
<section class="mobile-delivery-recovery-panel">
|
||||
<header>
|
||||
@@ -2076,6 +2086,7 @@
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/saved-searches.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/following.js"></script>
|
||||
<script src="static/search-reply-draft-store.js"></script>
|
||||
<script src="static/conversation-reply-draft-store.js"></script>
|
||||
<script src="static/conversation-photo-drafts.js"></script>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
if (name === 'today') return options.openToday();
|
||||
if (name === 'agenda') return options.openAgenda();
|
||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||
if (name === 'following' && options.openFollowing) return options.openFollowing();
|
||||
if (name === 'filed' && options.openFiled) return options.openFiled();
|
||||
options.selectFilter(name);
|
||||
const action = options.firstAction(name);
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
});
|
||||
root.searchPreviewWatchStatus = state => ({
|
||||
watching:'Starting watch…', unwatching:'Stopping watch…',
|
||||
watched:'Watching. Future activity will appear in Updates.',
|
||||
unwatched:'Stopped watching. Assignment and planning are unchanged.',
|
||||
watched:'Watching · available in Following. Future activity will appear in Updates.',
|
||||
unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.',
|
||||
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
|
||||
})[state.status] || '';
|
||||
root.renderSearchPreviewWatch = (detail, state, button) => {
|
||||
|
||||
@@ -130,6 +130,7 @@ const SHELL = [
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/saved-searches.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/following.js',
|
||||
BASE + 'static/search-reply-draft-store.js',
|
||||
BASE + 'static/conversation-reply-draft-store.js',
|
||||
BASE + 'static/conversation-photo-drafts.js',
|
||||
|
||||
Reference in new issue
Block a user