// === 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.' + '
' + '🔓 Upgrade to Pro' + ''; } function setLicenseEditMode(on) { if (!licenseKeyEl) return; const isOn = !!on; // Input enabled only when editing OR not licensed licenseKeyEl.disabled = licenseValid ? !isOn : false; if (licenseValid) { // Licensed: default view hides Activate; editing shows Save/Cancel if (licenseActivateBtn) { licenseActivateBtn.style.display = isOn ? '' : 'none'; licenseActivateBtn.textContent = isOn ? 'Save' : 'Activate'; } if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = licenseActivateBtn.textContent == 'Save' ? 'none' : ''; if (licenseEditBtn) licenseEditBtn.style.display = isOn ? 'none' : ''; if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = isOn ? '' : 'none'; } else { // Free: show Activate only if (licenseActivateBtn) { licenseActivateBtn.style.display = ''; licenseActivateBtn.textContent = 'Activate'; } if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = 'none'; if (licenseEditBtn) licenseEditBtn.style.display = 'none'; if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = 'none'; } } function applyLicenseUI() { // enable/disable Pro radios const radios = modeGroup.querySelectorAll('input[type="radio"][name="actionMode"]'); radios.forEach(r => { const isPro = (r.value === 'insert' || r.value === 'auto'); if (isPro) { r.disabled = !licenseValid; r.closest('.radio').setAttribute('aria-disabled', !licenseValid ? 'true' : 'false'); } }); if (!licenseValid) { // Force visible selection to 'copy' for clarity const copy = modeGroup.querySelector('input[type="radio"][value="copy"]'); if (copy) copy.checked = true; licenseStatusEl && (licenseStatusEl.innerHTML = freeStatusHTML()); } else { // Restore selected mode if Pro; if current actionMode is pro, check it const target = modeGroup.querySelector(`input[type="radio"][value="${actionMode}"]`) || modeGroup.querySelector('input[type="radio"][value="auto"]'); if (target) target.checked = true; licenseStatusEl && (licenseStatusEl.textContent = 'Pro active'); } // Reset to non-edit view per license state if (licenseValid) { if (licenseKeyEl) licenseKeyEl.disabled = true; if (licenseActivateBtn) { licenseActivateBtn.style.display = 'none'; licenseActivateBtn.textContent = 'Activate'; } if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = ''; if (licenseEditBtn) licenseEditBtn.style.display = ''; if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = 'none'; } else { if (licenseKeyEl) licenseKeyEl.disabled = false; if (licenseActivateBtn) { licenseActivateBtn.style.display = ''; licenseActivateBtn.textContent = 'Activate'; } if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = 'none'; if (licenseEditBtn) licenseEditBtn.style.display = 'none'; if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = 'none'; } setStatusTag(); // --- PATCH: Refresh tone section after license state changes --- try { renderToneSettingsSection(); } catch(_) {} } function applyModeUI() { // Ensure the selected radio matches actionMode (if not locked) const input = modeGroup.querySelector(`input[type="radio"][value="${actionMode}"]`); if (input && !input.disabled) input.checked = true; } // --- License account id helper (best practice: hash + label) --- async function accountId() { // Return a stable, privacy-preserving identifier and a masked label for UI // { id: , label: } // Try cache first const cached = await chrome.storage.local.get(['accountId','accountLabel']); if (cached.accountId && cached.accountLabel) { return { id: cached.accountId, label: cached.accountLabel }; } let raw = ''; let label = ''; try { if (chrome.identity && chrome.identity.getProfileUserInfo) { const info = await new Promise(res => chrome.identity.getProfileUserInfo(res)); // info: { email, id } if (info?.email) { raw = (info.email || '').trim().toLowerCase(); label = maskEmail(info.email); } else if (info?.id) { raw = String(info.id); label = shortId(info.id); } } } catch {} if (!raw) { // Fallback to a per-profile UUID const got = await chrome.storage.local.get(['profileUUID']); if (got?.profileUUID) { raw = got.profileUUID; label = shortId(raw); } else { raw = crypto.randomUUID(); label = shortId(raw); await chrome.storage.local.set({ profileUUID: raw }); } } const hashed = await sha256Base64Url(raw); await chrome.storage.local.set({ accountId: hashed, accountLabel: label }); return { id: hashed, label }; } function maskEmail(email){ const [u, d] = String(email).split('@'); if (!d) return shortId(email); const head = u.slice(0,1); const tail = u.slice(-1); return `${head}${u.length>2?'***':''}${tail}@${d}`; } function shortId(s){ const x = String(s); return x.length <= 8 ? x : `${x.slice(0,4)}…${x.slice(-2)}`; } async function sha256Base64Url(input){ const enc = new TextEncoder(); const buf = await crypto.subtle.digest('SHA-256', enc.encode(input)); const bytes = new Uint8Array(buf); let bin = ''; for (let i=0;iPreferred skin tone
`; sheet.querySelector('#tab-pro')?.appendChild(sec) || sheet.appendChild(sec); } const pal = sec.querySelector('#tone-palette'); pal.innerHTML = ''; const lockEl = sec.querySelector('#tone-lock'); lockEl.checked = !!toneLock; // Disable lock checkbox and visually dim if license is invalid lockEl.disabled = !licenseValid; if (!licenseValid) { lockEl.parentElement.style.opacity = '0.5'; } else { lockEl.parentElement.style.opacity = ''; } lockEl.onchange = async (e)=>{ await setToneLock(!!e.target.checked); showToast(toneLock ? 'Tone lock enabled' : 'Tone lock disabled'); }; const idx = TONE_SLUGS.indexOf(preferredToneSlug); const basePreview = '✋'; SKIN_TONES.forEach((t,i)=>{ const b = document.createElement('button'); b.className = 'tone-chip'; b.title = t.label; b.textContent = basePreview + t.ch; // small preview b.style.cssText = 'height:32px;border-radius:8px;padding:0 8px;border:1px solid var(--c-border, rgba(0,0,0,.12));background:var(--c-chip,#f3f4f6)'; if (i === idx) b.classList.add('selected'); // Disable tone chips and visually dim if license is invalid if (!licenseValid) { b.disabled = true; b.style.opacity = '0.5'; b.style.pointerEvents = 'none'; } b.addEventListener('click', async ()=>{ await setPreferredToneIndex(i); showToast('Preferred tone set'); renderToneSettingsSection(); }); pal.appendChild(b); }); const note = sec.querySelector('#tone-note'); if (!preferredToneSlug) { note.textContent = 'Tip: choosing a tone will lock it for next picks if the toggle is on.'; } else { note.textContent = toneLock ? `Using ${preferredToneSlug.replace('-', ' ')} tone by default.` : 'Lock is off — we will ask via the palette.'; } } function openSheet() { sheet.removeAttribute('hidden'); backdrop.removeAttribute('hidden'); requestAnimationFrame(() => { sheet.classList.add('show'); backdrop.classList.add('show'); }); // lock page scroll while sheet is open try { __prevOverflowY = document.documentElement.style.overflowY || ''; document.documentElement.style.overflowY = 'hidden'; // also lock body in case the site CSS scrolls body instead of root document.body && (document.body.style.overflowY = 'hidden'); } catch(_) {} } function closeSheet() { sheet.classList.remove('show'); backdrop.classList.remove('show'); setTimeout(() => { sheet.setAttribute('hidden',''); backdrop.setAttribute('hidden',''); // restore page scroll after the closing animation completes try { document.documentElement.style.overflowY = __prevOverflowY || ''; document.body && (document.body.style.overflowY = ''); } catch(_) {} }, 180); } settingsBtn?.addEventListener('click', openSheet); sheetClose?.addEventListener('click', closeSheet); backdrop?.addEventListener('click', closeSheet); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSheet(); }); // Tabs logic for settings sheet const tabsWrap = document.querySelector('#settings-sheet .tabs'); if (tabsWrap) { tabsWrap.addEventListener('click', (e) => { const btn = e.target.closest('.tab'); if (!btn) return; const target = btn.dataset.tab; document.querySelectorAll('#settings-sheet .tab').forEach(b => b.classList.toggle('active', b === btn)); document.querySelectorAll('#settings-sheet .tabpane').forEach(p => p.classList.toggle('active', p.id === `tab-${target}`)); }); } modeGroup?.addEventListener('change', async (e) => { if (e.target && e.target.name === 'actionMode') { const val = e.target.value; // If not licensed and user clicked a Pro option, bounce back to copy if (!licenseValid && (val === 'insert' || val === 'auto')) { showToast('Pro required for Insert/Automatic'); const copy = modeGroup.querySelector('input[value="copy"]'); if (copy) copy.checked = true; actionMode = 'copy'; } else { actionMode = val; } await saveSettings(); } }); licenseActivateBtn?.addEventListener('click', async () => { const key = (licenseKeyEl?.value || '').trim(); if (!key) { showToast('Enter a license key'); return; } try { setLicenseBusy(true, 'Verifying…'); // Call your API /license/verify (works for Gumroad and Mayar) const acct = await accountId(); const res = await fetch('https://api.dewemoji.com/v1/license/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, account_id: acct.id, version: chrome.runtime.getManifest().version }) }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.ok) throw new Error(data?.error || `Verify ${res.status}`); licenseValid = true; refreshPageLimit(); licenseKeyCurrent = key; await chrome.storage.local.set({ licenseValid: true, licenseKey: key, lastLicenseCheck: Date.now() }); applyLicenseUI(); setLicenseEditMode(false); setStatusTag(); // PATCH: Immediately re-render tone section to enable controls after activation renderToneSettingsSection(); showToast('License activated ✓'); // Refresh results so Pro headers/tier take effect immediately fetchPage(true).catch(console.error); setLicenseBusy(false); } catch (e) { setLicenseBusy(false); licenseValid = false; await chrome.storage.local.set({ licenseValid: false }); applyLicenseUI(); setStatusTag(); showToast(`Activation failed${e?.message ? ': ' + e.message : ''}`); } }); licenseEditBtn?.addEventListener('click', () => { setLicenseEditMode(true); }); licenseCancelEditBtn?.addEventListener('click', async () => { const data = await chrome.storage.local.get(['licenseKey']); if (licenseKeyEl) licenseKeyEl.value = data.licenseKey || ''; setLicenseEditMode(false); }); licenseDeactivateBtn?.addEventListener('click', async () => { if (!licenseValid) return; const ok = await showConfirmModal({ title: 'Deactivate Dewemoji Pro?', message: 'Deactivate Pro on this device?', okText: 'Deactivate', cancelText: 'Cancel' }); if (!ok) return; setLicenseBusy(true, 'Deactivating…'); try { // (Optional) TODO: call your API /license/deactivate here, e.g.: // const acct = await accountId(); // await fetch('https://YOUR-API/license/deactivate', { // method: 'POST', headers: { 'Content-Type': 'application/json' }, // body: JSON.stringify({ key: (licenseKeyEl?.value || '').trim(), account_id: acct.id }) // }); licenseValid = false; refreshPageLimit(); await chrome.storage.local.set({ licenseValid: false, licenseKey: '' }); if (licenseKeyEl) licenseKeyEl.value = ''; applyLicenseUI(); // PATCH: Immediately re-render tone section to disable controls after deactivation renderToneSettingsSection(); licenseKeyCurrent = ''; setLicenseEditMode(false); showToast('License deactivated'); fetchPage(true).catch(console.error); setLicenseBusy(false); } catch (e) { setLicenseBusy(false); showToast('Could not deactivate'); } }); function renderDiag(obj) { const lines = []; lines.push(`Content script loaded: ${obj ? 'yes' : 'no'}`); if (!obj) return lines.join('\n'); lines.push(`Active editable type: ${obj.activeType ?? 'none'}`); lines.push(`Has caret/selection: ${obj.hasRange ? 'yes' : 'no'}`); lines.push(`Last insert result: ${obj.lastInsertOK === null ? 'n/a' : obj.lastInsertOK ? 'success' : 'failed'}`); if (obj.lastInsertMessage) lines.push(`Note: ${obj.lastInsertMessage}`); return lines.join('\n'); } diagRunBtn?.addEventListener('click', async () => { diagOut.textContent = ''; diagSpin.style.display = 'inline'; try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab?.id) { diagOut.textContent = 'No active tab.'; return; } const ready = await ensureContentScript(tab.id); if (!ready) { diagOut.textContent = renderDiag(null); return; } const info = await chrome.tabs.sendMessage(tab.id, { type: 'dewemoji_diag' }); diagOut.textContent = renderDiag(info || null); } catch (e) { diagOut.textContent = `Error: ${e?.message || e}`; } finally { diagSpin.style.display = 'none'; } }); async function performEmojiAction(glyph) { // Free users always copy const mode = licenseValid ? actionMode : 'copy'; if (mode === 'copy') { await navigator.clipboard.writeText(glyph); showToast('Copied ✅'); return; } if (mode === 'insert') { const ok = await insert(glyph, { strict: true }); // no copy fallback showToast(ok ? 'Inserted ✅' : '❗ No editable field'); return; } // mode === 'auto' (insert else copy) { const ok = await insert(glyph, { strict: false }); showToast(ok ? 'Inserted ✅' : 'Copied ✅'); return; } } async function setStatusTag(){ const dewemojiStatusTag = document.getElementById('dewemoji-status'); if(licenseValid){ dewemojiStatusTag.innerText = 'Pro'; dewemojiStatusTag.classList.add('pro'); dewemojiStatusTag.classList.remove('free'); }else{ dewemojiStatusTag.innerText = 'Free'; dewemojiStatusTag.classList.add('free'); dewemojiStatusTag.classList.remove('pro'); } } setStatusTag().catch(() => {}); const IS_WINDOWS = navigator.userAgent.includes('Windows'); // Build Twemoji SVG url from codepoints function twemojiSrcFromCodepoints(cp) { return `https://twemoji.maxcdn.com/v/latest/svg/${cp}.svg`; } // OPTIONAL: as a secondary image fallback if Twemoji doesn't have it function notoSrcFromCodepoints(cp) { // Twemoji uses "1f469-200d-1f4bb.svg" // Noto repo names use underscores: "emoji_u1f469_200d_1f4bb.svg" const underscored = cp.split('-').join('_'); return `https://cdn.jsdelivr.net/gh/googlefonts/noto-emoji@main/svg/emoji_u${underscored}.svg`; } /** * Ensure an emoji is renderable inside `container` on Windows, * and return a canonical glyph string for copy/insert. * - Tries twemoji.parse first * - If nothing replaced, forces an using toCodePoint() * - Returns { glyph, usedImg } */ function ensureRenderableAndGlyph(emojiStr, container) { let glyph = emojiStr; if (!(IS_WINDOWS && window.twemoji)) { container.textContent = glyph; return { glyph, usedImg: false }; } // Try normal twemoji.parse on a temp node const temp = document.createElement('span'); temp.textContent = emojiStr; window.twemoji.parse(temp, { folder: 'svg', ext: '.svg', base: 'https://twemoji.maxcdn.com/v/latest/', attributes: () => ({ draggable: 'false', alt: '' }) }); const img = temp.querySelector('img'); if (img) { // Parsed OK — adopt result container.replaceChildren(img); return { glyph, usedImg: true }; } // Force build from codepoints (handles cases parse() didn't catch) try { const cp = window.twemoji.convert.toCodePoint(emojiStr); const img2 = new Image(); img2.alt = emojiStr; img2.draggable = false; img2.src = notoSrcFromCodepoints(cp); img2.onerror = () => { img2.onerror = null; // prevent loops img2.src = twemojiSrcFromCodepoints(cp); }; container.replaceChildren(img2); // Normalize the glyph for copying from the same codepoints // (twemoji provides the inverse) glyph = window.twemoji.convert.fromCodePoint(cp); return { glyph, usedImg: true }; } catch { // Last resort: show text (may be tofu) but keep original glyph container.textContent = emojiStr; return { glyph: emojiStr, usedImg: false }; } } function renderCard(e) { const card = document.createElement('div'); card.className = 'card'; card.title = e.name || ''; // Policy: skip forbidden entries entirely if (isForbiddenEntry(e)) return; const FAMILY = isFamilyEntry(e); const emo = document.createElement('div'); emo.className = 'emo'; let baseGlyph = e.emoji || e.symbol || e.text || ''; // Strictly sanitize: families & any non‑toneable policy items must not carry tone modifiers if (FAMILY || !isToneableByPolicy(e) || isMultiHuman(baseGlyph)) { baseGlyph = stripSkinTone(baseGlyph); } const prefIdxPromise = getPreferredToneIndex(); (async () => { let renderGlyph = baseGlyph; if (licenseValid && toneLock && isToneableByPolicy(e) && !FAMILY) { const idx = await prefIdxPromise; if (idx >= 0) { const base = stripSkinTone(e.emoji_base || baseGlyph); if (isToneableByPolicy(e) && canApplyToneTo(base)) { renderGlyph = withSkinTone(base, SKIN_TONES[idx].ch); } } } const res = ensureRenderableAndGlyph(renderGlyph, emo); baseGlyph = res.glyph; })(); // --- Name (truncated by default; marquee on hover/active) --- function buildNameStatic(text){ const el = document.createElement('div'); el.className = 'nm'; el.textContent = text; return el; } function buildNameMarquee(text){ const mq = document.createElement('marquee'); mq.className = 'nm'; mq.setAttribute('behavior','scroll'); mq.setAttribute('direction','left'); mq.setAttribute('scrollamount','3'); mq.textContent = text; return mq; } // --- Name: truncated (tooltip on hover); marquee only when .card.active --- function buildNameStatic(text){ const el = document.createElement('div'); el.className = 'nm'; el.textContent = text; // tooltip for hover el.title = text; return el; } function buildNameMarquee(text){ const mq = document.createElement('marquee'); mq.className = 'nm'; mq.setAttribute('behavior','scroll'); mq.setAttribute('direction','left'); mq.setAttribute('scrollamount','3'); mq.setAttribute('truespeed',''); // keep tooltip text mq.title = text; mq.textContent = text; return mq; } let nameEl = buildNameStatic(e.name || ''); // Toggle helpers invoked by tone-row open/close card.__setNameStatic = () => { if (!nameEl || nameEl.tagName !== 'MARQUEE') return; const s = buildNameStatic(e.name || ''); nameEl.replaceWith(s); nameEl = s; }; card.__setNameMarquee = () => { // If already marquee, do nothing if (nameEl && nameEl.tagName === 'MARQUEE') return; // Ensure we only marquee when the static text overflows its container const needsMarquee = (() => { try { // Force a layout read; the element must be in the DOM const el = nameEl; if (!el) return false; // Small epsilon to avoid float rounding issues return (el.scrollWidth - el.clientWidth) > 1; } catch(_) { return false; } })(); if (!needsMarquee) return; // keep static if it fits const m = buildNameMarquee(e.name || ''); nameEl.replaceWith(m); nameEl = m; // best-effort nudge so it begins smoothly requestAnimationFrame(() => { try { m.stop && m.stop(); m.start && m.start(); } catch(_){} }); }; // Optional UI cue: dots for toneable entries if (isToneableByPolicy(e)) { const dots = document.createElement('div'); dots.className = 'tone-ind'; dots.style.cssText = 'position:absolute;top:6px;right:6px;display:flex;gap:3px;'; const sw = (c)=>{ const d=document.createElement('span'); d.style.cssText=`width:6px;height:6px;border-radius:50%;background:${c};opacity:.9;`; return d; }; dots.append(sw('#f7d7c4')); dots.append(sw('#c68642')); dots.append(sw('#8d5524')); card.appendChild(dots); } card.addEventListener('click', async () => { // For non‑Pro, or non‑toneable, or families → perform action immediately if (!licenseValid || !isToneableByPolicy(e) || isFamilyEntry(e)) { performEmojiAction(baseGlyph).catch(console.error); return; } // Open the row‑spanning tone chooser right below this card's row openToneRowFor(card, e, baseGlyph); }); card.append(emo, nameEl); list.appendChild(card); } // pick a tofu cell const el = document.querySelector('.card .emo'); const raw = el?.textContent || ''; console.log('RAW points:', [...raw].map(c=>c.codePointAt(0).toString(16))); if (window.twemoji) { const cp = window.twemoji.convert.toCodePoint(raw); console.log('Twemoji codepoints:', cp); console.log('Canonical from codepoints:', window.twemoji.convert.fromCodePoint(cp)); } (async function verifyLicenseOnBoot(){ try { const { licenseKey, lastLicenseCheck, licenseValid: storedValid } = await chrome.storage.local.get(['licenseKey','lastLicenseCheck','licenseValid']); if (!licenseKey) return; const day = 24*60*60*1000; // If licenseValid is true and last check is within a day, skip checking and set UI immediately if (storedValid && (Date.now() - (lastLicenseCheck||0) < day)) { licenseValid = true; refreshPageLimit(); applyLicenseUI(); setStatusTag(); licenseStatusEl && (licenseStatusEl.textContent = 'Pro active'); return; } // Otherwise, show "Checking license…" while verifying licenseStatusEl && (licenseStatusEl.textContent = 'Checking license…'); const acct = await accountId(); const res = await fetch('https://api.dewemoji.com/v1/license/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: licenseKey, account_id: acct.id, version: chrome.runtime.getManifest().version }) }); const data = await res.json().catch(() => ({})); const ok = !!data.ok; await chrome.storage.local.set({ licenseValid: ok, lastLicenseCheck: Date.now() }); licenseValid = ok; refreshPageLimit(); applyLicenseUI(); setStatusTag(); if (licenseStatusEl) { if (licenseValid) { licenseStatusEl.textContent = 'Pro active'; } else { licenseStatusEl.innerHTML = freeStatusHTML(); } } } catch {} })(); // Re-render tone section when opening settings (in case lock/pref changed during session) const __openSheet = openSheet; openSheet = function(){ __openSheet(); const genBtn = document.querySelector('#settings-sheet .tab[data-tab="general"]'); const proBtn = document.querySelector('#settings-sheet .tab[data-tab="pro"]'); genBtn?.classList.add('active'); proBtn?.classList.remove('active'); document.getElementById('tab-general')?.classList.add('active'); document.getElementById('tab-pro')?.classList.remove('active'); renderToneSettingsSection(); };