feat: the extension adds Discord channels to an artist you pick, and its tests gate the XPI (milestone 429)
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 24s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m22s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 3m13s
CI and images / build-web (push) Successful in 1m42s
CI and images / smoke-web (push) Successful in 54s
CI and images / promote (push) Successful in 1s
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 24s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m22s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 3m13s
CI and images / build-web (push) Successful in 1m42s
CI and images / smoke-web (push) Successful in 54s
CI and images / promote (push) Successful in 1s
Server (#4420) - extension_service gains a Discord pattern (server or channel, jump links, ptb/canary; not DMs or threads), mirrored in platforms.js and pinned by the shared artist-url-samples.json. - probe on a Discord URL matches the source by ids under any artist, reports a whole-server source as covering the channel, suggests the artist who owns another source on the same server, and names server/channel via the stored token (best-effort, bounded, no rate-limit waits). - quick-add takes artist_id / artist_name; Discord URLs are stored canonical. Extension (#4421, #4422) - Content script on discord.com; SPA navigation by URL polling (the old pushState patch ran in the isolated world and never fired); stale probes are dropped. - Discord chip opens an Add panel: this channel or the whole server, and the suggested artist / a search / a new name. - Popup: sources show artist, platform and state; a Discord token export is verified by FC and the result shown. Token capture covers ptb/canary. - Pure logic in lib/chip.js and lib/popup-format.js, with specs. CI (#4423) - extension.yml's lane (web-ext lint, vitest, XPI contents) moves into build.yml as extension-test and joins the needs of sign-extension, build-web and build-agent. As a separate workflow it gated nothing: a red extension suite still signed and shipped the XPI (rule 177). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
@@ -40,3 +40,45 @@
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Discord Add panel — sits above the chip. Same slate/parchment palette. */
|
||||
.fc-panel {
|
||||
all: revert;
|
||||
position: fixed; bottom: 76px; right: 24px; z-index: 2147483647;
|
||||
box-sizing: border-box; width: 320px; max-width: calc(100vw - 48px);
|
||||
padding: 14px 16px; border-radius: 10px;
|
||||
background: rgb(20, 23, 26); color: rgb(232, 228, 216);
|
||||
border: 1px solid rgb(60, 64, 70);
|
||||
font: 14px/1.4 system-ui, sans-serif;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.fc-panel__title { font-weight: 600; color: rgb(244, 186, 122); }
|
||||
.fc-panel__sub { color: rgb(170, 166, 156); font-size: 12px; margin-bottom: 8px; }
|
||||
.fc-panel__label {
|
||||
margin: 10px 0 4px; font-size: 11px; letter-spacing: 0.06em;
|
||||
text-transform: uppercase; color: rgb(170, 166, 156);
|
||||
}
|
||||
.fc-panel__radio { display: flex; gap: 8px; align-items: center; padding: 2px 0; cursor: pointer; }
|
||||
.fc-panel__radio input { margin: 0; accent-color: rgb(244, 186, 122); }
|
||||
.fc-panel__input {
|
||||
all: revert; box-sizing: border-box; width: 100%;
|
||||
padding: 7px 9px; border-radius: 6px;
|
||||
border: 1px solid rgb(70, 74, 80); background: rgb(12, 14, 16); color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
.fc-panel__input:focus { outline: 2px solid rgb(244, 186, 122); outline-offset: -1px; }
|
||||
.fc-panel__results { display: flex; flex-direction: column; max-height: 160px; overflow-y: auto; }
|
||||
.fc-panel__result {
|
||||
all: revert; text-align: left; padding: 6px 9px; border: none; border-radius: 4px;
|
||||
background: transparent; color: inherit; font: inherit; cursor: pointer;
|
||||
}
|
||||
.fc-panel__result:hover, .fc-panel__result:focus { background: rgb(36, 40, 46); }
|
||||
.fc-panel__hint { margin-top: 8px; font-size: 12px; color: rgb(170, 166, 156); }
|
||||
.fc-panel__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.fc-panel__btn {
|
||||
all: revert; padding: 6px 14px; border-radius: 999px; cursor: pointer;
|
||||
border: 1px solid rgb(70, 74, 80); background: transparent; color: inherit;
|
||||
font: 500 13px/1.2 system-ui, sans-serif;
|
||||
}
|
||||
.fc-panel__btn--primary { border-color: rgb(244, 186, 122); background: rgb(244, 186, 122); color: rgb(20, 23, 26); }
|
||||
.fc-panel__btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
@@ -5,42 +5,61 @@
|
||||
// Cached probe result for the current URL so click-handlers know which
|
||||
// action to dispatch without round-tripping again.
|
||||
let currentProbe = null;
|
||||
// Bumped on every evaluate(): a probe that answers after the operator has
|
||||
// navigated on is for a page they've left, and must not repaint the chip.
|
||||
let generation = 0;
|
||||
let lastUrl = window.location.href;
|
||||
|
||||
evaluate();
|
||||
|
||||
const reEval = () => evaluate();
|
||||
window.addEventListener('popstate', reEval);
|
||||
const origPush = history.pushState;
|
||||
history.pushState = function () { origPush.apply(this, arguments); reEval(); };
|
||||
// SPA navigation. Patreon, SubscribeStar and above all Discord change
|
||||
// channel/page without a reload. Patching history.pushState from here never
|
||||
// worked: a content script runs in an isolated world, so the page's own
|
||||
// pushState is not the function we'd replace. Polling the URL is the one
|
||||
// signal that sees every navigation, and costs a string compare.
|
||||
window.addEventListener('popstate', () => onUrlMaybeChanged());
|
||||
setInterval(onUrlMaybeChanged, 500);
|
||||
|
||||
function onUrlMaybeChanged() {
|
||||
if (window.location.href === lastUrl) return;
|
||||
lastUrl = window.location.href;
|
||||
closePanel();
|
||||
evaluate();
|
||||
}
|
||||
|
||||
async function evaluate() {
|
||||
const mine = ++generation;
|
||||
const url = window.location.href;
|
||||
const platform = getPlatformFromUrl(url);
|
||||
const onArtist = platform && isArtistPage(url, platform);
|
||||
const btn = document.getElementById('fc-add-source-btn');
|
||||
if (!onArtist) {
|
||||
if (btn) btn.remove();
|
||||
removeButton();
|
||||
currentProbe = null;
|
||||
return;
|
||||
}
|
||||
// On artist pages, ask the backend what state the URL is in BEFORE
|
||||
// injecting the button — so the chip can render the right state on
|
||||
// first paint instead of flashing the generic "Add" copy and
|
||||
// updating afterwards.
|
||||
// Ask the backend what state the URL is in BEFORE drawing the button, so
|
||||
// the chip renders the right state on first paint instead of flashing the
|
||||
// generic "Add" copy and updating afterwards.
|
||||
let probe;
|
||||
try {
|
||||
probe = await browser.runtime.sendMessage({ type: 'PROBE_SOURCE', url });
|
||||
} catch (e) {
|
||||
probe = { error: e?.message || 'probe failed' };
|
||||
}
|
||||
if (mine !== generation) return;
|
||||
currentProbe = probe;
|
||||
if (probe?.state === 'unknown_platform') {
|
||||
if (btn) btn.remove();
|
||||
removeButton();
|
||||
return;
|
||||
}
|
||||
renderButton(probe);
|
||||
}
|
||||
|
||||
function removeButton() {
|
||||
document.getElementById('fc-add-source-btn')?.remove();
|
||||
closePanel();
|
||||
}
|
||||
|
||||
function renderButton(probe) {
|
||||
let btn = document.getElementById('fc-add-source-btn');
|
||||
if (!btn) {
|
||||
@@ -51,79 +70,36 @@
|
||||
}
|
||||
// Reset state classes so re-renders (SPA navigation) don't stack.
|
||||
btn.className = 'fc-add-source-btn';
|
||||
btn.classList.add(`fc-add-source-btn--${stateModifier(probe)}`);
|
||||
btn.textContent = labelFor(probe);
|
||||
btn.classList.add(`fc-add-source-btn--${chipState(probe)}`);
|
||||
btn.textContent = chipLabel(probe, PLATFORMS[probe?.platform]?.name || probe?.platform || '');
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
function stateModifier(probe) {
|
||||
if (!probe || probe.error) return 'new';
|
||||
return ({
|
||||
source_match: 'source-match',
|
||||
artist_match: 'artist-match',
|
||||
new: 'new',
|
||||
})[probe.state] || 'new';
|
||||
}
|
||||
|
||||
function labelFor(probe) {
|
||||
if (!probe || probe.error) return '+ Add to FabledCurator';
|
||||
const platformName = platformDisplayName(probe.platform);
|
||||
const artistName = probe.artist?.name;
|
||||
switch (probe.state) {
|
||||
case 'source_match':
|
||||
return `✓ In FabledCurator · ${platformName}`;
|
||||
case 'artist_match':
|
||||
return `+ Add ${platformName} source to ${artistName || 'artist'}`;
|
||||
case 'new':
|
||||
default:
|
||||
return '+ Add to FabledCurator';
|
||||
}
|
||||
}
|
||||
|
||||
function platformDisplayName(key) {
|
||||
return PLATFORMS[key]?.name || key || '';
|
||||
}
|
||||
|
||||
async function onClick() {
|
||||
const btn = document.getElementById('fc-add-source-btn');
|
||||
if (!btn) return;
|
||||
btn.disabled = true;
|
||||
const original = btn.textContent;
|
||||
const probe = currentProbe;
|
||||
|
||||
if (probe?.state === 'source_match') {
|
||||
btn.textContent = 'Opening…';
|
||||
try {
|
||||
const r = await browser.runtime.sendMessage({
|
||||
type: 'OPEN_ARTIST_PAGE',
|
||||
slug: probe.artist?.slug,
|
||||
});
|
||||
if (r?.error) showToast(`Error: ${r.error}`, 'error');
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = original;
|
||||
}
|
||||
await openArtist(btn, probe.artist?.slug);
|
||||
return;
|
||||
}
|
||||
// A Discord URL names a channel, not a creator — ask which artist.
|
||||
if (probe?.platform === 'discord') {
|
||||
if (document.getElementById('fc-discord-panel')) closePanel();
|
||||
else openDiscordPanel(probe);
|
||||
return;
|
||||
}
|
||||
await add(btn, { url: window.location.href });
|
||||
}
|
||||
|
||||
btn.textContent = 'Adding…';
|
||||
async function openArtist(btn, slug) {
|
||||
btn.disabled = true;
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'Opening…';
|
||||
try {
|
||||
const r = await browser.runtime.sendMessage({
|
||||
type: 'ADD_AS_SOURCE',
|
||||
url: window.location.href,
|
||||
});
|
||||
if (r?.error) {
|
||||
showToast(`Error: ${r.error}`, 'error');
|
||||
} else {
|
||||
const verb = r.created_source ? 'Added' : 'Already a source for';
|
||||
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success');
|
||||
// Re-probe so the chip flips green without waiting for the next
|
||||
// navigation.
|
||||
evaluate();
|
||||
return;
|
||||
}
|
||||
const r = await browser.runtime.sendMessage({ type: 'OPEN_ARTIST_PAGE', slug });
|
||||
if (r?.error) showToast(`Error: ${r.error}`, 'error');
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e.message}`, 'error');
|
||||
} finally {
|
||||
@@ -132,6 +108,153 @@
|
||||
}
|
||||
}
|
||||
|
||||
// One add, shared by the one-click chip and the Discord panel. Resolves
|
||||
// true on success.
|
||||
async function add(btn, request) {
|
||||
btn.disabled = true;
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'Adding…';
|
||||
try {
|
||||
const r = await browser.runtime.sendMessage({ type: 'ADD_AS_SOURCE', ...request });
|
||||
if (r?.error) {
|
||||
showToast(`Error: ${r.error}`, 'error');
|
||||
return false;
|
||||
}
|
||||
const verb = r.created_source ? 'Added to' : 'Already a source for';
|
||||
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success');
|
||||
// Re-probe so the chip flips green without waiting for a navigation.
|
||||
evaluate();
|
||||
return true;
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e.message}`, 'error');
|
||||
return false;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = original;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Discord Add panel ----
|
||||
// Where: this channel or the whole server. Who: the suggested artist, one
|
||||
// found by search, or a new one by name. Built with createElement only —
|
||||
// server, channel and artist names are other people's text.
|
||||
|
||||
function el(tag, props = {}, children = []) {
|
||||
const node = document.createElement(tag);
|
||||
const { class: className, text, ...rest } = props;
|
||||
if (className) node.className = className;
|
||||
if (text != null) node.textContent = text;
|
||||
Object.assign(node, rest);
|
||||
for (const c of children) node.appendChild(c);
|
||||
return node;
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
document.getElementById('fc-discord-panel')?.remove();
|
||||
}
|
||||
|
||||
function openDiscordPanel(probe) {
|
||||
closePanel();
|
||||
const d = probe.discord || {};
|
||||
const choice = discordPanelDefaults(probe);
|
||||
let searchSeq = 0;
|
||||
|
||||
const scopeRow = (value, label, disabled) => {
|
||||
const input = el('input', {
|
||||
type: 'radio', name: 'fc-discord-scope', value,
|
||||
checked: choice.scope === value, disabled,
|
||||
});
|
||||
input.addEventListener('change', () => { choice.scope = value; refresh(); });
|
||||
return el('label', { class: 'fc-panel__radio' }, [input, el('span', { text: label })]);
|
||||
};
|
||||
|
||||
const nameInput = el('input', {
|
||||
type: 'text', class: 'fc-panel__input', value: choice.artistName,
|
||||
placeholder: 'Artist name — search or type a new one',
|
||||
autocomplete: 'off', spellcheck: false,
|
||||
});
|
||||
const results = el('div', { class: 'fc-panel__results' });
|
||||
const hint = el('div', { class: 'fc-panel__hint' });
|
||||
const addBtn = el('button', { class: 'fc-panel__btn fc-panel__btn--primary', text: 'Add' });
|
||||
const cancelBtn = el('button', { class: 'fc-panel__btn', text: 'Cancel' });
|
||||
|
||||
const panel = el('div', { id: 'fc-discord-panel', class: 'fc-panel' }, [
|
||||
el('div', { class: 'fc-panel__title', text: 'Add Discord source' }),
|
||||
el('div', { class: 'fc-panel__sub', text: serverLabel(d) }),
|
||||
el('div', { class: 'fc-panel__label', text: 'Follow' }),
|
||||
scopeRow('channel', d.channel_id ? channelLabel(d) : 'this channel', !d.channel_id),
|
||||
scopeRow('server', `Every channel in ${serverLabel(d)}`, false),
|
||||
el('div', { class: 'fc-panel__label', text: 'Artist' }),
|
||||
nameInput,
|
||||
results,
|
||||
hint,
|
||||
el('div', { class: 'fc-panel__actions' }, [cancelBtn, addBtn]),
|
||||
]);
|
||||
|
||||
function refresh() {
|
||||
const req = discordAddRequest(choice);
|
||||
addBtn.disabled = !req;
|
||||
if (!req) hint.textContent = 'Pick an artist or type a name.';
|
||||
else if (req.artistId != null) hint.textContent = `Connects to ${choice.artist.name} in FabledCurator.`;
|
||||
else hint.textContent = `Adds to “${req.artistName}” — created if FabledCurator has no artist by that name.`;
|
||||
}
|
||||
|
||||
function showResults(rows) {
|
||||
results.replaceChildren(...rows.map((a) => {
|
||||
const row = el('button', { class: 'fc-panel__result', text: a.name });
|
||||
row.addEventListener('click', () => {
|
||||
choice.artist = { id: a.id, name: a.name };
|
||||
choice.artistName = a.name;
|
||||
nameInput.value = a.name;
|
||||
results.replaceChildren();
|
||||
refresh();
|
||||
});
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
let debounce = null;
|
||||
nameInput.addEventListener('input', () => {
|
||||
choice.artistName = nameInput.value;
|
||||
refresh();
|
||||
clearTimeout(debounce);
|
||||
const q = nameInput.value.trim();
|
||||
if (!q) { results.replaceChildren(); return; }
|
||||
debounce = setTimeout(async () => {
|
||||
const mine = ++searchSeq;
|
||||
let r;
|
||||
try {
|
||||
r = await browser.runtime.sendMessage({ type: 'SEARCH_ARTISTS', q });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (mine !== searchSeq || r?.error) return;
|
||||
showResults(r.artists || []);
|
||||
}, 200);
|
||||
});
|
||||
// Keep Discord's global shortcuts from eating keystrokes meant for us.
|
||||
panel.addEventListener('keydown', (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Escape') closePanel();
|
||||
if (e.key === 'Enter' && e.target === nameInput && !addBtn.disabled) addBtn.click();
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', closePanel);
|
||||
addBtn.addEventListener('click', async () => {
|
||||
const req = discordAddRequest(choice);
|
||||
if (!req) return;
|
||||
const btn = document.getElementById('fc-add-source-btn');
|
||||
addBtn.disabled = true;
|
||||
const ok = await add(btn || addBtn, req);
|
||||
if (ok) closePanel();
|
||||
else refresh();
|
||||
});
|
||||
|
||||
document.body.appendChild(panel);
|
||||
refresh();
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function showToast(text, kind) {
|
||||
const t = document.createElement('div');
|
||||
t.className = `fc-toast fc-toast--${kind}`;
|
||||
|
||||
Reference in New Issue
Block a user