Files
dwindown d689249f3f ux: improve skintone interaction & active state behavior
- Remove swatch modal → replaced with inline tone row UX
- Add active card highlight and tone selection row spanning 4 columns
- Apply tone immediately on click (copy/insert according to settings)
- Add tooltip on hover for names (replacing marquee on hover)
- Enable marquee animation only for overflowed text when active
- Integrate forbid & non-toneable filters matching site policy (LGBT & fantasy exclusions)
- Sync tone whitelist (roles like technologist, scientist, firefighter, etc.)
- Sync tone blacklist (e.g. merman, mermaid, deaf man/woman, woman beard, men with bunny ears)
- Add overflow-y hidden toggle when settings modal shown
- Move inline styles to style.css for cleaner structure
- Refactor panel.js for maintainable tone row injection logic
2025-10-02 11:27:25 +07:00

1855 lines
67 KiB
JavaScript
Raw Permalink 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');
// --- Branded confirm modal helper ---
function showConfirmModal(opts = {}) {
return new Promise((resolve) => {
const modal = document.getElementById('confirm-modal');
const back = document.getElementById('confirm-backdrop');
const title = document.getElementById('confirm-title');
const msg = document.getElementById('confirm-message');
const okBtn = document.getElementById('confirm-ok');
const noBtn = document.getElementById('confirm-cancel');
if (!modal || !back) return resolve(window.confirm(opts.message || 'Are you sure?'));
title.textContent = opts.title || 'Confirm';
msg.textContent = opts.message || 'Are you sure?';
okBtn.textContent = opts.okText || 'OK';
noBtn.textContent = opts.cancelText || 'Cancel';
function close(val){
modal.setAttribute('hidden','');
back.setAttribute('hidden','');
document.removeEventListener('keydown', onKey);
okBtn.removeEventListener('click', onOk);
noBtn.removeEventListener('click', onNo);
back.removeEventListener('click', onNo);
resolve(val);
}
function onOk(){ close(true); }
function onNo(){ close(false); }
function onKey(e){ if (e.key === 'Escape') close(false); if (e.key === 'Enter') close(true); }
back.removeAttribute('hidden');
modal.removeAttribute('hidden');
document.addEventListener('keydown', onKey);
okBtn.addEventListener('click', onOk);
noBtn.addEventListener('click', onNo);
back.addEventListener('click', onNo);
// focus primary
setTimeout(()=> okBtn.focus(), 0);
});
}
// --- License busy helpers (UI feedback for activate/deactivate) ---
let __licensePrev = {
text: '',
activateDisabled: false,
keyDisabled: false,
deactivateDisabled: false,
editDisabled: false,
cancelDisabled: false,
};
function setLicenseBusy(on, label){
const btn = licenseActivateBtn;
if (!btn) return;
if (on) {
// snapshot current states
__licensePrev.text = btn.textContent;
__licensePrev.activateDisabled = !!btn.disabled;
__licensePrev.keyDisabled = !!(licenseKeyEl && licenseKeyEl.disabled);
__licensePrev.deactivateDisabled = !!(licenseDeactivateBtn && licenseDeactivateBtn.disabled);
__licensePrev.editDisabled = !!(licenseEditBtn && licenseEditBtn.disabled);
__licensePrev.cancelDisabled = !!(licenseCancelEditBtn && licenseCancelEditBtn.disabled);
// set busy states
btn.textContent = label || 'Verifying…';
btn.disabled = true;
if (licenseKeyEl) licenseKeyEl.disabled = true;
if (licenseDeactivateBtn) licenseDeactivateBtn.disabled = true;
if (licenseEditBtn) licenseEditBtn.disabled = true;
if (licenseCancelEditBtn) licenseCancelEditBtn.disabled = true;
if (licenseStatusEl) licenseStatusEl.textContent = 'Checking license…';
} else {
// restore previous states
btn.textContent = __licensePrev.text || 'Activate';
btn.disabled = !!__licensePrev.activateDisabled;
if (licenseKeyEl) licenseKeyEl.disabled = !!__licensePrev.keyDisabled;
if (licenseDeactivateBtn) licenseDeactivateBtn.disabled = !!__licensePrev.deactivateDisabled;
if (licenseEditBtn) licenseEditBtn.disabled = !!__licensePrev.editDisabled;
if (licenseCancelEditBtn) licenseCancelEditBtn.disabled = !!__licensePrev.cancelDisabled;
// Re-apply UI (visibility/display) for current license state
applyLicenseUI();
}
}
const diagRunBtn = document.getElementById('diag-run');
const diagSpin = document.getElementById('diag-spin');
const diagOut = document.getElementById('diag-out');
const API = {
base: "https://api.dewemoji.com/v1",
list: "/emojis"
};
API.cats = "/categories";
let PAGE_LIMIT = 20; // Free default
function refreshPageLimit(){
PAGE_LIMIT = licenseValid ? 50 : 20; // Pro gets bigger pages
}
refreshPageLimit();
const FRONTEND_ID = 'ext-v1';
let CAT_MAP = null; // { "Category": ["sub1","sub2", ...], ... }
// Preferred, human-first category order (fallback: alphabetical)
const PREFERRED_CATEGORY_ORDER = [
"Smileys & Emotion",
"People & Body",
"Animals & Nature",
"Food & Drink",
"Travel & Places",
"Activities",
"Objects",
"Symbols",
"Flags"
];
const CAT_INDEX = Object.fromEntries(PREFERRED_CATEGORY_ORDER.map((c, i) => [c, i]));
// Minimal local subcategories to enable the sub-select even without API
const LOCAL_SUBS = {
'Travel & Places': ['place-religious','place-geographic','transport-water'],
'People & Body': ['hand-fingers-open','hand-fingers-partial','person-gesture'],
'Animals & Nature': ['animal-mammal','animal-bird','animal-bug'],
'Activities': ['sport','game','event'],
};
function categoryComparator(a, b) {
const ia = CAT_INDEX[a];
const ib = CAT_INDEX[b];
if (ia !== undefined && ib !== undefined) return ia - ib; // both in preferred list
if (ia !== undefined) return -1; // a preferred, b not
if (ib !== undefined) return 1; // b preferred, a not
return a.localeCompare(b, undefined, { sensitivity: "base" }); // both not preferred → 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) {
const data = await res.json();
// Normalize payloads:
// A) New shape: { items: [ { name: 'People & Body', subcategories: ['person','person-gesture'] }, ... ] }
// B) Legacy shape: { 'People & Body': ['person','person-gesture'], ... }
if (data && Array.isArray(data.items)) {
const map = {};
for (const it of data.items) {
if (!it || typeof it.name !== 'string') continue;
const name = it.name;
const subsRaw = Array.isArray(it.subcategories) ? it.subcategories : [];
const subs = subsRaw
.map(s => (typeof s === 'string') ? s : (s && (s.slug || s.name) || ''))
.filter(Boolean);
map[name] = subs;
}
CAT_MAP = map;
} else if (data && typeof data === 'object') {
CAT_MAP = data; // assume already in object-map form
} else {
throw new Error('bad_categories_payload');
}
ok = Object.keys(CAT_MAP || {}).length > 0;
}
} catch (_) {}
if (!ok) {
// Silent fallback with a minimal subcategory seed
CAT_MAP = Object.fromEntries(PREFERRED_CATEGORY_ORDER.map(c => [c, LOCAL_SUBS[c] || []]));
}
populateCategorySelect();
}
function populateCategorySelect() {
const catSel = document.getElementById('cat');
const subSel = document.getElementById('sub');
if (!catSel || !CAT_MAP) return;
// reset
catSel.innerHTML = `<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 raw = (CAT_MAP?.[category] || []).slice();
const subs = raw
.map(s => (typeof s === 'string') ? s : (s && (s.slug || s.name) || ''))
.filter(Boolean)
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true }));
subs.forEach(sc => {
const opt = document.createElement('option');
opt.value = sc;
opt.textContent = sc;
subSel.appendChild(opt);
});
subSel.disabled = subs.length === 0;
subSel.classList.toggle('opacity-50', subs.length === 0);
subSel.classList.toggle('pointer-events-none', subs.length === 0);
}
let page = 1, total = 0, items = [];
let lastServerTier = null; // 'pro' | null (free) | undefined (no response yet)
const q = document.getElementById('q');
const cat = document.getElementById('cat');
const sub = document.getElementById('sub');
const clearBtn = document.getElementById('clear');
const list = document.getElementById('list');
const more = document.getElementById('more');
const count = document.getElementById('count');
const verEl = document.getElementById('ver');
function setVersionBadge() {
try {
const { name, version } = chrome.runtime.getManifest();
if (verEl) {
verEl.textContent = `v${version}`;
verEl.title = `${name} v${version}`;
}
} catch (e) {
// ignore if unavailable
}
}
setVersionBadge();
// --- Alert helper for messages (warn/error, light/dark theme) ---
function buildAlert(kind, title, desc){
const isDark = document.documentElement.classList.contains('theme-dark');
const tone = (kind === 'error')
? (isDark
? { bg:'#3b0f0f', border:'#7f1d1d', text:'#fecaca', icon:'‼' }
: { bg:'#fee2e2', border:'#fecaca', text:'#7f1d1d', icon:'‼' })
: (isDark
? { bg:'#3b2f0f', border:'#a16207', text:'#fde68a', icon:'⚠️' }
: { bg:'#fef3c7', border:'#fde68a', text:'#92400e', icon:'⚠️' });
const wrap = document.createElement('div');
wrap.style.gridColumn = '1 / -1';
wrap.style.background = tone.bg;
wrap.style.border = `1px solid ${tone.border}`;
wrap.style.color = tone.text;
wrap.style.borderRadius = '10px';
wrap.style.padding = '10px 12px';
wrap.style.display = 'flex';
wrap.style.alignItems = 'flex-start';
wrap.style.gap = '10px';
wrap.className = 'dewemoji-alert';
const ico = document.createElement('span');
ico.textContent = tone.icon;
ico.style.fontSize = '18px';
ico.style.lineHeight = '20px';
const body = document.createElement('div');
const h = document.createElement('div');
h.textContent = title || '';
h.style.fontWeight = '600';
h.style.marginBottom = desc ? '2px' : '0';
const p = document.createElement('div');
p.textContent = desc || '';
p.style.opacity = '0.9';
p.style.fontSize = '12.5px';
body.appendChild(h);
if (desc) body.appendChild(p);
wrap.append(ico, body);
return wrap;
}
// --- Loading spinner (flip-flop with Load More) ---
let spinnerEl = null;
function showSpinner() {
if (spinnerEl) return;
// Hide Load More while loading
if (more) more.classList.add('hidden');
spinnerEl = document.createElement('div');
spinnerEl.id = 'grid-spinner';
spinnerEl.className = 'w-full flex justify-center py-6';
spinnerEl.innerHTML = '<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,''); }
// === Policy: forbid list & nontoneable list (mirror of site script.js) ===
const FORBID_NAMES = new Set([
'woman: beard',
'man with veil',
'pregnant man',
'man with bunny ears',
'men with bunny ears',
]);
const NON_TONEABLE_NAMES = new Set([
// Core non-toneable bases
'mechanical arm', 'mechanical leg','anatomical heart','brain','lungs','tooth','bone','eyes','eye','tongue','mouth','biting lips',
// Fantasy subset (problematic gendered variants remain but not toneable)
'genie','man genie','woman genie','zombie','man zombie','woman zombie','troll',
// Activities & gestures we treat as non-toneable
'skier','snowboarder','speaking head','bust in silhouette','busts in silhouette','people hugging','family','footprints','fingerprint',
'people fencing',
// Directional variants we keep but not toneable
'person walking facing right','person running facing right','person kneeling facing right',
// Accessibility
'deaf man','deaf woman',
// Merfolk
'merman','mermaid'
]);
function normName(e){
return String(e?.name || '').trim().toLowerCase();
}
function isForbiddenEntry(e){
const n = normName(e);
return FORBID_NAMES.has(n);
}
function isNonToneableByPolicy(e){
const n = normName(e);
if (!n) return false;
// Families never toneable
if (isFamilyEntry(e)) return true;
// Explicit list
if (NON_TONEABLE_NAMES.has(n)) return true;
// Buckets by subcategory we consider symbol-like (person-symbol) or family
const sc = String(e?.subcategory || '').toLowerCase();
if (sc === 'family' || sc === 'person-symbol') return true;
return false;
}
function isToneableByPolicy(e){
// Must advertise tone support, must not be family, and not on our non-toneable list.
if (!e?.supports_skin_tone) return false;
if (isNonToneableByPolicy(e)) return false;
return true;
}
// --- Tone applicability heuristic ---
function canApplyToneTo(emojiStr){
if (!emojiStr) return false;
const cps = Array.from(emojiStr);
// If any explicit Emoji_Modifier_Base exists, it's safe
try {
for (let i = 0; i < cps.length; i++) {
if (/\p{Emoji_Modifier_Base}/u.test(cps[i])) return true;
}
} catch { /* property escapes may not be supported; fall through */ }
// Fallback heuristic:
// Count human bases (👨 U+1F468, 👩 U+1F469, 🧑 U+1F9D1)
let humanCount = 0;
for (let i = 0; i < cps.length; i++) {
const cp = cps[i].codePointAt(0);
if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) humanCount++;
}
// Allow tones for couples (2 humans) and single-person emojis; block families (3+ humans)
if (humanCount >= 3) return false; // families
if (humanCount === 2) return true; // couples (kiss, couple-with-heart, etc.)
if (humanCount === 1) return true; // single human (ok)
// Non-human (hands etc.) — many support tone, but those have modifier base property
// Without the property present, avoid applying to prevent stray squares
return false;
}
// Helper: Detect multi-human ZWJ sequences
function isMultiHuman(s){
if (!s) return false;
const hasZWJ = /\u200D/.test(s);
if (!hasZWJ) return false;
let count = 0;
for (const ch of Array.from(s)) {
const cp = ch.codePointAt(0);
if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) count++;
}
return count >= 2;
}
// Helper: Detect family entries by metadata
function isFamilyEntry(e){
const n = String(e?.name||'').toLowerCase();
const sc = String(e?.subcategory||'').toLowerCase();
return n.startsWith('family:') || sc === 'family';
}
function withSkinTone(base, ch){
const emojiChar = base || '';
const modifierChar = ch || '';
if (!emojiChar || !modifierChar) return emojiChar;
// Guard: if we heuristically think tone shouldn't be applied, return base
if (!canApplyToneTo(emojiChar)) return emojiChar;
// Split into codepoints (keeps surrogate pairs intact)
const cps = Array.from(emojiChar);
// Try to find the last Emoji_Modifier_Base in the sequence
let lastBaseIdx = -1;
try {
for (let i = 0; i < cps.length; i++) {
if (/\p{Emoji_Modifier_Base}/u.test(cps[i])) lastBaseIdx = i;
}
} catch (_) {
// Property escapes not supported → handled by fallback below
}
// Fallback: if no base detected, treat first human as base (man, woman, person)
if (lastBaseIdx === -1) {
const cp0 = cps[0] ? cps[0].codePointAt(0) : 0;
const HUMAN_BASES = new Set([0x1F468, 0x1F469, 0x1F9D1]); // 👨, 👩, 🧑
if (HUMAN_BASES.has(cp0)) lastBaseIdx = 0;
}
if (lastBaseIdx === -1) return emojiChar; // avoid appending stray square
// Insert after the base (and after VS16 if present)
const afterBase = (idx) => {
if (cps[idx + 1] && cps[idx + 1].codePointAt(0) === 0xFE0F) return idx + 2; // VS16
return idx + 1;
};
const insertPos = afterBase(lastBaseIdx);
const out = cps.slice(0, insertPos).concat([modifierChar], cps.slice(insertPos));
return out.join('');
}
// Tone helpers: keep globals in sync and expose helpers for slug
async function getPreferredToneIndex(){
return new Promise(res => {
chrome.storage.sync.get(['preferredSkinTone'], v => {
preferredToneSlug = v?.preferredSkinTone || null;
const idx = TONE_SLUGS.indexOf(preferredToneSlug);
res(idx >= 0 ? idx : -1);
});
});
}
async function setPreferredToneIndex(i){
preferredToneSlug = TONE_SLUGS[i] || null;
return new Promise(res => chrome.storage.sync.set({ preferredSkinTone: preferredToneSlug }, () => res()));
}
async function getToneLock(){
return new Promise(res => chrome.storage.sync.get(['toneLock'], v => { toneLock = !!v.toneLock; res(toneLock); }));
}
async function setToneLock(val){
toneLock = !!val;
return new Promise(res => chrome.storage.sync.set({ toneLock }, () => res()));
}
// debounce
let timer;
function debounced(fn, delay=250){ clearTimeout(timer); timer = setTimeout(fn, delay); }
// === P0: Usage & Cache ===
const CLIENT_FREE_DAILY_LIMIT = 30; // can be tuned; Pro => unlimited
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
const QUERY_CACHE = new Map(); // in-memory cache: key => {ts, data}
const PREFETCHING = new Set(); // tracks in-flight prefetch keys (sig|page)
function todayKeyUTC(){
const d = new Date();
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth()+1).padStart(2,'0');
const day = String(d.getUTCDate()).padStart(2,'0');
return `usage_${y}${m}${day}`;
}
async function getDailyUsage(){
const key = todayKeyUTC();
const got = await chrome.storage.local.get([key]);
return got[key] || { used: 0, limit: CLIENT_FREE_DAILY_LIMIT };
}
async function setDailyUsage(obj){
const key = todayKeyUTC();
await chrome.storage.local.set({ [key]: obj });
}
function normalize(str){ return String(str||'').trim().toLowerCase(); }
function slugifyLabel(s){
const x = normalize(s).replace(/&/g,'and').replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'');
return x || 'all';
}
function signatureFor(qVal, catVal, subVal){
const qn = normalize(qVal);
const cats = slugifyLabel(catVal || 'all');
const subs = subVal ? slugifyLabel(subVal) : '';
return `${qn}|${cats}|${subs}`;
}
async function getPersistedCache(){
const got = await chrome.storage.local.get(['searchCache']);
return got.searchCache || {}; // { key: { ts, data } }
}
async function savePersistedCache(cacheObj){
await chrome.storage.local.set({ searchCache: cacheObj });
}
function isFresh(ts){ return (Date.now() - ts) < CACHE_TTL_MS; }
function buildHeaders(){
const base = { 'X-Dewemoji-Frontend': FRONTEND_ID };
if (licenseValid && licenseKeyCurrent) {
try {
const acctPromise = accountId();
return acctPromise.then(acct => ({
...base,
// New preferred auth
'Authorization': `Bearer ${licenseKeyCurrent}`,
// Legacy headers kept for backward compatibility
'X-License-Key': licenseKeyCurrent,
'X-Account-Id': acct.id
})).catch(()=>base);
} catch {
return Promise.resolve({ ...base, 'Authorization': `Bearer ${licenseKeyCurrent}`, 'X-License-Key': licenseKeyCurrent });
}
}
return Promise.resolve(base);
}
async function prefetchNextIfNeeded(currentSig){
try {
// only prefetch when there are more results and we know the next page index
const nextPage = page + 1;
if (!(items.length < total && total > 0)) return;
const key = `${currentSig}|${nextPage}`;
if (QUERY_CACHE.has(key) || PREFETCHING.has(key)) return;
// check persisted cache
const persisted = await getPersistedCache();
const ent = persisted[key];
if (ent && isFresh(ent.ts)) {
QUERY_CACHE.set(key, ent); // lift to memory
return;
}
PREFETCHING.add(key);
// Build URL for next page
const params = new URLSearchParams({ page: String(nextPage), limit: String(PAGE_LIMIT) });
if (q.value.trim()) params.set('q', q.value.trim());
if (cat.value.trim()) params.set('category', cat.value.trim());
if (sub.value.trim()) params.set('subcategory', sub.value.trim());
const url = `${API.base}${API.list}?${params.toString()}`;
// headers (with optional license/account)
const headers = await buildHeaders();
// fetch quietly; avoid throwing UI errors — prefetch is best-effort
const res = await fetch(url, { cache: 'no-store', headers });
if (!res.ok) { PREFETCHING.delete(key); return; }
const data = await res.json().catch(()=>null);
if (!data || !Array.isArray(data.items)) { PREFETCHING.delete(key); return; }
const record = { ts: Date.now(), data };
QUERY_CACHE.set(key, record);
const persisted2 = await getPersistedCache();
persisted2[key] = record;
// prune stale
for (const k of Object.keys(persisted2)) { if (!persisted2[k] || !isFresh(persisted2[k].ts)) delete persisted2[k]; }
await savePersistedCache(persisted2);
} catch { /* silent */ }
finally { PREFETCHING.delete(`${currentSig}|${page+1}`); }
}
async function fetchPage(reset=false) {
more.disabled = true;
refreshPageLimit();
if (reset) { page = 1; items = []; list.innerHTML = ""; }
if (reset) { PREFETCHING.clear(); }
if (reset) { autoLoadBusy = false; }
// Build signature and check limits only for page 1
const sig = signatureFor(q.value, cat.value, sub.value);
// Gate on soft cap for Free (Pro unlimited)
if (!licenseValid && page === 1) {
const usage = await getDailyUsage();
const limit = usage.limit ?? CLIENT_FREE_DAILY_LIMIT;
// Check persisted cache first; a cached result does not consume usage
const persisted = await getPersistedCache();
const entry = persisted[sig];
const hasFreshPersist = entry && isFresh(entry.ts);
const hasFreshMem = QUERY_CACHE.has(`${sig}|1`) && isFresh(QUERY_CACHE.get(`${sig}|1`).ts);
if (usage.used >= limit && !hasFreshPersist && !hasFreshMem) {
// At cap and no cache — block network to be fair to server & UX
showToast('Daily limit reached — Upgrade to Pro');
updateFooter();
return;
}
}
try {
showSpinner();
// Cache key per page
const key = `${sig}|${page}`;
// 1) Try memory cache
const mem = QUERY_CACHE.get(key);
if (mem && isFresh(mem.ts)) {
const data = mem.data;
total = data.total || 0;
for (const e of (data.items || [])) { items.push(e); renderCard(e); }
// Kick off background prefetch for the next page (cached only)
const currentSig = signatureFor(q.value, cat.value, sub.value);
prefetchNextIfNeeded(currentSig).catch(()=>{});
try { ensureSentinel(); setupAutoLoadObserver(); } catch(_) {}
updateFooter();
return;
}
// 2) Try persisted cache
const persisted = await getPersistedCache();
const ent = persisted[key];
if (ent && isFresh(ent.ts)) {
total = ent.data.total || 0;
for (const e of (ent.data.items || [])) { items.push(e); renderCard(e); }
QUERY_CACHE.set(key, { ts: ent.ts, data: ent.data }); // promote to mem
// Kick off background prefetch for the next page (cached only)
const currentSig = signatureFor(q.value, cat.value, sub.value);
prefetchNextIfNeeded(currentSig).catch(()=>{});
try { ensureSentinel(); setupAutoLoadObserver(); } catch(_) {}
updateFooter();
return;
}
// 3) Network fetch
const params = new URLSearchParams({ page: String(page), limit: String(PAGE_LIMIT) });
if (q.value.trim()) params.set('q', q.value.trim());
if (cat.value.trim()) params.set('category', cat.value.trim());
if (sub.value.trim()) params.set('subcategory', sub.value.trim());
const url = `${API.base}${API.list}?${params.toString()}`;
// (usage increment moved to after successful fetch)
const headers = await buildHeaders();
// Optional nicety: offline guard (early exit if no network AND no fresh cache)
if (!navigator.onLine) {
const hasFreshMem = mem && isFresh(mem.ts);
const hasFreshPersist = ent && isFresh(ent.ts);
if (!hasFreshMem && !hasFreshPersist) {
throw new Error('offline');
}
}
const res = await fetch(url, { cache: 'no-store', headers });
if (!res.ok) throw new Error(`API ${res.status}`);
lastServerTier = res.headers.get('X-Dewemoji-Tier'); // 'pro' for Pro; null for Free/whitelist
const data = await res.json();
total = data.total || 0;
if (page === 1 && (!Array.isArray(data.items) || data.items.length === 0)) {
list.innerHTML = '';
items = [];
list.appendChild(buildAlert('warn', 'No results', 'Try another keyword, category, or tone.'));
setLoadMore('Load more', { hidden: true, disabled: true });
updateFooter();
return;
}
// Count usage only on successful network responses for Free, page 1, and when not cached
if (!licenseValid && page === 1) {
const cacheHas = !!(mem && isFresh(mem.ts)) || !!(ent && isFresh(ent.ts));
if (!cacheHas) {
const usage = await getDailyUsage();
const limit = usage.limit ?? CLIENT_FREE_DAILY_LIMIT;
usage.used = Math.min(limit, (usage.used || 0) + 1);
await setDailyUsage(usage);
}
}
// Save to caches
const record = { ts: Date.now(), data };
QUERY_CACHE.set(key, record);
const persisted2 = await getPersistedCache();
persisted2[key] = record;
// prune old entries occasionally (simple heuristic)
const now = Date.now();
for (const k of Object.keys(persisted2)) {
if (!persisted2[k] || !isFresh(persisted2[k].ts)) delete persisted2[k];
}
await savePersistedCache(persisted2);
// Render
for (const e of (data.items || [])) { items.push(e); renderCard(e); }
// Kick off background prefetch for the next page (cached only)
const currentSig = signatureFor(q.value, cat.value, sub.value);
prefetchNextIfNeeded(currentSig).catch(()=>{});
try { ensureSentinel(); setupAutoLoadObserver(); } catch(_) {}
updateFooter();
} catch (err) {
console.error('Fetch failed', err);
if (reset || page === 1) {
list.innerHTML = '';
items = []; total = 0;
}
list?.appendChild(buildAlert('error', 'Failed to load', 'Check your internet connection and try again.'));
if (page > 1) {
expectRetry = true;
setLoadMore('Retry', { hidden: false, disabled: false });
} else {
setLoadMore('Load more', { hidden: true, disabled: true });
}
} finally {
hideSpinner();
}
}
let autoObserver = null;
let autoLoadBusy = false;
let autoSentinel = null; // thin element placed right after the grid list
function ensureSentinel(){
if (autoSentinel && autoSentinel.isConnected) return autoSentinel;
autoSentinel = document.getElementById('autoload-sentinel');
if (!autoSentinel) {
autoSentinel = document.createElement('div');
autoSentinel.id = 'autoload-sentinel';
autoSentinel.style.cssText = 'width:100%;height:1px;';
}
try {
// Place immediately AFTER the grid list so intersecting means we reached the end
if (list && autoSentinel.parentElement !== list.parentElement) {
list.insertAdjacentElement('afterend', autoSentinel);
}
} catch(_) {}
return autoSentinel;
}
function setupAutoLoadObserver(){
const sentinel = ensureSentinel();
if (!sentinel) return;
if (autoObserver) {
try { autoObserver.unobserve(sentinel); } catch(_) {}
} else {
autoObserver = new IntersectionObserver((entries)=>{
const hit = entries.some(e => e.isIntersecting);
if (!hit) return;
if (autoLoadBusy) return;
const loading = !!spinnerEl;
const canLoad = items.length < total && total > 0;
if (loading || !canLoad) return;
autoLoadBusy = true;
setLoadMore('Loading…', { disabled: true });
page += 1;
fetchPage(false).finally(()=>{ autoLoadBusy = false; });
}, { root: null, rootMargin: '300px', threshold: 0 });
}
try { autoObserver.observe(sentinel); } catch(_) {}
}
// --- Load More helpers ---
let expectRetry = false;
function setLoadMore(label, opts = {}) {
if (!more) return;
if (label != null) more.textContent = label;
if (opts.hidden === true) more.classList.add('hidden');
if (opts.hidden === false) more.classList.remove('hidden');
if (typeof opts.disabled === 'boolean') more.disabled = opts.disabled;
}
async function updateFooter() {
// results text stays
count.textContent = `${items.length} / ${total}`;
// usage text append (right-aligned feel handled by CSS; here we append visually)
try {
const { used, limit } = await getDailyUsage();
const isServerPro = lastServerTier === 'pro';
const suffix = (licenseValid || isServerPro) ? '' : (limit != null ? ` · ${used} / ${limit} today` : '');
count.textContent = `${items.length} / ${total}${suffix}`;
} catch {}
const loading = !!spinnerEl;
const canLoadMore = items.length < total && total > 0;
if (canLoadMore) {
expectRetry = false;
setLoadMore('Load more', { hidden: loading, disabled: loading });
} else {
setLoadMore('Load more', { hidden: true, disabled: true });
}
// ensure auto-load observer is active when more is visible
try { setupAutoLoadObserver(); } catch(_) {}
}
async function ensureContentScript(tabId) {
// try ping
try {
const pong = await chrome.tabs.sendMessage(tabId, { type: 'dewemoji_ping' });
if (pong?.ok) return true;
} catch {}
// inject then ping again
try {
await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] });
const pong2 = await chrome.tabs.sendMessage(tabId, { type: 'dewemoji_ping' });
return !!pong2?.ok;
} catch {
return false;
}
}
async function insert(text, opts = {}) {
const strict = !!opts.strict; // if true, do NOT fallback to copy
if (!text) return false;
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) { if (!strict) { await navigator.clipboard.writeText(text); } return false; }
// A) Try inline injection (fast path)
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (txt) => {
const el = document.activeElement;
const isEditable = el && (
el.tagName === 'TEXTAREA' ||
(el.tagName === 'INPUT' && /^(text|search|email|url|tel|number|password)?$/i.test(el.type)) ||
el.isContentEditable
);
if (!isEditable) return false;
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
const start = el.selectionStart ?? el.value.length;
const end = el.selectionEnd ?? start;
const val = el.value ?? '';
el.value = val.slice(0, start) + txt + val.slice(end);
const pos = start + txt.length;
try { el.setSelectionRange(pos, pos); } catch {}
el.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}
try { document.execCommand('insertText', false, txt); return true; } catch { return false; }
},
args: [text]
});
if (result) return true; // inserted via inline
// B) Ensure content.js present, then ask it (robust path)
if (await ensureContentScript(tab.id)) {
const res = await chrome.tabs.sendMessage(tab.id, { type: 'dewemoji_insert', text });
if (res?.ok) return true; // inserted via content script
}
// C) Fallback
if (!strict) { await navigator.clipboard.writeText(text); }
return false;
} catch {
if (!strict) { await navigator.clipboard.writeText(text); }
return false;
}
}
async function migrateDailyLimit(){
try{
const usage = await getDailyUsage();
const target = CLIENT_FREE_DAILY_LIMIT; // 30
if (!licenseValid) {
if (typeof usage.limit !== 'number' || usage.limit > target) {
usage.limit = target;
await setDailyUsage(usage);
}
}
}catch{}
}
function updateClearButtonIcon() {
if (!clearBtn) return;
const hasText = q.value.trim().length > 0;
if (hasText) {
clearBtn.innerHTML = `<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);
}
// === Tone row UX (row-spanning picker) =========================
const GRID_COLS = 4; // grid is 4 columns in the panel
let __toneRow = null; // the expanded row element (if any)
let __activeCard = null; // the card that owns the expanded row
let __prevOverflowY = '';
function closeToneRow(){
if (__activeCard) {
__activeCard.classList.remove('active');
__activeCard.style.background = '';
__activeCard.style.boxShadow = '';
if (typeof __activeCard.__setNameStatic === 'function') {
__activeCard.__setNameStatic();
}
__activeCard = null;
}
if (__toneRow && __toneRow.parentNode) __toneRow.parentNode.removeChild(__toneRow);
__toneRow = null;
}
function buildToneRow(baseGlyph, activeIdx, onPick){
const row = document.createElement('div');
row.className = 'tone-row';
SKIN_TONES.forEach((t,i)=>{
const btn = document.createElement('button');
btn.className = 'tone-option';
const variant = canApplyToneTo(baseGlyph) ? withSkinTone(baseGlyph, t.ch) : baseGlyph;
const span = document.createElement('span');
ensureRenderableAndGlyph(variant, span);
btn.appendChild(span);
if (i === activeIdx) btn.classList.add('selected');
btn.addEventListener('click', ()=> onPick(i, t, variant));
row.appendChild(btn);
});
return row;
}
function openToneRowFor(card, item, baseGlyph){
// Toggle behavior
if (__activeCard === card) { closeToneRow(); return; }
// Close any previous
closeToneRow();
__activeCard = card;
// Visual highlight for the active owner card
card.classList.add('active');
card.style.background = 'var(--c-chip,#eef2ff)';
card.style.boxShadow = '0 0 0 2px rgba(59,130,246,.65) inset';
if (isToneableByPolicy(item) && typeof card.__setNameMarquee === 'function') {
card.__setNameMarquee();
}
// Decide where to insert: after the end of this 4-col row
const cardsOnly = Array.from(list.querySelectorAll('.card'));
const idx = cardsOnly.indexOf(card);
const rowStart = Math.floor(idx / GRID_COLS) * GRID_COLS;
const rowEnd = Math.min(rowStart + GRID_COLS - 1, cardsOnly.length - 1);
const anchor = cardsOnly[rowEnd] || card;
const prefIdxPromise = getPreferredToneIndex();
(async ()=>{
const currentIdx = await prefIdxPromise;
const base = stripSkinTone(item.emoji_base || baseGlyph);
__toneRow = buildToneRow(base, currentIdx, async (newIdx, tone, variant)=>{
if (toneLock && currentIdx < 0) {
await setPreferredToneIndex(newIdx);
showToast('Preferred tone set');
}
await performEmojiAction(variant);
closeToneRow();
});
anchor.insertAdjacentElement('afterend', __toneRow);
try { __toneRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch(_){}
})();
}
// (obsolete) placeholder; real renderCard defined later with Windows support + Pro tone UI
function renderCard(e) {}
// --- Popover helpers for skin tone picker ---
function showPopoverNear(anchorEl, popEl){
const r = anchorEl.getBoundingClientRect();
Object.assign(popEl.style, {
position: 'fixed',
left: `${Math.min(window.innerWidth - 8, Math.max(8, r.left))}px`,
top: `${Math.min(window.innerHeight - 8, r.bottom + 6)}px`,
zIndex: 9999,
});
document.body.appendChild(popEl);
}
function removePopover(){
const p = document.getElementById('dewemoji-tone-popover');
if (p && p.parentNode) p.parentNode.removeChild(p);
document.removeEventListener('click', handleOutsideClose, true);
}
function handleOutsideClose(e){
const p = document.getElementById('dewemoji-tone-popover');
if (p && !p.contains(e.target)) removePopover();
}
function buildTonePopover(base, activeIdx, onPick){
const el = document.createElement('div');
el.id = 'dewemoji-tone-popover';
el.setAttribute('role','dialog');
el.style.cssText = 'background: var(--c-bg, #fff); border: 1px solid var(--c-border, rgba(0,0,0,.12)); padding:6px; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.15); display:flex; gap:6px;';
SKIN_TONES.forEach((t,i) => {
const btn = document.createElement('button');
btn.className = 'tone-btn';
btn.title = t.label;
btn.style.cssText = 'min-width:32px;height:32px;border-radius:6px;border:1px solid transparent;display:flex;align-items:center;justify-content:center;background:var(--c-chip,#f3f4f6)';
// Build the toned variant only when applicable; else preview base to avoid squares
const variant = canApplyToneTo(base) ? withSkinTone(base, t.ch) : base;
const span = document.createElement('span');
span.style.display = 'inline-flex';
span.style.alignItems = 'center';
span.style.justifyContent = 'center';
ensureRenderableAndGlyph(variant, span);
btn.appendChild(span);
if (i === activeIdx) btn.classList.add('selected');
btn.addEventListener('click', () => onPick(i, t));
el.appendChild(btn);
});
setTimeout(() => document.addEventListener('click', handleOutsideClose, true), 0);
return el;
}
// initial
loadCategories().catch(console.error);
(async () => {
try {
await loadSettings();
await migrateDailyLimit();
} catch(_) {}
await fetchPage(true).catch(console.error);
try { setupAutoLoadObserver(); } catch(_) {}
})();
async function loadSettings() {
const data = await chrome.storage.local.get(['licenseValid', 'licenseKey', 'actionMode']);
licenseValid = !!data.licenseValid;
refreshPageLimit();
actionMode = data.actionMode || 'copy';
if (data.licenseKey) licenseKeyEl && (licenseKeyEl.value = data.licenseKey);
licenseKeyCurrent = data.licenseKey || '';
applyLicenseUI();
applyModeUI();
setStatusTag();
// tone settings from sync storage
await getPreferredToneIndex();
await getToneLock();
renderToneSettingsSection();
// ensure license field starts in non-edit mode
setLicenseEditMode(false);
}
async function saveSettings() {
await chrome.storage.local.set({ licenseValid, actionMode, licenseKey: licenseKeyEl?.value || '' });
}
// --- Helper to render Free status with CTA ---
function freeStatusHTML(){
return 'Free mode — Pro features locked.' +
'<br><a href="https://dewemoji.com/pricing" target="_blank" class="cta-link"' +
' style="margin-left:6px; text-decoration: underline; font-weight:500;">' +
'🔓 Upgrade to Pro' +
'</a>';
}
function setLicenseEditMode(on) {
if (!licenseKeyEl) return;
const isOn = !!on;
// Input enabled only when editing OR not licensed
licenseKeyEl.disabled = licenseValid ? !isOn : false;
if (licenseValid) {
// Licensed: default view hides Activate; editing shows Save/Cancel
if (licenseActivateBtn) {
licenseActivateBtn.style.display = isOn ? '' : 'none';
licenseActivateBtn.textContent = isOn ? 'Save' : 'Activate';
}
if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = licenseActivateBtn.textContent == 'Save' ? 'none' : '';
if (licenseEditBtn) licenseEditBtn.style.display = isOn ? 'none' : '';
if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = isOn ? '' : 'none';
} else {
// Free: show Activate only
if (licenseActivateBtn) { licenseActivateBtn.style.display = ''; licenseActivateBtn.textContent = 'Activate'; }
if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = 'none';
if (licenseEditBtn) licenseEditBtn.style.display = 'none';
if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = 'none';
}
}
function applyLicenseUI() {
// enable/disable Pro radios
const radios = modeGroup.querySelectorAll('input[type="radio"][name="actionMode"]');
radios.forEach(r => {
const isPro = (r.value === 'insert' || r.value === 'auto');
if (isPro) {
r.disabled = !licenseValid;
r.closest('.radio').setAttribute('aria-disabled', !licenseValid ? 'true' : 'false');
}
});
if (!licenseValid) {
// Force visible selection to 'copy' for clarity
const copy = modeGroup.querySelector('input[type="radio"][value="copy"]');
if (copy) copy.checked = true;
licenseStatusEl && (licenseStatusEl.innerHTML = freeStatusHTML());
} else {
// Restore selected mode if Pro; if current actionMode is pro, check it
const target = modeGroup.querySelector(`input[type="radio"][value="${actionMode}"]`) ||
modeGroup.querySelector('input[type="radio"][value="auto"]');
if (target) target.checked = true;
licenseStatusEl && (licenseStatusEl.textContent = 'Pro active');
}
// Reset to non-edit view per license state
if (licenseValid) {
if (licenseKeyEl) licenseKeyEl.disabled = true;
if (licenseActivateBtn) { licenseActivateBtn.style.display = 'none'; licenseActivateBtn.textContent = 'Activate'; }
if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = '';
if (licenseEditBtn) licenseEditBtn.style.display = '';
if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = 'none';
} else {
if (licenseKeyEl) licenseKeyEl.disabled = false;
if (licenseActivateBtn) { licenseActivateBtn.style.display = ''; licenseActivateBtn.textContent = 'Activate'; }
if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = 'none';
if (licenseEditBtn) licenseEditBtn.style.display = 'none';
if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = 'none';
}
setStatusTag();
// --- PATCH: Refresh tone section after license state changes ---
try { renderToneSettingsSection(); } catch(_) {}
}
function applyModeUI() {
// Ensure the selected radio matches actionMode (if not locked)
const input = modeGroup.querySelector(`input[type="radio"][value="${actionMode}"]`);
if (input && !input.disabled) input.checked = true;
}
// --- License account id helper (best practice: hash + label) ---
async function accountId() {
// Return a stable, privacy-preserving identifier and a masked label for UI
// { id: <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;
// Disable lock checkbox and visually dim if license is invalid
lockEl.disabled = !licenseValid;
if (!licenseValid) {
lockEl.parentElement.style.opacity = '0.5';
} else {
lockEl.parentElement.style.opacity = '';
}
lockEl.onchange = async (e)=>{ await setToneLock(!!e.target.checked); showToast(toneLock ? 'Tone lock enabled' : 'Tone lock disabled'); };
const idx = TONE_SLUGS.indexOf(preferredToneSlug);
const basePreview = '✋';
SKIN_TONES.forEach((t,i)=>{
const b = document.createElement('button');
b.className = 'tone-chip';
b.title = t.label;
b.textContent = basePreview + t.ch; // small preview
b.style.cssText = 'height:32px;border-radius:8px;padding:0 8px;border:1px solid var(--c-border, rgba(0,0,0,.12));background:var(--c-chip,#f3f4f6)';
if (i === idx) b.classList.add('selected');
// Disable tone chips and visually dim if license is invalid
if (!licenseValid) {
b.disabled = true;
b.style.opacity = '0.5';
b.style.pointerEvents = 'none';
}
b.addEventListener('click', async ()=>{
await setPreferredToneIndex(i);
showToast('Preferred tone set');
renderToneSettingsSection();
});
pal.appendChild(b);
});
const note = sec.querySelector('#tone-note');
if (!preferredToneSlug) {
note.textContent = 'Tip: choosing a tone will lock it for next picks if the toggle is on.';
} else {
note.textContent = toneLock ? `Using ${preferredToneSlug.replace('-', ' ')} tone by default.` : 'Lock is off — we will ask via the palette.';
}
}
function openSheet() {
sheet.removeAttribute('hidden'); backdrop.removeAttribute('hidden');
requestAnimationFrame(() => { sheet.classList.add('show'); backdrop.classList.add('show'); });
// lock page scroll while sheet is open
try {
__prevOverflowY = document.documentElement.style.overflowY || '';
document.documentElement.style.overflowY = 'hidden';
// also lock body in case the site CSS scrolls body instead of root
document.body && (document.body.style.overflowY = 'hidden');
} catch(_) {}
}
function closeSheet() {
sheet.classList.remove('show'); backdrop.classList.remove('show');
setTimeout(() => {
sheet.setAttribute('hidden',''); backdrop.setAttribute('hidden','');
// restore page scroll after the closing animation completes
try {
document.documentElement.style.overflowY = __prevOverflowY || '';
document.body && (document.body.style.overflowY = '');
} catch(_) {}
}, 180);
}
settingsBtn?.addEventListener('click', openSheet);
sheetClose?.addEventListener('click', closeSheet);
backdrop?.addEventListener('click', closeSheet);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSheet(); });
// Tabs logic for settings sheet
const tabsWrap = document.querySelector('#settings-sheet .tabs');
if (tabsWrap) {
tabsWrap.addEventListener('click', (e) => {
const btn = e.target.closest('.tab');
if (!btn) return;
const target = btn.dataset.tab;
document.querySelectorAll('#settings-sheet .tab').forEach(b => b.classList.toggle('active', b === btn));
document.querySelectorAll('#settings-sheet .tabpane').forEach(p => p.classList.toggle('active', p.id === `tab-${target}`));
});
}
modeGroup?.addEventListener('change', async (e) => {
if (e.target && e.target.name === 'actionMode') {
const val = e.target.value;
// If not licensed and user clicked a Pro option, bounce back to copy
if (!licenseValid && (val === 'insert' || val === 'auto')) {
showToast('Pro required for Insert/Automatic');
const copy = modeGroup.querySelector('input[value="copy"]');
if (copy) copy.checked = true;
actionMode = 'copy';
} else {
actionMode = val;
}
await saveSettings();
}
});
licenseActivateBtn?.addEventListener('click', async () => {
const key = (licenseKeyEl?.value || '').trim();
if (!key) { showToast('Enter a license key'); return; }
try {
setLicenseBusy(true, 'Verifying…');
// Call your API /license/verify (works for Gumroad and Mayar)
const acct = await accountId();
const res = await fetch('https://api.dewemoji.com/v1/license/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, account_id: acct.id, version: chrome.runtime.getManifest().version })
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) throw new Error(data?.error || `Verify ${res.status}`);
licenseValid = true;
refreshPageLimit();
licenseKeyCurrent = key;
await chrome.storage.local.set({ licenseValid: true, licenseKey: key, lastLicenseCheck: Date.now() });
applyLicenseUI();
setLicenseEditMode(false);
setStatusTag();
// PATCH: Immediately re-render tone section to enable controls after activation
renderToneSettingsSection();
showToast('License activated ✓');
// Refresh results so Pro headers/tier take effect immediately
fetchPage(true).catch(console.error);
setLicenseBusy(false);
} catch (e) {
setLicenseBusy(false);
licenseValid = false;
await chrome.storage.local.set({ licenseValid: false });
applyLicenseUI();
setStatusTag();
showToast(`Activation failed${e?.message ? ': ' + e.message : ''}`);
}
});
licenseEditBtn?.addEventListener('click', () => {
setLicenseEditMode(true);
});
licenseCancelEditBtn?.addEventListener('click', async () => {
const data = await chrome.storage.local.get(['licenseKey']);
if (licenseKeyEl) licenseKeyEl.value = data.licenseKey || '';
setLicenseEditMode(false);
});
licenseDeactivateBtn?.addEventListener('click', async () => {
if (!licenseValid) return;
const ok = await showConfirmModal({
title: 'Deactivate Dewemoji Pro?',
message: 'Deactivate Pro on this device?',
okText: 'Deactivate',
cancelText: 'Cancel'
});
if (!ok) return;
setLicenseBusy(true, 'Deactivating…');
try {
// (Optional) TODO: call your API /license/deactivate here, e.g.:
// const acct = await accountId();
// await fetch('https://YOUR-API/license/deactivate', {
// method: 'POST', headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ key: (licenseKeyEl?.value || '').trim(), account_id: acct.id })
// });
licenseValid = false;
refreshPageLimit();
await chrome.storage.local.set({ licenseValid: false, licenseKey: '' });
if (licenseKeyEl) licenseKeyEl.value = '';
applyLicenseUI();
// PATCH: Immediately re-render tone section to disable controls after deactivation
renderToneSettingsSection();
licenseKeyCurrent = '';
setLicenseEditMode(false);
showToast('License deactivated');
fetchPage(true).catch(console.error);
setLicenseBusy(false);
} catch (e) {
setLicenseBusy(false);
showToast('Could not deactivate');
}
});
function renderDiag(obj) {
const lines = [];
lines.push(`Content script loaded: ${obj ? 'yes' : 'no'}`);
if (!obj) return lines.join('\n');
lines.push(`Active editable type: ${obj.activeType ?? 'none'}`);
lines.push(`Has caret/selection: ${obj.hasRange ? 'yes' : 'no'}`);
lines.push(`Last insert result: ${obj.lastInsertOK === null ? 'n/a' : obj.lastInsertOK ? 'success' : 'failed'}`);
if (obj.lastInsertMessage) lines.push(`Note: ${obj.lastInsertMessage}`);
return lines.join('\n');
}
diagRunBtn?.addEventListener('click', async () => {
diagOut.textContent = '';
diagSpin.style.display = 'inline';
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) { diagOut.textContent = 'No active tab.'; return; }
const ready = await ensureContentScript(tab.id);
if (!ready) { diagOut.textContent = renderDiag(null); return; }
const info = await chrome.tabs.sendMessage(tab.id, { type: 'dewemoji_diag' });
diagOut.textContent = renderDiag(info || null);
} catch (e) {
diagOut.textContent = `Error: ${e?.message || e}`;
} finally {
diagSpin.style.display = 'none';
}
});
async function performEmojiAction(glyph) {
// Free users always copy
const mode = licenseValid ? actionMode : 'copy';
if (mode === 'copy') {
await navigator.clipboard.writeText(glyph);
showToast('Copied ✅');
return;
}
if (mode === 'insert') {
const ok = await insert(glyph, { strict: true }); // no copy fallback
showToast(ok ? 'Inserted ✅' : '❗ No editable field');
return;
}
// mode === 'auto' (insert else copy)
{
const ok = await insert(glyph, { strict: false });
showToast(ok ? 'Inserted ✅' : 'Copied ✅');
return;
}
}
async function setStatusTag(){
const dewemojiStatusTag = document.getElementById('dewemoji-status');
if(licenseValid){
dewemojiStatusTag.innerText = 'Pro';
dewemojiStatusTag.classList.add('pro');
dewemojiStatusTag.classList.remove('free');
}else{
dewemojiStatusTag.innerText = 'Free';
dewemojiStatusTag.classList.add('free');
dewemojiStatusTag.classList.remove('pro');
}
}
setStatusTag().catch(() => {});
const IS_WINDOWS = navigator.userAgent.includes('Windows');
// Build Twemoji SVG url from codepoints
function twemojiSrcFromCodepoints(cp) {
return `https://twemoji.maxcdn.com/v/latest/svg/${cp}.svg`;
}
// OPTIONAL: as a secondary image fallback if Twemoji doesn't have it
function notoSrcFromCodepoints(cp) {
// Twemoji uses "1f469-200d-1f4bb.svg"
// Noto repo names use underscores: "emoji_u1f469_200d_1f4bb.svg"
const underscored = cp.split('-').join('_');
return `https://cdn.jsdelivr.net/gh/googlefonts/noto-emoji@main/svg/emoji_u${underscored}.svg`;
}
/**
* Ensure an emoji is renderable inside `container` on Windows,
* and return a canonical glyph string for copy/insert.
* - Tries twemoji.parse first
* - If nothing replaced, forces an <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 || '';
// Policy: skip forbidden entries entirely
if (isForbiddenEntry(e)) return;
const FAMILY = isFamilyEntry(e);
const emo = document.createElement('div');
emo.className = 'emo';
let baseGlyph = e.emoji || e.symbol || e.text || '';
// Strictly sanitize: families & any nontoneable policy items must not carry tone modifiers
if (FAMILY || !isToneableByPolicy(e) || isMultiHuman(baseGlyph)) {
baseGlyph = stripSkinTone(baseGlyph);
}
const prefIdxPromise = getPreferredToneIndex();
(async () => {
let renderGlyph = baseGlyph;
if (licenseValid && toneLock && isToneableByPolicy(e) && !FAMILY) {
const idx = await prefIdxPromise;
if (idx >= 0) {
const base = stripSkinTone(e.emoji_base || baseGlyph);
if (isToneableByPolicy(e) && canApplyToneTo(base)) {
renderGlyph = withSkinTone(base, SKIN_TONES[idx].ch);
}
}
}
const res = ensureRenderableAndGlyph(renderGlyph, emo);
baseGlyph = res.glyph;
})();
// --- Name (truncated by default; marquee on hover/active) ---
function buildNameStatic(text){
const el = document.createElement('div');
el.className = 'nm';
el.textContent = text;
return el;
}
function buildNameMarquee(text){
const mq = document.createElement('marquee');
mq.className = 'nm';
mq.setAttribute('behavior','scroll');
mq.setAttribute('direction','left');
mq.setAttribute('scrollamount','3');
mq.textContent = text;
return mq;
}
// --- Name: truncated (tooltip on hover); marquee only when .card.active ---
function buildNameStatic(text){
const el = document.createElement('div');
el.className = 'nm';
el.textContent = text;
// tooltip for hover
el.title = text;
return el;
}
function buildNameMarquee(text){
const mq = document.createElement('marquee');
mq.className = 'nm';
mq.setAttribute('behavior','scroll');
mq.setAttribute('direction','left');
mq.setAttribute('scrollamount','3');
mq.setAttribute('truespeed','');
// keep tooltip text
mq.title = text;
mq.textContent = text;
return mq;
}
let nameEl = buildNameStatic(e.name || '');
// Toggle helpers invoked by tone-row open/close
card.__setNameStatic = () => {
if (!nameEl || nameEl.tagName !== 'MARQUEE') return;
const s = buildNameStatic(e.name || '');
nameEl.replaceWith(s);
nameEl = s;
};
card.__setNameMarquee = () => {
// If already marquee, do nothing
if (nameEl && nameEl.tagName === 'MARQUEE') return;
// Ensure we only marquee when the static text overflows its container
const needsMarquee = (() => {
try {
// Force a layout read; the element must be in the DOM
const el = nameEl;
if (!el) return false;
// Small epsilon to avoid float rounding issues
return (el.scrollWidth - el.clientWidth) > 1;
} catch(_) { return false; }
})();
if (!needsMarquee) return; // keep static if it fits
const m = buildNameMarquee(e.name || '');
nameEl.replaceWith(m);
nameEl = m;
// best-effort nudge so it begins smoothly
requestAnimationFrame(() => { try { m.stop && m.stop(); m.start && m.start(); } catch(_){} });
};
// Optional UI cue: dots for toneable entries
if (isToneableByPolicy(e)) {
const dots = document.createElement('div');
dots.className = 'tone-ind';
dots.style.cssText = 'position:absolute;top:6px;right:6px;display:flex;gap:3px;';
const sw = (c)=>{ const d=document.createElement('span'); d.style.cssText=`width:6px;height:6px;border-radius:50%;background:${c};opacity:.9;`; return d; };
dots.append(sw('#f7d7c4')); dots.append(sw('#c68642')); dots.append(sw('#8d5524'));
card.appendChild(dots);
}
card.addEventListener('click', async () => {
// For nonPro, or nontoneable, or families → perform action immediately
if (!licenseValid || !isToneableByPolicy(e) || isFamilyEntry(e)) {
performEmojiAction(baseGlyph).catch(console.error);
return;
}
// Open the rowspanning tone chooser right below this card's row
openToneRowFor(card, e, baseGlyph);
});
card.append(emo, nameEl);
list.appendChild(card);
}
// pick a tofu cell
const el = document.querySelector('.card .emo');
const raw = el?.textContent || '';
console.log('RAW points:', [...raw].map(c=>c.codePointAt(0).toString(16)));
if (window.twemoji) {
const cp = window.twemoji.convert.toCodePoint(raw);
console.log('Twemoji codepoints:', cp);
console.log('Canonical from codepoints:', window.twemoji.convert.fromCodePoint(cp));
}
(async function verifyLicenseOnBoot(){
try {
const { licenseKey, lastLicenseCheck, licenseValid: storedValid } = await chrome.storage.local.get(['licenseKey','lastLicenseCheck','licenseValid']);
if (!licenseKey) return;
const day = 24*60*60*1000;
// If licenseValid is true and last check is within a day, skip checking and set UI immediately
if (storedValid && (Date.now() - (lastLicenseCheck||0) < day)) {
licenseValid = true;
refreshPageLimit();
applyLicenseUI();
setStatusTag();
licenseStatusEl && (licenseStatusEl.textContent = 'Pro active');
return;
}
// Otherwise, show "Checking license…" while verifying
licenseStatusEl && (licenseStatusEl.textContent = 'Checking license…');
const acct = await accountId();
const res = await fetch('https://api.dewemoji.com/v1/license/verify', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: licenseKey, account_id: acct.id, version: chrome.runtime.getManifest().version })
});
const data = await res.json().catch(() => ({}));
const ok = !!data.ok;
await chrome.storage.local.set({ licenseValid: ok, lastLicenseCheck: Date.now() });
licenseValid = ok;
refreshPageLimit();
applyLicenseUI();
setStatusTag();
if (licenseStatusEl) {
if (licenseValid) {
licenseStatusEl.textContent = 'Pro active';
} else {
licenseStatusEl.innerHTML = freeStatusHTML();
}
}
} catch {}
})();
// Re-render tone section when opening settings (in case lock/pref changed during session)
const __openSheet = openSheet;
openSheet = function(){
__openSheet();
const genBtn = document.querySelector('#settings-sheet .tab[data-tab="general"]');
const proBtn = document.querySelector('#settings-sheet .tab[data-tab="pro"]');
genBtn?.classList.add('active'); proBtn?.classList.remove('active');
document.getElementById('tab-general')?.classList.add('active');
document.getElementById('tab-pro')?.classList.remove('active');
renderToneSettingsSection();
};