(() => { const API_BASES = ['https://dewemoji.com/v1', 'http://127.0.0.1:8000/v1']; const SEARCH_PATHS = ['/extension/search', '/emojis', '/search']; const FRONTEND_ID = 'apk-main-v1'; const PAGE_LIMIT = 50; const STORE_FALLBACK_KEY = 'dewemoji_apk_companion_state_v2'; const TOAST_MS = 2200; const LONG_PRESS_MS = 560; const OVERLAY_MODE = (() => { try { return new URLSearchParams(window.location.search).get('mode') === 'overlay'; } catch (_) { return false; } })(); const TONE_CHAR_BY_INDEX = { 1: '\u{1F3FB}', 2: '\u{1F3FC}', 3: '\u{1F3FD}', 4: '\u{1F3FE}', 5: '\u{1F3FF}', }; const TONE_CPS = new Set([0x1F3FB, 0x1F3FC, 0x1F3FD, 0x1F3FE, 0x1F3FF]); const MODIFIABLE_BASES = new Set([ 0x1F9D1, 0x1F468, 0x1F469, 0x1F466, 0x1F467, 0x1F476, 0x1F575, 0x1F93C, 0x26F9, 0x1F3CB, 0x1F93D, 0x1F93E, 0x1F926, ]); const NOT_TONEABLE_BASES = new Set([ 0x1F9BE, 0x1F9BF, 0x1FAC0, 0x1F9E0, 0x1FAC1, 0x1F9B7, 0x1F9B4, 0x1F440, 0x1F441, 0x1F445, 0x1F444, 0x1FAE6, 0x1F9DE, 0x1F9DF, 0x1F9CC, 0x26F7, 0x1F3C2, ]); const NON_TONEABLE_NAME_PATTERNS = [ /\bspeaking head\b/i, /\bbust in silhouette\b/i, /\bbusts in silhouette\b/i, /\bfootprints?\b/i, /\bfingerprint\b/i, /\bfamily\b/i, /\bpeople hugging\b/i, /\bpeople with bunny ears\b/i, /\bmen with bunny ears\b/i, /\bwomen with bunny ears\b/i, /\bpeople wrestling\b/i, /\bpeople fencing\b/i, /\bperson fencing\b/i, /\bmen wrestling\b/i, /\bwomen wrestling\b/i, /\bmerman\b/i, /\bmermaid\b/i, /\bdeaf man\b/i, /\bdeaf woman\b/i, /\bperson running facing right\b/i, /\bperson walking facing right\b/i, /\bperson kneeling facing right\b/i, ]; const ROLE_TONEABLE_NAME_PATTERNS = [ /\btechnologist\b/i, /\bscientist\b/i, /\boffice worker\b/i, /\bfactory worker\b/i, /\bmechanic\b/i, /\bcook\b/i, /\bfarmer\b/i, /\bjudge\b/i, /\bteacher\b/i, /\bstudent\b/i, /\bhealth worker\b/i, /\bsinger\b/i, /\bastronaut\b/i, /\bfirefighter\b/i, /\bfacepalming\b/i, /\bdancing\b/i, /\bdetective\b/i, /\bfencing\b/i, /\bbouncing ball\b/i, /\blifting weights\b/i, /\bwrestling\b/i, ]; const TONE_CATEGORY_ALLOWLIST = new Set(['people & body', 'activities']); const ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS = [ /\bmen with bunny ears\b/i, /\bwomen with bunny ears\b/i, /\bperson fencing\b/i, /\bmen wrestling\b/i, /\bwomen wrestling\b/i, ]; const ANDROID_TONE_RENDER_BLOCKLIST_SLUGS = new Set([ 'men-with-bunny-ears', 'women-with-bunny-ears', 'person-fencing', 'men-wrestling', 'women-wrestling', ]); const ANDROID_TONE_IMAGE_FALLBACK_SLUGS = new Set([ 'men-with-bunny-ears', 'women-with-bunny-ears', 'person-fencing', 'men-wrestling', 'women-wrestling', ]); const IS_ANDROID_RENDERER = (() => { try { if (/Android/i.test(navigator.userAgent || '')) return true; const platform = window.Capacitor?.getPlatform?.(); return platform === 'android'; } catch (_) { return /Android/i.test(navigator.userAgent || ''); } })(); const state = { apiBase: API_BASES[0], authApiKey: '', authTier: 'guest', authEmail: '', authName: '', bubbleEnabled: false, theme: '', showEmojiNames: false, toneLock: false, preferredToneIndex: 0, categoriesMap: {}, page: 1, total: 0, hasMore: false, loading: false, requestSeq: 0, searchDebounce: null, bubbleStatus: { platform: 'web', pluginAvailable: false, overlayGranted: false, notificationsEnabled: false, running: false, }, }; const els = { root: document.documentElement, body: document.body, q: document.getElementById('q'), clear: document.getElementById('clear'), theme: document.getElementById('theme'), settings: document.getElementById('settings'), cat: document.getElementById('cat'), sub: document.getElementById('sub'), list: document.getElementById('list'), autoloadSentinel: document.getElementById('autoload-sentinel'), more: document.getElementById('more'), count: document.getElementById('count'), ver: document.getElementById('ver'), toast: document.getElementById('toast'), statusTag: document.getElementById('dewemoji-status'), sheetBackdrop: document.getElementById('sheet-backdrop'), settingsSheet: document.getElementById('settings-sheet'), sheetClose: document.getElementById('sheet-close'), tabs: Array.from(document.querySelectorAll('#settings-sheet .tab')), panes: Array.from(document.querySelectorAll('#settings-sheet .tabpane')), tabGeneral: document.getElementById('tab-general'), tabAccount: document.getElementById('tab-account'), tabBubble: document.getElementById('tab-bubble'), refresh: document.getElementById('refresh'), apiStatus: document.getElementById('api-status'), sessionSummary: document.getElementById('session-summary'), showEmojiNamesToggle: document.getElementById('show-emoji-names-toggle'), toneLockToggle: document.getElementById('tone-lock-toggle'), preferredSkinToneRadios: Array.from(document.querySelectorAll('input[name="preferredSkinTone"]')), accountConnectForm: document.getElementById('account-connect-form'), accountConnected: document.getElementById('account-connected'), accountEmail: document.getElementById('account-email'), accountPassword: document.getElementById('account-password'), accountLogin: document.getElementById('account-login'), accountLogout: document.getElementById('account-logout'), accountStatus: document.getElementById('account-status'), accountGreeting: document.getElementById('account-greeting'), bubblePlatformStatus: document.getElementById('bubble-platform-status'), bubbleOverlayStatus: document.getElementById('bubble-overlay-status'), bubbleNotifyStatus: document.getElementById('bubble-notify-status'), bubbleRunningStatus: document.getElementById('bubble-running-status'), bubbleEnableBtn: document.getElementById('bubble-enable-btn'), bubbleDisableBtn: document.getElementById('bubble-disable-btn'), bubbleOverlaySettingsBtn: document.getElementById('bubble-overlay-settings-btn'), bubbleNotifySettingsBtn: document.getElementById('bubble-notify-settings-btn'), bubbleHelp: document.getElementById('bubble-help'), }; let toastTimer = null; let autoObserver = null; let autoLoadBusy = false; let autoLoadRaf = 0; let toneRowEl = null; let toneRowCard = null; let toneOutsideHandler = null; let overlayHeightReportRaf = 0; let overlayHeightObserver = null; function getCapacitorPlugin() { try { const cap = window.Capacitor; if (!cap) return null; if (cap.Plugins && cap.Plugins.DewemojiOverlay) return cap.Plugins.DewemojiOverlay; if (typeof cap.registerPlugin === 'function') return cap.registerPlugin('DewemojiOverlay'); } catch (err) { console.warn('Failed to init plugin', err); } return null; } const overlayPlugin = getCapacitorPlugin(); function platform() { try { return window.Capacitor?.getPlatform?.() || 'web'; } catch (_) { return 'web'; } } function isAndroid() { return platform() === 'android'; } function orderedApiBases() { const first = (state.apiBase || '').replace(/\/+$/, ''); const seen = new Set(); return [first, ...API_BASES] .map((v) => String(v || '').trim().replace(/\/+$/, '')) .filter((v) => { if (!v || seen.has(v)) return false; seen.add(v); return true; }); } function showToast(message) { if (!els.toast) return; els.toast.textContent = String(message || ''); els.toast.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => { els.toast.classList.remove('show'); scheduleOverlayPanelHeightReport(); }, TOAST_MS); scheduleOverlayPanelHeightReport(); } function hasOverlayPanelHeightBridge() { return OVERLAY_MODE && typeof window.DewemojiOverlayHost?.setContentHeight === 'function'; } function computeOverlayContentHeightCssPx() { const body = document.body; let flowBottom = 0; if (body?.children?.length) { Array.from(body.children).forEach((el) => { if (!(el instanceof HTMLElement)) return; const cs = window.getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden') return; if (cs.position === 'fixed') return; const top = Number(el.offsetTop || 0); const height = Number(el.offsetHeight || 0) || Math.ceil(el.getBoundingClientRect().height || 0); const marginBottom = parseFloat(cs.marginBottom || '0') || 0; flowBottom = Math.max(flowBottom, Math.ceil(top + height + marginBottom)); }); } const heights = [flowBottom]; if (els.toast && els.toast.classList.contains('show')) { const r = els.toast.getBoundingClientRect(); heights.push(Math.ceil(r.bottom + window.scrollY + 8)); } return Math.max(0, ...heights.map((v) => Number(v) || 0)); } function reportOverlayPanelHeightNow() { if (!hasOverlayPanelHeightBridge()) return; try { const cssPx = Math.ceil(computeOverlayContentHeightCssPx()); if (cssPx > 0) window.DewemojiOverlayHost.setContentHeight(cssPx); } catch (err) { console.debug('overlay height report failed', err); } } function scheduleOverlayPanelHeightReport() { if (!hasOverlayPanelHeightBridge()) return; if (overlayHeightReportRaf) cancelAnimationFrame(overlayHeightReportRaf); overlayHeightReportRaf = requestAnimationFrame(() => { overlayHeightReportRaf = 0; reportOverlayPanelHeightNow(); }); } function setupOverlayModeBehavior() { if (!OVERLAY_MODE) return; els.root?.classList.add('overlay-mode'); els.body?.classList.add('overlay-mode'); if (els.theme) { els.theme.hidden = true; els.theme.setAttribute('aria-hidden', 'true'); els.theme.tabIndex = -1; } if (els.settings) { els.settings.textContent = '↗'; els.settings.title = 'Open full app'; els.settings.setAttribute('aria-label', 'Open full app'); } if (els.settingsSheet) { els.settingsSheet.hidden = true; els.settingsSheet.classList.remove('show'); } if (els.sheetBackdrop) { els.sheetBackdrop.hidden = true; els.sheetBackdrop.classList.remove('show'); } if (!hasOverlayPanelHeightBridge() || !els.body) return; if (!overlayHeightObserver) { overlayHeightObserver = new MutationObserver(() => scheduleOverlayPanelHeightReport()); overlayHeightObserver.observe(els.body, { childList: true, subtree: true, characterData: true, attributes: true, }); } window.addEventListener('resize', scheduleOverlayPanelHeightReport); window.addEventListener('load', scheduleOverlayPanelHeightReport, { once: true }); setTimeout(scheduleOverlayPanelHeightReport, 0); setTimeout(scheduleOverlayPanelHeightReport, 120); } async function copyTextToClipboard(text, { successMessage = 'Copied.', failMessage = 'Copy failed. Try again.', } = {}) { const value = String(text || ''); if (!value) return false; try { if (typeof window.DewemojiOverlayHost?.copyText === 'function') { const ok = !!window.DewemojiOverlayHost.copyText(value); if (!ok) throw new Error('overlay_copy_failed'); } else if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); } else { const ta = document.createElement('textarea'); ta.value = value; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } showToast(successMessage); return true; } catch (_) { showToast(failMessage); return false; } } function setApiStatus(message) { if (els.apiStatus) els.apiStatus.textContent = String(message || ''); } function setVersionBadge() { if (!els.ver) return; if (OVERLAY_MODE) { els.ver.textContent = 'Quick · Bubble'; return; } const p = platform(); els.ver.textContent = p === 'android' ? 'APK · Android' : 'APK · Preview'; } function tierLabel(tier) { if (tier === 'personal' || tier === 'pro') return 'Personal'; if (tier === 'free') return 'Free'; return 'Guest'; } function setStatusTag() { if (!els.statusTag) return; const tier = state.authApiKey ? state.authTier : 'guest'; const label = tierLabel(tier); els.statusTag.textContent = label; els.statusTag.classList.remove('pro', 'free'); if (label === 'Personal') els.statusTag.classList.add('pro'); else els.statusTag.classList.add('free'); } function applyTheme(theme) { const next = theme === 'light' ? 'light' : 'dark'; state.theme = next; const nextClass = next === 'light' ? 'theme-light' : 'theme-dark'; [els.root, els.body].forEach((node) => { if (!node?.classList) return; node.classList.remove('theme-light', 'theme-dark'); node.classList.add(nextClass); }); if (els.theme) els.theme.textContent = next === 'light' ? '🌞' : '🌙'; } function applyEmojiNameVisibility(showNames) { state.showEmojiNames = !!showNames; els.body.classList.toggle('emoji-names-hidden', !state.showEmojiNames); if (els.showEmojiNamesToggle) { els.showEmojiNamesToggle.checked = state.showEmojiNames; } } function normalizePreferredToneIndex(value) { const num = Number(value); if (!Number.isFinite(num)) return 0; return Math.max(0, Math.min(5, Math.trunc(num))); } function stripSkinTone(emojiChar) { return String(emojiChar || '').replace(/[\u{1F3FB}-\u{1F3FF}]/gu, ''); } function containsZWJ(s) { return /\u200D/.test(String(s || '')); } function countHumans(s) { let n = 0; for (const ch of Array.from(String(s || ''))) { const cp = ch.codePointAt(0); if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) n += 1; } return n; } function canApplyToneTo(s) { if (!s) return false; try { const cp0 = String(s).codePointAt(0); if (NOT_TONEABLE_BASES.has(cp0)) return false; } catch (_) {} if (!containsZWJ(s)) return true; return countHumans(s) <= 2; } function isNameNonToneable(item) { const name = String(item?.name || '').toLowerCase(); return NON_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(name)); } function isRoleToneable(item) { const name = String(item?.name || '').toLowerCase(); return ROLE_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(name)); } // Site/extension parity: only treat a subset of "variants" as valid skin-tone // variants. Some API rows include variants that are not safe to tone-lock in UI. function isToneAllowedForItem(item) { if (!item?.supports_skin_tone) return false; const category = String(item?.category || '').toLowerCase(); if (!TONE_CATEGORY_ALLOWLIST.has(category)) return false; if (isNameNonToneable(item)) return false; if (IS_ANDROID_RENDERER) { const name = String(item?.name || '').toLowerCase(); const slug = String(item?.slug || '').toLowerCase(); const usesImageFallback = ANDROID_TONE_IMAGE_FALLBACK_SLUGS.has(slug); if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_SLUGS.has(slug)) return false; if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS.some((rx) => rx.test(name))) return false; } const base = stripSkinTone(item?.emoji_base || item?.emoji || ''); if (!base || !canApplyToneTo(base)) return false; const name = String(item?.name || '').toLowerCase(); const looksGenderedRole = category === 'people & body' && (name.startsWith('man ') || name.startsWith('woman ') || /[:\-,]\s*(man|woman)\b/.test(name)); if (looksGenderedRole && !isRoleToneable(item)) return false; return true; } function toCodePoints(str) { const out = []; for (const ch of String(str || '')) out.push(ch.codePointAt(0)); return out; } function fromCodePoints(arr) { try { return String.fromCodePoint(...arr); } catch (_) { return ''; } } function toCodePointHexSequence(str) { const cps = toCodePoints(str); return cps.map((cp) => cp.toString(16)).join('-'); } function twemojiCodePoint(str) { try { const cp = window.twemoji?.convert?.toCodePoint?.(String(str || '')); if (cp) return cp; } catch (_) {} return toCodePointHexSequence(str); } function twemojiSvgUrlFromCodepoints(cp) { return `https://twemoji.maxcdn.com/v/latest/svg/${cp}.svg`; } function twemojiCdnjsSvgUrlFromCodepoints(cp) { return `https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${cp}.svg`; } // Use image fallback for toned emoji glyphs in APK/preview to avoid color-rectangle // artifacts on platforms/fonts that don't compose skin-tone sequences correctly. function shouldUseToneImageFallback(_item, emojiStr) { const glyph = String(emojiStr || ''); if (!glyph) return false; return /[\u{1F3FB}-\u{1F3FF}]/u.test(glyph) || /\u200D/u.test(glyph); } function renderEmojiGlyph(container, emojiStr, { item = null } = {}) { if (!container) return; const glyph = String(emojiStr || ''); container.__dewemojiRenderedGlyph = glyph; if (!glyph) { container.__dewemojiRenderStatus = 'empty'; container.textContent = ''; return; } if (!shouldUseToneImageFallback(item, glyph)) { container.__dewemojiRenderStatus = 'text-direct'; container.__dewemojiFallbackCp = ''; container.textContent = glyph; return; } const cp = twemojiCodePoint(glyph); if (!cp) { container.__dewemojiRenderStatus = 'text-no-codepoint'; container.textContent = glyph; return; } // Render text immediately so the UI feels instant; swap to SVG once loaded. const requestKey = `${glyph}|${cp}`; container.__dewemojiFallbackKey = requestKey; container.__dewemojiFallbackCp = cp; container.__dewemojiRenderStatus = 'image-loading'; container.textContent = glyph; const img = new Image(); img.alt = glyph; img.draggable = false; img.decoding = 'async'; img.loading = 'lazy'; img.className = 'emoji-fallback-img'; img.style.width = '1em'; img.style.height = '1em'; img.style.objectFit = 'contain'; img.style.display = 'block'; img.onload = () => { if (!container.isConnected) return; if (container.__dewemojiFallbackKey !== requestKey) return; container.__dewemojiRenderStatus = img.__dewemojiFallbackStage === 'secondary' ? 'image-secondary' : 'image-primary'; container.replaceChildren(img); }; // Twemoji first, then a second Twemoji CDN mirror, then plain text fallback. img.__dewemojiFallbackStage = 'primary'; img.onerror = () => { img.onerror = () => { if (container.__dewemojiFallbackKey === requestKey) { container.__dewemojiRenderStatus = 'image-failed'; container.textContent = glyph; } }; img.__dewemojiFallbackStage = 'secondary'; img.src = twemojiCdnjsSvgUrlFromCodepoints(cp); }; img.src = twemojiSvgUrlFromCodepoints(cp); } function applyToneAfterFirstCodePoint(emojiChar, toneChar) { if (!emojiChar || !toneChar) return emojiChar; const toneCp = String(toneChar).codePointAt(0); if (!TONE_CPS.has(toneCp)) return emojiChar; const cps = toCodePoints(emojiChar); if (!cps.length) return emojiChar; if (cps.some((cp) => TONE_CPS.has(cp))) return emojiChar; const VS16 = 0xFE0F; let pos = 1; let rightStart = pos; if (cps[pos] === VS16) { rightStart = pos + 1; } const left = cps.slice(0, pos); left.push(toneCp); return fromCodePoints(left.concat(cps.slice(rightStart))) || emojiChar; } // Ported from the website tone renderer. It avoids Android/WebView cases where // VS16 before the modifier renders a separate tone swatch square. function applyToneSmart(emojiChar, toneChar) { if (!emojiChar || !toneChar) return emojiChar; const toneCp = String(toneChar).codePointAt(0); if (!TONE_CPS.has(toneCp)) return emojiChar; const cps = toCodePoints(emojiChar); for (const cp of cps) { if (TONE_CPS.has(cp)) return emojiChar; } const VS16 = 0xFE0F; const ZWJ = 0x200D; const idxs = []; for (let i = 0; i < cps.length; i += 1) { if (MODIFIABLE_BASES.has(cps[i])) idxs.push(i); } if (idxs.length === 0) { if (cps.includes(ZWJ)) return emojiChar; let pos = 1; let rightStart = pos; if (cps[1] === VS16) { rightStart = 2; } const left = cps.slice(0, pos); left.push(toneCp); return fromCodePoints(left.concat(cps.slice(rightStart))) || emojiChar; } if (idxs.length === 2) { const out = cps.slice(); const insertAfter = (baseIdx) => { let pos = baseIdx + 1; if (out[pos] === VS16) out.splice(pos, 1); out.splice(pos, 0, toneCp); }; insertAfter(idxs[1]); insertAfter(idxs[0]); return fromCodePoints(out) || emojiChar; } let pos = idxs[0] + 1; let rightStart = pos; if (cps[pos] === VS16) { rightStart = pos + 1; } const left = cps.slice(0, pos); left.push(toneCp); return fromCodePoints(left.concat(cps.slice(rightStart))) || emojiChar; } function applyToneSettings(toneLock, preferredToneIndex) { state.toneLock = !!toneLock; state.preferredToneIndex = normalizePreferredToneIndex(preferredToneIndex); if (state.toneLock) { closeToneRow(); } if (els.toneLockToggle) els.toneLockToggle.checked = state.toneLock; if (els.preferredSkinToneRadios?.length) { for (const radio of els.preferredSkinToneRadios) { radio.checked = Number(radio.value) === state.preferredToneIndex; } } } function hasToneVariants(item) { if (!isToneAllowedForItem(item)) return false; return !!(item && Array.isArray(item.variants) && item.variants.length > 0); } function toneGlyphForIndex(item, toneIndex) { const idx = normalizePreferredToneIndex(toneIndex); const base = stripSkinTone(item?.emoji_base || item?.emoji || ''); if (!hasToneVariants(item)) return String(base || item?.emoji || item?.emoji_base || ''); if (idx <= 0) { return String(base || item?.emoji || item?.emoji_base || item?.variants?.[0] || ''); } const toneChar = TONE_CHAR_BY_INDEX[idx]; if (base && toneChar) { const smart = applyToneSmart(base, toneChar); // Prefer the website renderer result when it produced a toned sequence. if (smart && smart !== base) return String(smart); // Generic fallback for gender/ZWJ person sequences not covered by the // narrow MODIFIABLE_BASES set copied from the site. const generic = applyToneAfterFirstCodePoint(base, toneChar); if (generic && generic !== base) return String(generic); } return String(item?.variants?.[idx - 1] || base || item?.emoji || item?.emoji_base || ''); } function glyphForCardDisplay(item) { if (state.toneLock && hasToneVariants(item)) { return toneGlyphForIndex(item, state.preferredToneIndex); } return String(item?.emoji || item?.emoji_base || ''); } function glyphForTap(item) { return glyphForCardDisplay(item); } function refreshRenderedCardGlyphs() { if (!els.list) return; els.list.querySelectorAll('.card').forEach((card) => { const item = card.__dewemojiItem; const emo = card.__emoEl; if (!item || !emo) return; renderEmojiGlyph(emo, glyphForCardDisplay(item), { item }); }); if (toneRowEl && toneRowCard?.__dewemojiItem) { openToneRowFor(toneRowCard, toneRowCard.__dewemojiItem, { preserveIfSame: true }); } } function preferredTheme() { const media = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)'); return media && media.matches ? 'light' : 'dark'; } function setActiveTab(tabName) { els.tabs.forEach((btn) => { const active = btn.dataset.tab === tabName; btn.classList.toggle('active', active); btn.setAttribute('aria-selected', active ? 'true' : 'false'); }); els.panes.forEach((pane) => { const active = pane.id === `tab-${tabName}`; pane.classList.toggle('active', active); }); } function openSheet(tabName = 'general') { if (OVERLAY_MODE) { openFullAppFromOverlay(); return; } if (!els.settingsSheet || !els.sheetBackdrop) return; closeToneRow(); setActiveTab(tabName); els.settingsSheet.hidden = false; els.sheetBackdrop.hidden = false; requestAnimationFrame(() => { els.settingsSheet.classList.add('show'); els.sheetBackdrop.classList.add('show'); }); } function closeSheet() { if (!els.settingsSheet || !els.sheetBackdrop) return; els.settingsSheet.classList.remove('show'); els.sheetBackdrop.classList.remove('show'); setTimeout(() => { els.settingsSheet.hidden = true; els.sheetBackdrop.hidden = true; }, 180); } async function callPlugin(method, args = {}) { if (!overlayPlugin || typeof overlayPlugin[method] !== 'function') { throw new Error('plugin_unavailable'); } return overlayPlugin[method](args); } function openFullAppFromOverlay() { if (!OVERLAY_MODE) return; try { if (typeof window.DewemojiOverlayHost?.openFullApp === 'function') { window.DewemojiOverlayHost.openFullApp(); return; } } catch (err) { console.warn('overlay open full app failed', err); } showToast('Use notification to open the full app for settings.'); } async function loadPersistedState() { try { const res = await callPlugin('getAppState'); return (res && res.state && typeof res.state === 'object') ? res.state : {}; } catch (_) { try { return JSON.parse(localStorage.getItem(STORE_FALLBACK_KEY) || '{}'); } catch (_) { return {}; } } } async function savePersistedStatePatch(patch = {}) { const payload = { authApiKey: state.authApiKey || '', authTier: state.authTier || 'guest', authEmail: state.authEmail || '', authName: state.authName || '', bubbleEnabled: !!state.bubbleEnabled, theme: state.theme || 'dark', showEmojiNames: !!state.showEmojiNames, toneLock: !!state.toneLock, preferredToneIndex: normalizePreferredToneIndex(state.preferredToneIndex), ...patch, }; try { await callPlugin('setAppState', { state: payload }); } catch (_) { try { localStorage.setItem(STORE_FALLBACK_KEY, JSON.stringify(payload)); } catch (_) {} } } function updateSessionSummary() { if (!els.sessionSummary) return; const parts = []; if (state.authApiKey) { parts.push(`Connected as ${state.authEmail || 'user'} (${tierLabel(state.authTier)})`); } else { parts.push('Guest mode. Public keywords only.'); } parts.push(state.bubbleStatus.running ? 'Bubble is running.' : 'Bubble is stopped.'); parts.push(state.showEmojiNames ? 'Emoji names are visible.' : 'Emoji names are hidden for larger emoji.'); parts.push(state.toneLock ? `Tone lock is on (preferred tone ${state.preferredToneIndex || 'default'}).` : 'Tone lock is off (tap copies default tone).'); parts.push('Tap an emoji to copy. Paste manually in your other app.'); els.sessionSummary.textContent = parts.join(' '); } function updateAccountUI() { const connected = !!state.authApiKey; if (els.accountConnectForm) els.accountConnectForm.style.display = connected ? 'none' : ''; if (els.accountConnected) els.accountConnected.style.display = connected ? '' : 'none'; if (!connected) { if (els.accountStatus) els.accountStatus.textContent = 'Not connected. Public keywords only.'; if (els.accountEmail && state.authEmail) els.accountEmail.value = state.authEmail; return; } const label = tierLabel(state.authTier); const displayName = (state.authName || (state.authEmail || 'User').split('@')[0] || 'User').trim(); if (els.accountStatus) els.accountStatus.textContent = `Connected as ${state.authEmail || 'user'} (${label})`; if (els.accountGreeting) els.accountGreeting.textContent = `Hi, ${displayName}`; } function setBubbleValue(el, text, tone) { if (!el) return; el.textContent = text; el.classList.remove('ok', 'warn', 'bad'); if (tone) el.classList.add(tone); } function updateBubbleUI() { const s = state.bubbleStatus; setBubbleValue(els.bubblePlatformStatus, s.pluginAvailable ? 'Android APK' : `${s.platform} (unavailable)`, s.pluginAvailable ? 'ok' : 'warn'); setBubbleValue(els.bubbleOverlayStatus, s.overlayGranted ? 'Granted' : 'Required', s.overlayGranted ? 'ok' : 'warn'); setBubbleValue(els.bubbleNotifyStatus, s.notificationsEnabled ? 'Enabled' : 'Required', s.notificationsEnabled ? 'ok' : 'warn'); setBubbleValue(els.bubbleRunningStatus, s.running ? 'Running' : 'Stopped', s.running ? 'ok' : 'warn'); if (els.bubbleEnableBtn) els.bubbleEnableBtn.disabled = !s.pluginAvailable || s.running; if (els.bubbleDisableBtn) els.bubbleDisableBtn.disabled = !s.pluginAvailable || !s.running; if (els.bubbleOverlaySettingsBtn) els.bubbleOverlaySettingsBtn.disabled = !s.pluginAvailable; if (els.bubbleNotifySettingsBtn) els.bubbleNotifySettingsBtn.disabled = !s.pluginAvailable; if (!els.bubbleHelp) return; if (!s.pluginAvailable) { els.bubbleHelp.textContent = 'Bubble controls are available only in the Android APK.'; } else if (!s.overlayGranted) { els.bubbleHelp.textContent = 'Grant overlay permission first, then enable the bubble.'; } else if (!s.notificationsEnabled) { els.bubbleHelp.textContent = 'Enable notifications before starting the bubble so the persistent stop control is visible.'; } else if (s.running) { els.bubbleHelp.textContent = 'Bubble is running. Tap it from other apps to open Dewemoji, copy emoji, then switch back and paste.'; } else { els.bubbleHelp.textContent = 'Bubble is off by default. It opens Dewemoji for quick search and copy.'; } } async function refreshBubbleStatus() { state.bubbleStatus.platform = platform(); state.bubbleStatus.pluginAvailable = isAndroid() && !!overlayPlugin; if (!state.bubbleStatus.pluginAvailable) { state.bubbleStatus.overlayGranted = false; state.bubbleStatus.notificationsEnabled = false; state.bubbleStatus.running = false; updateBubbleUI(); updateSessionSummary(); return; } const [overlayRes, notifyRes, runningRes] = await Promise.allSettled([ callPlugin('isOverlayPermissionGranted'), callPlugin('areNotificationsEnabled'), callPlugin('isBubbleRunning'), ]); state.bubbleStatus.overlayGranted = overlayRes.status === 'fulfilled' && !!overlayRes.value.granted; state.bubbleStatus.notificationsEnabled = notifyRes.status === 'fulfilled' && !!notifyRes.value.enabled; state.bubbleStatus.running = runningRes.status === 'fulfilled' && !!runningRes.value.running; updateBubbleUI(); updateSessionSummary(); } async function enableBubble() { await refreshBubbleStatus(); if (!state.bubbleStatus.pluginAvailable) { showToast('Bubble is available only in the Android APK.'); return; } if (!state.bubbleStatus.overlayGranted) { showToast('Grant overlay permission first.'); try { await callPlugin('openOverlayPermissionSettings'); } catch (_) {} return; } if (!state.bubbleStatus.notificationsEnabled) { showToast('Enable notifications first.'); try { await callPlugin('openNotificationSettings'); } catch (_) {} return; } try { const res = await callPlugin('startBubble'); if (res && res.started === false) { const reason = res.reason || 'unknown'; showToast(reason === 'notifications_required' ? 'Notifications required for bubble.' : 'Could not start bubble.'); } else { state.bubbleEnabled = true; await savePersistedStatePatch({ bubbleEnabled: true }); showToast('Bubble enabled. Tap it from other apps to open Dewemoji.'); } } catch (err) { console.error('startBubble failed', err); showToast('Could not start bubble.'); } await refreshBubbleStatus(); } async function disableBubble() { try { await callPlugin('stopBubble'); } catch (err) { console.warn('stopBubble failed', err); } state.bubbleEnabled = false; await savePersistedStatePatch({ bubbleEnabled: false }); showToast('Bubble disabled.'); await refreshBubbleStatus(); } function authHeaders() { const headers = { 'X-Dewemoji-Frontend': FRONTEND_ID }; if (state.authApiKey) headers.Authorization = `Bearer ${state.authApiKey}`; return headers; } async function fetchJson(url, options) { const res = await fetch(url, options); let data = null; try { data = await res.json(); } catch (_) { data = null; } return { res, data }; } async function fetchCategories() { let lastError = null; for (const base of orderedApiBases()) { try { const { res, data } = await fetchJson(`${base}/categories`, { cache: 'no-store', headers: authHeaders() }); if (res.status === 401) return { unauthorized: true }; if (res.ok && data && typeof data === 'object') { state.apiBase = base; return { ok: true, data }; } lastError = new Error(`Categories ${res.status}`); } catch (err) { lastError = err; } } throw lastError || new Error('Failed to load categories'); } async function fetchCatalogPage(page) { const params = new URLSearchParams({ page: String(page), limit: String(PAGE_LIMIT) }); const q = (els.q?.value || '').trim(); const category = (els.cat?.value || '').trim(); const subcategory = (!els.sub?.disabled ? (els.sub?.value || '').trim() : ''); if (q) params.set('q', q); if (category) params.set('category', category); if (subcategory) params.set('subcategory', subcategory); let lastError = null; for (const base of orderedApiBases()) { for (const path of SEARCH_PATHS) { try { const { res, data } = await fetchJson(`${base}${path}?${params.toString()}`, { cache: 'no-store', headers: authHeaders() }); if (res.status === 401) return { unauthorized: true }; if (res.ok && data && Array.isArray(data.items)) { state.apiBase = base; return { ok: true, data, res, path }; } lastError = new Error(`API ${res.status}`); } catch (err) { lastError = err; } } } throw lastError || new Error('Failed to load catalog'); } function normalizeCategoryMap(payload) { if (!payload || typeof payload !== 'object') return {}; if (Array.isArray(payload.items)) { const out = {}; for (const item of payload.items) { if (!item || typeof item.name !== 'string') continue; out[item.name] = Array.isArray(item.subcategories) ? item.subcategories.map(String) : []; } return out; } const out = {}; for (const [k, v] of Object.entries(payload)) { if (['ok', 'error', 'message'].includes(k)) continue; out[String(k)] = Array.isArray(v) ? v.map(String) : []; } return out; } function categoryNames() { return Object.keys(state.categoriesMap).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); } function populateCategorySelect() { if (!els.cat) return; const current = els.cat.value; els.cat.innerHTML = ''; for (const name of categoryNames()) { const opt = document.createElement('option'); opt.value = name; opt.textContent = name; els.cat.appendChild(opt); } if (current && state.categoriesMap[current]) els.cat.value = current; populateSubcategorySelect(); } function populateSubcategorySelect() { if (!els.cat || !els.sub) return; const current = els.sub.value; const category = els.cat.value; const subs = Array.isArray(state.categoriesMap[category]) ? [...state.categoriesMap[category]] : []; subs.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); els.sub.innerHTML = ''; subs.forEach((sub) => { const opt = document.createElement('option'); opt.value = sub; opt.textContent = sub; els.sub.appendChild(opt); }); els.sub.disabled = subs.length === 0; if (subs.includes(current)) els.sub.value = current; } function cardCount() { return els.list ? els.list.querySelectorAll('.card').length : 0; } function updateCountLabel() { if (!els.count) return; const shown = cardCount(); if (state.total > 0) { els.count.textContent = `${shown} of ${state.total}`; } else { els.count.textContent = `${shown} items`; } if (els.more) { els.more.disabled = state.loading || !state.hasMore; els.more.style.visibility = state.hasMore ? 'visible' : 'hidden'; els.more.textContent = state.loading ? 'Loading…' : 'Load more'; } scheduleAutoLoadCheck(); } function clearList() { closeToneRow(); if (els.list) els.list.innerHTML = ''; state.total = 0; state.page = 1; state.hasMore = false; autoLoadBusy = false; updateCountLabel(); } function canAutoLoadMore() { return !!els.autoloadSentinel && !state.loading && !autoLoadBusy && state.hasMore && cardCount() > 0; } async function autoLoadMoreIfNeeded() { if (!canAutoLoadMore()) return; autoLoadBusy = true; try { await loadCatalog(false); } finally { autoLoadBusy = false; scheduleAutoLoadCheck(); } } function scheduleAutoLoadCheck() { if (autoLoadRaf) { cancelAnimationFrame(autoLoadRaf); autoLoadRaf = 0; } if (!els.autoloadSentinel || !('IntersectionObserver' in window)) return; if (!canAutoLoadMore()) return; autoLoadRaf = requestAnimationFrame(() => { autoLoadRaf = 0; if (!canAutoLoadMore()) return; const rect = els.autoloadSentinel.getBoundingClientRect(); const preloadThreshold = window.innerHeight + 240; if (rect.top <= preloadThreshold) { autoLoadMoreIfNeeded().catch(console.error); } }); } function setupAutoLoadObserver() { if (!els.autoloadSentinel || !('IntersectionObserver' in window)) return; if (autoObserver) { try { autoObserver.disconnect(); } catch (_) {} } autoObserver = new IntersectionObserver( (entries) => { const hit = entries.some((entry) => entry.isIntersecting); if (!hit) return; autoLoadMoreIfNeeded().catch(console.error); }, { root: null, rootMargin: '0px 0px 240px 0px', threshold: 0 } ); autoObserver.observe(els.autoloadSentinel); } function buildAlert(type, title, message) { const box = document.createElement('div'); box.className = `alert ${type || ''}`.trim(); const strong = document.createElement('strong'); strong.textContent = title; const p = document.createElement('div'); p.className = 'muted'; p.textContent = message; box.append(strong, p); return box; } async function copyEmoji(glyph) { const text = String(glyph || ''); if (!text) return; await copyTextToClipboard(text, { successMessage: 'Copied. Switch back and paste.', failMessage: 'Copy failed. Try again.', }); } async function shareEmoji(glyph) { const text = String(glyph || ''); if (!text) return; if (navigator.share) { try { await navigator.share({ text }); return; } catch (err) { if (err?.name === 'AbortError') return; } } await copyEmoji(text); showToast('Share unavailable. Copied instead.'); } function closeToneRow() { if (toneOutsideHandler) { document.removeEventListener('pointerdown', toneOutsideHandler, true); toneOutsideHandler = null; } if (toneRowCard) { toneRowCard.classList.remove('active'); } if (toneRowEl && toneRowEl.parentElement) { toneRowEl.parentElement.removeChild(toneRowEl); } toneRowEl = null; toneRowCard = null; scheduleOverlayPanelHeightReport(); } function toneCodePointForIndex(idx) { return TONE_CHAR_BY_INDEX[normalizePreferredToneIndex(idx)] || ''; } function isPartiallyVisible(el) { if (!el || !el.getBoundingClientRect) return false; const rect = el.getBoundingClientRect(); const vw = window.innerWidth || document.documentElement.clientWidth || 0; const vh = window.innerHeight || document.documentElement.clientHeight || 0; return rect.bottom > 0 && rect.top < vh && rect.right > 0 && rect.left < vw; } function buildVisibleToneDebugReport() { const toneChar = toneCodePointForIndex(state.preferredToneIndex); const toneLockActive = !!(state.toneLock && state.preferredToneIndex > 0); const cards = Array.from(els.list?.querySelectorAll?.('.card') || []); const visibleCards = cards.filter(isPartiallyVisible); const visible = visibleCards.map((card, index) => { const item = card.__dewemojiItem || null; const emo = card.__emoEl || null; const expectedGlyph = glyphForCardDisplay(item); const displayedGlyph = String( emo?.querySelector?.('img')?.alt || emo?.textContent || '' ); const toneAllowed = !!isToneAllowedForItem(item); const fallbackEligible = shouldUseToneImageFallback(item, expectedGlyph); const renderStatus = String(emo?.__dewemojiRenderStatus || 'unknown'); const renderCp = String(emo?.__dewemojiFallbackCp || ''); const itemSupportsTone = !!item?.supports_skin_tone; const hasVariants = !!(item && Array.isArray(item.variants) && item.variants.length > 0); const hasToneableDot = card.classList.contains('toneable'); const flags = []; if (toneLockActive && itemSupportsTone && !toneAllowed) flags.push('tone_filtered'); if (fallbackEligible && renderStatus === 'image-failed') flags.push('fallback_failed'); if (fallbackEligible && renderStatus === 'image-loading') flags.push('fallback_loading'); if (toneLockActive && toneAllowed && toneChar && !expectedGlyph.includes(toneChar)) flags.push('expected_missing_tone_char'); if (toneLockActive && toneAllowed && toneChar && !displayedGlyph.includes(toneChar) && !emo?.querySelector?.('img')) { flags.push('display_missing_tone_char'); } return { visibleIndex: index, name: String(item?.name || ''), slug: String(item?.slug || ''), emoji: String(item?.emoji || ''), emoji_base: String(item?.emoji_base || ''), expectedGlyph, displayedGlyph, expectedCodepoints: twemojiCodePoint(expectedGlyph), displayedCodepoints: twemojiCodePoint(displayedGlyph), category: String(item?.category || ''), subcategory: String(item?.subcategory || ''), supports_skin_tone: itemSupportsTone, hasVariants, toneAllowed, toneableDot: hasToneableDot, fallbackEligible, renderStatus, fallbackCodepoints: renderCp, fallbackPrimaryUrl: renderCp ? twemojiSvgUrlFromCodepoints(renderCp) : '', fallbackSecondaryUrl: renderCp ? twemojiCdnjsSvgUrlFromCodepoints(renderCp) : '', flags, }; }); const flagged = visible.filter((row) => row.flags.length > 0); return { generatedAt: new Date().toISOString(), platform: platform(), ui: { theme: state.theme, toneLock: state.toneLock, preferredToneIndex: state.preferredToneIndex, preferredToneChar: toneChar || '', query: String(els.q?.value || ''), category: String(els.cat?.value || ''), subcategory: String(els.sub?.value || ''), }, counts: { totalRenderedCards: cards.length, visibleCards: visible.length, flaggedVisibleCards: flagged.length, }, flagged, visible, }; } async function copyVisibleToneDebugReport() { const report = buildVisibleToneDebugReport(); const payload = JSON.stringify(report, null, 2); const ok = await copyTextToClipboard(payload, { successMessage: `Tone debug copied (${report.counts.flaggedVisibleCards} flagged / ${report.counts.visibleCards} visible).`, failMessage: 'Could not copy tone debug report.', }); if (!ok) return; console.info('Dewemoji tone debug report', report); } function persistToneSettings() { savePersistedStatePatch({ toneLock: state.toneLock, preferredToneIndex: state.preferredToneIndex, }).catch(console.error); } function chooseTone(item, toneIndex, { copy = true } = {}) { applyToneSettings(state.toneLock, toneIndex); updateSessionSummary(); persistToneSettings(); refreshRenderedCardGlyphs(); if (copy) { copyEmoji(toneGlyphForIndex(item, toneIndex)).catch(console.error); } } function openToneRowFor(card, item, { preserveIfSame = false } = {}) { if (state.toneLock) return; if (!hasToneVariants(item) || !els.list || !card?.isConnected) return; const sameCard = toneRowEl && toneRowCard === card; if (sameCard && !preserveIfSame) { closeToneRow(); return; } closeToneRow(); const row = document.createElement('div'); row.className = 'tone-row'; row.setAttribute('role', 'group'); row.setAttribute('aria-label', 'Skin tone picker'); const options = [{ idx: 0, glyph: toneGlyphForIndex(item, 0), label: 'Default' }]; (Array.isArray(item.variants) ? item.variants : []).forEach((_, i) => { options.push({ idx: i + 1, glyph: toneGlyphForIndex(item, i + 1), label: `Tone ${i + 1}` }); }); options.forEach((opt) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'tone-option'; btn.title = opt.label; btn.setAttribute('aria-label', `${opt.label} ${item?.name || 'emoji'}`); renderEmojiGlyph(btn, opt.glyph, { item }); if (state.preferredToneIndex === opt.idx) { btn.classList.add('selected'); } btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); closeToneRow(); chooseTone(item, opt.idx, { copy: true }); }); row.appendChild(btn); }); const note = document.createElement('div'); note.className = 'tone-note'; note.textContent = state.toneLock ? 'Tap a tone to copy and update your preferred tone (tone lock is on).' : 'Tap a tone to copy and save as preferred. Turn on Tone lock to use it on normal tap.'; row.appendChild(note); card.insertAdjacentElement('afterend', row); card.classList.add('active'); toneRowEl = row; toneRowCard = card; toneOutsideHandler = (event) => { const target = event.target; if (toneRowEl?.contains(target) || toneRowCard?.contains(target)) return; closeToneRow(); }; setTimeout(() => { if (toneOutsideHandler) { document.addEventListener('pointerdown', toneOutsideHandler, true); } scheduleOverlayPanelHeightReport(); }, 0); } function renderCard(item) { const card = document.createElement('button'); card.type = 'button'; card.className = 'card'; if (item && item.source === 'private') card.classList.add('private'); if (hasToneVariants(item)) card.classList.add('toneable'); const emoji = glyphForCardDisplay(item); const name = String(item?.name || item?.slug || 'emoji'); card.title = `${name}${item?.category ? `\n${item.category}` : ''}${item?.subcategory ? ` / ${item.subcategory}` : ''}`; card.setAttribute('aria-label', `Copy ${name}`); const emo = document.createElement('div'); emo.className = 'emo'; renderEmojiGlyph(emo, emoji, { item }); const nm = document.createElement('div'); nm.className = 'nm'; nm.textContent = name; card.__dewemojiItem = item; card.__emoEl = emo; card.append(emo, nm); let longPressTimer = 0; let longPressTriggered = false; let startX = 0; let startY = 0; const cancelLongPress = () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = 0; } }; if (hasToneVariants(item)) { card.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse' && e.button !== 0) return; if (state.toneLock) return; cancelLongPress(); longPressTriggered = false; startX = e.clientX; startY = e.clientY; longPressTimer = window.setTimeout(() => { longPressTimer = 0; longPressTriggered = true; openToneRowFor(card, item); }, LONG_PRESS_MS); }); card.addEventListener('pointermove', (e) => { if (!longPressTimer) return; if (Math.abs(e.clientX - startX) > 16 || Math.abs(e.clientY - startY) > 16) { cancelLongPress(); } }); ['pointerup', 'pointercancel', 'pointerleave'].forEach((type) => { card.addEventListener(type, cancelLongPress); }); } card.addEventListener('click', (e) => { if (longPressTriggered) { longPressTriggered = false; e.preventDefault(); e.stopPropagation(); return; } copyEmoji(glyphForTap(item)).catch(console.error); }); card.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); if (hasToneVariants(item) && !state.toneLock) { openToneRowFor(card, item, { preserveIfSame: true }); return; } shareEmoji(glyphForTap(item)).catch(console.error); }); return card; } function renderItems(items, { reset = false } = {}) { if (!els.list) return; if (reset) { closeToneRow(); els.list.innerHTML = ''; } if (reset && (!items || items.length === 0)) { els.list.appendChild(buildAlert('warn', 'No results', 'Try another keyword, category, or subcategory.')); updateCountLabel(); scheduleOverlayPanelHeightReport(); return; } (items || []).forEach((item) => els.list.appendChild(renderCard(item))); updateCountLabel(); scheduleOverlayPanelHeightReport(); } async function clearAuth({ silent = false } = {}) { state.authApiKey = ''; state.authTier = 'guest'; state.authName = ''; await savePersistedStatePatch({ authApiKey: '', authTier: 'guest', authName: '' }); setStatusTag(); updateAccountUI(); updateSessionSummary(); if (!silent) showToast('Disconnected. Public keywords only.'); } async function loadCategories() { try { const result = await fetchCategories(); if (result.unauthorized) { await clearAuth({ silent: true }); return; } if (result.ok) { state.categoriesMap = normalizeCategoryMap(result.data); populateCategorySelect(); } } catch (err) { console.warn('loadCategories failed', err); } } async function loadCatalog(reset = false) { if (state.loading) return; const requestId = ++state.requestSeq; state.loading = true; if (reset) { state.page = 1; state.total = 0; state.hasMore = false; if (els.list) els.list.innerHTML = ''; } updateCountLabel(); setApiStatus(reset ? 'Loading catalog…' : 'Loading more…'); try { const page = reset ? 1 : state.page; const result = await fetchCatalogPage(page); if (requestId !== state.requestSeq) return; if (result.unauthorized) { if (state.authApiKey) { await clearAuth({ silent: true }); await loadCategories(); setApiStatus('Session expired. Showing guest catalog.'); state.loading = false; updateCountLabel(); await loadCatalog(reset); return; } throw new Error('unauthorized'); return; } const resTier = result.res?.headers?.get?.('X-Dewemoji-Tier'); if (state.authApiKey && resTier) { state.authTier = resTier === 'pro' ? 'personal' : String(resTier || 'free'); setStatusTag(); updateAccountUI(); await savePersistedStatePatch({ authTier: state.authTier }); } const data = result.data || {}; const items = Array.isArray(data.items) ? data.items : []; state.total = Number(data.total || 0); state.hasMore = (page - 1) * PAGE_LIMIT + items.length < state.total; state.page = page + 1; renderItems(items, { reset }); const shown = cardCount(); setApiStatus(`Loaded ${shown} items from ${state.total || shown}.`); scheduleAutoLoadCheck(); } catch (err) { console.error('loadCatalog failed', err); if (requestId !== state.requestSeq) return; if (!els.list || (reset && els.list.children.length === 0)) { if (els.list) { els.list.innerHTML = ''; els.list.appendChild(buildAlert('error', 'Failed to load', 'Check your internet connection and try again.')); } } state.hasMore = false; setApiStatus('Catalog request failed.'); updateCountLabel(); } finally { if (requestId === state.requestSeq) { state.loading = false; updateCountLabel(); } } } async function login() { const email = String(els.accountEmail?.value || '').trim(); const password = String(els.accountPassword?.value || '').trim(); if (!email || !password) { showToast('Enter email and password.'); return; } if (els.accountLogin) { els.accountLogin.disabled = true; els.accountLogin.textContent = 'Connecting…'; } let lastErr = null; try { for (const base of orderedApiBases()) { try { const { res, data } = await fetchJson(`${base}/user/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dewemoji-Frontend': FRONTEND_ID, }, body: JSON.stringify({ email, password }), }); if (!res.ok || !data || data.ok === false || !data.api_key) { lastErr = new Error((data && data.error) || `Login ${res.status}`); continue; } state.apiBase = base; state.authApiKey = String(data.api_key || ''); state.authTier = String(data.user?.tier || 'free'); state.authEmail = String(data.user?.email || email); state.authName = String(data.user?.name || ''); await savePersistedStatePatch({ authApiKey: state.authApiKey, authTier: state.authTier, authEmail: state.authEmail, authName: state.authName, }); setStatusTag(); updateAccountUI(); updateSessionSummary(); showToast('Account connected. Private keyword matches can appear in search.'); await loadCategories(); await loadCatalog(true); return; } catch (err) { lastErr = err; } } throw lastErr || new Error('Login failed'); } catch (err) { console.error('login failed', err); showToast(`Connect failed${err?.message ? `: ${err.message}` : ''}`); } finally { if (els.accountPassword) els.accountPassword.value = ''; if (els.accountLogin) { els.accountLogin.disabled = false; els.accountLogin.textContent = 'Connect'; } } } async function logout() { if (!state.authApiKey) return; const token = state.authApiKey; try { await fetch(`${state.apiBase}/user/logout`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'X-Dewemoji-Frontend': FRONTEND_ID, }, }); } catch (_) {} await clearAuth(); await loadCategories(); await loadCatalog(true); } function scheduleSearch() { clearTimeout(state.searchDebounce); state.searchDebounce = setTimeout(() => { loadCatalog(true).catch(console.error); }, 250); } function bindEvents() { els.settings?.addEventListener('click', () => { if (OVERLAY_MODE) { openFullAppFromOverlay(); return; } openSheet('general'); }); els.sheetClose?.addEventListener('click', closeSheet); els.sheetBackdrop?.addEventListener('click', closeSheet); els.tabs.forEach((btn) => { btn.addEventListener('click', () => setActiveTab(btn.dataset.tab || 'general')); }); document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; if (toneRowEl) { closeToneRow(); return; } closeSheet(); }); els.theme?.addEventListener('click', async () => { const next = state.theme === 'dark' ? 'light' : 'dark'; applyTheme(next); await savePersistedStatePatch({ theme: next }); }); els.showEmojiNamesToggle?.addEventListener('change', async () => { const next = !!els.showEmojiNamesToggle.checked; applyEmojiNameVisibility(next); updateSessionSummary(); await savePersistedStatePatch({ showEmojiNames: next }); }); els.toneLockToggle?.addEventListener('change', async () => { const next = !!els.toneLockToggle.checked; applyToneSettings(next, state.preferredToneIndex); refreshRenderedCardGlyphs(); updateSessionSummary(); await savePersistedStatePatch({ toneLock: next, preferredToneIndex: state.preferredToneIndex }); }); els.preferredSkinToneRadios?.forEach((radio) => { radio.addEventListener('change', async () => { if (!radio.checked) return; const next = normalizePreferredToneIndex(radio.value); applyToneSettings(state.toneLock, next); refreshRenderedCardGlyphs(); updateSessionSummary(); await savePersistedStatePatch({ preferredToneIndex: next, toneLock: state.toneLock }); }); }); els.clear?.addEventListener('click', () => { if (els.q) els.q.value = ''; scheduleSearch(); }); els.q?.addEventListener('input', scheduleSearch); els.q?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); clearTimeout(state.searchDebounce); loadCatalog(true).catch(console.error); } }); els.cat?.addEventListener('change', () => { populateSubcategorySelect(); loadCatalog(true).catch(console.error); }); els.sub?.addEventListener('change', () => loadCatalog(true).catch(console.error)); els.more?.addEventListener('click', () => { if (!state.loading && state.hasMore) loadCatalog(false).catch(console.error); }); els.refresh?.addEventListener('click', async () => { await refreshBubbleStatus(); await loadCategories(); await loadCatalog(true); }); els.accountLogin?.addEventListener('click', (e) => { e.preventDefault(); login().catch(console.error); }); els.accountPassword?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); login().catch(console.error); } }); els.accountLogout?.addEventListener('click', (e) => { e.preventDefault(); logout().catch(console.error); }); els.bubbleEnableBtn?.addEventListener('click', (e) => { e.preventDefault(); enableBubble().catch(console.error); }); els.bubbleDisableBtn?.addEventListener('click', (e) => { e.preventDefault(); disableBubble().catch(console.error); }); els.bubbleOverlaySettingsBtn?.addEventListener('click', async (e) => { e.preventDefault(); try { await callPlugin('openOverlayPermissionSettings'); } catch (_) { showToast('Overlay settings unavailable here.'); } }); els.bubbleNotifySettingsBtn?.addEventListener('click', async (e) => { e.preventDefault(); try { await callPlugin('openNotificationSettings'); } catch (_) { showToast('Notification settings unavailable here.'); } }); window.addEventListener('focus', () => refreshBubbleStatus().catch(console.error)); document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshBubbleStatus().catch(console.error); }); window.addEventListener('resize', scheduleAutoLoadCheck); } window.dewemojiHandleAndroidBack = () => { if (toneRowEl) { closeToneRow(); return true; } if (els.settingsSheet && !els.settingsSheet.hidden) { closeSheet(); return true; } if (els.toast && els.toast.classList.contains('show')) { els.toast.classList.remove('show'); return true; } const active = document.activeElement; if (active && (active.tagName === 'INPUT' || active.tagName === 'SELECT')) { active.blur(); return true; } return false; }; async function init() { bindEvents(); setupOverlayModeBehavior(); setupAutoLoadObserver(); setVersionBadge(); clearList(); const persisted = await loadPersistedState(); state.authApiKey = String(persisted.authApiKey || ''); state.authTier = String(persisted.authTier || 'guest'); state.authEmail = String(persisted.authEmail || ''); state.authName = String(persisted.authName || ''); state.bubbleEnabled = !!persisted.bubbleEnabled; state.theme = String(persisted.theme || preferredTheme()); state.showEmojiNames = !!persisted.showEmojiNames; state.toneLock = !!persisted.toneLock; state.preferredToneIndex = normalizePreferredToneIndex(persisted.preferredToneIndex); if (els.accountEmail && state.authEmail) els.accountEmail.value = state.authEmail; applyTheme(state.theme); applyEmojiNameVisibility(state.showEmojiNames); applyToneSettings(state.toneLock, state.preferredToneIndex); setStatusTag(); updateAccountUI(); setApiStatus('Loading catalog…'); await refreshBubbleStatus(); await loadCategories(); await loadCatalog(true); if (state.bubbleEnabled && !state.bubbleStatus.running && state.bubbleStatus.pluginAvailable) { els.bubbleHelp.textContent = 'Bubble was enabled before but is not currently running. Tap “Enable bubble” to start it again.'; } updateSessionSummary(); scheduleOverlayPanelHeightReport(); } init().catch((err) => { console.error('init failed', err); setApiStatus('Dewemoji failed to initialize.'); clearList(); els.list?.appendChild(buildAlert('error', 'Failed to initialize', 'Reopen the app and try again.')); }); })();