// === 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'); // --- License busy helpers (UI feedback for activate/deactivate) --- let __licensePrev = { text: '', disabled: false }; function setLicenseBusy(on, label){ const btn = licenseActivateBtn; if (!btn) return; if (on) { __licensePrev.text = btn.textContent; __licensePrev.disabled = btn.disabled; btn.textContent = label || 'Verifying…'; btn.disabled = true; licenseKeyEl && (licenseKeyEl.disabled = true); licenseDeactivateBtn && (licenseDeactivateBtn.disabled = true); licenseEditBtn && (licenseEditBtn.disabled = true); licenseCancelEditBtn && (licenseCancelEditBtn.disabled = true); licenseStatusEl && (licenseStatusEl.textContent = 'Checking license…'); } else { btn.textContent = __licensePrev.text || 'Activate'; btn.disabled = __licensePrev.disabled || false; // Restore enabled state based on current license view 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) { CAT_MAP = await res.json(); ok = true; } } 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 subs = (CAT_MAP?.[category] || []).slice().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,''); } function withSkinTone(base, ch){ return `${base||''}${ch||''}`; } // 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); } // (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 b = document.createElement('button'); b.className = 'tone-btn'; b.textContent = withSkinTone(base, t.ch); b.title = t.label; b.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)'; if (i === activeIdx) b.classList.add('selected'); b.addEventListener('click', () => onPick(i, t)); el.appendChild(b); }); 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 || '' }); } 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.textContent = 'Free mode — Pro features locked.'); } 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(); } 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; 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'); 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'); }); } function closeSheet() { sheet.classList.remove('show'); backdrop.classList.remove('show'); setTimeout(() => { sheet.setAttribute('hidden',''); backdrop.setAttribute('hidden',''); }, 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(); 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 = confirm('Deactivate Pro on this device?'); 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(); 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 || ''; const emo = document.createElement('div'); emo.className = 'emo'; let baseGlyph = e.emoji || e.symbol || e.text || ''; const prefIdxPromise = getPreferredToneIndex(); (async () => { let renderGlyph = baseGlyph; if (licenseValid && toneLock && e.supports_skin_tone) { const idx = await prefIdxPromise; if (idx >= 0) { const base = e.emoji_base || stripSkinTone(baseGlyph); renderGlyph = withSkinTone(base, SKIN_TONES[idx].ch); } } const res = ensureRenderableAndGlyph(renderGlyph, emo); baseGlyph = res.glyph; })(); const name = document.createElement('div'); name.className = 'nm'; name.textContent = e.name || ''; card.addEventListener('click', async () => { if (!licenseValid || !e.supports_skin_tone) { performEmojiAction(baseGlyph).catch(console.error); return; } const idx = await prefIdxPromise; const base = e.emoji_base || stripSkinTone(baseGlyph); if (toneLock && idx >= 0) { const variant = withSkinTone(base, SKIN_TONES[idx].ch); performEmojiAction(variant).catch(console.error); return; } removePopover(); const pop = buildTonePopover(base, idx, async (newIdx, tone) => { const variant = withSkinTone(base, tone.ch); if (toneLock && idx < 0) { await setPreferredToneIndex(newIdx); showToast('Preferred tone set'); } performEmojiAction(variant).catch(console.error); removePopover(); }); showPopoverNear(card, pop); }); card.append(emo, name); 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 } = await chrome.storage.local.get(['licenseKey','lastLicenseCheck']); if (!licenseKey) return; licenseStatusEl && (licenseStatusEl.textContent = 'Checking license…'); const day = 24*60*60*1000; if (Date.now() - (lastLicenseCheck||0) < day) return; 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(); licenseStatusEl && (licenseStatusEl.textContent = licenseValid ? 'Pro active' : 'Free mode — Pro features locked.'); } 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(); };