Files
emoji-chrome-extension/panel.js

1852 lines
65 KiB
JavaScript
Executable File
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.
// Feature access is now free in extension UI. Billing applies to private keywords on backend.
let licenseValid = true;
// Persistent settings
let actionMode = 'copy'; // 'copy' | 'insert' | 'auto'
let licenseKeyCurrent = '';
let authApiKey = '';
let authTier = 'guest'; // guest | free | personal
let authEmail = '';
let authName = '';
// 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');
const accountEmailEl = document.getElementById('account-email');
const accountPasswordEl = document.getElementById('account-password');
const accountLoginBtn = document.getElementById('account-login');
const accountLogoutBtn = document.getElementById('account-logout');
const accountStatusEl = document.getElementById('account-status');
const accountGreetingEl = document.getElementById('account-greeting');
const accountConnectFormEl = document.getElementById('account-connect-form');
const accountConnectedEl = document.getElementById('account-connected');
// --- 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 API = {
base: "https://dewemoji.com/v1",
list: "/emojis"
};
API.cats = "/categories";
let PAGE_LIMIT = 50;
function refreshPageLimit(){
PAGE_LIMIT = 50;
}
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
}
// === Extension verification token (GCM registration) ===
const EXT_TOKEN_KEY = 'dewemojiExtToken';
const EXT_TOKEN_COOLDOWN_MS = 60000;
let extTokenPromise = null;
let extTokenLastAttempt = 0;
async function getExtensionToken(force=false) {
if (extTokenPromise) return extTokenPromise;
extTokenPromise = (async () => {
try {
const got = await chrome.storage.local.get([EXT_TOKEN_KEY]);
if (got[EXT_TOKEN_KEY] && !force) return got[EXT_TOKEN_KEY];
if (!force && extTokenLastAttempt && Date.now() - extTokenLastAttempt < EXT_TOKEN_COOLDOWN_MS) {
return null;
}
extTokenLastAttempt = Date.now();
const res = await chrome.runtime.sendMessage({ type: 'dewemoji_get_ext_token', force });
if (res?.token) {
await chrome.storage.local.set({ [EXT_TOKEN_KEY]: res.token });
return res.token;
}
return null;
} catch (err) {
console.error('Token retrieval failed:', err);
return null;
}
})().finally(() => {
extTokenPromise = null;
});
return extTokenPromise;
}
async function getExtensionTokenCached() {
try {
const got = await chrome.storage.local.get([EXT_TOKEN_KEY]);
return got[EXT_TOKEN_KEY] || null;
} catch {
return null;
}
}
async function loadCategories() {
// Try live endpoint; fall back silently to local list (no subs)
let ok = false;
try {
const headers = await buildHeaders();
const res = await fetch(`${API.base}${API.cats}`, { headers });
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 ENABLE_RESULT_CACHE = false; // always fetch latest from API
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(){
if (!ENABLE_RESULT_CACHE) return {};
const got = await chrome.storage.local.get(['searchCache']);
return got.searchCache || {}; // { key: { ts, data } }
}
async function savePersistedCache(cacheObj){
if (!ENABLE_RESULT_CACHE) return;
await chrome.storage.local.set({ searchCache: cacheObj });
}
function isFresh(ts){ return ENABLE_RESULT_CACHE && (Date.now() - ts) < CACHE_TTL_MS; }
async function buildHeaders(){
const base = { 'X-Dewemoji-Frontend': FRONTEND_ID };
try {
if (chrome?.runtime?.id) base['X-Extension-Id'] = chrome.runtime.id;
} catch {}
if (authApiKey) {
base['Authorization'] = `Bearer ${authApiKey}`;
}
return base;
}
function getListPaths(){
// Try newest route first, then legacy-compatible fallbacks.
return ['/extension/search', '/emojis', '/search'];
}
function getApiBases(){
const list = [
API.base,
'https://dewemoji.com/v1',
'http://127.0.0.1:8000/v1',
];
const seen = new Set();
const out = [];
for (const raw of list) {
const base = String(raw || '').trim().replace(/\/+$/, '');
if (!base || seen.has(base)) continue;
seen.add(base);
out.push(base);
}
return out;
}
async function fetchJsonWithFallback(params, headers, { quiet = false } = {}) {
let lastRes = null;
const tried = [];
const paths = getListPaths();
const bases = getApiBases();
for (const base of bases) {
for (const path of paths) {
const url = `${base}${path}?${params.toString()}`;
try {
const res = await fetch(url, { cache: 'no-store', headers });
tried.push(`${base}${path}:${res.status}`);
if (res.ok) {
const data = await res.json().catch(() => null);
// Lock to first healthy base to keep future requests fast.
API.base = base;
return { res, data, path, base };
}
lastRes = res;
} catch (err) {
const msg = err && err.message ? err.message : 'network_error';
tried.push(`${base}${path}:${msg}`);
}
}
}
if (quiet) return { res: lastRes, data: null, path: null, base: null };
throw new Error(`API ${lastRes ? lastRes.status : 0} (${tried.join(' | ')})`);
}
async function prefetchNextIfNeeded(currentSig){
if (!ENABLE_RESULT_CACHE) return;
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 headers = await buildHeaders();
// fetch quietly; avoid throwing UI errors — prefetch is best-effort
const { data } = await fetchJsonWithFallback(params, headers, { quiet: true });
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; }
if (reset && !ENABLE_RESULT_CACHE) {
QUERY_CACHE.clear();
try { await chrome.storage.local.remove(['searchCache']); } catch (_) {}
}
// Build signature and check limits only for page 1
const sig = signatureFor(q.value, cat.value, sub.value);
// Client-side cap disabled; backend is source of truth for rate/sanity limits.
try {
showSpinner();
// Cache key per page
const key = `${sig}|${page}`;
// 1) Try memory cache
const mem = ENABLE_RESULT_CACHE ? QUERY_CACHE.get(key) : null;
if (ENABLE_RESULT_CACHE && mem && isFresh(mem.ts)) {
const data = mem.data;
total = data.total || 0;
for (const e of (data.items || [])) { items.push(e); renderCard(e); }
const currentSig = signatureFor(q.value, cat.value, sub.value);
prefetchNextIfNeeded(currentSig).catch(()=>{});
try { ensureSentinel(); setupAutoLoadObserver(); } catch(_) {}
updateFooter();
return;
}
// 2) Try persisted cache
let ent = null;
if (ENABLE_RESULT_CACHE) {
const persisted = await getPersistedCache();
ent = persisted[key] || null;
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
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 headers = await buildHeaders();
// (usage increment moved to after successful fetch)
// 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, data } = await fetchJsonWithFallback(params, headers);
if (!res || !data) throw new Error('API 0');
lastServerTier = res.headers.get('X-Dewemoji-Tier'); // 'pro' for Pro; null for Free/whitelist
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;
}
// Client-side usage counter disabled.
// Save to caches
if (ENABLE_RESULT_CACHE) {
const record = { ts: Date.now(), data };
QUERY_CACHE.set(key, record);
const persisted2 = await getPersistedCache();
persisted2[key] = record;
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)
if (ENABLE_RESULT_CACHE) {
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(['actionMode', 'authApiKey', 'authTier', 'authEmail', 'authName']);
licenseValid = true;
refreshPageLimit();
actionMode = data.actionMode || 'copy';
authApiKey = data.authApiKey || '';
authTier = data.authTier || 'guest';
authEmail = data.authEmail || '';
authName = data.authName || '';
if (accountEmailEl && authEmail) accountEmailEl.value = authEmail;
applyLicenseUI();
applyModeUI();
updateAccountUI();
setStatusTag();
// tone settings from sync storage
await getPreferredToneIndex();
await getToneLock();
renderToneSettingsSection();
setLicenseEditMode(false);
}
async function saveSettings() {
await chrome.storage.local.set({ licenseValid: true, actionMode, authApiKey, authTier, authEmail, authName });
}
// --- Helper to render Free status with CTA ---
function freeStatusHTML(){
return 'All extension features are available. Connect account to use private keywords.';
}
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() {
// Extension features are available for everyone in current model.
const radios = modeGroup.querySelectorAll('input[type="radio"][name="actionMode"]');
radios.forEach(r => {
r.disabled = false;
r.closest('.radio')?.setAttribute('aria-disabled', 'false');
});
const target = modeGroup.querySelector(`input[type="radio"][value="${actionMode}"]`) ||
modeGroup.querySelector('input[type="radio"][value="copy"]');
if (target) target.checked = true;
setStatusTag();
try { renderToneSettingsSection(); } catch(_) {}
}
function updateAccountUI() {
if (!accountStatusEl) return;
if (!authApiKey) {
accountStatusEl.textContent = 'Not connected. Public keywords only.';
if (accountConnectFormEl) accountConnectFormEl.style.display = '';
if (accountConnectedEl) accountConnectedEl.style.display = 'none';
if (accountLoginBtn) {
accountLoginBtn.disabled = false;
accountLoginBtn.style.display = '';
accountLoginBtn.textContent = 'Connect';
}
if (accountLogoutBtn) {
accountLogoutBtn.disabled = true;
accountLogoutBtn.style.display = 'none';
}
if (accountEmailEl) accountEmailEl.disabled = false;
if (accountPasswordEl) accountPasswordEl.disabled = false;
return;
}
const tierLabel = authTier === 'personal' ? 'Personal' : 'Free';
const fallbackName = (authEmail || 'User').split('@')[0] || 'User';
const displayName = (authName || '').trim() || fallbackName;
accountStatusEl.textContent = `Connected as ${authEmail || 'user'} (${tierLabel})`;
if (accountGreetingEl) accountGreetingEl.textContent = `Hi, ${displayName}`;
if (accountConnectFormEl) accountConnectFormEl.style.display = 'none';
if (accountConnectedEl) accountConnectedEl.style.display = '';
if (accountLoginBtn) {
accountLoginBtn.disabled = true;
accountLoginBtn.style.display = 'none';
}
if (accountLogoutBtn) {
accountLogoutBtn.disabled = false;
accountLogoutBtn.style.display = '';
}
if (accountEmailEl) accountEmailEl.disabled = true;
if (accountPasswordEl) accountPasswordEl.disabled = true;
}
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;
actionMode = val;
await saveSettings();
}
});
accountLoginBtn?.addEventListener('click', async () => {
if (authApiKey) {
updateAccountUI();
return;
}
const email = (accountEmailEl?.value || '').trim();
const password = (accountPasswordEl?.value || '').trim();
if (!email || !password) {
showToast('Enter email and password');
return;
}
try {
accountLoginBtn.disabled = true;
accountLoginBtn.textContent = 'Connecting...';
const res = await fetch(`${API.base}/user/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data?.ok) {
throw new Error(data?.error || `Login ${res.status}`);
}
authApiKey = data?.api_key || '';
authTier = String(data?.user?.tier || 'free');
authEmail = String(data?.user?.email || email);
authName = String(data?.user?.name || '');
await saveSettings();
updateAccountUI();
setStatusTag();
if (accountPasswordEl) accountPasswordEl.value = '';
showToast('Account connected');
fetchPage(true).catch(console.error);
} catch (e) {
showToast(`Connect failed${e?.message ? ': ' + e.message : ''}`);
} finally {
accountLoginBtn.disabled = false;
accountLoginBtn.textContent = 'Connect';
}
});
accountLogoutBtn?.addEventListener('click', async () => {
if (!authApiKey) return;
try {
const ok = await showConfirmModal({
title: 'Disconnect account?',
message: 'You can still use public keywords after logout.',
okText: 'Disconnect',
cancelText: 'Cancel',
});
if (!ok) return;
await fetch(`${API.base}/user/logout`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${authApiKey}` },
}).catch(() => null);
} finally {
authApiKey = '';
authTier = 'guest';
authName = '';
await saveSettings();
updateAccountUI();
setStatusTag();
showToast('Account disconnected');
fetchPage(true).catch(console.error);
}
});
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(authTier === 'personal'){
dewemojiStatusTag.innerText = 'Personal';
dewemojiStatusTag.classList.add('pro');
dewemojiStatusTag.classList.remove('free');
}else if(authTier === 'free'){
dewemojiStatusTag.innerText = 'Free';
dewemojiStatusTag.classList.add('free');
dewemojiStatusTag.classList.remove('pro');
}else{
dewemojiStatusTag.innerText = 'Guest';
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 non-toneable or families → perform action immediately
if (!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(){
// Legacy license flow removed. Account auth is loaded via loadSettings().
})();
// 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();
};