(() => {
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.'));
});
})();