Files
emoji-chrome-extension/panel.js
2025-09-06 23:58:42 +07:00

1412 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// === 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 → AZ
}
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 = `<option value="">All categories</option>`;
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 = `<option value="">All subcategories</option>`;
subSel.disabled = true;
}
function populateSubcategorySelect(category) {
const subSel = document.getElementById('sub');
if (!subSel) return;
subSel.innerHTML = `<option value="">All subcategories</option>`;
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 = '<div class="animate-spin inline-block w-8 h-8 border-4 border-current border-t-transparent text-blue-600 rounded-full" role="status" aria-label="loading"></div>';
// 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18 6L6 18M6 6l12 12"/></svg>`;
clearBtn.title = 'Clear';
clearBtn.setAttribute('aria-label', 'Clear');
clearBtn.classList.add('nm');
clearBtn.dataset.mode = 'clear';
} else {
clearBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M21.074 12.154a.75.75 0 0 1 .672.82c-.49 4.93-4.658 8.776-9.724 8.776c-2.724 0-5.364-.933-7.238-2.68L3 20.85a.75.75 0 0 1-.75-.75v-3.96c0-.714.58-1.29 1.291-1.29h3.97a.75.75 0 0 1 .75.75l-2.413 2.407c1.558 1.433 3.78 2.243 6.174 2.243c4.29 0 7.817-3.258 8.232-7.424a.75.75 0 0 1 .82-.672m-18.82-1.128c.49-4.93 4.658-8.776 9.724-8.776c2.724 0 5.364.933 7.238 2.68L21 3.15a.75.75 0 0 1 .75.75v3.96c0 .714-.58 1.29-1.291 1.29h-3.97a.75.75 0 0 1-.75-.75l2.413-2.408c-1.558-1.432-3.78-2.242-6.174-2.242c-4.29 0-7.817 3.258-8.232 7.424a.75.75 0 1 1-1.492-.148"/></svg>`; // 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: <hashed id>, label: <masked email or short id> }
// 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;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
}
// --- Tone Settings UI (in Settings modal) ---
function renderToneSettingsSection(){
if (!sheet) return;
let sec = document.getElementById('tone-settings');
if (!sec) {
sec = document.createElement('section');
sec.id = 'tone-settings';
sec.innerHTML = `
<h3 class="h">Preferred skin tone</h3>
<label class="toggle"><input type="checkbox" id="tone-lock"> <span>Lock preferred tone (use automatically)</span></label>
<div id="tone-palette" class="tone-palette" style="display:flex;gap:8px;margin:8px 0 4px 0;"></div>
<div id="tone-note" class="note"></div>
`;
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 <img> 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();
};