feat: autocomplete mobile teammate mentions (#447)
This commit is contained in:
1 parent
5a6ef62c4c
commit
0311f85d96
17 files changed
+418
-17
No files matched your search
@@ -249,6 +249,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||
.issue-comment-composer button { min-height:44px; width:100%; }
|
||||
.mention-options { display:grid; max-width:100%; max-height:220px; overflow:auto; border:1px solid #315781; border-radius:10px; background:#101f34; box-shadow:0 10px 28px rgba(0,0,0,.35); }
|
||||
.mention-options[hidden] { display:none; }
|
||||
.mention-option { min-height:44px; max-width:100%; overflow:hidden; padding:9px 12px; border:0; border-bottom:1px solid #203a5c; border-radius:0; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:#101f34; color:#dbeafe; }
|
||||
.mention-option:last-child { border-bottom:0; }
|
||||
.mention-option[aria-selected="true"], .mention-option:hover { background:#17365a; color:#fff; }
|
||||
.mention-status:empty { display:none; }
|
||||
.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.comment-actions button { min-height:44px; width:100%; }
|
||||
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
|
||||
@@ -226,6 +226,29 @@
|
||||
return payload;
|
||||
}
|
||||
|
||||
function loadMentionCandidates(repository, query) {
|
||||
return fetchReviewJson(
|
||||
'api/v1/repos/' + repository + '/mention-candidates?q=' + encodeURIComponent(query),
|
||||
{headers:{Accept:'application/json'}},
|
||||
);
|
||||
}
|
||||
const issueMentions = createMentionComposer({
|
||||
textarea:qs('#issue-comment'), listbox:qs('#issue-comment-mentions'),
|
||||
status:qs('#issue-comment-mention-status'), getRepository:()=>selectedIssue?.repository,
|
||||
loadCandidates:loadMentionCandidates,
|
||||
});
|
||||
const pullMentions = createMentionComposer({
|
||||
textarea:qs('#pull-comment'), listbox:qs('#pull-comment-mentions'),
|
||||
status:qs('#pull-comment-mention-status'), getRepository:()=>selectedPull?.repository,
|
||||
loadCandidates:loadMentionCandidates,
|
||||
});
|
||||
const updateMentions = createMentionComposer({
|
||||
textarea:qs('#update-reply'), listbox:qs('#update-reply-mentions'),
|
||||
status:qs('#update-reply-mention-status'), getRepository:()=>selectedUpdate?.repository,
|
||||
loadCandidates:loadMentionCandidates,
|
||||
});
|
||||
[issueMentions, pullMentions, updateMentions].forEach(controller => controller.start());
|
||||
|
||||
async function api(url) {
|
||||
const response = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
@@ -517,6 +540,7 @@
|
||||
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
|
||||
onOpen: item => {
|
||||
selectedUpdate = item;
|
||||
updateMentions.dismiss();
|
||||
qs('#update-sheet').classList.add('open');
|
||||
qs('#update-sheet-key').textContent = item.key || '';
|
||||
qs('#update-sheet-title').textContent = item.title || 'Unread update';
|
||||
@@ -1902,6 +1926,7 @@
|
||||
qs('#issue-planning').inert = false;
|
||||
qs('#issue-handoff').inert = false;
|
||||
selectedIssue = item;
|
||||
issueMentions.dismiss();
|
||||
selectedIssueOffline = Boolean(offlineDetail);
|
||||
selectedIssueDetail = null;
|
||||
issueConversation = null;
|
||||
@@ -2079,6 +2104,7 @@
|
||||
if (!item) return;
|
||||
qs('#pull-review').inert = false;
|
||||
selectedPull = item;
|
||||
pullMentions.dismiss();
|
||||
pullTrigger = trigger;
|
||||
selectedPullDetail = null;
|
||||
pullConversation = null;
|
||||
|
||||
@@ -292,6 +292,8 @@
|
||||
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
|
||||
<h2 id="issue-comment-title">Add comment</h2>
|
||||
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||||
<div class="mention-options" id="issue-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="issue-comment-mention-status" aria-live="polite"></div>
|
||||
<div class="comment-actions">
|
||||
<button id="send-issue-comment" type="button">Post comment</button>
|
||||
<button id="send-issue-comment-next" type="button" hidden>Comment & next</button>
|
||||
@@ -462,6 +464,8 @@
|
||||
<h2 id="update-reply-title">Reply</h2>
|
||||
<label for="update-reply" class="small">Continue this issue or pull-request conversation</label>
|
||||
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
|
||||
<button id="send-update-reply" type="button">Send reply</button>
|
||||
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
@@ -496,6 +500,8 @@
|
||||
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
|
||||
<h2 id="pull-comment-title">Add comment</h2>
|
||||
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||||
<div class="mention-options" id="pull-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="pull-comment-mention-status" aria-live="polite"></div>
|
||||
<div class="comment-actions">
|
||||
<button id="send-pull-comment" type="button">Post comment</button>
|
||||
<button id="send-pull-comment-next" type="button" hidden>Comment & next</button>
|
||||
@@ -663,6 +669,7 @@
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/mobile-search-viewport.js"></script>
|
||||
<script src="static/mobile-composer-viewport.js"></script>
|
||||
<script src="static/mention-composer.js"></script>
|
||||
<script src="static/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,147 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createMentionComposer = api.create;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function() {
|
||||
'use strict';
|
||||
|
||||
function activeMention(value, caret) {
|
||||
const before = String(value || '').slice(0, Math.max(0, caret));
|
||||
const match = before.match(/(^|\s)@([A-Za-z0-9_.-]{2,39})$/);
|
||||
if (!match) return null;
|
||||
const start = before.length - match[2].length - 1;
|
||||
return { start, end:before.length, query:match[2] };
|
||||
}
|
||||
|
||||
function insertMention(value, mention, login) {
|
||||
const text = String(value || '');
|
||||
const suffixStart = mention.end < text.length && /\s/.test(text[mention.end]) ? mention.end + 1 : mention.end;
|
||||
const inserted = '@' + login + ' ';
|
||||
return {
|
||||
value:text.slice(0, mention.start) + inserted + text.slice(suffixStart),
|
||||
caret:mention.start + inserted.length,
|
||||
};
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const textarea = options.textarea;
|
||||
const listbox = options.listbox;
|
||||
const status = options.status;
|
||||
const setTimer = options.setTimer || setTimeout;
|
||||
const clearTimer = options.clearTimer || clearTimeout;
|
||||
const createOption = options.createOption || (() => document.createElement('button'));
|
||||
let timer = null;
|
||||
let requestId = 0;
|
||||
let mention = null;
|
||||
let candidates = [];
|
||||
let activeIndex = -1;
|
||||
|
||||
function dismiss() {
|
||||
requestId += 1;
|
||||
candidates = [];
|
||||
activeIndex = -1;
|
||||
listbox.replaceChildren();
|
||||
listbox.hidden = true;
|
||||
textarea.setAttribute('aria-expanded', 'false');
|
||||
textarea.removeAttribute('aria-activedescendant');
|
||||
status.textContent = '';
|
||||
}
|
||||
|
||||
function activate(index) {
|
||||
if (!candidates.length) return;
|
||||
activeIndex = (index + candidates.length) % candidates.length;
|
||||
listbox.children.forEach((option, optionIndex) => {
|
||||
option.setAttribute('aria-selected', optionIndex === activeIndex ? 'true' : 'false');
|
||||
});
|
||||
textarea.setAttribute('aria-activedescendant', listbox.children[activeIndex].id);
|
||||
}
|
||||
|
||||
function select(index) {
|
||||
if (!mention || !candidates[index]) return;
|
||||
const result = insertMention(textarea.value, mention, candidates[index].login);
|
||||
textarea.value = result.value;
|
||||
textarea.setSelectionRange(result.caret, result.caret);
|
||||
dismiss();
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent?.(new Event('input', { bubbles:true }));
|
||||
}
|
||||
|
||||
function render(items) {
|
||||
candidates = Array.isArray(items) ? items : [];
|
||||
activeIndex = -1;
|
||||
listbox.replaceChildren();
|
||||
candidates.forEach((candidate, index) => {
|
||||
const option = createOption();
|
||||
option.type = 'button';
|
||||
option.id = listbox.id + '-option-' + index;
|
||||
option.className = 'mention-option';
|
||||
option.dataset.login = candidate.login;
|
||||
option.setAttribute('role', 'option');
|
||||
option.setAttribute('aria-selected', 'false');
|
||||
option.textContent = '@' + candidate.login + (candidate.name !== candidate.login ? ' · ' + candidate.name : '');
|
||||
option.addEventListener('pointerdown', event => {
|
||||
event.preventDefault();
|
||||
select(index);
|
||||
});
|
||||
listbox.appendChild(option);
|
||||
});
|
||||
listbox.hidden = !candidates.length;
|
||||
textarea.setAttribute('aria-expanded', candidates.length ? 'true' : 'false');
|
||||
status.textContent = candidates.length ? candidates.length + ' teammates found.' : 'No matching teammates.';
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
mention = activeMention(textarea.value, textarea.selectionStart);
|
||||
const repository = options.getRepository();
|
||||
if (!mention || !repository) {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
const currentRequest = ++requestId;
|
||||
timer = setTimer(async () => {
|
||||
try {
|
||||
const items = await options.loadCandidates(repository, mention.query);
|
||||
if (currentRequest === requestId && repository === options.getRepository()) render(items);
|
||||
} catch (_error) {
|
||||
if (currentRequest === requestId) {
|
||||
listbox.hidden = true;
|
||||
textarea.setAttribute('aria-expanded', 'false');
|
||||
status.textContent = 'Teammate suggestions unavailable; you can keep typing.';
|
||||
}
|
||||
}
|
||||
}, options.delayMs ?? 180);
|
||||
}
|
||||
|
||||
function keydown(event) {
|
||||
if (event.key === 'Escape' && !listbox.hidden) {
|
||||
event.preventDefault();
|
||||
dismiss();
|
||||
} else if (event.key === 'ArrowDown' && candidates.length) {
|
||||
event.preventDefault();
|
||||
activate(activeIndex + 1);
|
||||
} else if (event.key === 'ArrowUp' && candidates.length) {
|
||||
event.preventDefault();
|
||||
activate(activeIndex < 0 ? candidates.length - 1 : activeIndex - 1);
|
||||
} else if (event.key === 'Enter' && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
select(activeIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
textarea.setAttribute('autocomplete', 'off');
|
||||
textarea.setAttribute('aria-autocomplete', 'list');
|
||||
textarea.setAttribute('aria-controls', listbox.id);
|
||||
textarea.setAttribute('aria-expanded', 'false');
|
||||
textarea.addEventListener('input', schedule);
|
||||
textarea.addEventListener('click', schedule);
|
||||
textarea.addEventListener('keydown', keydown);
|
||||
},
|
||||
dismiss,
|
||||
};
|
||||
}
|
||||
|
||||
return { activeMention, insertMention, create };
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v75';
|
||||
const CACHE = 'stackchain-dashboard-shell-v76';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
@@ -57,6 +57,7 @@ const SHELL = [
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
BASE + 'static/mention-composer.js',
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user