diff --git a/public/s/js/messages.js b/public/s/js/messages.js
index 103158f..883ff40 100644
--- a/public/s/js/messages.js
+++ b/public/s/js/messages.js
@@ -1789,39 +1789,154 @@ if (window.__dmLoaded) {
// Hold-to-preview (uses shared window.stickerPreview from comments.js)
let holdTimer = null;
let holdFired = false;
- const startHold = () => {
+ let isTouchHold = false;
+ const startHold = (fromTouch) => {
holdFired = false;
+ isTouchHold = !!fromTouch;
holdTimer = setTimeout(() => {
holdFired = true;
- window.stickerPreview?.show(url, isVideo);
- }, 400);
+ window.stickerPreview?.show(url, isVideo, name, url);
+ }, 800);
};
- const cancelHold = () => { clearTimeout(holdTimer); };
+ const cancelHold = () => { clearTimeout(holdTimer); isTouchHold = false; };
const endHold = () => {
clearTimeout(holdTimer);
if (holdFired) {
holdFired = false;
- // Suppress the click that fires on mouseup after a preview gesture
el.addEventListener('click', (e) => {
e.stopImmediatePropagation();
e.preventDefault();
}, { once: true, capture: true });
+ if (!isTouchHold) window.stickerPreview?.hide();
+ // Mobile: preview stays open until user taps overlay
+ } else {
+ window.stickerPreview?.hide();
}
- window.stickerPreview?.hide();
+ isTouchHold = false;
};
- el.addEventListener('mousedown', startHold);
+ el.addEventListener('mousedown', () => startHold(false));
el.addEventListener('mouseup', endHold);
el.addEventListener('mouseleave', cancelHold);
// Instant switch when already previewing
el.addEventListener('mouseenter', () => {
- if (window.stickerPreview?.isShowing) window.stickerPreview.show(url, isVideo);
+ if (window.stickerPreview?.isShowing) window.stickerPreview.show(url, isVideo, name, url);
});
- el.addEventListener('touchstart', startHold, { passive: true });
+ el.addEventListener('touchstart', () => startHold(true), { passive: true });
+ el.addEventListener('touchmove', cancelHold, { passive: true });
el.addEventListener('touchend', endHold);
- el.addEventListener('touchcancel', endHold);
+ el.addEventListener('touchcancel', cancelHold);
+ // Desktop-only right-click → fav context menu
+ el.addEventListener('contextmenu', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (navigator.maxTouchPoints > 0) return;
+ if (holdFired) return;
+ window._favContextMenu?.show(e.clientX, e.clientY, name, url, () => {
+ if (activeTabId === '__favs__') showDmTab('__favs__');
+ });
+ });
return el;
}
+ // Ensure fav utils are available even when no comment section exists on this page
+ if (!window._emojiPickerFavUtils) {
+ window._emojiPickerFavUtils = {
+ KEY: 'f0ck_emoji_favs',
+ get() { try { return JSON.parse(localStorage.getItem(this.KEY) || '[]'); } catch(e) { return []; } },
+ has(name) { return this.get().some(f => f.name === name); },
+ toggle(name, url) {
+ const favs = this.get();
+ const idx = favs.findIndex(f => f.name === name);
+ const action = idx >= 0 ? 'remove' : 'add';
+ if (idx >= 0) favs.splice(idx, 1); else favs.push({ name, url });
+ localStorage.setItem(this.KEY, JSON.stringify(favs));
+ const csrf = window.f0ckSession?.csrf_token;
+ if (csrf) {
+ fetch('/api/v2/user/emoji-favorites/toggle', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, 'X-Requested-With': 'XMLHttpRequest' },
+ body: JSON.stringify({ name, url, action })
+ }).catch(() => {});
+ }
+ },
+ syncFromDB() {
+ fetch('/api/v2/user/emoji-favorites', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
+ .then(r => r.ok ? r.json() : null)
+ .then(data => {
+ if (data?.success && Array.isArray(data.favorites)) {
+ localStorage.setItem(this.KEY, JSON.stringify(data.favorites));
+ window._emojiPickerRefresh?.();
+ }
+ }).catch(() => {});
+ }
+ };
+ if (window.f0ckSession?.logged_in) window._emojiPickerFavUtils.syncFromDB();
+ }
+
+ let activeTabId = null;
+ let dmTabBar = null;
+ let dmGridArea = null;
+
+ function showDmTab(packId, packs) {
+ activeTabId = packId;
+ if (dmTabBar) dmTabBar.querySelectorAll('.ep-tab').forEach(t =>
+ t.classList.toggle('active', t.dataset.packId === String(packId ?? 'null'))
+ );
+ if (!dmGridArea) return;
+ dmGridArea.innerHTML = '';
+
+ if (packId === '__favs__') {
+ const favs = window._emojiPickerFavUtils?.get() || [];
+ if (!favs.length) {
+ dmGridArea.innerHTML = '
No favorites yet.
Hold a sticker to preview, then tap ★
';
+ return;
+ }
+ favs.forEach(({ name, url }) => {
+ const el = makeDmEmojiEl(url, name);
+ el.addEventListener('click', (ev) => {
+ ev.stopPropagation();
+ const pos = textarea.selectionStart ?? textarea.value.length;
+ const val = textarea.value;
+ textarea.value = val.slice(0, pos) + `:${name}:` + val.slice(pos);
+ textarea.focus();
+ const newPos = pos + name.length + 2;
+ textarea.setSelectionRange(newPos, newPos);
+ });
+ dmGridArea.appendChild(el);
+ });
+ return;
+ }
+
+ const pack = packs.find(p => String(p.id ?? null) === String(packId ?? null)) || packs[0];
+ if (!pack) return;
+ pack.emojis.forEach(({ name, url }) => {
+ const el = makeDmEmojiEl(url, name);
+ el.addEventListener('click', (ev) => {
+ ev.stopPropagation();
+ const pos = textarea.selectionStart ?? textarea.value.length;
+ const val = textarea.value;
+ textarea.value = val.slice(0, pos) + `:${name}:` + val.slice(pos);
+ textarea.focus();
+ const newPos = pos + name.length + 2;
+ textarea.setSelectionRange(newPos, newPos);
+ });
+ dmGridArea.appendChild(el);
+ });
+ // Autoplay webm stickers
+ dmGridArea.querySelectorAll('video').forEach(v =>
+ v.play().catch(() => v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true }))
+ );
+ // Touch slide: switch preview as finger moves
+ dmGridArea.addEventListener('touchmove', (e) => {
+ if (!window.stickerPreview?.isShowing) return;
+ const touch = e.touches[0];
+ const target = document.elementFromPoint(touch.clientX, touch.clientY);
+ if (target && target !== dmGridArea && (target.tagName === 'IMG' || target.tagName === 'VIDEO') && target._stickerSrc) {
+ window.stickerPreview.show(target._stickerSrc, target._stickerIsVideo);
+ }
+ }, { passive: true });
+ }
+
function buildDmPickerContent(emojis, packs) {
picker.innerHTML = '';
picker.classList.remove('has-tabs');
@@ -1848,58 +1963,30 @@ if (window.__dmLoaded) {
// ── Tabbed picker ──
picker.classList.add('has-tabs');
- const tabBar = document.createElement('div');
- tabBar.className = 'emoji-picker-tabs';
+ dmTabBar = document.createElement('div');
+ dmTabBar.className = 'emoji-picker-tabs';
- const gridArea = document.createElement('div');
- gridArea.className = 'emoji-picker-grid';
+ dmGridArea = document.createElement('div');
+ dmGridArea.className = 'emoji-picker-grid';
- let activeTabId = null;
-
- const showTab = (packId) => {
- activeTabId = packId;
- tabBar.querySelectorAll('.ep-tab').forEach(t =>
- t.classList.toggle('active', t.dataset.packId === String(packId ?? 'null'))
- );
- gridArea.innerHTML = '';
- const pack = packs.find(p => String(p.id ?? null) === String(packId ?? null)) || packs[0];
- if (!pack) return;
- pack.emojis.forEach(({ name, url }) => {
- const el = makeDmEmojiEl(url, name);
- el.addEventListener('click', (ev) => {
- ev.stopPropagation();
- const pos = textarea.selectionStart ?? textarea.value.length;
- const val = textarea.value;
- textarea.value = val.slice(0, pos) + `:${name}:` + val.slice(pos);
- textarea.focus();
- const newPos = pos + name.length + 2;
- textarea.setSelectionRange(newPos, newPos);
- });
- gridArea.appendChild(el);
- });
- // Autoplay any webm stickers in the grid
- gridArea.querySelectorAll('video').forEach(v =>
- v.play().catch(() => v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true }))
- );
- // Touch slide: switch preview immediately as finger moves over a new sticker
- gridArea.onTouchMoveBound = null; // reset before re-adding
- gridArea.addEventListener('touchmove', (e) => {
- if (!window.stickerPreview?.isShowing) return;
- const touch = e.touches[0];
- const target = document.elementFromPoint(touch.clientX, touch.clientY);
- if (target && target !== gridArea && (target.tagName === 'IMG' || target.tagName === 'VIDEO') && target._stickerSrc) {
- window.stickerPreview.show(target._stickerSrc, target._stickerIsVideo);
- }
- }, { passive: true });
- };
+ // ── Favorites tab ──
+ const favsTab = document.createElement('button');
+ favsTab.className = 'ep-tab ep-tab-favs';
+ favsTab.dataset.packId = '__favs__';
+ favsTab.title = 'Favorites';
+ favsTab.type = 'button';
+ favsTab.innerHTML = '';
+ favsTab.addEventListener('mousedown', e => e.preventDefault());
+ favsTab.addEventListener('click', () => showDmTab('__favs__', packs));
+ dmTabBar.appendChild(favsTab);
+ // ── Pack tabs ──
packs.forEach((pack) => {
const tab = document.createElement('button');
tab.className = 'ep-tab';
tab.dataset.packId = String(pack.id ?? null);
tab.title = pack.name || 'Emojis';
tab.type = 'button';
- // Always use static img for tab icon (thumb_url or first non-video emoji)
if (pack.emojis && pack.emojis.length > 0) {
const iconUrl = pack.thumb_url
|| (pack.emojis.find(e => !e.url.endsWith('.webm')) || pack.emojis[0]).url;
@@ -1912,13 +1999,18 @@ if (window.__dmLoaded) {
tab.textContent = '☺';
}
tab.addEventListener('mousedown', e => e.preventDefault());
- tab.addEventListener('click', () => showTab(pack.id ?? null));
- tabBar.appendChild(tab);
+ tab.addEventListener('click', () => showDmTab(pack.id ?? null, packs));
+ dmTabBar.appendChild(tab);
});
- picker.appendChild(tabBar);
- picker.appendChild(gridArea);
- showTab(packs[0]?.id ?? null);
+ picker.appendChild(dmTabBar);
+ picker.appendChild(dmGridArea);
+ showDmTab(packs[0]?.id ?? null, packs);
+
+ // Expose refresh hook so syncFromDB can update the open favs tab
+ window._emojiPickerRefresh = () => {
+ if (activeTabId === '__favs__') showDmTab('__favs__', packs);
+ };
}
function populatePicker() {