// === License & mode (stub) === // Flip this to true for local testing of Pro features if you want: let licenseValid = false; // <- default: Free // Persistent settings let actionMode = 'copy'; // 'copy' | 'insert' | 'auto' let licenseKeyCurrent = ''; // Tone settings (persisted in sync storage) let toneLock = false; // if true, always use preferred tone let preferredToneSlug = null; // 'light' | 'medium-light' | 'medium' | 'medium-dark' | 'dark' // Elements const settingsBtn = document.getElementById('settings'); const sheet = document.getElementById('settings-sheet'); const sheetClose = document.getElementById('sheet-close'); const backdrop = document.getElementById('sheet-backdrop'); const modeGroup = document.getElementById('mode-group'); const licenseKeyEl = document.getElementById('license-key'); const licenseActivateBtn = document.getElementById('license-activate'); const licenseStatusEl = document.getElementById('license-status'); const licenseDeactivateBtn = document.getElementById('license-deactivate'); const licenseEditBtn = document.getElementById('license-edit'); const licenseCancelEditBtn = document.getElementById('license-cancel-edit'); // --- Branded confirm modal helper --- function showConfirmModal(opts = {}) { return new Promise((resolve) => { const modal = document.getElementById('confirm-modal'); const back = document.getElementById('confirm-backdrop'); const title = document.getElementById('confirm-title'); const msg = document.getElementById('confirm-message'); const okBtn = document.getElementById('confirm-ok'); const noBtn = document.getElementById('confirm-cancel'); if (!modal || !back) return resolve(window.confirm(opts.message || 'Are you sure?')); title.textContent = opts.title || 'Confirm'; msg.textContent = opts.message || 'Are you sure?'; okBtn.textContent = opts.okText || 'OK'; noBtn.textContent = opts.cancelText || 'Cancel'; function close(val){ modal.setAttribute('hidden',''); back.setAttribute('hidden',''); document.removeEventListener('keydown', onKey); okBtn.removeEventListener('click', onOk); noBtn.removeEventListener('click', onNo); back.removeEventListener('click', onNo); resolve(val); } function onOk(){ close(true); } function onNo(){ close(false); } function onKey(e){ if (e.key === 'Escape') close(false); if (e.key === 'Enter') close(true); } back.removeAttribute('hidden'); modal.removeAttribute('hidden'); document.addEventListener('keydown', onKey); okBtn.addEventListener('click', onOk); noBtn.addEventListener('click', onNo); back.addEventListener('click', onNo); // focus primary setTimeout(()=> okBtn.focus(), 0); }); } // --- License busy helpers (UI feedback for activate/deactivate) --- let __licensePrev = { text: '', activateDisabled: false, keyDisabled: false, deactivateDisabled: false, editDisabled: false, cancelDisabled: false, }; function setLicenseBusy(on, label){ const btn = licenseActivateBtn; if (!btn) return; if (on) { // snapshot current states __licensePrev.text = btn.textContent; __licensePrev.activateDisabled = !!btn.disabled; __licensePrev.keyDisabled = !!(licenseKeyEl && licenseKeyEl.disabled); __licensePrev.deactivateDisabled = !!(licenseDeactivateBtn && licenseDeactivateBtn.disabled); __licensePrev.editDisabled = !!(licenseEditBtn && licenseEditBtn.disabled); __licensePrev.cancelDisabled = !!(licenseCancelEditBtn && licenseCancelEditBtn.disabled); // set busy states btn.textContent = label || 'Verifying…'; btn.disabled = true; if (licenseKeyEl) licenseKeyEl.disabled = true; if (licenseDeactivateBtn) licenseDeactivateBtn.disabled = true; if (licenseEditBtn) licenseEditBtn.disabled = true; if (licenseCancelEditBtn) licenseCancelEditBtn.disabled = true; if (licenseStatusEl) licenseStatusEl.textContent = 'Checking license…'; } else { // restore previous states btn.textContent = __licensePrev.text || 'Activate'; btn.disabled = !!__licensePrev.activateDisabled; if (licenseKeyEl) licenseKeyEl.disabled = !!__licensePrev.keyDisabled; if (licenseDeactivateBtn) licenseDeactivateBtn.disabled = !!__licensePrev.deactivateDisabled; if (licenseEditBtn) licenseEditBtn.disabled = !!__licensePrev.editDisabled; if (licenseCancelEditBtn) licenseCancelEditBtn.disabled = !!__licensePrev.cancelDisabled; // Re-apply UI (visibility/display) for current license state applyLicenseUI(); } } const diagRunBtn = document.getElementById('diag-run'); const diagSpin = document.getElementById('diag-spin'); const diagOut = document.getElementById('diag-out'); const API = { base: "https://api.dewemoji.com/v1", list: "/emojis" }; API.cats = "/categories"; let PAGE_LIMIT = 20; // Free default function refreshPageLimit(){ PAGE_LIMIT = licenseValid ? 50 : 20; // Pro gets bigger pages } refreshPageLimit(); const FRONTEND_ID = 'ext-v1'; let CAT_MAP = null; // { "Category": ["sub1","sub2", ...], ... } // Preferred, human-first category order (fallback: alphabetical) const PREFERRED_CATEGORY_ORDER = [ "Smileys & Emotion", "People & Body", "Animals & Nature", "Food & Drink", "Travel & Places", "Activities", "Objects", "Symbols", "Flags" ]; const CAT_INDEX = Object.fromEntries(PREFERRED_CATEGORY_ORDER.map((c, i) => [c, i])); // Minimal local subcategories to enable the sub-select even without API const LOCAL_SUBS = { 'Travel & Places': ['place-religious','place-geographic','transport-water'], 'People & Body': ['hand-fingers-open','hand-fingers-partial','person-gesture'], 'Animals & Nature': ['animal-mammal','animal-bird','animal-bug'], 'Activities': ['sport','game','event'], }; function categoryComparator(a, b) { const ia = CAT_INDEX[a]; const ib = CAT_INDEX[b]; if (ia !== undefined && ib !== undefined) return ia - ib; // both in preferred list if (ia !== undefined) return -1; // a preferred, b not if (ib !== undefined) return 1; // b preferred, a not return a.localeCompare(b, undefined, { sensitivity: "base" }); // both not preferred → A–Z } async function loadCategories() { // Try live endpoint; fall back silently to local list (no subs) let ok = false; try { const res = await fetch(`${API.base}${API.cats}`); if (res && res.ok) { const data = await res.json(); // Normalize payloads: // A) New shape: { items: [ { name: 'People & Body', subcategories: ['person','person-gesture'] }, ... ] } // B) Legacy shape: { 'People & Body': ['person','person-gesture'], ... } if (data && Array.isArray(data.items)) { const map = {}; for (const it of data.items) { if (!it || typeof it.name !== 'string') continue; const name = it.name; const subsRaw = Array.isArray(it.subcategories) ? it.subcategories : []; const subs = subsRaw .map(s => (typeof s === 'string') ? s : (s && (s.slug || s.name) || '')) .filter(Boolean); map[name] = subs; } CAT_MAP = map; } else if (data && typeof data === 'object') { CAT_MAP = data; // assume already in object-map form } else { throw new Error('bad_categories_payload'); } ok = Object.keys(CAT_MAP || {}).length > 0; } } catch (_) {} if (!ok) { // Silent fallback with a minimal subcategory seed CAT_MAP = Object.fromEntries(PREFERRED_CATEGORY_ORDER.map(c => [c, LOCAL_SUBS[c] || []])); } populateCategorySelect(); } function populateCategorySelect() { const catSel = document.getElementById('cat'); const subSel = document.getElementById('sub'); if (!catSel || !CAT_MAP) return; // reset catSel.innerHTML = ``; Object.keys(CAT_MAP).sort(categoryComparator).forEach(cat => { const opt = document.createElement('option'); opt.value = cat; opt.textContent = cat; catSel.appendChild(opt); }); // clear subs initially subSel.innerHTML = ``; subSel.disabled = true; } function populateSubcategorySelect(category) { const subSel = document.getElementById('sub'); if (!subSel) return; subSel.innerHTML = ``; const raw = (CAT_MAP?.[category] || []).slice(); const subs = raw .map(s => (typeof s === 'string') ? s : (s && (s.slug || s.name) || '')) .filter(Boolean) .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true })); subs.forEach(sc => { const opt = document.createElement('option'); opt.value = sc; opt.textContent = sc; subSel.appendChild(opt); }); subSel.disabled = subs.length === 0; subSel.classList.toggle('opacity-50', subs.length === 0); subSel.classList.toggle('pointer-events-none', subs.length === 0); } let page = 1, total = 0, items = []; let lastServerTier = null; // 'pro' | null (free) | undefined (no response yet) const q = document.getElementById('q'); const cat = document.getElementById('cat'); const sub = document.getElementById('sub'); const clearBtn = document.getElementById('clear'); const list = document.getElementById('list'); const more = document.getElementById('more'); const count = document.getElementById('count'); const verEl = document.getElementById('ver'); function setVersionBadge() { try { const { name, version } = chrome.runtime.getManifest(); if (verEl) { verEl.textContent = `v${version}`; verEl.title = `${name} v${version}`; } } catch (e) { // ignore if unavailable } } setVersionBadge(); // --- Alert helper for messages (warn/error, light/dark theme) --- function buildAlert(kind, title, desc){ const isDark = document.documentElement.classList.contains('theme-dark'); const tone = (kind === 'error') ? (isDark ? { bg:'#3b0f0f', border:'#7f1d1d', text:'#fecaca', icon:'‼' } : { bg:'#fee2e2', border:'#fecaca', text:'#7f1d1d', icon:'‼' }) : (isDark ? { bg:'#3b2f0f', border:'#a16207', text:'#fde68a', icon:'⚠️' } : { bg:'#fef3c7', border:'#fde68a', text:'#92400e', icon:'⚠️' }); const wrap = document.createElement('div'); wrap.style.gridColumn = '1 / -1'; wrap.style.background = tone.bg; wrap.style.border = `1px solid ${tone.border}`; wrap.style.color = tone.text; wrap.style.borderRadius = '10px'; wrap.style.padding = '10px 12px'; wrap.style.display = 'flex'; wrap.style.alignItems = 'flex-start'; wrap.style.gap = '10px'; wrap.className = 'dewemoji-alert'; const ico = document.createElement('span'); ico.textContent = tone.icon; ico.style.fontSize = '18px'; ico.style.lineHeight = '20px'; const body = document.createElement('div'); const h = document.createElement('div'); h.textContent = title || ''; h.style.fontWeight = '600'; h.style.marginBottom = desc ? '2px' : '0'; const p = document.createElement('div'); p.textContent = desc || ''; p.style.opacity = '0.9'; p.style.fontSize = '12.5px'; body.appendChild(h); if (desc) body.appendChild(p); wrap.append(ico, body); return wrap; } // --- Loading spinner (flip-flop with Load More) --- let spinnerEl = null; function showSpinner() { if (spinnerEl) return; // Hide Load More while loading if (more) more.classList.add('hidden'); spinnerEl = document.createElement('div'); spinnerEl.id = 'grid-spinner'; spinnerEl.className = 'w-full flex justify-center py-6'; spinnerEl.innerHTML = '
'; // place under the grid (list?.parentElement || document.body).appendChild(spinnerEl); } function hideSpinner() { if (spinnerEl) { spinnerEl.remove(); spinnerEl = null; } // Recompute Load More visibility after loading completes if (typeof updateFooter === 'function') { try { updateFooter(); } catch(_) {} } else if (more) { more.classList.remove('hidden'); } } // THEME — two states only: 'light' | 'dark' const themeBtn = document.getElementById('theme'); const toastEl = document.getElementById('toast'); async function initTheme() { const stored = (await chrome.storage.local.get('theme')).theme; const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; const initial = stored || (prefersDark ? 'dark' : 'light'); await chrome.storage.local.set({ theme: initial }); applyTheme(initial); } function applyTheme(theme) { const clsLight = 'theme-light'; const clsDark = 'theme-dark'; document.body.classList.remove(clsLight, clsDark); document.documentElement.classList.remove(clsLight, clsDark); // <-- add const cls = (theme === 'dark') ? clsDark : clsLight; document.body.classList.add(cls); document.documentElement.classList.add(cls); // <-- add // Button shows the opposite icon so it's clear what tapping will do themeBtn.textContent = theme === 'dark' ? '🌞' : '🌙'; themeBtn.dataset.theme = theme; } async function toggleTheme() { const curr = themeBtn.dataset.theme === 'dark' ? 'dark' : 'light'; const next = curr === 'dark' ? 'light' : 'dark'; await chrome.storage.local.set({ theme: next }); applyTheme(next); } themeBtn?.addEventListener('click', toggleTheme); initTheme(); updateClearButtonIcon(); // --- Skin tone (Pro) ------------------------------------------------------- const SKIN_TONES = [ { key: '1f3fb', ch: '\u{1F3FB}', slug: 'light', label: 'Light' }, { key: '1f3fc', ch: '\u{1F3FC}', slug: 'medium-light', label: 'Medium-Light' }, { key: '1f3fd', ch: '\u{1F3FD}', slug: 'medium', label: 'Medium' }, { key: '1f3fe', ch: '\u{1F3FE}', slug: 'medium-dark', label: 'Medium-Dark' }, { key: '1f3ff', ch: '\u{1F3FF}', slug: 'dark', label: 'Dark' }, ]; const TONE_SLUGS = SKIN_TONES.map(t => t.slug); const STRIP_TONE_RE = /[\u{1F3FB}-\u{1F3FF}]/gu; function stripSkinTone(s){ return (s||'').replace(STRIP_TONE_RE,''); } // === Policy: forbid list & non‑toneable list (mirror of site script.js) === const FORBID_NAMES = new Set([ 'woman: beard', 'man with veil', 'pregnant man', 'man with bunny ears', 'men with bunny ears', ]); const NON_TONEABLE_NAMES = new Set([ // Core non-toneable bases 'mechanical arm', 'mechanical leg','anatomical heart','brain','lungs','tooth','bone','eyes','eye','tongue','mouth','biting lips', // Fantasy subset (problematic gendered variants remain but not toneable) 'genie','man genie','woman genie','zombie','man zombie','woman zombie','troll', // Activities & gestures we treat as non-toneable 'skier','snowboarder','speaking head','bust in silhouette','busts in silhouette','people hugging','family','footprints','fingerprint', 'people fencing', // Directional variants we keep but not toneable 'person walking facing right','person running facing right','person kneeling facing right', // Accessibility 'deaf man','deaf woman', // Merfolk 'merman','mermaid' ]); function normName(e){ return String(e?.name || '').trim().toLowerCase(); } function isForbiddenEntry(e){ const n = normName(e); return FORBID_NAMES.has(n); } function isNonToneableByPolicy(e){ const n = normName(e); if (!n) return false; // Families never toneable if (isFamilyEntry(e)) return true; // Explicit list if (NON_TONEABLE_NAMES.has(n)) return true; // Buckets by subcategory we consider symbol-like (person-symbol) or family const sc = String(e?.subcategory || '').toLowerCase(); if (sc === 'family' || sc === 'person-symbol') return true; return false; } function isToneableByPolicy(e){ // Must advertise tone support, must not be family, and not on our non-toneable list. if (!e?.supports_skin_tone) return false; if (isNonToneableByPolicy(e)) return false; return true; } // --- Tone applicability heuristic --- function canApplyToneTo(emojiStr){ if (!emojiStr) return false; const cps = Array.from(emojiStr); // If any explicit Emoji_Modifier_Base exists, it's safe try { for (let i = 0; i < cps.length; i++) { if (/\p{Emoji_Modifier_Base}/u.test(cps[i])) return true; } } catch { /* property escapes may not be supported; fall through */ } // Fallback heuristic: // Count human bases (👨 U+1F468, 👩 U+1F469, 🧑 U+1F9D1) let humanCount = 0; for (let i = 0; i < cps.length; i++) { const cp = cps[i].codePointAt(0); if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) humanCount++; } // Allow tones for couples (2 humans) and single-person emojis; block families (3+ humans) if (humanCount >= 3) return false; // families if (humanCount === 2) return true; // couples (kiss, couple-with-heart, etc.) if (humanCount === 1) return true; // single human (ok) // Non-human (hands etc.) — many support tone, but those have modifier base property // Without the property present, avoid applying to prevent stray squares return false; } // Helper: Detect multi-human ZWJ sequences function isMultiHuman(s){ if (!s) return false; const hasZWJ = /\u200D/.test(s); if (!hasZWJ) return false; let count = 0; for (const ch of Array.from(s)) { const cp = ch.codePointAt(0); if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) count++; } return count >= 2; } // Helper: Detect family entries by metadata function isFamilyEntry(e){ const n = String(e?.name||'').toLowerCase(); const sc = String(e?.subcategory||'').toLowerCase(); return n.startsWith('family:') || sc === 'family'; } function withSkinTone(base, ch){ const emojiChar = base || ''; const modifierChar = ch || ''; if (!emojiChar || !modifierChar) return emojiChar; // Guard: if we heuristically think tone shouldn't be applied, return base if (!canApplyToneTo(emojiChar)) return emojiChar; // Split into codepoints (keeps surrogate pairs intact) const cps = Array.from(emojiChar); // Try to find the last Emoji_Modifier_Base in the sequence let lastBaseIdx = -1; try { for (let i = 0; i < cps.length; i++) { if (/\p{Emoji_Modifier_Base}/u.test(cps[i])) lastBaseIdx = i; } } catch (_) { // Property escapes not supported → handled by fallback below } // Fallback: if no base detected, treat first human as base (man, woman, person) if (lastBaseIdx === -1) { const cp0 = cps[0] ? cps[0].codePointAt(0) : 0; const HUMAN_BASES = new Set([0x1F468, 0x1F469, 0x1F9D1]); // 👨, 👩, 🧑 if (HUMAN_BASES.has(cp0)) lastBaseIdx = 0; } if (lastBaseIdx === -1) return emojiChar; // avoid appending stray square // Insert after the base (and after VS16 if present) const afterBase = (idx) => { if (cps[idx + 1] && cps[idx + 1].codePointAt(0) === 0xFE0F) return idx + 2; // VS16 return idx + 1; }; const insertPos = afterBase(lastBaseIdx); const out = cps.slice(0, insertPos).concat([modifierChar], cps.slice(insertPos)); return out.join(''); } // Tone helpers: keep globals in sync and expose helpers for slug async function getPreferredToneIndex(){ return new Promise(res => { chrome.storage.sync.get(['preferredSkinTone'], v => { preferredToneSlug = v?.preferredSkinTone || null; const idx = TONE_SLUGS.indexOf(preferredToneSlug); res(idx >= 0 ? idx : -1); }); }); } async function setPreferredToneIndex(i){ preferredToneSlug = TONE_SLUGS[i] || null; return new Promise(res => chrome.storage.sync.set({ preferredSkinTone: preferredToneSlug }, () => res())); } async function getToneLock(){ return new Promise(res => chrome.storage.sync.get(['toneLock'], v => { toneLock = !!v.toneLock; res(toneLock); })); } async function setToneLock(val){ toneLock = !!val; return new Promise(res => chrome.storage.sync.set({ toneLock }, () => res())); } // debounce let timer; function debounced(fn, delay=250){ clearTimeout(timer); timer = setTimeout(fn, delay); } // === P0: Usage & Cache === const CLIENT_FREE_DAILY_LIMIT = 30; // can be tuned; Pro => unlimited const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours const QUERY_CACHE = new Map(); // in-memory cache: key => {ts, data} const PREFETCHING = new Set(); // tracks in-flight prefetch keys (sig|page) function todayKeyUTC(){ const d = new Date(); const y = d.getUTCFullYear(); const m = String(d.getUTCMonth()+1).padStart(2,'0'); const day = String(d.getUTCDate()).padStart(2,'0'); return `usage_${y}${m}${day}`; } async function getDailyUsage(){ const key = todayKeyUTC(); const got = await chrome.storage.local.get([key]); return got[key] || { used: 0, limit: CLIENT_FREE_DAILY_LIMIT }; } async function setDailyUsage(obj){ const key = todayKeyUTC(); await chrome.storage.local.set({ [key]: obj }); } function normalize(str){ return String(str||'').trim().toLowerCase(); } function slugifyLabel(s){ const x = normalize(s).replace(/&/g,'and').replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,''); return x || 'all'; } function signatureFor(qVal, catVal, subVal){ const qn = normalize(qVal); const cats = slugifyLabel(catVal || 'all'); const subs = subVal ? slugifyLabel(subVal) : ''; return `${qn}|${cats}|${subs}`; } async function getPersistedCache(){ const got = await chrome.storage.local.get(['searchCache']); return got.searchCache || {}; // { key: { ts, data } } } async function savePersistedCache(cacheObj){ await chrome.storage.local.set({ searchCache: cacheObj }); } function isFresh(ts){ return (Date.now() - ts) < CACHE_TTL_MS; } function buildHeaders(){ const base = { 'X-Dewemoji-Frontend': FRONTEND_ID }; if (licenseValid && licenseKeyCurrent) { try { const acctPromise = accountId(); return acctPromise.then(acct => ({ ...base, // New preferred auth 'Authorization': `Bearer ${licenseKeyCurrent}`, // Legacy headers kept for backward compatibility 'X-License-Key': licenseKeyCurrent, 'X-Account-Id': acct.id })).catch(()=>base); } catch { return Promise.resolve({ ...base, 'Authorization': `Bearer ${licenseKeyCurrent}`, 'X-License-Key': licenseKeyCurrent }); } } return Promise.resolve(base); } async function prefetchNextIfNeeded(currentSig){ try { // only prefetch when there are more results and we know the next page index const nextPage = page + 1; if (!(items.length < total && total > 0)) return; const key = `${currentSig}|${nextPage}`; if (QUERY_CACHE.has(key) || PREFETCHING.has(key)) return; // check persisted cache const persisted = await getPersistedCache(); const ent = persisted[key]; if (ent && isFresh(ent.ts)) { QUERY_CACHE.set(key, ent); // lift to memory return; } PREFETCHING.add(key); // Build URL for next page const params = new URLSearchParams({ page: String(nextPage), limit: String(PAGE_LIMIT) }); if (q.value.trim()) params.set('q', q.value.trim()); if (cat.value.trim()) params.set('category', cat.value.trim()); if (sub.value.trim()) params.set('subcategory', sub.value.trim()); const url = `${API.base}${API.list}?${params.toString()}`; // headers (with optional license/account) const headers = await buildHeaders(); // fetch quietly; avoid throwing UI errors — prefetch is best-effort const res = await fetch(url, { cache: 'no-store', headers }); if (!res.ok) { PREFETCHING.delete(key); return; } const data = await res.json().catch(()=>null); if (!data || !Array.isArray(data.items)) { PREFETCHING.delete(key); return; } const record = { ts: Date.now(), data }; QUERY_CACHE.set(key, record); const persisted2 = await getPersistedCache(); persisted2[key] = record; // prune stale for (const k of Object.keys(persisted2)) { if (!persisted2[k] || !isFresh(persisted2[k].ts)) delete persisted2[k]; } await savePersistedCache(persisted2); } catch { /* silent */ } finally { PREFETCHING.delete(`${currentSig}|${page+1}`); } } async function fetchPage(reset=false) { more.disabled = true; refreshPageLimit(); if (reset) { page = 1; items = []; list.innerHTML = ""; } if (reset) { PREFETCHING.clear(); } if (reset) { autoLoadBusy = false; } // Build signature and check limits only for page 1 const sig = signatureFor(q.value, cat.value, sub.value); // Gate on soft cap for Free (Pro unlimited) if (!licenseValid && page === 1) { const usage = await getDailyUsage(); const limit = usage.limit ?? CLIENT_FREE_DAILY_LIMIT; // Check persisted cache first; a cached result does not consume usage const persisted = await getPersistedCache(); const entry = persisted[sig]; const hasFreshPersist = entry && isFresh(entry.ts); const hasFreshMem = QUERY_CACHE.has(`${sig}|1`) && isFresh(QUERY_CACHE.get(`${sig}|1`).ts); if (usage.used >= limit && !hasFreshPersist && !hasFreshMem) { // At cap and no cache — block network to be fair to server & UX showToast('Daily limit reached — Upgrade to Pro'); updateFooter(); return; } } try { showSpinner(); // Cache key per page const key = `${sig}|${page}`; // 1) Try memory cache const mem = QUERY_CACHE.get(key); if (mem && isFresh(mem.ts)) { const data = mem.data; total = data.total || 0; for (const e of (data.items || [])) { items.push(e); renderCard(e); } // Kick off background prefetch for the next page (cached only) const currentSig = signatureFor(q.value, cat.value, sub.value); prefetchNextIfNeeded(currentSig).catch(()=>{}); try { ensureSentinel(); setupAutoLoadObserver(); } catch(_) {} updateFooter(); return; } // 2) Try persisted cache const persisted = await getPersistedCache(); const ent = persisted[key]; if (ent && isFresh(ent.ts)) { total = ent.data.total || 0; for (const e of (ent.data.items || [])) { items.push(e); renderCard(e); } QUERY_CACHE.set(key, { ts: ent.ts, data: ent.data }); // promote to mem // Kick off background prefetch for the next page (cached only) const currentSig = signatureFor(q.value, cat.value, sub.value); prefetchNextIfNeeded(currentSig).catch(()=>{}); try { ensureSentinel(); setupAutoLoadObserver(); } catch(_) {} updateFooter(); return; } // 3) Network fetch const params = new URLSearchParams({ page: String(page), limit: String(PAGE_LIMIT) }); if (q.value.trim()) params.set('q', q.value.trim()); if (cat.value.trim()) params.set('category', cat.value.trim()); if (sub.value.trim()) params.set('subcategory', sub.value.trim()); const url = `${API.base}${API.list}?${params.toString()}`; // (usage increment moved to after successful fetch) const headers = await buildHeaders(); // Optional nicety: offline guard (early exit if no network AND no fresh cache) if (!navigator.onLine) { const hasFreshMem = mem && isFresh(mem.ts); const hasFreshPersist = ent && isFresh(ent.ts); if (!hasFreshMem && !hasFreshPersist) { throw new Error('offline'); } } const res = await fetch(url, { cache: 'no-store', headers }); if (!res.ok) throw new Error(`API ${res.status}`); lastServerTier = res.headers.get('X-Dewemoji-Tier'); // 'pro' for Pro; null for Free/whitelist const data = await res.json(); total = data.total || 0; if (page === 1 && (!Array.isArray(data.items) || data.items.length === 0)) { list.innerHTML = ''; items = []; list.appendChild(buildAlert('warn', 'No results', 'Try another keyword, category, or tone.')); setLoadMore('Load more', { hidden: true, disabled: true }); updateFooter(); return; } // Count usage only on successful network responses for Free, page 1, and when not cached if (!licenseValid && page === 1) { const cacheHas = !!(mem && isFresh(mem.ts)) || !!(ent && isFresh(ent.ts)); if (!cacheHas) { const usage = await getDailyUsage(); const limit = usage.limit ?? CLIENT_FREE_DAILY_LIMIT; usage.used = Math.min(limit, (usage.used || 0) + 1); await setDailyUsage(usage); } } // Save to caches const record = { ts: Date.now(), data }; QUERY_CACHE.set(key, record); const persisted2 = await getPersistedCache(); persisted2[key] = record; // prune old entries occasionally (simple heuristic) const now = Date.now(); for (const k of Object.keys(persisted2)) { if (!persisted2[k] || !isFresh(persisted2[k].ts)) delete persisted2[k]; } await savePersistedCache(persisted2); // Render for (const e of (data.items || [])) { items.push(e); renderCard(e); } // Kick off background prefetch for the next page (cached only) const currentSig = signatureFor(q.value, cat.value, sub.value); prefetchNextIfNeeded(currentSig).catch(()=>{}); try { ensureSentinel(); setupAutoLoadObserver(); } catch(_) {} updateFooter(); } catch (err) { console.error('Fetch failed', err); if (reset || page === 1) { list.innerHTML = ''; items = []; total = 0; } list?.appendChild(buildAlert('error', 'Failed to load', 'Check your internet connection and try again.')); if (page > 1) { expectRetry = true; setLoadMore('Retry', { hidden: false, disabled: false }); } else { setLoadMore('Load more', { hidden: true, disabled: true }); } } finally { hideSpinner(); } } let autoObserver = null; let autoLoadBusy = false; let autoSentinel = null; // thin element placed right after the grid list function ensureSentinel(){ if (autoSentinel && autoSentinel.isConnected) return autoSentinel; autoSentinel = document.getElementById('autoload-sentinel'); if (!autoSentinel) { autoSentinel = document.createElement('div'); autoSentinel.id = 'autoload-sentinel'; autoSentinel.style.cssText = 'width:100%;height:1px;'; } try { // Place immediately AFTER the grid list so intersecting means we reached the end if (list && autoSentinel.parentElement !== list.parentElement) { list.insertAdjacentElement('afterend', autoSentinel); } } catch(_) {} return autoSentinel; } function setupAutoLoadObserver(){ const sentinel = ensureSentinel(); if (!sentinel) return; if (autoObserver) { try { autoObserver.unobserve(sentinel); } catch(_) {} } else { autoObserver = new IntersectionObserver((entries)=>{ const hit = entries.some(e => e.isIntersecting); if (!hit) return; if (autoLoadBusy) return; const loading = !!spinnerEl; const canLoad = items.length < total && total > 0; if (loading || !canLoad) return; autoLoadBusy = true; setLoadMore('Loading…', { disabled: true }); page += 1; fetchPage(false).finally(()=>{ autoLoadBusy = false; }); }, { root: null, rootMargin: '300px', threshold: 0 }); } try { autoObserver.observe(sentinel); } catch(_) {} } // --- Load More helpers --- let expectRetry = false; function setLoadMore(label, opts = {}) { if (!more) return; if (label != null) more.textContent = label; if (opts.hidden === true) more.classList.add('hidden'); if (opts.hidden === false) more.classList.remove('hidden'); if (typeof opts.disabled === 'boolean') more.disabled = opts.disabled; } async function updateFooter() { // results text stays count.textContent = `${items.length} / ${total}`; // usage text append (right-aligned feel handled by CSS; here we append visually) try { const { used, limit } = await getDailyUsage(); const isServerPro = lastServerTier === 'pro'; const suffix = (licenseValid || isServerPro) ? '' : (limit != null ? ` · ${used} / ${limit} today` : ''); count.textContent = `${items.length} / ${total}${suffix}`; } catch {} const loading = !!spinnerEl; const canLoadMore = items.length < total && total > 0; if (canLoadMore) { expectRetry = false; setLoadMore('Load more', { hidden: loading, disabled: loading }); } else { setLoadMore('Load more', { hidden: true, disabled: true }); } // ensure auto-load observer is active when more is visible try { setupAutoLoadObserver(); } catch(_) {} } async function ensureContentScript(tabId) { // try ping try { const pong = await chrome.tabs.sendMessage(tabId, { type: 'dewemoji_ping' }); if (pong?.ok) return true; } catch {} // inject then ping again try { await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] }); const pong2 = await chrome.tabs.sendMessage(tabId, { type: 'dewemoji_ping' }); return !!pong2?.ok; } catch { return false; } } async function insert(text, opts = {}) { const strict = !!opts.strict; // if true, do NOT fallback to copy if (!text) return false; try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab?.id) { if (!strict) { await navigator.clipboard.writeText(text); } return false; } // A) Try inline injection (fast path) const [{ result }] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: (txt) => { const el = document.activeElement; const isEditable = el && ( el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && /^(text|search|email|url|tel|number|password)?$/i.test(el.type)) || el.isContentEditable ); if (!isEditable) return false; if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') { const start = el.selectionStart ?? el.value.length; const end = el.selectionEnd ?? start; const val = el.value ?? ''; el.value = val.slice(0, start) + txt + val.slice(end); const pos = start + txt.length; try { el.setSelectionRange(pos, pos); } catch {} el.dispatchEvent(new Event('input', { bubbles: true })); return true; } try { document.execCommand('insertText', false, txt); return true; } catch { return false; } }, args: [text] }); if (result) return true; // inserted via inline // B) Ensure content.js present, then ask it (robust path) if (await ensureContentScript(tab.id)) { const res = await chrome.tabs.sendMessage(tab.id, { type: 'dewemoji_insert', text }); if (res?.ok) return true; // inserted via content script } // C) Fallback if (!strict) { await navigator.clipboard.writeText(text); } return false; } catch { if (!strict) { await navigator.clipboard.writeText(text); } return false; } } async function migrateDailyLimit(){ try{ const usage = await getDailyUsage(); const target = CLIENT_FREE_DAILY_LIMIT; // 30 if (!licenseValid) { if (typeof usage.limit !== 'number' || usage.limit > target) { usage.limit = target; await setDailyUsage(usage); } } }catch{} } function updateClearButtonIcon() { if (!clearBtn) return; const hasText = q.value.trim().length > 0; if (hasText) { clearBtn.innerHTML = ``; clearBtn.title = 'Clear'; clearBtn.setAttribute('aria-label', 'Clear'); clearBtn.classList.add('nm'); clearBtn.dataset.mode = 'clear'; } else { clearBtn.innerHTML = ``; // refresh icon clearBtn.title = 'Refresh'; clearBtn.setAttribute('aria-label', 'Refresh'); clearBtn.classList.add('nm'); clearBtn.dataset.mode = 'refresh'; } } // events q.addEventListener('input', () => { updateClearButtonIcon(); debounced(() => fetchPage(true)); }); cat.addEventListener('change', () => { populateSubcategorySelect(cat.value); sub.value = ''; fetchPage(true).catch(console.error); }); sub.addEventListener('change', () => { fetchPage(true).catch(console.error); }); clearBtn.addEventListener('click', () => { const mode = clearBtn.dataset.mode; if (mode === 'clear') { q.value = ''; updateClearButtonIcon(); fetchPage(true); } else { // refresh when empty fetchPage(true); } }); more.addEventListener('click', () => { if (expectRetry) { // retry same page without incrementing expectRetry = false; setLoadMore('Loading…', { disabled: true }); fetchPage(false); return; } more.disabled = true; page += 1; fetchPage(false); }); function showToast(text='Done') { if (!toastEl) return; toastEl.textContent = text; toastEl.classList.add('show'); clearTimeout(showToast._t); showToast._t = setTimeout(() => toastEl.classList.remove('show'), 1400); } // === Tone row UX (row-spanning picker) ========================= const GRID_COLS = 4; // grid is 4 columns in the panel let __toneRow = null; // the expanded row element (if any) let __activeCard = null; // the card that owns the expanded row let __prevOverflowY = ''; function closeToneRow(){ if (__activeCard) { __activeCard.classList.remove('active'); __activeCard.style.background = ''; __activeCard.style.boxShadow = ''; if (typeof __activeCard.__setNameStatic === 'function') { __activeCard.__setNameStatic(); } __activeCard = null; } if (__toneRow && __toneRow.parentNode) __toneRow.parentNode.removeChild(__toneRow); __toneRow = null; } function buildToneRow(baseGlyph, activeIdx, onPick){ const row = document.createElement('div'); row.className = 'tone-row'; SKIN_TONES.forEach((t,i)=>{ const btn = document.createElement('button'); btn.className = 'tone-option'; const variant = canApplyToneTo(baseGlyph) ? withSkinTone(baseGlyph, t.ch) : baseGlyph; const span = document.createElement('span'); ensureRenderableAndGlyph(variant, span); btn.appendChild(span); if (i === activeIdx) btn.classList.add('selected'); btn.addEventListener('click', ()=> onPick(i, t, variant)); row.appendChild(btn); }); return row; } function openToneRowFor(card, item, baseGlyph){ // Toggle behavior if (__activeCard === card) { closeToneRow(); return; } // Close any previous closeToneRow(); __activeCard = card; // Visual highlight for the active owner card card.classList.add('active'); card.style.background = 'var(--c-chip,#eef2ff)'; card.style.boxShadow = '0 0 0 2px rgba(59,130,246,.65) inset'; if (isToneableByPolicy(item) && typeof card.__setNameMarquee === 'function') { card.__setNameMarquee(); } // Decide where to insert: after the end of this 4-col row const cardsOnly = Array.from(list.querySelectorAll('.card')); const idx = cardsOnly.indexOf(card); const rowStart = Math.floor(idx / GRID_COLS) * GRID_COLS; const rowEnd = Math.min(rowStart + GRID_COLS - 1, cardsOnly.length - 1); const anchor = cardsOnly[rowEnd] || card; const prefIdxPromise = getPreferredToneIndex(); (async ()=>{ const currentIdx = await prefIdxPromise; const base = stripSkinTone(item.emoji_base || baseGlyph); __toneRow = buildToneRow(base, currentIdx, async (newIdx, tone, variant)=>{ if (toneLock && currentIdx < 0) { await setPreferredToneIndex(newIdx); showToast('Preferred tone set'); } await performEmojiAction(variant); closeToneRow(); }); anchor.insertAdjacentElement('afterend', __toneRow); try { __toneRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch(_){} })(); } // (obsolete) placeholder; real renderCard defined later with Windows support + Pro tone UI function renderCard(e) {} // --- Popover helpers for skin tone picker --- function showPopoverNear(anchorEl, popEl){ const r = anchorEl.getBoundingClientRect(); Object.assign(popEl.style, { position: 'fixed', left: `${Math.min(window.innerWidth - 8, Math.max(8, r.left))}px`, top: `${Math.min(window.innerHeight - 8, r.bottom + 6)}px`, zIndex: 9999, }); document.body.appendChild(popEl); } function removePopover(){ const p = document.getElementById('dewemoji-tone-popover'); if (p && p.parentNode) p.parentNode.removeChild(p); document.removeEventListener('click', handleOutsideClose, true); } function handleOutsideClose(e){ const p = document.getElementById('dewemoji-tone-popover'); if (p && !p.contains(e.target)) removePopover(); } function buildTonePopover(base, activeIdx, onPick){ const el = document.createElement('div'); el.id = 'dewemoji-tone-popover'; el.setAttribute('role','dialog'); el.style.cssText = 'background: var(--c-bg, #fff); border: 1px solid var(--c-border, rgba(0,0,0,.12)); padding:6px; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.15); display:flex; gap:6px;'; SKIN_TONES.forEach((t,i) => { const btn = document.createElement('button'); btn.className = 'tone-btn'; btn.title = t.label; btn.style.cssText = 'min-width:32px;height:32px;border-radius:6px;border:1px solid transparent;display:flex;align-items:center;justify-content:center;background:var(--c-chip,#f3f4f6)'; // Build the toned variant only when applicable; else preview base to avoid squares const variant = canApplyToneTo(base) ? withSkinTone(base, t.ch) : base; const span = document.createElement('span'); span.style.display = 'inline-flex'; span.style.alignItems = 'center'; span.style.justifyContent = 'center'; ensureRenderableAndGlyph(variant, span); btn.appendChild(span); if (i === activeIdx) btn.classList.add('selected'); btn.addEventListener('click', () => onPick(i, t)); el.appendChild(btn); }); setTimeout(() => document.addEventListener('click', handleOutsideClose, true), 0); return el; } // initial loadCategories().catch(console.error); (async () => { try { await loadSettings(); await migrateDailyLimit(); } catch(_) {} await fetchPage(true).catch(console.error); try { setupAutoLoadObserver(); } catch(_) {} })(); async function loadSettings() { const data = await chrome.storage.local.get(['licenseValid', 'licenseKey', 'actionMode']); licenseValid = !!data.licenseValid; refreshPageLimit(); actionMode = data.actionMode || 'copy'; if (data.licenseKey) licenseKeyEl && (licenseKeyEl.value = data.licenseKey); licenseKeyCurrent = data.licenseKey || ''; applyLicenseUI(); applyModeUI(); setStatusTag(); // tone settings from sync storage await getPreferredToneIndex(); await getToneLock(); renderToneSettingsSection(); // ensure license field starts in non-edit mode setLicenseEditMode(false); } async function saveSettings() { await chrome.storage.local.set({ licenseValid, actionMode, licenseKey: licenseKeyEl?.value || '' }); } // --- Helper to render Free status with CTA --- function freeStatusHTML(){ return 'Free mode — Pro features locked.' + '