1893 lines
62 KiB
JavaScript
1893 lines
62 KiB
JavaScript
(() => {
|
|
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 = '<option value="">All categories</option>';
|
|
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 = '<option value="">All subcategories</option>';
|
|
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.'));
|
|
});
|
|
})();
|